diff --git a/app/src/androidTest/java/org/monogram/CacheTtlRoomTest.kt b/app/src/androidTest/java/org/monogram/CacheTtlRoomTest.kt new file mode 100644 index 00000000..f0ff7776 --- /dev/null +++ b/app/src/androidTest/java/org/monogram/CacheTtlRoomTest.kt @@ -0,0 +1,133 @@ +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.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.database.entity.MessageTombstoneEntity +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.database.entity.ProfileTabsEntity +import org.monogram.core.database.entity.UpdateCursorEntity +import org.monogram.core.models.Chat +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId + +@RunWith(AndroidJUnit4::class) +class CacheTtlRoomTest { + @Test + fun pruneKeepsTheRecentPageAndLeavesCursorsAlone() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java) + .allowMainThreadQueries() + .build() + val now = 1_800_000_000_000L + val old = now / 1000 - CACHE_MESSAGE_TTL_SECONDS - 60 + val recent = now / 1000 - 3_600 + val warmup = OfflineWarmup(database) + try { + val kept = (1..121).map { id -> + message(7, id, if (id == 121) recent else old) + } + val pending = message(7, -1, old, pending = true) + val failed = message(7, 200, old, failed = true) + val openChat = (1..30).map { id -> message(9, id, old) } + run { + database.messageDao() + .upsertAll((kept + pending + failed + openChat).map { it.toEntity() }) + database.messageHoleDao().insert(MessageHoleEntity(7L, 1, Int.MAX_VALUE)) + database.messageTombstoneDao().insert( + listOf(MessageTombstoneEntity(7L, 3), MessageTombstoneEntity(7L, 50)), + ) + database.updateCursorDao().upsert(UpdateCursorEntity("updates", 4, 5, 6, 7, 0)) + database.updateCursorDao().upsert( + UpdateCursorEntity("$KEY_CHANNEL_CURSOR_PREFIX" + "1", 1, 0, 2, 0, 9), + ) + database.profileTabsDao().upsert(ProfileTabsEntity(1L, "{}", 0L)) + database.profileTabsDao().upsert(ProfileTabsEntity(2L, "{}", now)) + database.profileMembersDao().upsertAll( + listOf( + ProfileMemberEntity(1L, 5L, 0, "Old", role = "member"), + ProfileMemberEntity(2L, 6L, 0, "New", role = "member"), + ), + ) + database.profileMediaDao().upsertAll( + listOf( + ProfileMediaEntity(1L, "photo", 1, 0, 7L), + ProfileMediaEntity(2L, "photo", 1, 0, 7L), + ), + ) + database.profileCommonDao().upsertAll( + listOf( + ProfileCommonEntity(1L, 3L, 0, "Old"), + ProfileCommonEntity(2L, 4L, 0, "New"), + ), + ) + } + warmup.upsertChats(listOf(Chat(id = PeerId(7), title = "Kept"))) + warmup.setOpenChat(9L) + warmup.cacheHistoryPage(PeerId(7), listOf(message(7, 121, recent))) + warmup.pruneExpiredCache(nowMillis = now, force = true) + run { + val dao = database.messageDao() + assertNull(dao.byIds(7L, listOf(1)).singleOrNull()) + assertNotNull(dao.byIds(7L, listOf(22)).singleOrNull()) + assertNotNull(dao.byIds(7L, listOf(121)).singleOrNull()) + assertTrue(dao.byIds(7L, listOf(-1)).single().pending) + assertTrue(dao.byIds(7L, listOf(200)).single().failed) + assertEquals(30, dao.newestConfirmedIds(9L, 100).size) + assertEquals("Kept", database.chatDao().get(7L)?.title) + val account = database.updateCursorDao().get("updates") + assertEquals(4, account?.pts) + assertEquals(5, account?.qts) + assertEquals(6, account?.date) + assertEquals(7, account?.seq) + val channel = database.updateCursorDao().get("$KEY_CHANNEL_CURSOR_PREFIX" + "1") + assertEquals(9, channel?.channelPts) + assertNull(database.profileTabsDao().get(1L)) + assertNotNull(database.profileTabsDao().get(2L)) + assertTrue(database.profileMembersDao().list(1L).isEmpty()) + assertEquals(1, database.profileMembersDao().list(2L).size) + assertTrue(database.profileMediaDao().list(1L).isEmpty()) + assertEquals(1, database.profileMediaDao().list(2L).size) + assertTrue(database.profileCommonDao().list(1L).isEmpty()) + assertEquals(1, database.profileCommonDao().list(2L).size) + assertTrue(database.messageTombstoneDao().deletedIds(7L, listOf(3)).isEmpty()) + assertEquals(listOf(50), database.messageTombstoneDao().deletedIds(7L, listOf(50))) + val holes = database.messageHoleDao().forChat(7L, MessageHoleEntity.SOURCE_HISTORY) + assertTrue(holes.any { 1 in it.startId..it.endId }) + assertTrue(holes.none { hole -> (22..121).any { it in hole.startId..hole.endId } }) + assertFalse(holes.any { 121 in it.startId..it.endId }) + } + } finally { + database.close() + } + } + + private fun message( + chatId: Long, + id: Int, + date: Long, + pending: Boolean = false, + failed: Boolean = false, + ) = Message( + id = MessageId(PeerId(chatId), id), + senderId = null, + text = null, + date = date, + outgoing = pending || failed, + pending = pending, + failed = failed, + randomId = if (pending) 99L else null, + ) +} diff --git a/app/src/androidTest/java/org/monogram/MediaStreamingTest.kt b/app/src/androidTest/java/org/monogram/MediaStreamingTest.kt index c0fbdc02..c6a94295 100644 --- a/app/src/androidTest/java/org/monogram/MediaStreamingTest.kt +++ b/app/src/androidTest/java/org/monogram/MediaStreamingTest.kt @@ -14,10 +14,98 @@ import org.monogram.core.models.MessageId import org.monogram.core.models.PeerId import org.monogram.network.http.TelegramChunkFetcher import org.monogram.network.http.TelegramVideoDataSource +import org.monogram.network.http.FileCache import java.io.File import java.io.RandomAccessFile class MediaStreamingTest { + @Test + fun streamedRangesSurviveClearUntilAllReadersClose() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val directory = File(context.cacheDir, "streamed-range-lease") + directory.deleteRecursively() + val cache = FileCache(directory) + val expected = ByteArray(TelegramVideoDataSource.PART_SIZE.toInt()) { (it % 251).toByte() } + val message = Message( + MessageId(PeerId(42), 12), + senderId = null, + text = null, + date = 0, + outgoing = false, + mediaKind = "video", + mediaCacheKey = "video:range-lease", + fileSize = expected.size.toLong() + ) + var fetches = 0 + val factory = TelegramVideoDataSource.Factory( + message, + directory, + TelegramChunkFetcher { _, _, path, _ -> + fetches++ + File(path).writeBytes(expected) + Outcome.Ok(path) + }, + cache + ) + val first = factory.createDataSource() + val second = factory.createDataSource() + val spec = DataSpec.Builder().setUri("telegram://media/lease").build() + try { + first.open(spec) + second.open(spec) + assertEquals(1, first.read(ByteArray(1), 0, 1)) + assertEquals(1, second.read(ByteArray(1), 0, 1)) + assertEquals(1, fetches) + assertEquals(0L, cache.clear()) + first.close() + val actual = ByteArray(32) + assertEquals(32, second.read(actual, 0, 32)) + assertArrayEquals(expected.copyOfRange(1, 33), actual) + assertEquals(expected.size.toLong(), cache.totalBytes()) + second.close() + assertEquals(0L, cache.totalBytes()) + } finally { + first.close() + second.close() + directory.deleteRecursively() + } + } + + @Test + fun cachedPlaybackKeepsLeaseUntilReaderCloses() = kotlinx.coroutines.runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val directory = File(context.cacheDir, "leased-playback-regression") + directory.deleteRecursively() + val repository = org.monogram.network.http.MediaRepository( + directory, + telegramFetcher = org.monogram.network.http.TelegramMediaFetcher { _, _, path, _, _ -> + File(path).writeBytes(byteArrayOf(1, 2, 3, 4)) + Outcome.Ok(path) + }) + val source = repository.createCachedDataSourceFactory("photo:lease").createDataSource() + try { + val result = repository.ensureIndexedMedia( + PeerId(42), + 1, + "photo:lease", + priority = org.monogram.network.http.MediaPriority.USER + ) + assertTrue(result is Outcome.Ok) + val file = (result as Outcome.Ok).value + source.open(DataSpec.Builder().setUri(Uri.fromFile(file)).build()) + assertEquals(0L, repository.clearCache()) + val bytes = ByteArray(4) + assertEquals(4, source.read(bytes, 0, 4)) + assertArrayEquals(byteArrayOf(1, 2, 3, 4), bytes) + source.close() + assertTrue(!file.exists()) + } finally { + source.close() + repository.shutdown() + directory.deleteRecursively() + } + } + @Test fun arbitrarySeekReadsAlignedPartsAndCleansTemporaryFiles() { val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -34,7 +122,7 @@ class MediaStreamingTest { val start = offset.toInt() val chunk = bytes.copyOfRange(start, minOf(start + partSize, bytes.size)) RandomAccessFile(destination, "rw").use { file -> - file.seek(offset) + file.setLength(0) file.write(chunk) } Outcome.Ok(destination) @@ -83,7 +171,7 @@ class MediaStreamingTest { TelegramChunkFetcher { _, _, destination, requested -> assertEquals(offset, requested) RandomAccessFile(destination, "rw").use { file -> - file.seek(requested) + file.setLength(0) file.write(partial) } Outcome.Ok(destination) diff --git a/app/src/androidTest/java/org/monogram/MessageMergeTest.kt b/app/src/androidTest/java/org/monogram/MessageMergeTest.kt new file mode 100644 index 00000000..c3fdbcfb --- /dev/null +++ b/app/src/androidTest/java/org/monogram/MessageMergeTest.kt @@ -0,0 +1,383 @@ +package org.monogram + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.monogram.core.database.DatabaseProvider +import org.monogram.core.database.MonogramDatabase +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.SessionMetadataStore +import org.monogram.core.database.toModel +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 java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class MessageMergeTest { + @get:Rule + val migration = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + MonogramDatabase::class.java + ) + + @Test + fun versionFiveMigrationPreservesIdentity() { + val name = "merge-migration-regression.db" + migration.createDatabase(name, 5).use { db -> + db.execSQL("INSERT INTO meta (`key`, value) VALUES ('${SessionMetadataStore.KEY_USER_ID}', '42')") + } + migration.runMigrationsAndValidate( + name, + 7, + true, + DatabaseProvider.MIGRATION_5_6, + DatabaseProvider.MIGRATION_6_7, + ).use { db -> + db.query("SELECT value FROM meta WHERE `key` = '${SessionMetadataStore.KEY_USER_ID}'") + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("42", cursor.getString(0)) + } + } + InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(name) + } + + @Test + fun startupRemovesFailedAndUntrackedRowsButKeepsRetryableSends() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "startup-message-cleanup.db" + context.deleteDatabase(name) + var database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + val chatId = PeerId(42) + val pending = Message( + MessageId(chatId, -1), + senderId = null, + text = "fixture", + date = 1L, + outgoing = true, + pending = true, + randomId = 99L + ) + try { + val warmup = OfflineWarmup(database) + warmup.upsertMessages( + listOf( + pending, + pending.copy(id = MessageId(chatId, -2), randomId = 100L), + pending.copy(id = MessageId(chatId, -3), randomId = null), + ) + ) + warmup.markMessageFailed(chatId, -2) + assertTrue( + database.messageDao().byIds(chatId.value, listOf(-2)).single().toModel().failed + ) + database.close() + database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + val restored = OfflineWarmup(database).messages(chatId) + assertEquals(listOf(-1), restored.map { it.id.id }) + assertEquals(99L, restored.single().randomId) + assertTrue(restored.single().pending) + } finally { + database.close() + context.deleteDatabase(name) + } + } + + @Test + fun globalDeletionBlocksNonChannelHistoryButPreservesChannelIdentity() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + val user = Message( + MessageId(PeerId(42), 7), + senderId = null, + text = "fixture", + date = 1L, + outgoing = false + ) + val channel = user.copy(id = MessageId(PeerId(-1000000000042), 7)) + try { + val warmup = OfflineWarmup(database) + warmup.upsertMessages(listOf(user, channel)) + warmup.deleteMessages(null, listOf(7)) + warmup.cacheHistoryPage(user.id.chatId, listOf(user)) + warmup.cacheHistoryPage(channel.id.chatId, listOf(channel)) + assertTrue(warmup.messages(user.id.chatId).isEmpty()) + assertEquals(listOf(channel), warmup.messages(channel.id.chatId)) + } finally { + database.close() + } + } + + @Test + fun localIdCanBeReusedAfterPendingReconciliation() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + val pending = Message( + MessageId(PeerId(42), -1), + senderId = null, + text = "fixture", + date = 1L, + outgoing = true, + pending = true, + randomId = 99L + ) + try { + val warmup = OfflineWarmup(database) + warmup.upsertMessages(listOf(pending)) + warmup.deleteMessage(pending.id.chatId, pending.id.id) + val next = pending.copy(randomId = 100L) + warmup.upsertMessages(listOf(next)) + assertEquals(listOf(next), warmup.messages(pending.id.chatId)) + } finally { + database.close() + } + } + + @Test + fun reconciliationRollsBackOnWriteFailureAndDoesNotDeleteAReusedLocalId() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + val pending = Message( + MessageId(PeerId(42), -1), + senderId = null, + text = "fixture", + date = 1L, + outgoing = true, + pending = true, + randomId = 99L + ) + val confirmed = pending.copy(id = MessageId(pending.id.chatId, 7), pending = false) + try { + val warmup = OfflineWarmup(database) + warmup.upsertMessages(listOf(pending)) + database.openHelper.writableDatabase.execSQL( + "CREATE TRIGGER reject_confirmation BEFORE INSERT ON messages WHEN NEW.id = 7 BEGIN SELECT RAISE(ABORT, 'fixture failure'); END" + ) + var failed = false + try { + warmup.reconcileOutgoing(-1, confirmed, 99L) + } catch (_: android.database.sqlite.SQLiteException) { + failed = true + } + assertTrue(failed) + assertEquals(listOf(pending), warmup.messages(pending.id.chatId)) + database.openHelper.writableDatabase.execSQL("DROP TRIGGER reject_confirmation") + warmup.reconcileOutgoing(-1, confirmed, 99L) + assertEquals(listOf(confirmed), warmup.messages(pending.id.chatId)) + val next = pending.copy(randomId = 100L) + warmup.upsertMessages(listOf(next)) + warmup.reconcileOutgoing(-1, confirmed, 99L) + assertEquals(100L, warmup.messages(pending.id.chatId).single { it.pending }.randomId) + } finally { + database.close() + } + } + + @Test + fun staleHistoryCannotReplaceEditOrResurrectDeletion() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val name = "message-merge-regression.db" + context.deleteDatabase(name) + var database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + val message = Message( + MessageId(PeerId(42), 7), + senderId = null, + text = "original", + date = 1L, + outgoing = false + ) + try { + var warmup = OfflineWarmup(database) + warmup.applyIncomingMessage(message) + warmup.applyIncomingMessage(message) + assertEquals(1, warmup.chats().single().unreadCount) + warmup.applyIncomingMessage( + message.copy( + id = MessageId(message.id.chatId, 6), + text = "older" + ) + ) + assertEquals(2, warmup.chats().single().unreadCount) + assertEquals(7, warmup.chats().single().lastMessageId) + warmup.deleteMessages(message.id.chatId, listOf(6)) + warmup.applyMessageEdit(message.copy(text = "edited", editDate = 3L)) + warmup.upsertMessages(listOf(message)) + assertEquals("edited", warmup.messages(message.id.chatId).single().text) + warmup.deleteMessages(message.id.chatId, listOf(message.id.id)) + database.close() + database = Room.databaseBuilder(context, MonogramDatabase::class.java, name).build() + warmup = OfflineWarmup(database) + warmup.upsertMessages(listOf(message)) + warmup.applyIncomingMessage(message) + assertTrue(warmup.messages(message.id.chatId).isEmpty()) + assertEquals(null, warmup.chats().single().lastMessagePreview) + } finally { + database.close() + context.deleteDatabase(name) + } + } + + @Test + fun versionSevenMigrationAddsReceiptsAndPreservesIdentity() { + val name = "receipt-migration-regression.db" + migration.createDatabase(name, 7).use { db -> + db.execSQL("INSERT INTO meta (`key`, value) VALUES ('${SessionMetadataStore.KEY_USER_ID}', '42')") + } + migration.runMigrationsAndValidate(name, 8, true, DatabaseProvider.MIGRATION_7_8) + .use { db -> + db.query("SELECT value FROM meta WHERE `key` = '${SessionMetadataStore.KEY_USER_ID}'") + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("42", cursor.getString(0)) + } + db.execSQL("INSERT INTO update_batch_receipts VALUES (1, 1, 1)") + } + InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(name) + } + + private fun durableMessage(id: Int) = Message( + MessageId(PeerId(42), id), + senderId = null, + text = "fixture", + date = id.toLong(), + outgoing = false, + ) + + private fun cursor(pts: Int) = org.monogram.core.database.entity.UpdateCursorEntity( + SessionMetadataStore.KEY_PRIMARY_CURSOR, pts, 0, 1, 1, + ) + + @Test + fun durableBatchCommitsMessagesCursorAndReceiptTogether() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + try { + val warmup = OfflineWarmup(database) + val committed = warmup.applyDurableBatch(7L, 1L, cursor(5), mapOf(9L to 3)) { + warmup.applyIncomingMessage(durableMessage(5)) + } + assertTrue(committed) + assertEquals(listOf(5), warmup.messages(PeerId(42)).map { it.id.id }) + assertEquals( + 5, + database.updateCursorDao().get(SessionMetadataStore.KEY_PRIMARY_CURSOR)?.pts + ) + assertEquals(3, database.updateCursorDao().get("updates.channel:9")?.channelPts) + assertTrue(database.updateCursorDao().isBatchApplied(7L, 1L)) + } finally { + database.close() + } + } + + @Test + fun failureBeforeTheCommitLeavesNoReceiptCursorOrPartialRows() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + try { + val warmup = OfflineWarmup(database) + val failure = runCatching { + warmup.applyDurableBatch(7L, 1L, cursor(5), emptyMap()) { + warmup.applyIncomingMessage(durableMessage(5)) + error("process died before the commit") + } + } + assertTrue(failure.isFailure) + assertTrue(warmup.messages(PeerId(42)).isEmpty()) + assertEquals( + null, + database.updateCursorDao().get(SessionMetadataStore.KEY_PRIMARY_CURSOR) + ) + assertTrue(!database.updateCursorDao().isBatchApplied(7L, 1L)) + + var applied = 0 + val retried = warmup.applyDurableBatch(7L, 1L, cursor(5), emptyMap()) { + applied++ + warmup.applyIncomingMessage(durableMessage(5)) + } + assertTrue(retried) + assertEquals(1, applied) + assertEquals(1, warmup.chats().single().unreadCount) + } finally { + database.close() + } + } + + @Test + fun duplicateDeliveryAfterCommitButBeforeAcknowledgementChangesNothing() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + try { + val warmup = OfflineWarmup(database) + repeat(3) { + assertTrue( + warmup.applyDurableBatch(7L, 1L, cursor(5), emptyMap()) { + warmup.applyIncomingMessage(durableMessage(5)) + } + ) + } + assertEquals(1, warmup.chats().single().unreadCount) + assertEquals(1, warmup.messages(PeerId(42)).size) + } finally { + database.close() + } + } + + @Test + fun receiptsOfEarlierAccountsAreDroppedAndChannelCursorsNeverMoveBack() = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val database = Room.inMemoryDatabaseBuilder(context, MonogramDatabase::class.java).build() + try { + val warmup = OfflineWarmup(database) + warmup.applyDurableBatch(1L, 1L, null, mapOf(9L to 10)) {} + warmup.applyDurableBatch(2L, 1L, null, mapOf(9L to 4)) {} + assertTrue(!database.updateCursorDao().isBatchApplied(1L, 1L)) + assertTrue(database.updateCursorDao().isBatchApplied(2L, 1L)) + assertEquals(10, database.updateCursorDao().get("updates.channel:9")?.channelPts) + } finally { + database.close() + } + } + + @Test + fun durableBatchAndChatUpsertDoNotDeadlockTheRoomWriter() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val warmup = OfflineWarmup(DatabaseProvider.get(context)) + val chatId = PeerId(910_001) + val done = CountDownLatch(1) + val error = AtomicReference() + Thread { + try { + runBlocking { + withContext(Dispatchers.Default) { + assertTrue( + warmup.applyDurableBatch(8L, 91L, cursor(91), emptyMap()) { + warmup.applyIncomingMessage( + durableMessage(91).copy(id = MessageId(chatId, 91)), + ) + }, + ) + warmup.upsertChats(listOf(Chat(id = chatId, title = "room-writer"))) + } + } + done.countDown() + } catch (thrown: Throwable) { + error.set(thrown) + done.countDown() + } + }.apply { isDaemon = true }.start() + assertTrue(done.await(8, TimeUnit.SECONDS)) + assertNull(error.get()) + runBlocking { warmup.deleteMessages(chatId, listOf(91)) } + } +} diff --git a/app/src/androidTest/java/org/monogram/TabletStateTest.kt b/app/src/androidTest/java/org/monogram/TabletStateTest.kt index 15d1b7a4..27ff291a 100644 --- a/app/src/androidTest/java/org/monogram/TabletStateTest.kt +++ b/app/src/androidTest/java/org/monogram/TabletStateTest.kt @@ -520,6 +520,7 @@ class TabletStateTest { "hashCode" -> System.identityHashCode(proxy) "toString" -> "TabletStateFakeClient" "libraryVersion" -> "test" + "proxyConnectionStatus" -> org.monogram.network.bridge.session.ProxyConnectionStatus.Disabled "updates", "sessionLost" -> emptyFlow() "getChats" -> Outcome.Ok(chats) "getFolders" -> Outcome.Ok(folders) diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 20c367ba..d0750e26 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -3,6 +3,7 @@ package org.monogram import android.app.Application import android.os.Build import androidx.compose.material3.ComposeMaterial3Flags +import androidx.compose.material3.ExperimentalMaterial3Api import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -17,8 +18,10 @@ import kotlinx.coroutines.withTimeout import org.monogram.core.common.AppLog import org.monogram.core.common.DebugLog import org.monogram.core.common.DebugStats +import org.monogram.core.common.MediaCacheLimitStore import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog +import org.monogram.core.common.RuntimePressure import org.monogram.core.common.SponsorRegistry import org.monogram.core.common.TelegramCredentials import org.monogram.core.common.push.NotificationLocalStore @@ -31,6 +34,7 @@ import org.monogram.core.ui.AppUpdateSettings import org.monogram.core.ui.AppearanceSettings import org.monogram.core.ui.DownloadSettings import org.monogram.core.ui.DownloadState +import org.monogram.core.ui.FileLeases import org.monogram.core.ui.ImageCache import org.monogram.core.ui.perf.perfSpan import org.monogram.feature.settings.ProxySettingsStore @@ -127,7 +131,7 @@ class MonogramApp : Application() { client.setDownloadChunkKib(state.downloadChunkKib) } - @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate() { super.onCreate() ComposeMaterial3Flags.isCheckboxStylingFixEnabled = true @@ -135,6 +139,21 @@ class MonogramApp : Application() { perfSpan("app:settings") { AppLog.init(cacheDir) } + RuntimePressure.networkConstrained = { + getSystemService(android.net.ConnectivityManager::class.java)?.activeNetwork == null + } + RuntimePressure.thermal = { + if (Build.VERSION.SDK_INT < 29) { + false + } else { + val status = + getSystemService(android.os.PowerManager::class.java)?.currentThermalStatus ?: 0 + status >= android.os.PowerManager.THERMAL_STATUS_SEVERE + } + } + RuntimePressure.databaseBusy = { + DatabaseProvider.roomQueueDepth() > 4 + } settingsScope.launch(Dispatchers.IO) { perfSpan("app:debugStats") { DebugStats.install(BuildConfig.DEBUG, cacheDir) @@ -254,6 +273,7 @@ class MonogramApp : Application() { perfSpan("app:downloadConcurrency") { DownloadSettings.install(this, ::applyDownloadSettings) DownloadSettings.setPremium(sessionStore.readPremium()) + MediaCacheLimitStore.install(filesDir) } }, createMedia = { @@ -263,33 +283,44 @@ class MonogramApp : Application() { telegramChunkFetcher = TelegramChunkFetcher { chatId, messageId, destPath, offset -> client.downloadMessageMediaChunk(chatId, messageId, destPath, offset) }, - telegramFetcher = TelegramMediaFetcher { chatId, messageId, destPath, kind, priority -> - when (kind) { - MediaFetchKind.Thumb -> - client.downloadMessageThumb( - chatId, - messageId, - destPath, - priority - ) + telegramFetcher = object : TelegramMediaFetcher { + override fun promote(destPath: String, priority: Int) = + client.promoteDownload(destPath, priority) - MediaFetchKind.Display -> - client.downloadMessageDisplay( - 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.Full -> - client.downloadMessageMedia( - 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) }, @@ -299,8 +330,10 @@ class MonogramApp : Application() { peerInlineThumbPeek = TelegramPeerInlineThumbPeek { peerId -> client.peekPeerInlineThumb(peerId) }, + cacheLimitBytes = MediaCacheLimitStore.current.value.bytes, ) } + FileLeases.leaser = mediaRepository::leaseFile client.setDownloadProgressListener { path, downloaded, _ -> mediaRepository.onNativeProgress(path, downloaded) } @@ -421,11 +454,11 @@ 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) || - 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) + 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 b96b8836..e6be31e7 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -1,5 +1,6 @@ package org.monogram.root +import android.content.res.Resources.getSystem import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.StackNavigation @@ -25,12 +26,15 @@ import kotlinx.serialization.Serializable import org.monogram.BuildConfig import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome +import org.monogram.core.common.PerfLog +import org.monogram.core.common.RuntimePressure import org.monogram.core.common.push.NotificationLocalStore import org.monogram.core.common.push.PushRegistration import org.monogram.core.common.telegram.TelegramLink import org.monogram.core.common.telegram.parseTelegramLink import org.monogram.core.database.OfflineWarmup import org.monogram.core.database.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 @@ -49,8 +53,10 @@ import org.monogram.feature.settings.AppUpdateController import org.monogram.feature.settings.SettingsComponent import org.monogram.feature.settings.ui.ProxySession import org.monogram.network.bridge.BridgedMtprotoClient +import org.monogram.network.bridge.DurableUpdateBatch import org.monogram.network.bridge.MtprotoClient import org.monogram.network.bridge.MtprotoUpdate +import org.monogram.network.http.MediaPriority import org.monogram.network.http.MediaRepository class RootComponent( @@ -71,6 +77,38 @@ class RootComponent( ) : ComponentContext by componentContext { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val targetWarmup = org.monogram.feature.dialog.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 val navigation = StackNavigation() private var expiringSession = false private var accountFlagsVersion = 0L @@ -91,6 +129,7 @@ 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) { @@ -145,6 +184,7 @@ 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, @@ -249,6 +289,79 @@ 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 + ) + } + return cache.applyDurableBatch( + batch.accountGeneration, + batch.batchId, + cursor, + batch.channelCursors + ) { + batch.updates.forEach { + applyDurableUpdate( + cache, + it, + batch.accountGeneration, + batch.batchId + ) + } + } + } + + 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.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 + } + } + init { if (startOnHome && stack.value.active.configuration is Config.Auth) { navigation.replaceAll(Config.Home) @@ -262,7 +375,11 @@ class RootComponent( lifecycle.doOnStart { if (!stack.value.active.configuration.isAuthArea()) { scope.launch { - sessionStore?.readAuthorizedUserId()?.value?.let { pushRegistration?.setAccountUserId(it) } + sessionStore?.readAuthorizedUserId()?.value?.let { + pushRegistration?.setAccountUserId( + it + ) + } runCatching { pushRegistration?.reregister() } } } @@ -271,35 +388,14 @@ class RootComponent( scope.launch(start = CoroutineStart.UNDISPATCHED) { client.sessionLost().collect { expireSession() } } + // Message, read-state and delta updates reach the cache only through the + // durable batch commit; the live collector below must not apply them twice. + (client as? BridgedMtprotoClient)?.installDurableUpdateSink(::commitDurableBatch) scope.launch { client.updates().collect { update -> if (expiringSession) return@collect try { when (update) { - is MtprotoUpdate.NewMessage -> warmup?.applyIncomingMessage(update.message) - is MtprotoUpdate.MessageEdited -> warmup?.applyMessageEdit(update.message) - is MtprotoUpdate.MessagesDeleted -> warmup?.deleteMessages( - update.chatId, - update.messageIds - ) - - is MtprotoUpdate.MessageReactions -> warmup?.applyReactions( - update.chatId, - update.messageId, - update.reactionsJson - ) - - is MtprotoUpdate.ReadOutbox -> warmup?.applyOutboxRead( - update.chatId, - update.maxId - ) - - is MtprotoUpdate.DiscussionInbox -> warmup?.applyDiscussionRead( - update.channelId, - update.topMessageId, - update.readMaxId - ) - is MtprotoUpdate.FoldersChanged -> warmup?.replaceFolders(update.folders) is MtprotoUpdate.ChatsChanged -> { warmup?.upsertChats(update.chats) @@ -318,9 +414,6 @@ class RootComponent( ) is MtprotoUpdate.ReadInbox -> { - warmup?.applyInboxRead( - update.chatId, update.maxId, update.stillUnread, - ) if (update.stillUnread <= 0) { pushRegistration?.onChatRead(update.chatId.value) } @@ -343,16 +436,6 @@ class RootComponent( update.stillUnread, ) - is MtprotoUpdate.UnreadMentionsDelta -> warmup?.addUnreadMentions( - update.chatId, - update.delta, - ) - - is MtprotoUpdate.UnreadReactionsDelta -> warmup?.addUnreadReactions( - update.chatId, - update.delta, - ) - else -> Unit } } catch (e: CancellationException) { @@ -474,6 +557,7 @@ class RootComponent( ) is Config.Dialog -> { + targetWarmup.promote(config.chatId) val component = DialogComponent( componentContext = context, storeFactory = storeFactory, diff --git a/app/src/main/java/org/monogram/root/RootContent.kt b/app/src/main/java/org/monogram/root/RootContent.kt index 6ebe15ae..2167518c 100644 --- a/app/src/main/java/org/monogram/root/RootContent.kt +++ b/app/src/main/java/org/monogram/root/RootContent.kt @@ -104,6 +104,7 @@ import org.monogram.core.ui.perf.RecompositionProbe import org.monogram.feature.auth.ui.AuthContent import org.monogram.feature.chats.ui.ChatsContent import org.monogram.feature.dialog.DialogComponent +import org.monogram.feature.dialog.ui.AudioMessagePlaybackScope import org.monogram.feature.dialog.ui.DialogContent import org.monogram.feature.profile.ui.ProfileContent import org.monogram.feature.settings.ui.ProxySettingsContent @@ -114,7 +115,7 @@ import kotlin.math.roundToInt @OptIn(ExperimentalDecomposeApi::class, FaultyDecomposeApi::class) fun RootContent(component: RootComponent, modifier: Modifier = Modifier) { val stack by component.stack.subscribeAsState() - org.monogram.feature.dialog.ui.AudioMessagePlaybackScope( + AudioMessagePlaybackScope( enabled = !stack.active.instance.isSignedOut(), ) { togglePlayback -> RootPlaybackContent(component, modifier, togglePlayback) } } @@ -127,6 +128,7 @@ private fun RootPlaybackContent( togglePlayback: () -> Unit ) { RecompositionProbe("AppRoot") + SideEffect { component.noteFirstFrame() } val currentStack by component.stack.subscribeAsState() val playbackContext = LocalContext.current val playbackSession = remember(playbackContext) { MediaPlaybackHolder.session(playbackContext) } @@ -455,11 +457,13 @@ private fun RootPlaybackContent( component = instance.component, onOpenProxy = component::openProxy, ) + is RootComponent.Child.Proxy -> ProxySettingsContent( session = component.proxySession, onBack = component::onBack, showAppBar = true, ) + is RootComponent.Child.Home -> if (expanded) { EmptyDetailContent() } else if (compactHome) { diff --git a/core/common/src/main/java/org/monogram/core/common/MediaCacheLimit.kt b/core/common/src/main/java/org/monogram/core/common/MediaCacheLimit.kt new file mode 100644 index 00000000..a5b54970 --- /dev/null +++ b/core/common/src/main/java/org/monogram/core/common/MediaCacheLimit.kt @@ -0,0 +1,48 @@ +package org.monogram.core.common + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +/** User-chosen media cache ceiling. `bytes == null` means size eviction is off. */ +enum class MediaCacheLimit(val stored: String, val bytes: Long?) { + MB_500("500mb", 500L * 1024L * 1024L), + GB_1("1gb", 1L shl 30), + GB_2("2gb", 2L shl 30), + GB_5("5gb", 5L shl 30), + GB_10("10gb", 10L shl 30), + UNLIMITED("unlimited", null), + ; + + companion object { + val DEFAULT = GB_1 + + fun fromStored(raw: String?): MediaCacheLimit = + entries.firstOrNull { it.stored == raw?.trim() } ?: DEFAULT + } +} + +/** The choice lives beside the app files, not inside the media directory that clear deletes. */ +object MediaCacheLimitStore { + private const val FILE_NAME = "media-cache-limit" + + private val mutable = MutableStateFlow(MediaCacheLimit.DEFAULT) + val current: StateFlow = mutable.asStateFlow() + + fun install(directory: File) { + mutable.value = read(directory) + } + + fun read(directory: File): MediaCacheLimit { + val file = File(directory, FILE_NAME) + if (!file.isFile) return MediaCacheLimit.DEFAULT + return MediaCacheLimit.fromStored(runCatching { file.readText() }.getOrNull()) + } + + fun write(directory: File, limit: MediaCacheLimit) { + directory.mkdirs() + File(directory, FILE_NAME).writeText(limit.stored) + mutable.value = limit + } +} diff --git a/core/common/src/main/java/org/monogram/core/common/PerfLog.kt b/core/common/src/main/java/org/monogram/core/common/PerfLog.kt index 478344cc..28d1c7fd 100644 --- a/core/common/src/main/java/org/monogram/core/common/PerfLog.kt +++ b/core/common/src/main/java/org/monogram/core/common/PerfLog.kt @@ -1,7 +1,11 @@ package org.monogram.core.common import android.util.Log +import kotlinx.coroutines.ThreadContextElement import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicLong +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext /** * Opt-in netcode timing spans; off by default, switchable from adb: @@ -28,6 +32,47 @@ object PerfLog { private val pendingUpdateAtMs = ConcurrentLinkedQueue() + private val nextOpId = AtomicLong(1L) + private val threadOpId = ThreadLocal() + + fun newOpId(): Long = + (nextOpId.getAndIncrement() and OP_ID_MASK).let { if (it == 0L) 1L else it } + + fun currentOpId(): Long = threadOpId.get() ?: 0L + + class OpTrace(val opId: Long = newOpId()) : AbstractCoroutineContextElement(Key), + ThreadContextElement { + companion object Key : CoroutineContext.Key + + override fun updateThreadContext(context: CoroutineContext): Long { + val previous = threadOpId.get() ?: 0L + threadOpId.set(opId) + return previous + } + + override fun restoreThreadContext(context: CoroutineContext, oldState: Long) { + if (oldState == 0L) threadOpId.remove() else threadOpId.set(oldState) + } + } + + inline fun withOpId(opId: Long, block: () -> T): T { + val previous = swapOpId(opId) + try { + return block() + } finally { + swapOpId(previous) + } + } + + @PublishedApi + internal fun swapOpId(opId: Long): Long { + val previous = threadOpId.get() ?: 0L + if (opId == 0L) threadOpId.remove() else threadOpId.set(opId) + return previous + } + + private const val OP_ID_MASK = 0x7FFF_FFFFL + fun isEnabled(): Boolean { val now = nowMs() if (now - resolvedAt >= RESOLVE_INTERVAL_MS) { @@ -57,7 +102,9 @@ object PerfLog { fun event(op: String, detail: String = "") { DebugLog.ingestPerf(op, detail, null) if (!isEnabled()) return - runCatching { Log.i(TAG, if (detail.isEmpty()) op else "$op $detail") } + val opId = currentOpId() + val tagged = if (opId == 0L) detail else "opId=$opId $detail".trimEnd() + runCatching { Log.i(TAG, if (tagged.isEmpty()) op else "$op $tagged") } } /** @@ -74,8 +121,10 @@ object PerfLog { detail: String = "", ) { if (!isEnabled()) return + val opId = currentOpId() val line = buildString { append("op=").append(op) + if (opId != 0L) append(" opId=").append(opId) append(" phase=").append(phase) if (handle != null && handle != 0L) append(" handle=").append(handle) if (dispatchClass != null) append(" class=").append(dispatchClass) diff --git a/core/common/src/main/java/org/monogram/core/common/RuntimePressure.kt b/core/common/src/main/java/org/monogram/core/common/RuntimePressure.kt new file mode 100644 index 00000000..5940d3b7 --- /dev/null +++ b/core/common/src/main/java/org/monogram/core/common/RuntimePressure.kt @@ -0,0 +1,18 @@ +package org.monogram.core.common + +/** + * Optional signals installed by the process. Unset probes do not block work. + * Thermal, radio, and database probes are read by the selected-target warmup. + */ +object RuntimePressure { + @Volatile + var networkConstrained: () -> Boolean = { false } + + @Volatile + var thermal: () -> Boolean = { false } + + @Volatile + var databaseBusy: () -> Boolean = { false } + + fun blocked(): Boolean = networkConstrained() || thermal() || databaseBusy() +} diff --git a/core/common/src/main/java/org/monogram/core/common/WorkOwnership.kt b/core/common/src/main/java/org/monogram/core/common/WorkOwnership.kt new file mode 100644 index 00000000..67d7233d --- /dev/null +++ b/core/common/src/main/java/org/monogram/core/common/WorkOwnership.kt @@ -0,0 +1,44 @@ +package org.monogram.core.common + +/** + * One registry for shared work. HTTP transfers and the selected-target warmup + * acquire, release, promote, and finish under the same lock. + */ +object WorkOwnership { + data class State(val consumers: Int, val priority: Int, val terminal: Boolean) + + private class Entry(var consumers: Int, var priority: Int, var terminal: Boolean) + + private val entries = HashMap() + + fun acquire(key: String, priority: Int): State = synchronized(entries) { + val entry = entries.getOrPut(key) { Entry(0, priority, false) } + entry.consumers++ + if (priority > entry.priority) entry.priority = priority + entry.state() + } + + /** Remaining consumers, or null when this release removed the entry. */ + fun release(key: String): State? = synchronized(entries) { + val entry = entries[key] ?: return null + entry.consumers = (entry.consumers - 1).coerceAtLeast(0) + if (entry.consumers == 0 && entry.terminal) entries.remove(key) + entry.state() + } + + fun promote(key: String, priority: Int): Int = synchronized(entries) { + val entry = entries[key] ?: return priority + if (priority > entry.priority) entry.priority = priority + entry.priority + } + + fun finish(key: String) = synchronized(entries) { + val entry = entries[key] ?: return + entry.terminal = true + if (entry.consumers == 0) entries.remove(key) + } + + fun state(key: String): State? = synchronized(entries) { entries[key]?.state() } + + private fun Entry.state() = State(consumers, priority, terminal) +} diff --git a/core/common/src/test/java/org/monogram/core/common/MediaCacheLimitTest.kt b/core/common/src/test/java/org/monogram/core/common/MediaCacheLimitTest.kt new file mode 100644 index 00000000..14cba7d0 --- /dev/null +++ b/core/common/src/test/java/org/monogram/core/common/MediaCacheLimitTest.kt @@ -0,0 +1,34 @@ +package org.monogram.core.common + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class MediaCacheLimitTest { + @Test + fun unknownOrMissingChoiceIsOneGigabyte() { + assertEquals(MediaCacheLimit.GB_1, MediaCacheLimit.DEFAULT) + assertEquals(1L shl 30, MediaCacheLimit.GB_1.bytes) + assertEquals(MediaCacheLimit.GB_1, MediaCacheLimit.fromStored(null)) + assertEquals(MediaCacheLimit.GB_1, MediaCacheLimit.fromStored("nope")) + assertEquals(null, MediaCacheLimit.UNLIMITED.bytes) + } + + @Test + fun storedChoiceSurvivesANewRead() { + val directory = File.createTempFile("media-limit", "").apply { + delete() + mkdirs() + } + try { + assertEquals(MediaCacheLimit.GB_1, MediaCacheLimitStore.read(directory)) + MediaCacheLimit.entries.forEach { limit -> + MediaCacheLimitStore.write(directory, limit) + assertEquals(limit, MediaCacheLimitStore.read(directory)) + assertEquals(limit, MediaCacheLimitStore.current.value) + } + } finally { + directory.deleteRecursively() + } + } +} diff --git a/core/common/src/test/java/org/monogram/core/common/PerfLogTest.kt b/core/common/src/test/java/org/monogram/core/common/PerfLogTest.kt index 9a370c8d..0b4af614 100644 --- a/core/common/src/test/java/org/monogram/core/common/PerfLogTest.kt +++ b/core/common/src/test/java/org/monogram/core/common/PerfLogTest.kt @@ -3,6 +3,9 @@ package org.monogram.core.common import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.junit.Test class PerfLogTest { @@ -29,4 +32,36 @@ class PerfLogTest { Thread.sleep(2) assertTrue(PerfLog.nowMs() >= first) } + + @Test + fun opIdsArePositiveAndUnique() { + val first = PerfLog.newOpId() + val second = PerfLog.newOpId() + assertTrue(first > 0 && second > 0) + assertTrue(first != second) + } + + @Test + fun opIdSurvivesDispatcherHopsAndIsRestored() { + val trace = PerfLog.OpTrace() + assertEquals(0L, PerfLog.currentOpId()) + val seen = runBlocking(trace) { + val inherited = PerfLog.currentOpId() + val hopped = withContext(Dispatchers.Default) { PerfLog.currentOpId() } + inherited to hopped + } + assertEquals(trace.opId, seen.first) + assertEquals(trace.opId, seen.second) + assertEquals(0L, PerfLog.currentOpId()) + } + + @Test + fun withOpIdRestoresPreviousValue() { + PerfLog.withOpId(5L) { + assertEquals(5L, PerfLog.currentOpId()) + PerfLog.withOpId(9L) { assertEquals(9L, PerfLog.currentOpId()) } + assertEquals(5L, PerfLog.currentOpId()) + } + assertEquals(0L, PerfLog.currentOpId()) + } } diff --git a/core/common/src/test/java/org/monogram/core/common/WorkOwnershipTest.kt b/core/common/src/test/java/org/monogram/core/common/WorkOwnershipTest.kt new file mode 100644 index 00000000..663b2eec --- /dev/null +++ b/core/common/src/test/java/org/monogram/core/common/WorkOwnershipTest.kt @@ -0,0 +1,30 @@ +package org.monogram.core.common + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class WorkOwnershipTest { + @Test + fun oneReleaseDoesNotFinishWhileAnotherOwnerRemains() { + val key = "ownership-${System.nanoTime()}" + assertEquals(1, WorkOwnership.acquire(key, 1).consumers) + assertEquals(2, WorkOwnership.acquire(key, 1).consumers) + assertEquals(1, WorkOwnership.release(key)?.consumers) + assertFalse(WorkOwnership.state(key)!!.terminal) + WorkOwnership.finish(key) + assertEquals(0, WorkOwnership.release(key)?.consumers) + assertNull(WorkOwnership.state(key)) + } + + @Test + fun promoteNeverLowersPriority() { + val key = "promote-${System.nanoTime()}" + WorkOwnership.acquire(key, 10) + assertEquals(30, WorkOwnership.promote(key, 30)) + assertEquals(30, WorkOwnership.promote(key, 5)) + WorkOwnership.finish(key) + WorkOwnership.release(key) + } +} diff --git a/core/database/schemas/org.monogram.core.database.MonogramDatabase/6.json b/core/database/schemas/org.monogram.core.database.MonogramDatabase/6.json new file mode 100644 index 00000000..134bd0b3 --- /dev/null +++ b/core/database/schemas/org.monogram.core.database.MonogramDatabase/6.json @@ -0,0 +1,1052 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "09750c87d86d63857f7bc4048d98dc85", + "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, `canForward` INTEGER NOT NULL, `canDeleteOthers` INTEGER NOT NULL, `canManageTopics` INTEGER NOT NULL, `emojiStatusDocumentId` INTEGER, 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": "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" + } + ], + "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": "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, `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, `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": "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": "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": "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, 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 + } + ], + "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, '09750c87d86d63857f7bc4048d98dc85')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/org.monogram.core.database.MonogramDatabase/7.json b/core/database/schemas/org.monogram.core.database.MonogramDatabase/7.json new file mode 100644 index 00000000..7ca923c9 --- /dev/null +++ b/core/database/schemas/org.monogram.core.database.MonogramDatabase/7.json @@ -0,0 +1,1059 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "aaad1b3abd5bfece0be47096d79c9d1b", + "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, `canForward` INTEGER NOT NULL, `canDeleteOthers` INTEGER NOT NULL, `canManageTopics` INTEGER NOT NULL, `emojiStatusDocumentId` INTEGER, 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": "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" + } + ], + "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": "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, `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": "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": "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, 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 + } + ], + "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, 'aaad1b3abd5bfece0be47096d79c9d1b')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/org.monogram.core.database.MonogramDatabase/8.json b/core/database/schemas/org.monogram.core.database.MonogramDatabase/8.json new file mode 100644 index 00000000..d0b959d3 --- /dev/null +++ b/core/database/schemas/org.monogram.core.database.MonogramDatabase/8.json @@ -0,0 +1,1090 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "eeeb99da64651fe99634a6ed57fbceff", + "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, `canForward` INTEGER NOT NULL, `canDeleteOthers` INTEGER NOT NULL, `canManageTopics` INTEGER NOT NULL, `emojiStatusDocumentId` INTEGER, 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": "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" + } + ], + "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": "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, `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": "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, 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 + } + ], + "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, 'eeeb99da64651fe99634a6ed57fbceff')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/java/org/monogram/core/database/CacheTtl.kt b/core/database/src/main/java/org/monogram/core/database/CacheTtl.kt new file mode 100644 index 00000000..3a3ecc2d --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/CacheTtl.kt @@ -0,0 +1,84 @@ +package org.monogram.core.database + +import org.monogram.core.database.entity.MessageHoleEntity + +const val CACHE_MESSAGE_TTL_SECONDS = 30L * 24 * 60 * 60 +const val CACHE_PROFILE_TTL_MILLIS = 30L * 24 * 60 * 60 * 1000 +const val CACHE_PRUNE_CHATS = 4 +const val CACHE_PRUNE_ROWS = 40 + +data class CachedMessage( + val id: Int, + val dateSeconds: Long, + val pending: Boolean = false, + val failed: Boolean = false, +) + +/** Confirmed rows older than the TTL and outside the newest page. Pending and failed rows stay. */ +fun expiredMessageIds( + messages: List, + nowSeconds: Long, + keepNewest: Int = HISTORY_CACHE_WINDOW, + ttlSeconds: Long = CACHE_MESSAGE_TTL_SECONDS, +): List { + val confirmed = messages.filter { !it.pending && !it.failed } + val newest = confirmed.sortedByDescending { it.id }.take(keepNewest).map { it.id }.toSet() + val cutoff = nowSeconds - ttlSeconds + return confirmed.mapNotNull { row -> + when { + row.id in newest -> null + row.dateSeconds >= cutoff -> null + else -> row.id + } + } +} + +fun chatsToPrune(chatIds: List, openChatId: Long?): List = + if (openChatId == null) chatIds else chatIds.filter { it != openChatId } + +fun profileCacheStale( + updatedAtMillis: Long, + nowMillis: Long, + ttlMillis: Long = CACHE_PROFILE_TTL_MILLIS, +): Boolean = updatedAtMillis < nowMillis - ttlMillis + +fun idRanges(ids: List): List> { + if (ids.isEmpty()) return emptyList() + val sorted = ids.distinct().sorted() + val ranges = mutableListOf>() + var start = sorted.first() + var end = start + for (id in sorted.drop(1)) { + if (id.toLong() == end.toLong() + 1L) { + end = id + } else { + ranges += start to end + start = id + end = id + } + } + ranges += start to end + return ranges +} + +/** + * Adds holes for the deleted tail, then punches the kept ids back out so the + * newest page is not reported as missing. + */ +fun holesAfterCacheTtl( + chatId: Long, + existing: List, + deletedIds: List, + keptIds: List, +): List { + if (deletedIds.isEmpty()) return existing + val source = existing.firstOrNull()?.source ?: MessageHoleEntity.SOURCE_HISTORY + val added = idRanges(deletedIds).map { (start, end) -> + MessageHoleEntity(chatId, start, end, source) + } + var holes = mergeHistoryHoles(existing + added) + for ((start, end) in idRanges(keptIds)) { + holes = holes.flatMap { subtractHistoryRange(it, start, end) } + } + return holes +} 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 fca38e27..91d7fa21 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 @@ -2,21 +2,27 @@ package org.monogram.core.database import android.content.Context import android.database.sqlite.SQLiteDatabase +import android.os.Process import androidx.room.Room import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import kotlinx.coroutines.ThreadContextElement import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext import org.json.JSONObject import org.monogram.core.common.PerfLog import java.io.File +import java.util.ArrayDeque import java.util.concurrent.Executor import java.util.concurrent.Executors +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext object DatabaseProvider { @Volatile private var instance: MonogramDatabase? = null - const val SCHEMA_VERSION = 5 + const val SCHEMA_VERSION = 8 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -49,27 +55,108 @@ object DatabaseProvider { } } + val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS message_tombstones (chatId INTEGER NOT NULL, id INTEGER NOT NULL, PRIMARY KEY(chatId, id))") + } + } + + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE messages ADD COLUMN failed INTEGER NOT NULL DEFAULT 0") + } + } + + 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))" + ) + } + } + /** SQL stays off Dispatchers.IO, which media downloads also use. */ - private val roomWorker = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "monogram-room").apply { isDaemon = true } + private val queueLock = Object() + private val onRoomThread = ThreadLocal() + private val foregroundQueue = ArrayDeque() + private val maintenanceQueue = ArrayDeque() + private val roomThread = Thread({ + while (!Thread.currentThread().isInterrupted) { + val task = synchronized(queueLock) { + while (foregroundQueue.isEmpty() && maintenanceQueue.isEmpty()) { + queueLock.wait() + } + if (foregroundQueue.isNotEmpty()) foregroundQueue.removeFirst() else maintenanceQueue.removeFirst() + } + task.run() + } + }, "monogram-room").apply { + isDaemon = true + start() } + internal val roomExecutor: Executor = Executor { runnable -> val queuedAt = PerfLog.nowMs() - roomWorker.execute { + val opId = PerfLog.currentOpId() + val maintenance = RoomLane.current() == RoomLane.Kind.MAINTENANCE + val task = Runnable { val startedAt = PerfLog.nowMs() + onRoomThread.set(true) try { - runnable.run() + PerfLog.withOpId(opId) { runnable.run() } } finally { + onRoomThread.remove() if (PerfLog.isEnabled()) { - PerfLog.event( - "room_queue", - "wait_ms=${startedAt - queuedAt} run_ms=${PerfLog.nowMs() - startedAt}" - ) + PerfLog.withOpId(opId) { + PerfLog.event( + "room_queue", + "wait_ms=${startedAt - queuedAt} run_ms=${PerfLog.nowMs() - startedAt}" + ) + } + } + } + } + synchronized(queueLock) { + (if (maintenance) maintenanceQueue else foregroundQueue).addLast(task) + queueLock.notifyAll() + } + } + + @Suppress("unused") + private val keepRoomThread = roomThread + private val roomDispatcher = roomExecutor.asCoroutineDispatcher() + + private val roomQueryExecutor = roomPool("monogram-room-query", threads = 2, background = false) + private val roomTransactionExecutor = + roomPool("monogram-room-tx", threads = 1, background = true) + + internal suspend fun onRoom(block: suspend () -> T): T = + if (onRoomThread.get() == true) block() else withContext(roomDispatcher) { block() } + + private fun roomPool(name: String, threads: Int, background: Boolean): Executor { + val pool = Executors.newFixedThreadPool(threads) { worker -> + Thread(worker, name).apply { isDaemon = true } + } + return Executor { command -> + pool.execute { + if (background) { + runCatching { Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND) } + } + onRoomThread.set(true) + try { + command.run() + } finally { + onRoomThread.remove() } } } } - internal val roomDispatcher = roomExecutor.asCoroutineDispatcher() + + fun roomQueueDepth(): Int = synchronized(queueLock) { + foregroundQueue.size + maintenanceQueue.size + } private const val DB_NAME = "monogram.db" private const val IDENTITY_BACKUP = "session-identity.json" @@ -117,10 +204,18 @@ object DatabaseProvider { private fun build(app: Context): MonogramDatabase = Room.databaseBuilder(app, MonogramDatabase::class.java, DB_NAME) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8 + ) .setJournalMode(androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .setQueryExecutor(roomExecutor) - .setTransactionExecutor(roomExecutor) + .setQueryExecutor(roomQueryExecutor) + .setTransactionExecutor(roomTransactionExecutor) .addCallback(object : androidx.room.RoomDatabase.Callback() { override fun onOpen(db: SupportSQLiteDatabase) { db.execSQL("PRAGMA synchronous=NORMAL") @@ -181,3 +276,24 @@ object DatabaseProvider { private fun identityBackupFile(context: Context): File = File(context.filesDir, IDENTITY_BACKUP) } + +internal class RoomLane(val kind: Kind) : + AbstractCoroutineContextElement(Key), ThreadContextElement { + enum class Kind { FOREGROUND, MAINTENANCE } + + override fun updateThreadContext(context: CoroutineContext): Kind? { + val previous = current.get() + current.set(kind) + return previous + } + + override fun restoreThreadContext(context: CoroutineContext, oldState: Kind?) { + if (oldState == null) current.remove() else current.set(oldState) + } + + companion object Key : CoroutineContext.Key { + private val current = ThreadLocal() + fun current(): Kind = current.get() ?: Kind.FOREGROUND + fun maintenance(): RoomLane = RoomLane(Kind.MAINTENANCE) + } +} diff --git a/core/database/src/main/java/org/monogram/core/database/HistorySlice.kt b/core/database/src/main/java/org/monogram/core/database/HistorySlice.kt new file mode 100644 index 00000000..a89e0fa7 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/HistorySlice.kt @@ -0,0 +1,42 @@ +package org.monogram.core.database + +import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.models.Message + +/** + * Local history for one request. [rows] are whatever is already stored, including a + * partial offline window. [covered] is true only when that id span has been proven, + * never because the row count happens to look full. + */ +data class HistorySlice( + val rows: List, + val covered: Boolean, + val holes: List, + val confirmedStart: Boolean, + val confirmedEnd: Boolean, + val fresh: Boolean, +) + +fun historySliceOf( + rows: List, + coverageInitialized: Boolean, + holes: List, + requestedStart: Int, + requestedEnd: Int, +): HistorySlice { + val intersecting = holes.filter { hole -> + requestedStart <= requestedEnd && + hole.endId >= requestedStart && + hole.startId <= requestedEnd + } + val confirmedStart = coverageInitialized && holes.none { it.startId <= 1 && it.endId >= 1 } + val confirmedEnd = coverageInitialized && holes.none { it.endId == Int.MAX_VALUE } + return HistorySlice( + rows = rows, + covered = coverageInitialized && intersecting.isEmpty() && requestedStart <= requestedEnd, + holes = intersecting.map { it.startId.toLong()..it.endId.toLong() }, + confirmedStart = confirmedStart, + confirmedEnd = confirmedEnd, + fresh = coverageInitialized, + ) +} 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 601929f6..d5fe8307 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 @@ -170,6 +170,7 @@ fun MessageEntity.toModel(): Message = Message( senderName = senderName, replyMarkup = ReplyMarkups.parse(replyMarkupJson), pending = pending, + failed = failed, randomId = randomId, checklist = Checklists.parse(checklistJson), ) @@ -207,6 +208,7 @@ fun Message.toEntity(): MessageEntity = MessageEntity( senderName = senderName, replyMarkupJson = ReplyMarkups.serialize(replyMarkup), pending = pending, + failed = failed, randomId = randomId, checklistJson = Checklists.serialize(checklist), ) 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 b4925968..4c4330bb 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 @@ -6,6 +6,7 @@ import org.monogram.core.database.dao.ChatDao import org.monogram.core.database.dao.FolderDao import org.monogram.core.database.dao.MessageDao import org.monogram.core.database.dao.MessageHoleDao +import org.monogram.core.database.dao.MessageTombstoneDao import org.monogram.core.database.dao.MetaDao import org.monogram.core.database.dao.PeerDao import org.monogram.core.database.dao.ProfileCommonDao @@ -25,6 +26,7 @@ import org.monogram.core.database.entity.ProfileMediaEntity import org.monogram.core.database.entity.ProfileMemberEntity import org.monogram.core.database.entity.ProfileTabsEntity import org.monogram.core.database.entity.SponsorEntity +import org.monogram.core.database.entity.UpdateBatchReceiptEntity import org.monogram.core.database.entity.UpdateCursorEntity @Database( @@ -33,8 +35,10 @@ import org.monogram.core.database.entity.UpdateCursorEntity ChatEntity::class, FolderEntity::class, MessageEntity::class, + org.monogram.core.database.entity.MessageTombstoneEntity::class, MessageHoleEntity::class, UpdateCursorEntity::class, + UpdateBatchReceiptEntity::class, PeerEntity::class, ProfileTabsEntity::class, ProfileMemberEntity::class, @@ -42,7 +46,7 @@ import org.monogram.core.database.entity.UpdateCursorEntity ProfileCommonEntity::class, SponsorEntity::class, ], - version = 5, + version = 8, exportSchema = true, ) abstract class MonogramDatabase : RoomDatabase() { @@ -50,6 +54,7 @@ abstract class MonogramDatabase : RoomDatabase() { abstract fun chatDao(): ChatDao abstract fun folderDao(): FolderDao abstract fun messageDao(): MessageDao + abstract fun messageTombstoneDao(): MessageTombstoneDao abstract fun messageHoleDao(): MessageHoleDao abstract fun updateCursorDao(): UpdateCursorDao abstract fun peerDao(): PeerDao 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 f6bd6c61..2bf3a449 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 @@ -4,6 +4,7 @@ import androidx.room.withTransaction import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.sync.Mutex @@ -14,6 +15,8 @@ import org.monogram.core.common.AppLog import org.monogram.core.common.PerfLog import org.monogram.core.database.dao.ChatReadState import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.database.entity.UpdateBatchReceiptEntity +import org.monogram.core.database.entity.UpdateCursorEntity import org.monogram.core.models.Chat import org.monogram.core.models.Folder import org.monogram.core.models.Message @@ -21,6 +24,7 @@ import org.monogram.core.models.PeerId import org.monogram.core.models.Profile import org.monogram.core.models.chatListPreviewSource import org.monogram.core.models.isPlaceholderPeerTitle +import org.monogram.core.models.mergeIncomingMessage import org.monogram.core.models.mergeLocalCache /** @@ -34,10 +38,13 @@ open class OfflineWarmup( val recentHistory: RecentHistoryMemory = RecentHistoryMemory(), ) { private val cleanupMutex = Mutex() + @Volatile private var firstPaintSnapshot: OfflineWarmupSnapshot? = null + @Volatile private var recentWarmupChats: List = emptyList() + @Volatile private var recentWarmupJob: Job? = null @@ -61,6 +68,13 @@ open class OfflineWarmup( synchronized(this) { firstPaintSnapshot = snapshot } recentWarmupChats = chats PerfLog.mark("cache:startupSnapshot", PerfLog.nowMs() - started, "count=${chats.size}") + try { + pruneExpiredCache(force = true) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + AppLog.warn("warmup", "cache ttl prune failed") + } } private suspend fun warmRecentHistory(chats: List) { @@ -94,11 +108,86 @@ open class OfflineWarmup( open val usesIoDispatcher: Boolean get() = db != null private suspend inline fun roomIo(crossinline block: suspend () -> T): T = - if (db == null) block() else withContext(DatabaseProvider.roomDispatcher) { block() } + if (db == null) block() else DatabaseProvider.onRoom { block() } @Volatile private var startupCleanupDone = false + @Volatile + private var openChatId: Long? = null + + @Volatile + private var pruneOffset = 0 + + @Volatile + private var lastPruneAt = 0L + + fun setOpenChat(chatId: Long) { + openChatId = chatId + } + + fun clearOpenChat(chatId: Long) { + if (openChatId == chatId) openChatId = null + } + + suspend fun pruneExpiredCache( + nowMillis: Long = System.currentTimeMillis(), + force: Boolean = false, + ) { + if (db == null) return + if (!force && nowMillis - lastPruneAt < 30_000L) return + lastPruneAt = nowMillis + try { + withContext(RoomLane.maintenance()) { + roomIo { pruneOnRoom(nowMillis) } + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + AppLog.warn("warmup", "cache ttl prune failed") + } + } + + private suspend fun pruneOnRoom(nowMillis: Long) { + val database = db ?: return + val cutoffSeconds = nowMillis / 1000 - CACHE_MESSAGE_TTL_SECONDS + val chats = database.messageDao().chatIdsPage(CACHE_PRUNE_CHATS, pruneOffset) + 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) + if (drop.isNotEmpty()) { + database.messageDao().deleteInChat(chatId, drop) + database.messageTombstoneDao().deleteIds(chatId, drop) + val kept = (newest + database.messageDao() + .stickyIds(chatId, cutoffSeconds, 200)).distinct() + 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) } + } + kotlinx.coroutines.yield() + } + val cutoffMillis = nowMillis - CACHE_PROFILE_TTL_MILLIS + for (peerId in database.profileTabsDao().stalePeerIds(cutoffMillis, CACHE_PRUNE_ROWS)) { + currentCoroutineContext().ensureActive() + database.profileTabsDao().delete(peerId) + database.profileMembersDao().delete(peerId) + database.profileMediaDao().deletePeer(peerId) + database.profileCommonDao().delete(peerId) + } + } + /** * Drops rows left by process death before the first warmup read returns. */ @@ -191,8 +280,9 @@ open class OfflineWarmup( recentHistory.invalidate(message.id.chatId.value) val database = db ?: return@roomIo database.withTransaction { - database.messageDao().upsertAll(listOf(message.toEntity())) - refreshPreview(message.id.chatId, message.id.id) + if (mergeMessages(listOf(message)).isNotEmpty()) { + refreshPreview(message.id.chatId, message.id.id) + } } } @@ -227,7 +317,7 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().observeAll().map { it.toModel() } } @@ -238,7 +328,7 @@ open class OfflineWarmup( ensureStartupCleanup() return chats() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val main = database.chatDao().mainListPage(limit, 0) val archived = database.chatDao().archivePreview(archiveLimit) @@ -251,7 +341,7 @@ open class OfflineWarmup( ensureStartupCleanup() return 0 } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().mainListCount() } @@ -262,7 +352,7 @@ open class OfflineWarmup( ensureStartupCleanup() return chats().firstOrNull { it.id == chatId } } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().get(chatId.value)?.toModel() } @@ -273,34 +363,60 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.folderDao().observeAll().map { it.toModel() } } } - /** Drops stuck local rows with no random_id. Pending sends with a random_id wait for updateMessageID. */ + /** Drops failed/error rows and stuck local rows without a random id. Valid pending sends survive restart. */ open suspend fun dropUnsentAfterRestart() { val database = db ?: return roomIo { database.withTransaction { - val chatIds = database.messageDao().unsentChatIds() + val chatIds = database.messageDao().startupTransientChatIds() if (chatIds.isEmpty()) return@withTransaction - database.messageDao().deleteUnsent() + database.messageDao().deleteStartupTransient() chatIds.forEach { refreshLatestPreview(PeerId(it)) } } } } + 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 { + require(message.id.id > 0) + recentHistory.invalidate(message.id.chatId.value) + val database = db + if (database == null) { + deleteMessage(message.id.chatId, pendingId) + upsertMessages(listOf(message)) + return@roomIo + } + database.withTransaction { + 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) + } + mergeMessages(listOf(message.copy(pending = false, failed = false))) + refreshPreview(message.id.chatId, pendingId) + } + } + open suspend fun messages(chatId: PeerId, limit: Int = HISTORY_CACHE_WINDOW): List { val database = db ?: run { ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val pending = database.messageDao().pendingForChat(chatId.value).map { it.toModel() } - val latest = database.messageDao().latestForChat(chatId.value, limit).map { it.toModel() } + val latest = + database.messageDao().latestForChat(chatId.value, limit).map { it.toModel() } pending + latest } } @@ -311,53 +427,98 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val holes = database.messageHoleDao().forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) - if (ids.any { id -> holes.any { id in it.startId..it.endId } }) return@withContext emptyList() + if (ids.any { id -> holes.any { id in it.startId..it.endId } }) return@onRoom emptyList() val rows = database.messageDao().byIds(chatId.value, ids).map { it.toModel() } val byId = rows.associateBy { it.id.id } ids.mapNotNull { byId[it] } } } - open suspend fun olderMessages(chatId: PeerId, beforeId: Int, limit: Int = HISTORY_CACHE_WINDOW): List { + open suspend fun olderMessages( + chatId: PeerId, + beforeId: Int, + limit: Int = HISTORY_CACHE_WINDOW + ): List { val database = db ?: run { ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().olderThan(chatId.value, beforeId, limit) - if (rows.isEmpty()) return@withContext emptyList() + if (database.metaDao() + .get("history_coverage:${chatId.value}") == null + ) return@onRoom emptyList() + if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( chatId.value, rows.minOf { it.id }, beforeId - 1, MessageHoleEntity.SOURCE_HISTORY ) - ) return@withContext emptyList() + ) return@onRoom emptyList() rows.map { it.toModel() } } } - open suspend fun newerMessages(chatId: PeerId, afterId: Int, limit: Int = HISTORY_CACHE_WINDOW): List { + open suspend fun newerMessages( + chatId: PeerId, + afterId: Int, + limit: Int = HISTORY_CACHE_WINDOW + ): List { val database = db ?: run { ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().newerThan(chatId.value, afterId, limit) - if (rows.isEmpty()) return@withContext emptyList() + if (database.metaDao() + .get("history_coverage:${chatId.value}") == null + ) return@onRoom emptyList() + if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( chatId.value, afterId + 1, rows.maxOf { it.id }, MessageHoleEntity.SOURCE_HISTORY ) - ) return@withContext emptyList() + ) return@onRoom emptyList() rows.map { it.toModel() } } } + open suspend fun historySlice( + chatId: PeerId, + beforeId: Int? = null, + afterId: Int? = null, + limit: Int = HISTORY_CACHE_WINDOW, + ): HistorySlice { + 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 + } + 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 @@ -370,6 +531,7 @@ open class OfflineWarmup( reachesOldest: Boolean = false, olderBoundary: Int? = null, ) = roomIo { + recentHistory.invalidate(chatId.value) val database = db if (database == null) { upsertMessages(messages) @@ -377,18 +539,30 @@ open class OfflineWarmup( } database.withTransaction { val metaKey = "history_coverage:${chatId.value}" + val holeDao = database.messageHoleDao() if (database.metaDao().get(metaKey) == null) { - markHistoryHole(chatId, 1, Int.MAX_VALUE) + val holes = mergeHistoryHoles( + holeDao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + + MessageHoleEntity(chatId.value, 1, Int.MAX_VALUE) + ) + holeDao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + holes.forEach { holeDao.insert(it) } database.metaDao() .upsert(org.monogram.core.database.entity.MetaEntity(metaKey, "1")) } - upsertMessages(messages) + mergeMessages(messages) val ids = messages.filter { !it.pending && it.id.id > 0 }.map { it.id.id } val min = if (reachesOldest) 1 else ids.minOrNull() val max = if (reachesNewest) Int.MAX_VALUE else olderBoundary?.minus(1) ?: ids.maxOrNull() - if (min != null && max != null && min <= max) closeHistoryHole(chatId, min, max) + if (min != null && max != null && min <= max) { + val holes = holeDao.forChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + .flatMap { subtractHistoryRange(it, min, max) } + holeDao.clearChat(chatId.value, MessageHoleEntity.SOURCE_HISTORY) + holes.forEach { holeDao.insert(it) } + } } + pruneExpiredCache() } open suspend fun markHistoryHole(chatId: PeerId, startId: Int, endId: Int) = roomIo { @@ -426,7 +600,7 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.messageDao() .atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) @@ -455,9 +629,10 @@ open class OfflineWarmup( open suspend fun upsertChats(chats: List) { val database = db ?: return if (chats.isEmpty()) return - withContext(DatabaseProvider.roomDispatcher) { + DatabaseProvider.onRoom { database.withTransaction { - val stored = database.chatDao().getByIds(chats.map { it.id.value }).associateBy { it.id } + 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() @@ -480,21 +655,53 @@ open class OfflineWarmup( open suspend fun replaceMessages(chatId: PeerId, messages: List) = roomIo { recentHistory.invalidate(chatId.value) val database = db ?: return@roomIo - database.messageDao().clearChat(chatId.value) - if (messages.isNotEmpty()) { - database.messageDao().upsertAll(messages.map { it.toEntity() }) + database.withTransaction { + mergeMessages(messages) + database.messageDao().deleteExcept(chatId.value, messages.map { it.id.id }) } } - open suspend fun upsertMessages(messages: List) = roomIo { + 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 - db?.messageDao()?.upsertAll(messages.map { it.toEntity() }) + val database = db ?: return@roomIo + for (batch in chunkMessages(messages)) { + currentCoroutineContext().ensureActive() + database.withTransaction { mergeMessages(batch) } + kotlinx.coroutines.yield() + } + pruneExpiredCache() + } + + private suspend fun mergeMessages(messages: List): List { + val database = db ?: return emptyList() + val accepted = mutableListOf() + 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 { - recentHistory.invalidate(chatId.value) - db?.messageDao()?.delete(chatId.value, messageId) + deleteMessages(chatId, listOf(messageId)) } open suspend fun deleteMessages(chatId: PeerId?, messageIds: Collection) = roomIo { @@ -507,6 +714,9 @@ 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) + }) if (chatId != null) { database.messageDao().deleteInChat(chatId.value, ids) } else { @@ -518,7 +728,7 @@ open class OfflineWarmup( open suspend fun pruneAheadOfLastMessage(chats: List): List { val database = db ?: return emptyList() - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { val out = mutableListOf() for (chat in chats) { if (chat.lastMessageId <= 0) continue @@ -553,31 +763,61 @@ open class OfflineWarmup( } } - open suspend fun applyIncomingMessage(message: Message) = roomIo { + open suspend fun applyIncomingMessage( + message: Message, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, + ) = roomIo { recentHistory.invalidate(message.id.chatId.value) val database = db ?: return@roomIo database.withTransaction { - database.messageDao().upsertAll(listOf(message.toEntity())) + val existed = + database.messageDao().byIds(message.id.chatId.value, listOf(message.id.id)) + .isNotEmpty() + if (mergeMessages(listOf(message)).isEmpty()) return@withTransaction val stored = database.chatDao().get(message.id.chatId.value)?.toModel() - val unread = - if (message.outgoing || message.id.id <= (stored?.readInboxMaxId ?: 0) || - message.id.id <= (stored?.lastMessageId ?: 0) - ) { - stored?.unreadCount ?: 0 - } else { - (stored?.unreadCount ?: 0) + 1 + val chat = (stored ?: placeholderChat(message.id.chatId)) + .mergeIncomingMessage( + message, + alreadyStored = existed, + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, + ) + database.chatDao().upsertAll(listOf(chat.toEntity())) + } + } + + /** + * Commits one native update batch: its domain mutations, the application + * checkpoint and the receipt share a single transaction. A batch whose receipt + * exists is a duplicate delivery and changes nothing. Returns true when the + * batch is committed, whether now or earlier, so the caller may acknowledge it. + */ + open suspend fun applyDurableBatch( + accountGeneration: Long, + batchId: Long, + cursor: UpdateCursorEntity?, + channelCursors: Map, + apply: suspend () -> Unit, + ): Boolean = roomIo { + val database = db ?: return@roomIo true + 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)) } - val chat = (stored ?: placeholderChat(message.id.chatId)).copy( - lastMessagePreview = message.chatListPreviewSource(), - lastMessageOutgoing = message.outgoing, - lastMessageSenderName = message.senderName, - lastMessageMediaKind = message.mediaKind, - lastMessageDate = message.date, - unreadCount = unread, - lastMessageId = message.id.id, - lastMediaThumbCacheKey = message.thumbCacheKey ?: stored?.lastMediaThumbCacheKey, + } + dao.insertReceipt( + UpdateBatchReceiptEntity(accountGeneration, batchId, System.currentTimeMillis()) ) - database.chatDao().upsertAll(listOf(chat.toEntity())) + dao.pruneReceipts(accountGeneration, batchId - RECEIPT_WINDOW) + true } } @@ -589,10 +829,12 @@ open class OfflineWarmup( recentWarmupChats = emptyList() val database = db ?: return@roomIo database.messageDao().clearAll() + 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() @@ -659,6 +901,55 @@ fun staleIdsInsideFetchedWindow(cachedIds: List, fetchedIds: Collection, + maxRows: Int = MESSAGE_BATCH_ROWS, + maxBytes: Int = MESSAGE_BATCH_BYTES, +): List> { + val batches = mutableListOf>() + var current = mutableListOf() + var currentBytes = 0 + for (message in messages) { + val size = message.estimatedSerializedBytes() + if (current.isNotEmpty() && (current.size >= maxRows || currentBytes + size > maxBytes)) { + batches += current + current = mutableListOf() + currentBytes = 0 + } + current += message + currentBytes += size + } + if (current.isNotEmpty()) batches += current + return batches +} + const val DRAFT_META_PREFIX = "draft:" fun draftMetaKey(chatId: Long, threadId: Int = 0): String = 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 5b08c847..ecd1e8c2 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 @@ -54,6 +54,7 @@ open class SessionMetadataStore( val database = db ?: return database.metaDao().clear() database.updateCursorDao().clear() + database.updateCursorDao().clearReceipts() database.profileTabsDao().clear() database.profileMembersDao().clear() database.profileMediaDao().clear() 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 767dcb57..6df7e1fd 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 @@ -29,21 +29,14 @@ interface MessageDao { @Query("SELECT * FROM messages WHERE chatId = :chatId AND id IN (:ids)") suspend fun byIds(chatId: Long, ids: List): List - @Query( - """ - SELECT DISTINCT chatId FROM messages - WHERE (pending = 1 OR id < 0) AND (randomId IS NULL OR randomId = 0) - """, - ) - suspend fun unsentChatIds(): 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 - @Query( - """ - DELETE FROM messages - WHERE (pending = 1 OR id < 0) AND (randomId IS NULL OR randomId = 0) - """, - ) - suspend fun deleteUnsent() + @Query("DELETE FROM messages WHERE failed = 1 OR ((pending = 1 OR id < 0) AND (randomId IS NULL OR randomId = 0))") + suspend fun deleteStartupTransient() + + @Query("UPDATE messages SET failed = 1, pending = 0 WHERE chatId = :chatId AND id = :messageId") + suspend fun markFailed(chatId: Long, messageId: Int) @Query( """ @@ -113,6 +106,42 @@ interface MessageDao { @Query("DELETE FROM messages WHERE chatId = :chatId") suspend fun clearChat(chatId: Long) + @Query("SELECT DISTINCT chatId FROM messages LIMIT :limit OFFSET :offset") + suspend fun chatIdsPage(limit: Int, offset: Int): List + + @Query( + """ + SELECT id FROM messages + WHERE chatId = :chatId AND pending = 0 AND failed = 0 + ORDER BY id DESC + LIMIT :limit + """, + ) + suspend fun newestConfirmedIds(chatId: Long, limit: Int): List + + @Query( + """ + SELECT id FROM messages + WHERE chatId = :chatId AND pending = 0 AND failed = 0 AND date < :cutoffSeconds + ORDER BY id ASC + LIMIT :limit + """, + ) + suspend fun oldConfirmedIds(chatId: Long, cutoffSeconds: Long, limit: Int): List + + @Query( + """ + SELECT id FROM messages + WHERE chatId = :chatId AND (pending = 1 OR failed = 1 OR date >= :cutoffSeconds) + ORDER BY id DESC + LIMIT :limit + """, + ) + suspend fun stickyIds(chatId: Long, cutoffSeconds: Long, limit: Int): List + + @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") suspend fun clearAll() } diff --git a/core/database/src/main/java/org/monogram/core/database/dao/MessageTombstoneDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/MessageTombstoneDao.kt new file mode 100644 index 00000000..f9fb412d --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/dao/MessageTombstoneDao.kt @@ -0,0 +1,22 @@ +package org.monogram.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.monogram.core.database.entity.MessageTombstoneEntity + +@Dao +interface MessageTombstoneDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(rows: List) + + @Query("SELECT id FROM message_tombstones WHERE id IN (:ids) AND (chatId = :chatId OR (chatId = 0 AND :chatId > -1000000000000))") + suspend fun deletedIds(chatId: Long, ids: List): List + + @Query("DELETE FROM message_tombstones WHERE chatId = :chatId AND id IN (:ids)") + suspend fun deleteIds(chatId: Long, ids: List) + + @Query("DELETE FROM message_tombstones") + suspend fun clear() +} diff --git a/core/database/src/main/java/org/monogram/core/database/dao/ProfileDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/ProfileDao.kt index d3e7b466..63b9a745 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/ProfileDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/ProfileDao.kt @@ -17,6 +17,9 @@ interface ProfileTabsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entity: ProfileTabsEntity) + @Query("SELECT peerId FROM profile_tabs WHERE updatedAt < :cutoffMillis LIMIT :limit") + suspend fun stalePeerIds(cutoffMillis: Long, limit: Int): List + @Query("DELETE FROM profile_tabs WHERE peerId = :peerId") suspend fun delete(peerId: Long) @@ -50,6 +53,9 @@ interface ProfileMediaDao { @Query("DELETE FROM profile_media WHERE peerId = :peerId AND tab = :tab") suspend fun deleteTab(peerId: Long, tab: String) + @Query("DELETE FROM profile_media WHERE peerId = :peerId") + suspend fun deletePeer(peerId: Long) + @Query("DELETE FROM profile_media") suspend fun clear() } diff --git a/core/database/src/main/java/org/monogram/core/database/dao/UpdateCursorDao.kt b/core/database/src/main/java/org/monogram/core/database/dao/UpdateCursorDao.kt index 92ffa48a..f8398586 100644 --- a/core/database/src/main/java/org/monogram/core/database/dao/UpdateCursorDao.kt +++ b/core/database/src/main/java/org/monogram/core/database/dao/UpdateCursorDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import org.monogram.core.database.entity.UpdateBatchReceiptEntity import org.monogram.core.database.entity.UpdateCursorEntity @Dao @@ -16,4 +17,23 @@ interface UpdateCursorDao { @Query("DELETE FROM update_cursors") suspend fun clear() + + @Query( + "SELECT EXISTS(SELECT 1 FROM update_batch_receipts " + + "WHERE accountGeneration = :generation AND batchId = :batchId)" + ) + suspend fun isBatchApplied(generation: Long, batchId: Long): Boolean + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertReceipt(receipt: UpdateBatchReceiptEntity): Long + + /** Keeps a bounded window of the current account and drops receipts of earlier accounts. */ + @Query( + "DELETE FROM update_batch_receipts WHERE accountGeneration != :generation " + + "OR (accountGeneration = :generation AND batchId <= :upToBatchId)" + ) + suspend fun pruneReceipts(generation: Long, upToBatchId: Long) + + @Query("DELETE FROM update_batch_receipts") + suspend fun clearReceipts() } diff --git a/core/database/src/main/java/org/monogram/core/database/entity/MessageEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/MessageEntity.kt index ddcd139d..0d6c3271 100644 --- a/core/database/src/main/java/org/monogram/core/database/entity/MessageEntity.kt +++ b/core/database/src/main/java/org/monogram/core/database/entity/MessageEntity.kt @@ -1,6 +1,7 @@ package org.monogram.core.database.entity import androidx.room.Entity +import androidx.room.ColumnInfo import androidx.room.Index @Entity( @@ -41,6 +42,7 @@ data class MessageEntity( val senderName: String? = null, val replyMarkupJson: String? = null, val pending: Boolean = false, + @ColumnInfo(defaultValue = "0") val failed: Boolean = false, val randomId: Long? = null, val checklistJson: String? = null, ) diff --git a/core/database/src/main/java/org/monogram/core/database/entity/MessageTombstoneEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/MessageTombstoneEntity.kt new file mode 100644 index 00000000..5e08bc82 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/entity/MessageTombstoneEntity.kt @@ -0,0 +1,6 @@ +package org.monogram.core.database.entity + +import androidx.room.Entity + +@Entity(tableName = "message_tombstones", primaryKeys = ["chatId", "id"]) +data class MessageTombstoneEntity(val chatId: Long, val id: Int) diff --git a/core/database/src/main/java/org/monogram/core/database/entity/UpdateCursorEntity.kt b/core/database/src/main/java/org/monogram/core/database/entity/UpdateCursorEntity.kt index b9a25bd6..0c277614 100644 --- a/core/database/src/main/java/org/monogram/core/database/entity/UpdateCursorEntity.kt +++ b/core/database/src/main/java/org/monogram/core/database/entity/UpdateCursorEntity.kt @@ -12,3 +12,13 @@ data class UpdateCursorEntity( val seq: Int, val channelPts: Int = 0, ) + +@Entity( + tableName = "update_batch_receipts", + primaryKeys = ["accountGeneration", "batchId"], +) +data class UpdateBatchReceiptEntity( + val accountGeneration: Long, + val batchId: Long, + val appliedAtMs: Long, +) diff --git a/core/database/src/test/java/org/monogram/core/database/CacheTtlTest.kt b/core/database/src/test/java/org/monogram/core/database/CacheTtlTest.kt new file mode 100644 index 00000000..77f93cbf --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/CacheTtlTest.kt @@ -0,0 +1,53 @@ +package org.monogram.core.database + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.database.entity.MessageHoleEntity + +class CacheTtlTest { + private val now = 1_700_000_000L + private val old = now - CACHE_MESSAGE_TTL_SECONDS - 60 + private val recent = now - 86_400 + + @Test + fun keepsRecentMessagesAndTheNewestPage() { + val oldTail = (1..120).map { CachedMessage(it, old) } + val recentRow = CachedMessage(121, recent) + val drop = expiredMessageIds(oldTail + recentRow, now) + assertEquals((1..21).toList(), drop) + assertFalse(121 in drop) + assertFalse(22 in drop) + assertTrue(expiredMessageIds((1..150).map { CachedMessage(it, recent) }, now).isEmpty()) + } + + @Test + fun pendingFailedOpenChatAndFreshProfilesStay() { + val rows = listOf( + CachedMessage(1, old, pending = true), + CachedMessage(2, old, failed = true), + CachedMessage(3, old), + CachedMessage(4, old), + ) + assertEquals(listOf(3), expiredMessageIds(rows, now, keepNewest = 1)) + assertEquals(listOf(8L), chatsToPrune(listOf(4L, 8L), openChatId = 4L)) + assertFalse( + profileCacheStale( + updatedAtMillis = 50_000, + nowMillis = 60_000, + ttlMillis = 30_000 + ) + ) + assertTrue(profileCacheStale(updatedAtMillis = 1, nowMillis = 50_000, ttlMillis = 30_000)) + } + + @Test + fun deletedTailIsAHoleAndTheKeptPageIsNot() { + val existing = listOf(MessageHoleEntity(7L, 1, Int.MAX_VALUE)) + val kept = (100..110).toList() + val holes = holesAfterCacheTtl(7L, existing, deletedIds = (1..5).toList(), keptIds = kept) + assertTrue(holes.any { 1 in it.startId..it.endId && 5 in it.startId..it.endId }) + assertTrue(holes.none { hole -> kept.any { it in hole.startId..hole.endId } }) + } +} diff --git a/core/database/src/test/java/org/monogram/core/database/HistorySliceTest.kt b/core/database/src/test/java/org/monogram/core/database/HistorySliceTest.kt new file mode 100644 index 00000000..641c2086 --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/HistorySliceTest.kt @@ -0,0 +1,69 @@ +package org.monogram.core.database + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.database.entity.MessageHoleEntity +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId + +class HistorySliceTest { + private val chat = PeerId(7) + private fun row(id: Int) = Message( + MessageId(chat, id), + senderId = null, + text = "t", + date = id.toLong(), + outgoing = false, + ) + + private fun hole(start: Int, end: Int) = MessageHoleEntity(chat.value, start, end) + + @Test + fun anUninitializedCacheIsPartialAndUncovered() { + val slice = historySliceOf(listOf(row(10)), false, emptyList(), 1, 10) + assertEquals(listOf(10), slice.rows.map { it.id.id }) + assertFalse(slice.covered) + assertFalse(slice.fresh) + assertFalse(slice.confirmedStart) + assertFalse(slice.confirmedEnd) + } + + @Test + fun aHoleInsideTheRequestKeepsTheRowsButBlocksLocalSatisfaction() { + val slice = historySliceOf(listOf(row(8), row(4)), true, listOf(hole(5, 7)), 4, 8) + assertEquals(2, slice.rows.size) + assertFalse(slice.covered) + assertEquals(5L..7L, slice.holes.single()) + } + + @Test + fun provenCoverageWithoutAHoleIsLocal() { + val slice = historySliceOf(listOf(row(3), row(2), row(1)), true, emptyList(), 1, 3) + assertTrue(slice.covered) + assertTrue(slice.confirmedStart) + assertTrue(slice.confirmedEnd) + } + + @Test + fun openEndsStayUnconfirmedWhileTheSentinelHolesRemain() { + val slice = historySliceOf( + listOf(row(20)), + true, + listOf(hole(1, 10), hole(30, Int.MAX_VALUE)), + 20, + 20, + ) + assertTrue(slice.covered) + assertFalse(slice.confirmedStart) + assertFalse(slice.confirmedEnd) + } + + @Test + fun rowCountAloneNeverImpliesCoverage() { + val slice = historySliceOf(List(100) { row(it + 1) }, false, emptyList(), 1, 100) + assertFalse(slice.covered) + } +} 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 7f11c60f..a16551cf 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 @@ -19,7 +19,7 @@ import java.util.concurrent.atomic.AtomicReference class MessageMediaFieldsTest { @Test fun schemaVersionIsCurrent() { - assertEquals(5, DatabaseProvider.SCHEMA_VERSION) + assertEquals(8, DatabaseProvider.SCHEMA_VERSION) } @Test diff --git a/core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt b/core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt new file mode 100644 index 00000000..3ad51f7f --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt @@ -0,0 +1,111 @@ +package org.monogram.core.database + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.resume + +class RoomBoundsTest { + private fun message(id: Int, text: String = "m") = Message( + MessageId(PeerId(1), id), + senderId = null, + text = text, + date = 1L, + outgoing = false, + ) + + @Test + fun batchesStopAtOneHundredRowsAndAQuarterMegabyte() { + val byRows = chunkMessages(List(250) { message(it) }) + assertEquals(3, byRows.size) + assertEquals(100, byRows[0].size) + assertEquals(50, byRows[2].size) + + val heavy = message(1, text = "x".repeat(80 * 1024)) + val byBytes = chunkMessages(List(10) { heavy.copy(id = MessageId(PeerId(1), it)) }) + assertTrue(byBytes.size > 1) + byBytes.forEach { batch -> + val bytes = batch.sumOf { it.estimatedSerializedBytes() } + assertTrue(batch.size == 1 || bytes <= MESSAGE_BATCH_BYTES) + } + } + + @Test + fun aSingleOversizedRowIsNotSplit() { + val huge = message(1, text = "x".repeat(MESSAGE_BATCH_BYTES)) + assertEquals(1, chunkMessages(listOf(huge)).single().size) + } + + @Test + fun foregroundWorkPassesQueuedMaintenance() { + val order = Collections.synchronizedList(mutableListOf()) + val started = CountDownLatch(1) + val release = CountDownLatch(1) + runBlocking { + withContext(RoomLane.maintenance()) { + DatabaseProvider.roomExecutor.execute { + order += "maintenance-running" + started.countDown() + check(release.await(5, TimeUnit.SECONDS)) + } + } + } + check(started.await(5, TimeUnit.SECONDS)) + val done = CountDownLatch(2) + runBlocking { + withContext(RoomLane.maintenance()) { + DatabaseProvider.roomExecutor.execute { + order += "maintenance-queued" + done.countDown() + } + } + DatabaseProvider.roomExecutor.execute { + order += "foreground" + done.countDown() + } + } + release.countDown() + check(done.await(5, TimeUnit.SECONDS)) + assertEquals(listOf("maintenance-running", "foreground", "maintenance-queued"), order) + } + + @Test + fun nestedRoomHopInsideABlockingTransactionCompletes() { + val done = CountDownLatch(1) + val error = AtomicReference() + Thread { + try { + runBlocking { + DatabaseProvider.onRoom { + suspendCancellableCoroutine { outer -> + DatabaseProvider.roomExecutor.execute { + runBlocking { + DatabaseProvider.onRoom { + outer.resume(Unit) + } + } + } + } + } + } + done.countDown() + } catch (thrown: Throwable) { + error.set(thrown) + done.countDown() + } + }.apply { isDaemon = true }.start() + assertTrue(done.await(3, TimeUnit.SECONDS)) + assertNull(error.get()) + } +} 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 new file mode 100644 index 00000000..562ae4d9 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/ChatIncomingMerge.kt @@ -0,0 +1,64 @@ +package org.monogram.core.models + +import java.util.concurrent.ConcurrentHashMap + +/** + * Account generation plus the accepted batch order. Generation 0 is an untracked + * live edit and always applies. A repeated or older order does not. + */ +object IncomingOrder { + private val applied = ConcurrentHashMap>() + + fun accept(chatId: Long, accountGeneration: Long, acceptedOrder: Long): Boolean { + if (accountGeneration == 0L) return true + val previous = applied[chatId] + if (previous != null) { + val (generation, order) = previous + if (accountGeneration < generation) return false + if (accountGeneration == generation && acceptedOrder < order) return false + } + applied[chatId] = accountGeneration to acceptedOrder + return true + } +} + +/** + * The one rule for folding an incoming message into a dialog row. The cache, the + * chat list store and journal replay all use it, so arrival order and repeated + * delivery give the same row everywhere. + * + * A duplicate or already-read message leaves the unread count alone, and a + * message older than the current top one never replaces the preview. + */ +fun Chat.mergeIncomingMessage( + message: Message, + alreadyStored: Boolean = false, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, +): Chat { + if (!IncomingOrder.accept( + message.id.chatId.value, + accountGeneration, + acceptedOrder + ) + ) return this + val unread = + if (alreadyStored || message.outgoing || message.id.id <= readInboxMaxId || + message.id.id == lastMessageId + ) { + unreadCount + } else { + unreadCount + 1 + } + if (message.id.id < lastMessageId) return copy(unreadCount = unread) + return copy( + lastMessagePreview = message.chatListPreviewSource(), + lastMessageOutgoing = message.outgoing, + lastMessageSenderName = message.senderName, + lastMessageMediaKind = message.mediaKind, + lastMessageDate = message.date, + unreadCount = unread, + lastMessageId = message.id.id, + lastMediaThumbCacheKey = message.thumbCacheKey ?: lastMediaThumbCacheKey, + ) +} diff --git a/core/models/src/test/java/org/monogram/core/models/ChatIncomingMergeTest.kt b/core/models/src/test/java/org/monogram/core/models/ChatIncomingMergeTest.kt new file mode 100644 index 00000000..e607c7a7 --- /dev/null +++ b/core/models/src/test/java/org/monogram/core/models/ChatIncomingMergeTest.kt @@ -0,0 +1,64 @@ +package org.monogram.core.models + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatIncomingMergeTest { + private val chatId = PeerId(42) + private val chat = + Chat(id = chatId, title = "c", lastMessageId = 10, unreadCount = 2, readInboxMaxId = 5) + + private fun message(id: Int, outgoing: Boolean = false, text: String = "m$id") = + Message( + MessageId(chatId, id), + senderId = null, + text = text, + date = id.toLong(), + outgoing = outgoing + ) + + @Test + fun newerMessageUpdatesPreviewAndUnread() { + val merged = chat.mergeIncomingMessage(message(11)) + assertEquals(11, merged.lastMessageId) + assertEquals(3, merged.unreadCount) + } + + @Test + fun olderMessageKeepsTheTopPreviewButStillCounts() { + val merged = chat.mergeIncomingMessage(message(8)) + assertEquals(10, merged.lastMessageId) + assertEquals(chat.lastMessagePreview, merged.lastMessagePreview) + assertEquals(3, merged.unreadCount) + } + + @Test + fun repeatedDeliveryDoesNotCountTwice() { + val once = chat.mergeIncomingMessage(message(11)) + val twice = once.mergeIncomingMessage(message(11)) + assertEquals(once, twice) + assertEquals( + chat.unreadCount, + chat.mergeIncomingMessage(message(8), alreadyStored = true).unreadCount + ) + } + + @Test + fun anOlderAcceptedOrderDoesNotReplaceTheRow() { + val first = chat.mergeIncomingMessage(message(11), accountGeneration = 4, acceptedOrder = 2) + val stale = first.mergeIncomingMessage( + message(12), + accountGeneration = 4, + acceptedOrder = 1, + ) + assertEquals(first, stale) + val next = stale.mergeIncomingMessage(message(12), accountGeneration = 4, acceptedOrder = 3) + assertEquals(12, next.lastMessageId) + } + + @Test + fun outgoingAndAlreadyReadMessagesDoNotAddUnread() { + assertEquals(2, chat.mergeIncomingMessage(message(11, outgoing = true)).unreadCount) + assertEquals(2, chat.mergeIncomingMessage(message(4)).unreadCount) + } +} diff --git a/core/ui/src/main/java/org/monogram/core/ui/FileLeases.kt b/core/ui/src/main/java/org/monogram/core/ui/FileLeases.kt new file mode 100644 index 00000000..8101d44a --- /dev/null +++ b/core/ui/src/main/java/org/monogram/core/ui/FileLeases.kt @@ -0,0 +1,34 @@ +package org.monogram.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import java.io.File + +/** + * Keeps a cached media file from being trimmed or cleared while a consumer reads it. + * The media layer installs the leaser; without one (tests, previews) nothing is held. + */ +object FileLeases { + @Volatile + var leaser: ((File) -> AutoCloseable?)? = null + + fun acquire(file: File): AutoCloseable? = leaser?.invoke(file) + + inline fun hold(file: File, block: () -> T): T { + val lease = acquire(file) + try { + return block() + } finally { + lease?.close() + } + } +} + +/** Holds a lease on [file] for as long as the caller stays in composition with it. */ +@Composable +fun rememberFileLease(file: File?) { + DisposableEffect(file?.absolutePath) { + val lease = file?.let(FileLeases::acquire) + onDispose { lease?.close() } + } +} 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 4e571737..f806a201 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 @@ -2,12 +2,17 @@ package org.monogram.core.ui import android.content.Context import coil.Coil +import coil.EventListener import coil.ImageLoader import coil.annotation.ExperimentalCoilApi import coil.decode.VideoFrameDecoder import coil.disk.DiskCache import coil.memory.MemoryCache +import coil.request.ErrorResult +import coil.request.ImageRequest +import coil.request.SuccessResult import java.io.File +import java.util.concurrent.ConcurrentHashMap /** Process-wide Coil memory + durable disk cache for avatars, thumbs, and photos. */ object ImageCache { @@ -33,12 +38,34 @@ object ImageCache { } .respectCacheHeaders(false) .components { add(VideoFrameDecoder.Factory()) } + .eventListener(leaseListener) .crossfade(false) .build() loader = created Coil.setImageLoader(created) } + private val leases = ConcurrentHashMap() + + private val leaseListener = object : EventListener { + override fun onStart(request: ImageRequest) { + val file = request.data as? File ?: return + FileLeases.acquire(file)?.let { leases[request] = it } + } + + override fun onSuccess(request: ImageRequest, result: SuccessResult) { + leases.remove(request)?.close() + } + + override fun onError(request: ImageRequest, result: ErrorResult) { + leases.remove(request)?.close() + } + + override fun onCancel(request: ImageRequest) { + leases.remove(request)?.close() + } + } + @OptIn(ExperimentalCoilApi::class) fun clear() { loader?.memoryCache?.clear() diff --git a/core/ui/src/main/java/org/monogram/core/ui/RememberEnsuredFile.kt b/core/ui/src/main/java/org/monogram/core/ui/RememberEnsuredFile.kt index 84c2f819..a8f85d1c 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/RememberEnsuredFile.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/RememberEnsuredFile.kt @@ -61,5 +61,6 @@ fun rememberEnsuredFile( if (attempt < 2) delay(400L * (attempt + 1)) } } + rememberFileLease(file) return file } 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 fb4e622f..374d3661 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 @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView @@ -51,12 +52,16 @@ fun LoopingVideo( LaunchedEffect(player, loop, muted, playWhenReady, animationEnabled) { player.repeatMode = if (loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF player.volume = if (muted) 0f else 1f - if (playWhenReady && animationEnabled) { - player.playWhenReady = true - } else { - // A paused seek still draws that frame onto the current surface. - player.seekTo(0) - player.playWhenReady = false + snapshotFlow { MediaScrollGate.isScrolling() }.collect { scrolling -> + if (playWhenReady && animationEnabled && !scrolling) { + player.playWhenReady = true + } else if (!animationEnabled) { + // A paused seek still draws that frame onto the current surface. + player.seekTo(0) + player.playWhenReady = false + } else { + player.playWhenReady = false + } } } DisposableEffect(player) { diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/LottieLoop.kt b/core/ui/src/main/java/org/monogram/core/ui/components/LottieLoop.kt index 331d93d7..6775e9b0 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/LottieLoop.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/LottieLoop.kt @@ -55,6 +55,10 @@ fun LottieLoop( val outH = max(1, (height * scale).roundToInt()) var frameIndex = 0 while (isActive) { + if (MediaScrollGate.isScrolling()) { + delay(32) + continue + } val bmp = withContext(Dispatchers.Default) { val rgba = runCatching { LottieNative.renderFrame(handle, frameIndex.toFloat(), outW, outH) diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/MediaAnimation.kt b/core/ui/src/main/java/org/monogram/core/ui/components/MediaAnimation.kt index 1e37dafd..dc10cb76 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/components/MediaAnimation.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/components/MediaAnimation.kt @@ -1,6 +1,17 @@ package org.monogram.core.ui.components import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateMapOf /** Whether animated media (emoji statuses, avatars, stickers, typing dots) may keep playing. */ val LocalMediaAnimationEnabled = compositionLocalOf { true } + +object MediaScrollGate { + private val sources = mutableStateMapOf() + + fun set(source: Any, scrolling: Boolean) { + if (scrolling) sources[source] = true else sources.remove(source) + } + + fun isScrolling(): Boolean = sources.isNotEmpty() +} 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 bd4b8bb2..c73623a0 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 @@ -456,7 +456,11 @@ class MediaPlaybackSession(private val context: Context) { /** One media item, wrapped in the item's own data source factory when it streams. */ private fun mediaSourceFor(item: MediaViewerItem): PlaybackMediaSource? { val mediaItem = mediaItemFor(item) ?: return null - val factory = (item.source as? MediaSource.Stream)?.factory + val factory = when (val source = item.source) { + is MediaSource.Stream -> source.factory + is MediaSource.Local -> source.factory + null -> null + } return if (factory != null) { DefaultMediaSourceFactory(context).setDataSourceFactory(factory).createMediaSource(mediaItem) } else { 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 8a3efff8..aa13e95a 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 @@ -33,7 +33,7 @@ data class AlbumSelectionState(val active: Boolean = false, val ids: Set /** Where the bytes come from. [Stream] is used while a video is still downloading. */ sealed interface MediaSource { @Immutable - data class Local(val file: File) : 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 } diff --git a/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerPages.kt b/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerPages.kt index b42752ab..dc974447 100644 --- a/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerPages.kt +++ b/core/ui/src/main/java/org/monogram/core/ui/media/MediaViewerPages.kt @@ -11,6 +11,7 @@ import android.view.SurfaceView import android.view.View import android.view.ViewOutlineProvider import android.widget.ImageView +import org.monogram.core.ui.FileLeases import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState @@ -105,12 +106,12 @@ private fun ViewerLoadingIndicator(progress: Float?, modifier: Modifier = Modifi } } -internal fun decodeSpoilerPreview(file: java.io.File): Bitmap? { +internal fun decodeSpoilerPreview(file: java.io.File): Bitmap? = FileLeases.hold(file) { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } try { BitmapFactory.decodeFile(file.absolutePath, bounds) } catch (_: IllegalArgumentException) { - return null + return@hold null } if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null var sample = 1 @@ -121,7 +122,7 @@ internal fun decodeSpoilerPreview(file: java.io.File): Bitmap? { BitmapFactory.Options().apply { inSampleSize = sample }) } catch (_: IllegalArgumentException) { null - } ?: return null + } ?: return@hold null val tiny = decoded.scale(12, 12) if (tiny !== decoded) decoded.recycle() val pixels = IntArray(144) @@ -215,16 +216,18 @@ internal fun MediaPhotoPage( loading = true failed = false val decoded = withContext(Dispatchers.IO) { - try { - if (Build.VERSION.SDK_INT >= 28) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(fullFile)) - } else { - BitmapFactory.decodeFile(fullFile.absolutePath) + FileLeases.hold(fullFile) { + try { + if (Build.VERSION.SDK_INT >= 28) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(fullFile)) + } else { + BitmapFactory.decodeFile(fullFile.absolutePath) + } + } catch (_: java.io.IOException) { + null + } catch (_: IllegalArgumentException) { + null } - } catch (_: java.io.IOException) { - null - } catch (_: IllegalArgumentException) { - null } } bitmap = decoded 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 7975912d..ebd9a903 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 @@ -19,6 +19,7 @@ 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 private const val WAVEFORM_BINS = 64 @@ -46,7 +47,9 @@ suspend fun voiceWaveform(file: File): List = withContext(Dispatchers.IO) waveformCache[identity]?.let { return@withContext it } waveformInFlight[identity] ?: waveformScope.async(start = kotlinx.coroutines.CoroutineStart.LAZY) { try { - val amplitudes = decodeMutex.withLock { decodeVoiceWaveform(file) } + 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 } 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 f3ce201c..633e6cd0 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 @@ -211,7 +211,7 @@ internal class ChatsExecutor( } is MtprotoUpdate.NewMessage -> { val named = withSenderName(update.message) - absorbIncoming(named) + absorbIncoming(named, update.accountGeneration, update.acceptedOrder) } is MtprotoUpdate.MessageEdited -> { listPublicationMutex.withLock { @@ -1194,7 +1194,11 @@ internal class ChatsExecutor( } } - private fun absorbIncoming(message: Message) { + private fun absorbIncoming( + message: Message, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, + ) { scope.launch { var persistHidden: Chat? = null val published = listPublicationMutex.withLock { @@ -1202,7 +1206,8 @@ internal class ChatsExecutor( when { current == null -> false !current.isShownInChatList() -> { - persistHidden = current.withIncomingMessage(message) + persistHidden = + current.withIncomingMessage(message, accountGeneration, acceptedOrder) true } else -> { @@ -1213,7 +1218,7 @@ internal class ChatsExecutor( if (none { it.id == current.id }) add(current) } republishListedLocked( - applyIncomingMessage(listed, message), + applyIncomingMessage(listed, message, accountGeneration, acceptedOrder), fromCache = false, hasMore = null, keep = paintKeep(), @@ -1228,12 +1233,25 @@ internal class ChatsExecutor( warmup?.chat(message.id.chatId) } ?: return@launch if (!stored.isMainListRow()) { - warmup?.upsertChats(listOf(stored.withIncomingMessage(message))) + warmup?.upsertChats( + listOf( + stored.withIncomingMessage( + message, + accountGeneration, + acceptedOrder + ) + ) + ) return@launch } listPublicationMutex.withLock { republishListedLocked( - applyIncomingMessage(allListed() + stored, message), + applyIncomingMessage( + allListed() + stored, + message, + accountGeneration, + acceptedOrder + ), fromCache = false, hasMore = null, keep = paintKeep(), 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 b23c0604..3c5576e5 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 @@ -3,6 +3,7 @@ package org.monogram.feature.chats import org.monogram.core.database.dao.ChatReadState import org.monogram.core.models.Chat import org.monogram.core.models.Message +import org.monogram.core.models.mergeIncomingMessage import org.monogram.core.models.PeerId import org.monogram.core.models.chatListPreviewSource import org.monogram.core.models.isMigratedServicePlaceholder @@ -182,28 +183,25 @@ internal fun applyUnreadReactionsDelta(chats: List, chatId: PeerId, delta: } } -internal fun Chat.withIncomingMessage(message: Message): Chat { - val unread = - if (message.outgoing || message.id.id <= readInboxMaxId || - message.id.id == lastMessageId - ) { - unreadCount - } else { - unreadCount + 1 - } - return copy( - lastMessagePreview = message.chatListPreviewSource(), - lastMessageOutgoing = message.outgoing, - lastMessageSenderName = message.senderName, - lastMessageMediaKind = message.mediaKind, - lastMessageDate = message.date, - unreadCount = unread, - lastMessageId = message.id.id, - lastMediaThumbCacheKey = message.thumbCacheKey ?: lastMediaThumbCacheKey, - ) -} - -internal fun applyIncomingMessage(chats: List, message: Message): List { +internal fun Chat.withIncomingMessage( + message: Message, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, +): Chat = mergeIncomingMessage( + message, + accountGeneration = accountGeneration, + acceptedOrder = acceptedOrder, +) + +internal fun applyIncomingMessage( + chats: List, + message: Message, + accountGeneration: Long = 0, + acceptedOrder: Long = 0, +): List { val existing = chats.firstOrNull { it.id == message.id.chatId } ?: return chats - return mergeChats(chats, listOf(existing.withIncomingMessage(message))) + return mergeChats( + chats, + listOf(existing.withIncomingMessage(message, accountGeneration, acceptedOrder)), + ) } 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 79d90d14..93420bf6 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 @@ -60,6 +60,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.selected import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.AsyncImage @@ -479,7 +480,9 @@ internal fun ChatRow( checked = selected, onCheckedChange = null, enabled = recipientSelectable, - modifier = Modifier.size(48.dp).clearAndSetSemantics { }, + modifier = Modifier + .size(48.dp) + .clearAndSetSemantics { }, colors = CheckboxDefaults.colors( checkedColor = MaterialTheme.colorScheme.primary, uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant, @@ -598,6 +601,7 @@ private fun ChatRowContainer( }, ) .then(clickModifier) + .semantics { if (!selectingRecipient) this.selected = selected } .padding( horizontal = ChatRowMetrics.ContentPaddingH, vertical = ChatRowMetrics.ContentPaddingV, diff --git a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsLazyList.kt b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsLazyList.kt index d7c277c1..13b0912d 100644 --- a/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsLazyList.kt +++ b/feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatsLazyList.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -26,6 +27,7 @@ import org.monogram.core.models.SearchPeer import org.monogram.core.ui.components.FolderChipItem import org.monogram.core.ui.components.FolderChipRow import org.monogram.core.ui.components.FolderChips +import org.monogram.core.ui.components.MediaScrollGate import org.monogram.core.ui.media.MediaPlaybackHolder import org.monogram.core.ui.media.showsMiniPlayer import org.monogram.core.ui.menu.AppMenuPopup @@ -162,6 +164,18 @@ internal fun ChatsLazyList( } } } + val scrollGate = remember { Any() } + DisposableEffect(scrollGate) { + onDispose { MediaScrollGate.set(scrollGate, false) } + } + LaunchedEffect(listState, scrollGate) { + snapshotFlow { listState.isScrollInProgress }.collect { + MediaScrollGate.set( + scrollGate, + it + ) + } + } LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), 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 73de2e47..990b05f4 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 @@ -84,9 +84,11 @@ class DialogComponent( ) init { + warmup?.setOpenChat(chatId.value) lifecycle.doOnStart { client.setDialogForeground(true) } lifecycle.doOnStop { client.setDialogForeground(false) } lifecycle.doOnDestroy { + warmup?.clearOpenChat(chatId.value) client.setDialogForeground(false) mediaPreloader.close() pickerPreloader.close() diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreload.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreload.kt index d7bef400..6d9fa496 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreload.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreload.kt @@ -79,6 +79,7 @@ object DialogMediaPreload { preset: AutoDownloadPreset = DownloadSettings.activePreset(), autoplayVideos: Boolean = true, autoplayGifs: Boolean = true, + scrollDirection: Int = 0, ): Plan { if (messages.isEmpty()) { return Plan(IntRange.EMPTY, IntRange.EMPTY, emptyList(), emptyList()) @@ -92,8 +93,17 @@ object DialogMediaPreload { return Plan(IntRange.EMPTY, IntRange.EMPTY, emptyList(), emptyList()) } val visibleRange = visibleIndices.min()..visibleIndices.max() - val window = (visibleRange.first - radius).coerceAtLeast(0).. - (visibleRange.last + radius).coerceAtMost(messages.lastIndex) + val boundedRadius = radius.coerceAtLeast(0) + val ahead = boundedRadius + boundedRadius / 2 + val behind = boundedRadius * 2 - ahead + val before = when { + scrollDirection < 0 -> ahead; scrollDirection > 0 -> behind; else -> boundedRadius + } + val after = when { + scrollDirection > 0 -> ahead; scrollDirection < 0 -> behind; else -> boundedRadius + } + val window = (visibleRange.first - before).coerceAtLeast(0).. + (visibleRange.last + after).coerceAtMost(messages.lastIndex) val media = ArrayList() val instantViews = ArrayList() val seenIv = HashSet() diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreloader.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreloader.kt index 1f3f7396..265abcb7 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreloader.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/DialogMediaPreloader.kt @@ -22,9 +22,11 @@ class DialogMediaPreloader( private var mediaJob: Job? = null private var ivJob: Job? = null private var lastSignature: String? = null + private var previousVisibleId: Int? = null fun onVisible(messages: List, visibleIds: Set) { if (visibleIds.isEmpty()) { + previousVisibleId = null lastSignature = null mediaJob?.cancel() ivJob?.cancel() @@ -35,11 +37,20 @@ class DialogMediaPreloader( return } val settings = DownloadSettings.state.value + val visibleIndex = messages.indexOfFirst { it.id.id in visibleIds }.takeIf { it >= 0 } + val previousIndex = previousVisibleId?.let { id -> + messages.indexOfFirst { it.id.id == id }.takeIf { it >= 0 } + } + val direction = if (visibleIndex != null && previousIndex != null) { + visibleIndex.compareTo(previousIndex) + } else 0 + previousVisibleId = visibleIndex?.let { messages[it].id.id } val plan = DialogMediaPreload.plan( messages, visibleIds, autoplayVideos = settings.autoplayVideos, autoplayGifs = settings.autoplayGifs, + scrollDirection = direction, ) mediaRepository?.protectCacheKeys("dialog:${chatId.value}", plan.mediaKeys) val signature = plan.signature() @@ -72,6 +83,7 @@ class DialogMediaPreloader( } fun close() { + previousVisibleId = null lastSignature = null mediaJob?.cancel() ivJob?.cancel() 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 0f2d51e9..efc05243 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 @@ -296,7 +296,10 @@ internal class DialogStoreFactory( private val markupContext: CoroutineContext = Dispatchers.Default, ) { fun create(): DialogStore { - warmPickerMemory(sessionStore) + // 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) return object : DialogStore, Store by storeFactory.create( @@ -338,3 +341,10 @@ internal class DialogStoreFactory( ) {} } } + +private fun onAndroidMainThread(): Boolean = try { + val main = android.os.Looper.getMainLooper() ?: return false + android.os.Looper.myLooper() === main +} catch (_: RuntimeException) { + false +} 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 749ce270..af7e4af5 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 @@ -44,6 +44,7 @@ internal object StickerCatalogMemory { internal object StickerPackMemory { private val packs = LinkedHashMap() + private val nativeReady = HashSet() @Synchronized fun get(id: Long): StickerPack? = packs[id] @@ -53,12 +54,22 @@ internal object StickerPackMemory { packs[pack.id] = pack } + /** True after this process has indexed the pack documents in the native client. */ + @Synchronized + fun isNativeReady(id: Long): Boolean = id in nativeReady + + @Synchronized + fun markNativeReady(id: Long) { + nativeReady.add(id) + } + @Synchronized fun snapshot(): Map = packs.toMap() @Synchronized fun clear() { packs.clear() + nativeReady.clear() } } 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 14988f96..d5921328 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 @@ -20,14 +20,13 @@ class PickerMediaPreloader( val signature = plan.signature() if (signature == lastSignature) return lastSignature = signature - job?.cancel() - val dropped = lastKeys - plan.keys + val added = plan.tasks.filter { it.cacheKey !in lastKeys } lastKeys = plan.keys val repository = mediaRepository + if (added.isEmpty()) return job = scope.launch { - dropped.forEach { key -> repository?.cancel(key) } - val visible = plan.tasks.filter { it.priority >= MediaPriority.VISIBLE } - val nearby = plan.tasks.filter { it.priority < MediaPriority.VISIBLE } + val visible = added.filter { it.priority >= MediaPriority.VISIBLE } + val nearby = added.filter { it.priority < MediaPriority.VISIBLE } coroutineScope { visible.forEach { task -> launch { fetch(repository, task) } } } diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt new file mode 100644 index 00000000..34114de7 --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt @@ -0,0 +1,94 @@ +package org.monogram.feature.dialog + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.monogram.core.common.WorkOwnership +import org.monogram.network.http.MediaPriority + +const val PHONE_WARMUP_LIMIT = 20 +const val TABLET_WARMUP_LIMIT = 30 + +/** + * One explicit navigation target. Interest is released when the target changes, + * and opening that same target promotes the work instead of starting another copy. + * Optional work waits for the first usable frame and yields under pressure. + */ +class SelectedTargetWarmup( + private val scope: CoroutineScope, + private val tablet: () -> Boolean = { false }, + private val underPressure: () -> Boolean = { false }, + private val load: suspend (peerId: Long, anchorId: Int, limit: Int, priority: Int) -> Unit, +) { + private var generation = 0 + private var job: Job? = null + private var peerId: Long? = null + private var anchorId: Int = 0 + private var frameReady = false + private var priority = MediaPriority.IDLE + + fun onFirstFrame() { + frameReady = true + launch() + } + + fun select(peer: Long, anchor: Int = 0) { + if (peerId == peer && anchorId == anchor && job?.isActive == true) return + generation++ + peerId = peer + anchorId = anchor + priority = MediaPriority.IDLE + job?.cancel() + job = null + launch() + } + + /** The opened chat raises an in-flight idle fetch instead of leaving it background. */ + fun promote(peer: Long) { + if (peerId != peer) return + val raised = priority < MediaPriority.VISIBLE + priority = MediaPriority.VISIBLE + WorkOwnership.promote(ownerKey(peer), MediaPriority.VISIBLE) + if (job?.isActive == true && raised) { + generation++ + launch() + } else if (job?.isActive != true) { + generation++ + launch() + } + } + + fun clear() { + generation++ + peerId = null + job?.cancel() + job = null + } + + fun limit(): Int = if (tablet()) TABLET_WARMUP_LIMIT else PHONE_WARMUP_LIMIT + + private fun launch() { + val peer = peerId ?: return + if (!frameReady || underPressure()) return + val gen = generation + val anchor = anchorId + val requested = priority + val key = ownerKey(peer) + WorkOwnership.acquire(key, requested) + job?.cancel() + job = scope.launch { + try { + load(peer, anchor, limit(), WorkOwnership.state(key)?.priority ?: requested) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + } finally { + WorkOwnership.release(key) + if (gen == generation) WorkOwnership.finish(key) + } + } + } + + private fun ownerKey(peer: Long) = "history-target:$peer" +} 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 210aeb9f..6507a3d5 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 @@ -714,8 +714,30 @@ internal class DialogExecutor( } hydratePinned() hydrateSenderTags() - // Paint cache first, then always fetch. Skipping getHistory when a - // stale Room lastMessageId matched the cached tail hid new messages. + val slice = warmup?.historySlice(chatId, limit = HISTORY_FIRST_LIMIT) + if (slice != null && slice.rows.isNotEmpty() && cached.isEmpty()) { + publishPaintedHistory( + slice.rows, + fromCache = true, + liveEdge = true, + generation = gen + ) + } + val sliceNewestId = slice?.rows?.maxOfOrNull { it.id.id } ?: newestCachedId + val coveredLatest = + slice != null && + slice.covered && + slice.confirmedEnd && + slice.rows.isNotEmpty() && + (lastId <= 0 || sliceNewestId >= lastId) + if (coveredLatest) { + serverHistoryBoundaryId = slice.rows.minOf { it.id.id } + dispatch(Msg.HasOlder(!slice.confirmedStart)) + dispatch(Msg.Loading(false)) + loadPinned() + loadChatProfile() + return@launch + } AppLog.api( "dialog", "getHistory start chat=${chatId.value} lastId=$lastId newest=$newestCachedId", 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 57b652d3..7420ab0c 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.launch 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.common.telegram.TelegramError import org.monogram.core.models.ForumIo import org.monogram.core.models.Message @@ -135,7 +136,24 @@ internal fun DialogExecutor.jumpToMessageId(messageId: Int, atTop: Boolean = fal emit(Msg.LoadingNewer(false)) emit(Msg.Loading(true)) historyJob?.cancel() - historyJob = work.launch { + historyJob = work.launch(PerfLog.OpTrace()) { + if (ForumIo.historyThreadId(threadTopMsgId) <= 0) { + val around = warmup?.historySlice( + chatId, + beforeId = messageId + 1, + limit = HISTORY_PAGE_LIMIT, + ) + if (gen == historyGen && around != null && around.rows.isNotEmpty()) { + serverHistoryBoundaryId = around.rows.minOf { it.id.id } + emit(Msg.Messages(around.rows, fromCache = true, replace = true)) + emit(Msg.Anchor(messageId, atTop = atTop)) + emit(Msg.HasOlder(!around.confirmedStart)) + if (around.covered) { + emit(Msg.Loading(false)) + return@launch + } + } + } when ( val result = topicOrHistoryPage( offsetId = messageId, @@ -298,7 +316,7 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { val requestId = ++olderRequestId activeOlderRequestId = requestId olderJob?.cancel() - olderJob = work.launch { + olderJob = work.launch(PerfLog.OpTrace()) { fun ownsRequest() = gen == historyGen && activeOlderRequestId == requestId if (!ownsRequest()) return@launch val historyTop = ForumIo.historyThreadId(threadTopMsgId) @@ -307,6 +325,25 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { return@launch } if (!ownsRequest()) return@launch + if (historyTop <= 0) { + val slice = + warmup?.historySlice(chatId, beforeId = boundaryId, limit = HISTORY_PAGE_LIMIT) + 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) + ) { + emit(Msg.HasOlder(!slice.confirmedStart)) + emit(Msg.LoadingOlder(false)) + activeOlderRequestId = null + continueUnreadPaging() + return@launch + } + } when ( val result = if (historyTop > 0) { client.getReplies( @@ -343,11 +380,17 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { activeOlderRequestId = null if (warmup?.usesIoDispatcher == true || sessionStore != null) { withContext(Dispatchers.IO) { - warmup?.upsertMessages(result.value) + if (historyTop <= 0) warmup?.cacheHistoryPage( + chatId, result.value, olderBoundary = boundaryId, + reachesOldest = !historyHasMore(result.value.size), + ) else warmup?.upsertMessages(result.value) sessionStore?.upsertPeerMins(result.value) } } else { - warmup?.upsertMessages(result.value) + if (historyTop <= 0) warmup?.cacheHistoryPage( + chatId, result.value, olderBoundary = boundaryId, + reachesOldest = !historyHasMore(result.value.size), + ) else warmup?.upsertMessages(result.value) sessionStore?.upsertPeerMins(result.value) } continueUnreadPaging() @@ -444,10 +487,26 @@ internal fun DialogExecutor.loadNewer() { activeNewerRequestId = requestId emit(Msg.LoadingNewer(true)) newerJob?.cancel() - newerJob = work.launch { + newerJob = work.launch(PerfLog.OpTrace()) { fun ownsRequest() = gen == historyGen && activeNewerRequestId == requestId try { if (!ownsRequest()) return@launch + val newerSlice = if (ForumIo.historyThreadId(threadTopMsgId) <= 0) { + warmup?.historySlice(chatId, afterId = newest.id.id, limit = HISTORY_PAGE_LIMIT) + } else { + null + } + if (newerSlice != null && newerSlice.rows.isNotEmpty()) { + emit(Msg.Prepend(newerSlice.rows)) + } + 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, @@ -498,7 +557,7 @@ internal fun DialogExecutor.jump(epochSeconds: Int) { emit(Msg.LoadingNewer(false)) emit(Msg.Loading(true)) historyJob?.cancel() - historyJob = work.launch { + historyJob = work.launch(PerfLog.OpTrace()) { val historyTop = ForumIo.historyThreadId(threadTopMsgId) if (historyTop <= 0) { val cached = warmup?.let { cache -> @@ -513,9 +572,18 @@ internal fun DialogExecutor.jump(epochSeconds: Int) { val paint = dateJumpCacheWindow(cached, epochSeconds) if (paint.isNotEmpty() && gen == historyGen) { emit(Msg.Messages(paint, fromCache = true, replace = true)) + val slice = warmup?.historySlice( + chatId, + beforeId = paint.maxOf { it.id.id } + 1, + limit = HISTORY_PAGE_LIMIT, + ) + if (slice?.covered == true) { + serverHistoryBoundaryId = paint.minOf { it.id.id } + emit(Msg.HasOlder(!slice.confirmedStart)) + emit(Msg.Loading(false)) + return@launch + } emit(Msg.HasOlder(true)) - // Keep loading until the server jump page sets serverHistoryBoundaryId. - // Clearing it here lets LoadOlder use a cache min as offset_id. } } when ( diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt index a11cd41f..66225065 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogSend.kt @@ -252,15 +252,15 @@ internal suspend fun DialogExecutor.replaceOutgoing(pendingId: Int, sent: Messag sent.copy(entities = pending?.entities.orEmpty()) } else sent emit(Msg.ReplacePending(pendingId, resolved)) - warmup?.deleteMessage(chatId, pendingId) - warmup?.upsertMessages(listOf(resolved)) + warmup?.reconcileOutgoing(pendingId, resolved, pending?.randomId) } -internal fun DialogExecutor.failOrKeepOutgoing(pendingId: Int, error: TelegramError) { +internal suspend fun DialogExecutor.failOrKeepOutgoing(pendingId: Int, error: TelegramError) { if (isTransientSendFailure(error)) { AppLog.warn("dialog", error.logLine()) return } + warmup?.markMessageFailed(chatId, pendingId) emit(Msg.MarkFailed(pendingId)) handleError(error, false) } @@ -314,13 +314,12 @@ internal fun DialogExecutor.bindPendingId(randomId: Long, messageId: Int) { PerfLog.event("send", "phase=update_message_id") emit(Msg.BindPending(mappedRandomId, messageId)) work.launch { - warmup?.deleteMessage(chatId, pending.id.id) val bound = pending.copy( id = MessageId(pending.id.chatId, messageId), pending = false, failed = false, ) - warmup?.upsertMessages(listOf(bound)) + warmup?.reconcileOutgoing(pending.id.id, bound, pending.randomId) } } 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 c9d9571c..1e4124fb 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 @@ -215,32 +215,49 @@ internal fun DialogExecutor.refreshStickerCatalog(emoji: Boolean, hash: Long) { internal fun DialogExecutor.loadStickerPack(setId: Long, accessHash: Long) { val cached = StickerPackMemory.get(setId) ?: snapshot().loadedStickerPacks[setId] - if (cached != null && cached.previewDocumentIds.isNotEmpty()) { + 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)) { emit(Msg.StickerPackLoaded(cached)) return } if (!loadingStickerPacks.add(setId)) return - emit(Msg.StickerPackLoading(setId)) + if (!hasPreviews) emit(Msg.StickerPackLoading(setId)) work.launch { try { - if (cached == null) { + var known = cached + if (known == null) { sessionStore?.let { PickerDisk.readPack(it, setId) }?.let { disk -> StickerPackMemory.put(disk) emit(Msg.StickerPackLoaded(disk)) - if (disk.previewDocumentIds.isNotEmpty()) return@launch + known = disk } + } else { + val already = known + if (already != null) emit(Msg.StickerPackLoaded(already)) + } + val shown = known + if (shown != null && + shown.previewDocumentIds.isNotEmpty() && + StickerPackMemory.isNativeReady(setId) + ) { + return@launch } stickerPackRequests.acquire() try { when (val result = client.getStickerSet(setId, accessHash)) { is Outcome.Ok -> { StickerPackMemory.put(result.value) + StickerPackMemory.markNativeReady(setId) sessionStore?.let { PickerDisk.writePack(it, result.value) } emit(Msg.StickerPackLoaded(result.value)) } is Outcome.Err -> { handleError(result.telegramError, false) - emit(Msg.StickerPackFailed(setId)) + if (shown == null || shown.previewDocumentIds.isEmpty()) { + emit(Msg.StickerPackFailed(setId)) + } } } } finally { 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 4afbb3ec..51815802 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 @@ -84,7 +84,11 @@ internal class AudioMessagePlayback( val descriptor = MediaViewerItem( id = id, kind = if (message.mediaKind == "voice") MediaViewerKind.VOICE else MediaViewerKind.AUDIO, - source = file?.let(MediaSource::Local), + source = file?.let { + MediaSource.Local( + it, + message.mediaCacheKey?.let { key -> repo?.createCachedDataSourceFactory(key) }) + }, fileName = title, senderName = message.senderName, durationSeconds = message.mediaDuration, @@ -121,7 +125,16 @@ internal class AudioMessagePlayback( repository = null cacheKey = null clearRetry() - session.setQueue(listOf(descriptor.copy(source = MediaSource.Local(result.value), loading = false)), 0, + 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() } 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 31df8539..45ce4253 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.flow.distinctUntilChanged import org.monogram.core.models.Message import org.monogram.core.ui.components.LocalMediaAnimationEnabled +import org.monogram.core.ui.components.MediaScrollGate import org.monogram.core.ui.media.MediaPlaybackHolder import org.monogram.core.ui.media.MediaSource import org.monogram.core.ui.media.MediaSurface @@ -71,7 +72,7 @@ internal fun ChatInlineVideo( val surface = session.surface val currentId = session.current?.id val busyElsewhere = surface == MediaSurface.VIEWER || surface == MediaSurface.PIP - onScreen && autoplay && animationEnabled && + onScreen && autoplay && animationEnabled && !MediaScrollGate.isScrolling() && !session.isMessagePlayback && (!busyElsewhere || currentId == mediaId) && surface != MediaSurface.VIEWER && @@ -86,6 +87,7 @@ internal fun ChatInlineVideo( session.mute(true) session.setQueue(listOf(item), 0, autoplay = true, startMuted = true) session.attachSurface(MediaSurface.CHAT) + session.play() } } LaunchedEffect(mediaId) { diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/CustomEmoji.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/CustomEmoji.kt index 5704e28d..5ed9939c 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/CustomEmoji.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/CustomEmoji.kt @@ -157,7 +157,8 @@ internal fun CustomEmojiGlyph( val (mediaVisible, visibilityModifier) = rememberViewportVisible(documentId) val mediaAnimationEnabled = playback == null && animationEnabled && mediaVisible val placedVisibility = if (playback == null) visibilityModifier else Modifier - val cacheGeneration = repo?.cacheGeneration?.collectAsState()?.value ?: 0L + val cacheGeneration = repo?.cacheGeneration("emoji:$documentId") + ?.collectAsState(initial = 0L)?.value ?: 0L val file = rememberEnsuredFile( generation = cacheGeneration, identity = documentId, diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt index 0f07b4d0..931bb207 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/DialogHistoryPane.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableState @@ -85,6 +86,7 @@ import org.monogram.core.models.Message import org.monogram.core.models.PeerId import org.monogram.core.ui.AppearanceSettings import org.monogram.core.ui.components.ChatWallpaper +import org.monogram.core.ui.components.MediaScrollGate import org.monogram.core.ui.components.MessageListSkeleton import org.monogram.core.ui.components.UnreadBadge import org.monogram.core.ui.loading.MonogramLoading @@ -395,6 +397,15 @@ internal fun ColumnScope.DialogHistoryPane( component.onVisibleWindow(ids) } } + val scrollGate = remember { Any() } + DisposableEffect(scrollGate) { + onDispose { MediaScrollGate.set(scrollGate, false) } + } + LaunchedEffect(listState, scrollGate) { + snapshotFlow { listState.isScrollInProgress }.collect { + MediaScrollGate.set(scrollGate, it) + } + } LazyColumn( modifier = Modifier .fillMaxSize() diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaShareActions.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaShareActions.kt index 8e00534b..531c02f0 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaShareActions.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/MediaShareActions.kt @@ -74,6 +74,7 @@ internal object MediaShareActions { suspend fun saveToGallery(context: Context, file: File, mime: String, displayName: String): Uri? = withContext(Dispatchers.IO) { + keepExplicitCopy(context, file, displayName) runCatching { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { saveViaMediaStore(context, file, mime, displayName) @@ -85,6 +86,7 @@ internal object MediaShareActions { suspend fun saveToDocument(context: Context, file: File, destination: Uri): Boolean = withContext(Dispatchers.IO) { + keepExplicitCopy(context, file, file.name) try { context.contentResolver.openOutputStream(destination, "wt")?.use { output -> file.inputStream().use { input -> input.copyTo(output) } @@ -105,6 +107,18 @@ internal object MediaShareActions { putExtra(Intent.EXTRA_TITLE, shareFileName(displayName)) } + /** A copy outside the evictable media cache. Cache clear does not remove it. */ + private fun keepExplicitCopy(context: Context, file: File, displayName: String) { + if (!file.isFile) return + val dir = File(context.filesDir, "downloads").apply { mkdirs() } + val safe = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "file" } + runCatching { + file.inputStream().use { input -> + File(dir, safe).outputStream().use { output -> input.copyTo(output) } + } + } + } + private fun saveViaMediaStore(context: Context, file: File, mime: String, displayName: String): Uri? { val isVideo = mime.startsWith("video/") val collection = if (isVideo) { 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 458d7811..c19c3cc4 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 @@ -222,7 +222,11 @@ fun MessageMedia( MediaFetchKind.Display -> repo.ensureLocalMessageDisplay(message, priority = displayPriority) MediaFetchKind.Full -> - repo.ensureLocalMessageMedia(message, priority = mediaFullPriority(requestFull)) + repo.ensureLocalMessageMedia( + message, + priority = mediaFullPriority(requestFull), + retain = wantFull || userOwned, + ) } when (result) { is Outcome.Ok -> return@withContext result.value 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 77d76990..49f35efb 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 @@ -359,7 +359,9 @@ private fun MessageMediaViewer( id = key, kind = kind, source = when { - localFile != null -> MediaSource.Local(localFile) + 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), diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/StickerPlayer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/StickerPlayer.kt index 3db3fc04..48ec8b89 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/StickerPlayer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/StickerPlayer.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.monogram.core.ui.components.LocalMediaAnimationEnabled +import org.monogram.core.ui.components.MediaScrollGate import org.monogram.core.ui.components.argbFrameBitmap import org.monogram.core.ui.components.writeArgbFrame import org.monogram.feature.dialog.PanelFrameClip @@ -160,6 +161,10 @@ fun StickerPlayer( .toLong().coerceAtLeast(24L) var frameIndex = framesPerUpdate % frames while (isActive) { + if (MediaScrollGate.isScrolling()) { + delay(frameDelay) + continue + } if (!drawFrame(frameIndex)) return@LaunchedEffect frameIndex = (frameIndex + framesPerUpdate) % frames delay(frameDelay) diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoNoteBubble.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoNoteBubble.kt index 474bdc5f..3549d33a 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoNoteBubble.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoNoteBubble.kt @@ -71,7 +71,10 @@ internal fun VideoNoteBubble( MediaViewerItem( id = mediaId, kind = MediaViewerKind.VIDEO_NOTE, - source = message.mediaCacheKey?.let(repository::cachedFile)?.let(MediaSource::Local) + source = message.mediaCacheKey?.let { key -> + repository.cachedFile(key) + ?.let { MediaSource.Local(it, repository.createCachedDataSourceFactory(key)) } + } ?: MediaSource.Stream( ("telegram://media/" + message.id.chatId.value + "/" + message.id.id).toUri(), repository.createMessageDataSourceFactory(message) @@ -136,7 +139,9 @@ internal fun VideoNoteBubble( ) }, onRelease = { texture -> session.player.clearVideoTextureView(texture) }, - modifier = Modifier.fillMaxSize().clip(CircleShape), + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), ) val progress = if (selected && session.durationMs > 0) (session.positionMs.toFloat() / session.durationMs).coerceIn( diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoPlayer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoPlayer.kt index 92de86d8..cfc57e99 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoPlayer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoPlayer.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -61,6 +62,7 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import org.monogram.core.ui.components.LocalMediaAnimationEnabled import org.monogram.core.ui.components.MediaPreviewViewer +import org.monogram.core.ui.components.MediaScrollGate import org.monogram.core.ui.loading.MonogramCircularProgress import org.monogram.core.ui.media.MediaSeekBar import org.monogram.feature.dialog.R @@ -108,6 +110,7 @@ fun VideoPlayer( onClick: (() -> Unit)? = null, caption: String? = null, ) { + org.monogram.core.ui.rememberFileLease(file) val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val animationEnabled = LocalMediaAnimationEnabled.current @@ -308,9 +311,14 @@ fun VideoPlayer( } } - LaunchedEffect(fullscreen, active, animationEnabled) { - if (fullscreen || !active || !animationEnabled) player.pause() - else if (playing) player.play() + LaunchedEffect(fullscreen, active, animationEnabled, player) { + snapshotFlow { MediaScrollGate.isScrolling() }.collect { scrolling -> + when { + fullscreen || !active || !animationEnabled -> player.pause() + scrolling -> player.pause() + isGif || playing -> player.play() + } + } } if (fullscreen) { MediaPreviewViewer( diff --git a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VpxStickerPlayer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VpxStickerPlayer.kt index 3283ec89..cd5efba2 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VpxStickerPlayer.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VpxStickerPlayer.kt @@ -111,6 +111,7 @@ internal fun VpxStickerPlayer( ) { val animationEnabled = LocalMediaAnimationEnabled.current val playable = active && animationEnabled + org.monogram.core.ui.rememberFileLease(file) val frame = remember(file.absolutePath) { mutableStateOf(null) } val hasFrame = remember(file.absolutePath) { mutableStateOf(false) } val retirement = remember(file.absolutePath) { VpxFrameRetirement() } 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 fa006ffa..01677bb8 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 @@ -1266,6 +1266,36 @@ class DialogForumStoreTest { } } + @Test + fun diskStickerPackStillFetchesNativeLocationsOnce() = runTest { + StickerCatalogMemory.clear() + StickerPackMemory.clear() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val client = FakeClient() + val meta = FakeMeta() + val catalog = PickerDisk.encodeCatalog( + StickerCatalogSnapshot(0L, listOf(client.loadedPack)), + ) + meta.values["picker.pack.9"] = catalog.substringAfter("\"sets\":[").substringBeforeLast("]") + val store = DialogStoreFactory( + DefaultStoreFactory(), client, warmup = null, sessionStore = meta, + chatId = PeerId(5), seedIsForum = false, + mainContext = dispatcher, markupContext = dispatcher, + ).create() + try { + store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) + advanceUntilIdle() + assertEquals(listOf(42L), store.state.loadedStickerPacks[9L]?.previewDocumentIds) + assertEquals(1, client.stickerSetCalls) + store.accept(DialogStore.Intent.OpenStickerPack(9L, 11L)) + advanceUntilIdle() + assertEquals(1, client.stickerSetCalls) + } finally { + store.dispose() + StickerPackMemory.clear() + } + } + @Test fun openedStickerPackTracksLoadingAndLoadsEmojiOrStickers() = runTest { StickerCatalogMemory.clear() diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogMediaPreloadTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogMediaPreloadTest.kt index 0b0feccc..8e7f4f17 100644 --- a/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogMediaPreloadTest.kt +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/DialogMediaPreloadTest.kt @@ -11,6 +11,31 @@ import org.monogram.core.ui.AutoDownloadPreset import org.monogram.network.http.MediaPriority class DialogMediaPreloadTest { + @Test + fun directionBiasRetainsReverseCoverageWithoutGrowingTheWindow() { + val messages = (100 downTo 1).map { photo(it) } + val fixed = DialogMediaPreload.plan(messages, visibleIds = setOf(50), radius = 20) + val older = DialogMediaPreload.plan( + messages, + visibleIds = setOf(50), + radius = 20, + scrollDirection = 1 + ) + val newer = DialogMediaPreload.plan( + messages, + visibleIds = setOf(50), + radius = 20, + scrollDirection = -1 + ) + assertEquals(30..70, fixed.window) + assertEquals(40..80, older.window) + assertEquals(20..60, newer.window) + assertEquals(fixed.window.count(), older.window.count()) + assertEquals(fixed.window.count(), newer.window.count()) + assertTrue(older.window.first < older.visibleRange.first) + assertTrue(newer.window.last > newer.visibleRange.last) + } + @Test fun emptyVisibleFallsBackToNewestPlusRadius() { val messages = (50 downTo 1).map { photo(it) } diff --git a/feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt new file mode 100644 index 00000000..046e36e8 --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt @@ -0,0 +1,100 @@ +package org.monogram.feature.dialog + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.network.http.MediaPriority + +class SelectedTargetWarmupTest { + @Test + fun warmupWaitsForTheFirstFrameThenUsesTheDeviceCap() = runTest { + val seen = mutableListOf>() + val warmup = SelectedTargetWarmup(this, tablet = { false }) { peer, anchor, limit, _ -> + seen += Triple(peer, anchor, limit) + } + warmup.select(5L, 9) + assertTrue(seen.isEmpty()) + warmup.onFirstFrame() + testScheduler.runCurrent() + assertEquals(listOf(Triple(5L, 9, PHONE_WARMUP_LIMIT)), seen) + + val tablet = SelectedTargetWarmup(this, tablet = { true }) { peer, _, limit, _ -> + seen += Triple(peer, 0, limit) + } + tablet.onFirstFrame() + tablet.select(8L) + testScheduler.runCurrent() + assertEquals(TABLET_WARMUP_LIMIT, seen.last().third) + } + + @Test + fun changingTheTargetCancelsThePreviousOwner() = runTest { + val started = CompletableDeferred() + val warmup = SelectedTargetWarmup(this) { peer, _, _, _ -> + if (peer == 1L) { + started.complete(Unit) + awaitCancellation() + } + } + warmup.onFirstFrame() + warmup.select(1L) + testScheduler.runCurrent() + started.await() + warmup.select(2L) + testScheduler.runCurrent() + assertTrue(warmup.limit() == PHONE_WARMUP_LIMIT) + } + + @Test + fun pressureHoldsTheTargetUntilItClears() = runTest { + var pressure = true + val seen = mutableListOf() + val warmup = SelectedTargetWarmup(this, underPressure = { pressure }) { _, _, _, priority -> + seen += priority + } + warmup.onFirstFrame() + warmup.select(4L) + testScheduler.runCurrent() + assertTrue(seen.isEmpty()) + pressure = false + warmup.promote(4L) + testScheduler.runCurrent() + assertEquals(listOf(MediaPriority.VISIBLE), seen) + } + + @Test + fun promoteRestartsAnIdleWarmupAtVisiblePriority() = runTest { + val seen = mutableListOf() + val started = CompletableDeferred() + val warmup = SelectedTargetWarmup(this) { _, _, _, priority -> + seen += priority + if (priority < MediaPriority.VISIBLE) { + started.complete(Unit) + awaitCancellation() + } + } + warmup.onFirstFrame() + warmup.select(4L) + testScheduler.runCurrent() + started.await() + warmup.promote(4L) + assertEquals(listOf(MediaPriority.IDLE), seen) + testScheduler.runCurrent() + assertEquals(listOf(MediaPriority.IDLE, MediaPriority.VISIBLE), seen) + } + + @Test + fun clearReleasesInterestBeforeTheLoadStarts() = runTest { + var ran = false + val warmup = SelectedTargetWarmup(this) { _, _, _, _ -> ran = true } + warmup.onFirstFrame() + warmup.select(3L) + warmup.clear() + testScheduler.advanceUntilIdle() + assertFalse(ran) + } +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/CacheClear.kt b/feature/settings/src/main/java/org/monogram/feature/settings/CacheClear.kt new file mode 100644 index 00000000..ec7c0b11 --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/CacheClear.kt @@ -0,0 +1,26 @@ +package org.monogram.feature.settings + +import org.monogram.network.http.FileCache + +internal val storageKinds = listOf( + FileCache.KIND_PHOTOS, + FileCache.KIND_VIDEOS, + FileCache.KIND_FILES, + FileCache.KIND_STICKERS, + FileCache.KIND_GIFS, + FileCache.KIND_OTHER, +) + +/** Categories that have something to clear. Opening this set does not delete anything. */ +internal fun selectableCacheKinds(byKind: Map): List = + storageKinds.filter { (byKind[it] ?: 0L) > 0L } + +internal fun selectedCacheBytes(byKind: Map, selected: Set): Long = + selected.sumOf { kind -> byKind[kind] ?: 0L } + +/** Nothing is cleared until the user confirms, and then only the kinds still checked. */ +internal fun kindsToClear(selected: Set, confirmed: Boolean): Set = + if (confirmed) selected else emptySet() + +internal fun confirmedChatClear(chatId: Long?, confirmed: Boolean): Long? = + if (confirmed) chatId else null 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 25e8ea31..dcc009ed 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 @@ -20,17 +20,19 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.monogram.core.common.MediaCacheLimit +import org.monogram.core.common.MediaCacheLimitStore import org.monogram.core.common.push.NotificationLocalStore import org.monogram.core.common.push.PushRegistration import org.monogram.core.database.OfflineWarmup import org.monogram.core.database.SessionMetadataStore import org.monogram.core.models.AppUpdateState import org.monogram.core.models.PeerId +import org.monogram.feature.settings.ui.ProxySession import org.monogram.network.bridge.MtprotoClient import org.monogram.network.bridge.MtprotoTransportMode import org.monogram.network.bridge.ProxyConfig import org.monogram.network.http.MediaRepository -import org.monogram.feature.settings.ui.ProxySession @OptIn(ExperimentalCoroutinesApi::class) class SettingsComponent( @@ -142,7 +144,11 @@ class SettingsComponent( fun onCloseWallpapers() = wallpaperStore.accept(WallpaperStore.Intent.Close) fun onClearCache() = store.accept(SettingsStore.Intent.ClearCache) fun onClearChatCache(chatId: Long) = store.accept(SettingsStore.Intent.ClearChatCache(chatId)) - fun onClearKindCache(kind: String) = store.accept(SettingsStore.Intent.ClearKindCache(kind)) + fun onClearKinds(kinds: Set) = store.accept(SettingsStore.Intent.ClearKinds(kinds)) + fun onCacheLimit(directory: java.io.File, limit: MediaCacheLimit) { + MediaCacheLimitStore.write(directory, limit) + mediaRepository?.setCacheLimit(limit.bytes) + } fun onLogout() = store.accept(SettingsStore.Intent.Logout) fun onCheckForUpdates() = appUpdate?.checkForUpdates() fun onDownloadUpdate() = appUpdate?.downloadUpdate() 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 6e999ac0..05f41068 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 @@ -6,7 +6,9 @@ import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.monogram.core.common.Outcome import org.monogram.core.common.push.PushRegistration import org.monogram.core.common.telegram.TelegramError @@ -15,8 +17,8 @@ import org.monogram.core.database.SessionMetadataStore import org.monogram.core.models.Chat import org.monogram.core.models.PeerId import org.monogram.core.models.Profile -import org.monogram.network.bridge.MtprotoClient import org.monogram.core.ui.ImageCache +import org.monogram.network.bridge.MtprotoClient import org.monogram.network.http.MediaRepository interface SettingsStore : Store { @@ -24,7 +26,7 @@ interface SettingsStore : Store) : Intent data object Logout : Intent data object ExportDebugStats : Intent data object ClearDebugStats : Intent @@ -66,6 +68,9 @@ internal class SettingsStoreFactory( private val appVersion: String, private val buildStamp: String, private val pushRegistration: PushRegistration?, + private val clearKindCache: (String) -> Long = { kind -> + mediaRepository?.clearKindCache(kind) ?: 0L + }, ) { fun create(): SettingsStore = object : @@ -110,8 +115,10 @@ internal class SettingsStoreFactory( mediaRepository?.clearChatCache(intent.chatId) refreshCache() } - is SettingsStore.Intent.ClearKindCache -> { - mediaRepository?.clearKindCache(intent.kind) + is SettingsStore.Intent.ClearKinds -> { + kindsToClear(intent.kinds, confirmed = true).forEach { kind -> + clearKindCache(kind) + } refreshCache() } SettingsStore.Intent.Logout -> logout() @@ -149,17 +156,15 @@ internal class SettingsStoreFactory( } private fun refreshCache() { - dispatch(Msg.CacheBytes(mediaRepository?.cacheSizeBytes() ?: 0L)) - dispatch(Msg.CacheByKind(mediaRepository?.cacheByKind().orEmpty())) scope.launch { - dispatch( - Msg.CacheChats( - cacheChatRows( - usageByChat = mediaRepository?.cacheByChat().orEmpty(), - chats = warmup?.chats().orEmpty(), - ), - ), - ) + val bytes = withContext(Dispatchers.IO) { mediaRepository?.cacheSizeBytes() ?: 0L } + val byKind = + withContext(Dispatchers.IO) { mediaRepository?.cacheByKind().orEmpty() } + val byChat = + withContext(Dispatchers.IO) { mediaRepository?.cacheByChat().orEmpty() } + dispatch(Msg.CacheBytes(bytes)) + dispatch(Msg.CacheByKind(byKind)) + dispatch(Msg.CacheChats(cacheChatRows(byChat, warmup?.chats().orEmpty()))) } } 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 c3154b8c..208b9c43 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 @@ -69,6 +69,7 @@ import com.arkivanov.essenty.backhandler.BackDispatcher import com.arkivanov.essenty.backhandler.BackHandler import org.monogram.core.common.DebugLog import org.monogram.core.common.DebugStats +import org.monogram.core.common.MediaCacheLimitStore import org.monogram.core.common.Outcome import org.monogram.core.common.SponsorRegistry import org.monogram.core.models.peerAvatarCacheKey @@ -84,6 +85,9 @@ import org.monogram.feature.settings.R import org.monogram.feature.settings.SettingsComponent import org.monogram.feature.settings.SettingsFolderHost import org.monogram.feature.settings.SettingsPage +import org.monogram.feature.settings.confirmedChatClear +import org.monogram.feature.settings.kindsToClear +import org.monogram.feature.settings.selectableCacheKinds @OptIn(ExperimentalMaterial3Api::class, ExperimentalDecomposeApi::class) @Composable @@ -105,7 +109,11 @@ fun SettingsContent( var logKindName by rememberSaveable { mutableStateOf("all") } var logShareFailed by rememberSaveable { mutableStateOf(false) } val logContext = LocalContext.current - var confirmClear by rememberSaveable { mutableStateOf(false) } + var clearSelection by remember { mutableStateOf?>(null) } + var confirmChatId by remember { mutableStateOf(null) } + var pickingLimit by remember { mutableStateOf(false) } + val cacheLimit by MediaCacheLimitStore.current.collectAsStateWithLifecycle() + val cacheLimitText = cacheLimitLabel(cacheLimit) var debugStatsExpanded by rememberSaveable { mutableStateOf("") } val pages by component.pages.subscribeAsState() val page = pages.active.instance @@ -357,9 +365,12 @@ fun SettingsContent( SettingsPage.AutoDownload(autoDownloadNetworkKey(network)), ) }, - onClear = { confirmClear = true }, - onClearChat = component::onClearChatCache, - onClearKind = component::onClearKindCache, + cacheLimitLabel = cacheLimitText, + onPickLimit = { pickingLimit = true }, + onClear = { + clearSelection = selectableCacheKinds(state.cacheByKind).toSet() + }, + onClearChat = { confirmChatId = it }, onOpenDebugStats = if (DebugStats.enabled) { { component.openPage(SettingsPage.DebugStats) } } else { @@ -525,35 +536,42 @@ fun SettingsContent( ) } - if (confirmClear) { - AlertDialog( - onDismissRequest = { confirmClear = false }, - title = { Text(stringResource(R.string.settings_clear_cache)) }, - text = { - Text( - stringResource( - R.string.settings_clear_cache_confirm, - formatBytes(state.cacheBytes), - ), - ) + if (pickingLimit) { + CacheLimitPickerDialog( + current = cacheLimit, + onSelect = { limit -> + pickingLimit = false + component.onCacheLimit(logContext.filesDir, limit) }, - confirmButton = { - TextButton( - onClick = { - confirmClear = false - component.onClearCache() - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.settings_clear_cache_action)) + onDismiss = { pickingLimit = false }, + ) + } + clearSelection?.let { selected -> + CacheClearDialog( + byKind = state.cacheByKind, + selected = selected, + onToggle = { kind -> + clearSelection = selected.let { current -> + if (kind in current) current - kind else current + kind } }, - dismissButton = { - TextButton(onClick = { confirmClear = false }) { - Text(stringResource(R.string.settings_cancel)) - } + onDismiss = { clearSelection = null }, + onConfirm = { + val kinds = kindsToClear(selected, confirmed = true) + clearSelection = null + if (kinds.isNotEmpty()) component.onClearKinds(kinds) + }, + ) + } + confirmChatId?.let { chatId -> + val bytes = state.cacheChats.firstOrNull { it.chatId == chatId }?.bytes ?: 0L + CacheChatClearSheet( + bytesLabel = formatBytes(bytes), + onDismiss = { confirmChatId = null }, + onConfirm = { + val confirmed = confirmedChatClear(chatId, confirmed = true) + confirmChatId = null + if (confirmed != null) component.onClearChatCache(confirmed) }, ) } 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 289b6711..1a823ad1 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 @@ -1,17 +1,27 @@ +@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) + package org.monogram.feature.settings.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile @@ -27,11 +37,16 @@ import androidx.compose.material.icons.outlined.SignalCellularAlt import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material.icons.outlined.Wifi +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,7 +56,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import org.monogram.core.common.MediaCacheLimit import org.monogram.core.common.Outcome import org.monogram.core.models.PeerId import org.monogram.core.models.peerAvatarCacheKey @@ -49,9 +66,12 @@ import org.monogram.core.ui.AutoDownloadNetwork import org.monogram.core.ui.AutoDownloadPreset import org.monogram.core.ui.DownloadSettings import org.monogram.core.ui.DownloadState +import org.monogram.core.ui.components.AppModalSheet 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.SettingsCard +import org.monogram.core.ui.components.SettingsCardRow import org.monogram.core.ui.components.SettingsGroupCorner import org.monogram.core.ui.components.SettingsTile import org.monogram.core.ui.loading.MonogramLoading @@ -61,6 +81,9 @@ import org.monogram.core.ui.rememberEnsuredFile import org.monogram.core.ui.rememberStrippedPreview import org.monogram.feature.settings.R import org.monogram.feature.settings.SettingsStore +import org.monogram.feature.settings.selectableCacheKinds +import org.monogram.feature.settings.selectedCacheBytes +import org.monogram.feature.settings.storageKinds import org.monogram.network.http.FileCache import org.monogram.network.http.MediaPriority import org.monogram.network.http.MediaRepository @@ -169,13 +192,159 @@ internal fun LazyListScope.dataItems( loading: Boolean, download: DownloadState, onOpenAutoDownload: (AutoDownloadNetwork) -> Unit, + cacheLimitLabel: String, + onPickLimit: () -> Unit, onClear: () -> Unit, onClearChat: (Long) -> Unit, - onClearKind: (String) -> Unit, onOpenDebugStats: (() -> Unit)? = null, onOpenLogs: (() -> Unit)? = null, mediaRepository: MediaRepository? = null, ) { + item { Spacer(Modifier.height(8.dp)) } + item { + val photos = cacheByKind[FileCache.KIND_PHOTOS] ?: 0L + val videos = cacheByKind[FileCache.KIND_VIDEOS] ?: 0L + val files = cacheByKind[FileCache.KIND_FILES] ?: 0L + 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 } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.extraLarge) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.settings_cache), + style = MaterialTheme.typography.titleLargeEmphasized, + ) + Spacer(Modifier.height(20.dp)) + Box(contentAlignment = Alignment.Center) { + StorageRing(slices = slices) + Text( + text = formatBytes(cacheBytes), + style = MaterialTheme.typography.headlineSmallEmphasized, + modifier = Modifier.width(132.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.settings_cache_size, formatBytes(cacheBytes)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val extra = cacheMessage?.removePrefix("cleared:")?.toLongOrNull() + if (extra != null) { + Text( + text = stringResource(R.string.settings_cache_cleared, formatBytes(extra)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(20.dp)) + Button( + onClick = onClear, + enabled = !loading && selectableCacheKinds(cacheByKind).isNotEmpty(), + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + ) { + MonogramLoading( + visible = loading, + size = MonogramLoadingInlineSize, + ) + if (!loading) { + Icon(Icons.Outlined.DeleteSweep, contentDescription = null) + } + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.settings_clear_cache)) + } + } + } + item { Spacer(Modifier.height(8.dp)) } + item { + SettingsTile( + icon = Icons.Outlined.Storage, + title = stringResource(R.string.settings_cache_limit), + subtitle = cacheLimitLabel, + iconColor = MaterialTheme.colorScheme.primary, + position = ItemPosition.STANDALONE, + onClick = onPickLimit, + trailingContent = { DataChevron() }, + ) + } + item { Spacer(Modifier.height(8.dp)) } + storageKinds.forEachIndexed { index, kind -> + item(key = "cache-kind:$kind") { + val bytes = cacheByKind[kind] ?: 0L + SettingsCard( + 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 + }, + trailingContent = { + Text( + text = formatBytes(bytes), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + } + } + item { SectionHeader(stringResource(R.string.settings_storage_chats)) } + if (cacheChats.isEmpty()) { + item { + Text( + text = stringResource(R.string.settings_storage_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } else { + val sortedChats = cacheChats.sortedByDescending { it.bytes } + sortedChats.forEachIndexed { index, row -> + item(key = "cache-chat:${row.chatId}") { + ChatStorageRow( + row = row, + mediaRepository = mediaRepository, + 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) }, + ) + } + } + } item { Spacer(Modifier.height(8.dp)) } item { SectionHeader(stringResource(R.string.settings_autodownload)) } item { @@ -301,124 +470,198 @@ internal fun LazyListScope.dataItems( }, ) } - item { Spacer(Modifier.height(8.dp)) } - item { - val photos = cacheByKind[FileCache.KIND_PHOTOS] ?: 0L - val videos = cacheByKind[FileCache.KIND_VIDEOS] ?: 0L - val files = cacheByKind[FileCache.KIND_FILES] ?: 0L - 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 } + +} + +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) +} + +@Composable +internal fun CacheLimitPickerDialog( + current: MediaCacheLimit, + onSelect: (MediaCacheLimit) -> Unit, + onDismiss: () -> Unit, +) { + AppModalSheet(onDismissRequest = onDismiss) { + Text( + text = stringResource(R.string.settings_cache_limit), + style = MaterialTheme.typography.headlineSmallEmphasized, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) Column( modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.extraLarge) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .selectableGroup() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - StorageRing(slices = slices) - Spacer(Modifier.height(12.dp)) - Text( - text = formatBytes(cacheBytes), - style = MaterialTheme.typography.headlineMediumEmphasized, - ) - val extra = cacheMessage?.removePrefix("cleared:")?.toLongOrNull() - if (extra != null) { - Text( - text = stringResource(R.string.settings_cache_cleared, formatBytes(extra)), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + MediaCacheLimit.entries.forEach { limit -> + val checked = limit == current + SettingsCard(selected = checked) { + SettingsCardRow( + icon = Icons.Outlined.Storage, + title = cacheLimitLabel(limit), + iconColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.selectable( + selected = checked, + role = Role.RadioButton, + onClick = { onSelect(limit) }, + ), + trailingContent = { + RadioButton(selected = checked, onClick = null) + }, + ) + } } } } - item { Spacer(Modifier.height(8.dp)) } - val kinds = listOf( - FileCache.KIND_PHOTOS to R.string.settings_storage_photos, - FileCache.KIND_VIDEOS to R.string.settings_storage_videos, - FileCache.KIND_FILES to R.string.settings_storage_files, - FileCache.KIND_STICKERS to R.string.settings_storage_stickers, - FileCache.KIND_GIFS to R.string.settings_storage_gifs, - FileCache.KIND_OTHER to R.string.settings_storage_other, - ) - kinds.forEachIndexed { index, (kind, label) -> - item { - val bytes = cacheByKind[kind] ?: 0L - SettingsTile( - icon = storageCategoryIcon(kind), - title = stringResource(label), - subtitle = formatBytes(bytes), - iconColor = storageCategoryColor(kind), - titleColor = if (bytes > 0L) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - position = when (index) { - 0 -> ItemPosition.TOP - kinds.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - }, - enabled = !loading && bytes > 0L, - onClick = { onClearKind(kind) }, +} + +@Composable +internal fun CacheClearDialog( + byKind: Map, + selected: Set, + onToggle: (String) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val selectedBytes = selectedCacheBytes(byKind, selected) + AppModalSheet(onDismissRequest = onDismiss) { + Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.settings_clear_cache), + style = MaterialTheme.typography.headlineSmallEmphasized, ) - } - } - item { Spacer(Modifier.height(8.dp)) } - item { - SettingsTile( - icon = Icons.Outlined.DeleteSweep, - title = stringResource(R.string.settings_clear_cache), - subtitle = stringResource(R.string.settings_cache_size, formatBytes(cacheBytes)), - iconColor = MaterialTheme.colorScheme.error, - titleColor = MaterialTheme.colorScheme.error, - position = ItemPosition.STANDALONE, - enabled = !loading, - onClick = onClear, - trailingContent = { - MonogramLoading( - visible = loading, - size = MonogramLoadingInlineSize, - color = MaterialTheme.colorScheme.error, - ) - }, - ) - } - item { SectionHeader(stringResource(R.string.settings_storage_chats)) } - if (cacheChats.isEmpty()) { - item { + Spacer(Modifier.height(8.dp)) Text( - text = stringResource(R.string.settings_storage_empty), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = formatBytes(selectedBytes), + style = MaterialTheme.typography.headlineLargeEmphasized, + color = MaterialTheme.colorScheme.primary, ) } - } else { - val sortedChats = cacheChats.sortedByDescending { it.bytes } - sortedChats.forEachIndexed { index, row -> - item { - ChatStorageRow( - row = row, - mediaRepository = mediaRepository, + Column( + 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 { - sortedChats.size == 1 -> ItemPosition.STANDALONE + kinds.size == 1 -> ItemPosition.STANDALONE index == 0 -> ItemPosition.TOP - index == sortedChats.lastIndex -> ItemPosition.BOTTOM + index == kinds.lastIndex -> ItemPosition.BOTTOM else -> ItemPosition.MIDDLE }, - enabled = !loading, - onClick = { onClearChat(row.chatId) }, - ) + ) { + 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) }, + ), + trailingContent = { + Checkbox(checked = checked, onCheckedChange = null) + }, + ) + } + } + Text( + 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), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_cancel)) + } + Button( + onClick = onConfirm, + enabled = selectedBytes > 0L, + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .weight(1f) + .heightIn(min = 56.dp), + ) { + Icon(Icons.Outlined.DeleteSweep, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.settings_clear_cache_action)) + } + } + } +} + +@Composable +internal fun CacheChatClearSheet( + bytesLabel: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AppModalSheet(onDismissRequest = onDismiss) { + Text( + text = stringResource(R.string.settings_storage_clear_chat), + style = MaterialTheme.typography.headlineSmallEmphasized, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + Text( + text = stringResource(R.string.settings_clear_cache_confirm, bytesLabel), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_cancel)) + } + Button( + onClick = onConfirm, + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .weight(1f) + .heightIn(min = 56.dp), + ) { + Text(stringResource(R.string.settings_clear_cache_action)) } } } diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/StorageRing.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/StorageRing.kt index 8ee79ce4..d0b62657 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/StorageRing.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/StorageRing.kt @@ -2,12 +2,12 @@ package org.monogram.feature.settings.ui import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp @@ -17,22 +17,32 @@ internal fun StorageRing( modifier: Modifier = Modifier, ) { val total = slices.sumOf { it.second }.coerceAtLeast(1L) + val trackColor = MaterialTheme.colorScheme.surfaceContainerHighest Canvas(modifier = modifier.size(180.dp)) { val strokeWidth = 18.dp.toPx() val diameter = size.minDimension - strokeWidth val topLeft = Offset((size.width - diameter) / 2f, (size.height - diameter) / 2f) val arcSize = Size(diameter, diameter) + drawArc( + color = trackColor, + startAngle = -90f, + sweepAngle = 360f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = strokeWidth), + ) val live = slices.filter { it.second > 0L } val gap = if (live.size > 1) 4f else 0f - val stroke = Stroke(width = strokeWidth, cap = if (live.size > 1) StrokeCap.Round else StrokeCap.Butt) + val stroke = Stroke(width = strokeWidth) var start = -90f live.forEach { (color, bytes) -> val sweep = 360f * (bytes.toFloat() / total.toFloat()) - val drawSweep = (sweep - gap).coerceAtLeast(1.5f) + val sliceGap = minOf(gap, sweep / 2f) drawArc( color = color, - startAngle = start + gap / 2f, - sweepAngle = drawSweep, + startAngle = start + sliceGap / 2f, + sweepAngle = sweep - sliceGap, useCenter = false, topLeft = topLeft, size = arcSize, diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index 771f58b7..8d9749d2 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -300,7 +300,15 @@ Fotos y archivos — la lista de chats y el historial permanecen hasta cerrar sesión Borrar caché multimedia Borrar - ¿Eliminar %1$s de contenido en caché? Los chats y mensajes se mantienen + Libera %1$s en este dispositivo. Solo se quitan los archivos descargados + Libera los %1$s seleccionados en este dispositivo. Solo se quitan los archivos descargados + Tamaño máximo de la caché + Ilimitado + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB Borrados %1$s Fotos Videos @@ -309,7 +317,7 @@ GIFs Otros Chats - ¿Borrar la caché de este chat? + ¿Quitar los archivos descargados de este chat? Aún no hay contenido indexado — abre chats para crear el gráfico Cerrar sesión Este dispositivo olvidará la sesión de Telegram, los chats y el contenido en caché diff --git a/feature/settings/src/main/res/values-hy/strings.xml b/feature/settings/src/main/res/values-hy/strings.xml index 1d1da794..393ce905 100644 --- a/feature/settings/src/main/res/values-hy/strings.xml +++ b/feature/settings/src/main/res/values-hy/strings.xml @@ -300,7 +300,15 @@ Լուսանկարներ և ֆայլեր — զրույցների ցանկն ու պատմությունը մնում են մինչև դուրս գալը Մաքրել մեդիայի քեշը Մաքրել - Ջնջե՞լ %1$s քեշավորված մեդիա։ Զրույցներն ու հաղորդագրությունները կմնան + Ազատել %1$s այս սարքում։ Կհանվեն միայն ներբեռնված ֆայլերը + Ազատել ընտրված %1$s-ը այս սարքում։ Կհանվեն միայն ներբեռնված ֆայլերը + Քեշի առավելագույն չափը + Անսահմանափակ + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB Մաքրվեց %1$s Լուսանկարներ Տեսանյութեր @@ -309,7 +317,7 @@ GIF-եր Այլ Զրույցներ - Մաքրե՞լ այս զրույցի քեշը + Մաքրե՞լ այս զրույցի ներբեռնված ֆայլերը Մեդիան դեռ ինդեքսավորված չէ — բացեք զրույցները գծապատկերը կառուցելու համար Դուրս գալ Այս սարքը կմոռանա Telegram-ի նիստը, քեշավորված զրույցներն ու մեդիան diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml index 045e4f9c..3d0751dd 100644 --- a/feature/settings/src/main/res/values-ja/strings.xml +++ b/feature/settings/src/main/res/values-ja/strings.xml @@ -300,7 +300,15 @@ 写真とファイル — チャットリストと履歴はログアウトまで残ります メディアキャッシュを消去 消去 - キャッシュされた %1$s のメディアを削除しますか?チャットとメッセージは残ります + この端末の %1$s を空けます。消えるのはダウンロード済みのファイルだけです + 選択した %1$s をこの端末から空けます。消えるのはダウンロード済みのファイルだけです + キャッシュの上限 + 無制限 + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB %1$s を消去しました 写真 動画 @@ -309,7 +317,7 @@ GIF その他 チャット - このチャットのキャッシュを消去しますか? + このチャットのダウンロード済みファイルを消去しますか? まだメディアが記録されていません — チャットを開いてグラフを作成してください ログアウト この端末は 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 e275c8ec..0b3b0545 100644 --- a/feature/settings/src/main/res/values-pt-rBR/strings.xml +++ b/feature/settings/src/main/res/values-pt-rBR/strings.xml @@ -300,7 +300,15 @@ Fotos e arquivos — a lista de conversas e o histórico ficam até você sair Limpar cache de mídia Limpar - Excluir %1$s de mídia em cache? Conversas e mensagens permanecem + Liberar %1$s neste aparelho. Só os arquivos baixados saem + Liberar os %1$s selecionados neste aparelho. Só os arquivos baixados saem + Tamanho máximo do cache + Ilimitado + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB Limpo %1$s Fotos Vídeos @@ -309,7 +317,7 @@ GIFs Outros Conversas - Limpar o cache desta conversa? + Limpar os arquivos baixados desta conversa? Nenhuma mídia indexada ainda — abra conversas para montar o gráfico Sair Este dispositivo vai esquecer a sessão do Telegram, conversas e mídia em cache diff --git a/feature/settings/src/main/res/values-ru/strings.xml b/feature/settings/src/main/res/values-ru/strings.xml index 444549de..f8b61165 100644 --- a/feature/settings/src/main/res/values-ru/strings.xml +++ b/feature/settings/src/main/res/values-ru/strings.xml @@ -300,7 +300,15 @@ Фото и файлы — список чатов и история сохраняются до выхода Очистить кэш Telegram Очистить кэш - Удалить %1$s медиа из кэша? Сообщения останутся в облаке + Освободить %1$s на этом устройстве. Уйдут только скачанные файлы + Освободить выбранные %1$s на этом устройстве. Уйдут только скачанные файлы + Максимальный размер кэша + Без ограничений + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB Очищено %1$s Фото Видео @@ -309,7 +317,7 @@ GIF Прочее Чаты - Очистить кэш этого чата? + Очистить скачанные файлы этого чата? Медиа ещё не проиндексировано — откройте чаты, чтобы построить диаграмму Выйти Вы действительно хотите выйти? С устройства будут удалены данные кэша и сессия diff --git a/feature/settings/src/main/res/values-sk/strings.xml b/feature/settings/src/main/res/values-sk/strings.xml index 00ac2856..142098b6 100644 --- a/feature/settings/src/main/res/values-sk/strings.xml +++ b/feature/settings/src/main/res/values-sk/strings.xml @@ -300,7 +300,15 @@ Fotky a súbory — zoznam chatov a história zostávajú do odhlásenia Vymazať vyrovnávaciu pamäť médií Vymazať - Odstrániť %1$s médií z vyrovnávacej pamäte? Chaty a správy zostanú + Uvoľniť %1$s na tomto zariadení. Odídu len stiahnuté súbory + Uvoľniť vybrané %1$s na tomto zariadení. Odídu len stiahnuté súbory + Maximálna veľkosť vyrovnávacej pamäte + Neobmedzené + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB Vymazané %1$s Fotky Videá @@ -309,7 +317,7 @@ GIFy Iné Chaty - Vymazať vyrovnávaciu pamäť tohto chatu? + Vymazať stiahnuté súbory tohto chatu? Médiá zatiaľ nie sú indexované — otvorte chaty a vytvorte graf Odhlásiť sa Toto zariadenie zabudne reláciu Telegramu, uložené chaty a médiá diff --git a/feature/settings/src/main/res/values-tr/strings.xml b/feature/settings/src/main/res/values-tr/strings.xml index bb3e9182..a3de4f9e 100644 --- a/feature/settings/src/main/res/values-tr/strings.xml +++ b/feature/settings/src/main/res/values-tr/strings.xml @@ -300,7 +300,15 @@ Fotoğraflar ve dosyalar — sohbet listesi ve geçmiş çıkış yapana kadar kalır Medya önbelleğini temizle Temizle - Önbellekteki %1$s medya silinsin mi? Sohbetler ve mesajlar kalır + Bu cihazda %1$s boşalır. Yalnızca indirilen dosyalar kalkar + Seçilen %1$s bu cihazda boşalır. Yalnızca indirilen dosyalar kalkar + Önbellek boyutu sınırı + Sınırsız + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB %1$s temizlendi Fotoğraflar Videolar @@ -309,7 +317,7 @@ GIF\'ler Diğer Sohbetler - Bu sohbetin önbelleği temizlensin mi? + Bu sohbetin indirilen dosyaları temizlensin mi? Henüz medya dizinlenmedi — grafiği oluşturmak için sohbetleri açın Çıkış yap Bu cihaz Telegram oturumunu, önbellekteki sohbetleri ve medyayı unutacak diff --git a/feature/settings/src/main/res/values-uk/strings.xml b/feature/settings/src/main/res/values-uk/strings.xml index b53f86ff..5fc1a6cf 100644 --- a/feature/settings/src/main/res/values-uk/strings.xml +++ b/feature/settings/src/main/res/values-uk/strings.xml @@ -300,7 +300,15 @@ Фото і файли — список чатів та історія зберігаються до виходу Очистити кеш медіа Очистити - Видалити %1$s кешованих медіа? Чати й повідомлення залишаться + Звільнити %1$s на цьому пристрої. Зникнуть лише завантажені файли + Звільнити вибрані %1$s на цьому пристрої. Зникнуть лише завантажені файли + Максимальний розмір кешу + Без обмежень + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB Очищено %1$s Фото Відео @@ -309,7 +317,7 @@ GIF Інше Чати - Очистити кеш цього чату? + Очистити завантажені файли цього чату? Медіа ще не проіндексовано — відкрийте чати, щоб побудувати діаграму Вийти Цей пристрій забуде сесію Telegram, кешовані чати та медіа diff --git a/feature/settings/src/main/res/values-zh/strings.xml b/feature/settings/src/main/res/values-zh/strings.xml index c3b52873..79beca36 100644 --- a/feature/settings/src/main/res/values-zh/strings.xml +++ b/feature/settings/src/main/res/values-zh/strings.xml @@ -300,7 +300,15 @@ 照片和文件 — 聊天列表和历史记录保留到退出登录 清除媒体缓存 清除 - 删除 %1$s 的缓存媒体?聊天和消息会保留 + 释放此设备上的 %1$s。只移除已下载的文件 + 释放此设备上所选的 %1$s。只移除已下载的文件 + 最大缓存大小 + 无限制 + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB 已清除 %1$s 图片 视频 @@ -309,7 +317,7 @@ GIF 其他 聊天 - 清除此聊天的缓存? + 清除此聊天已下载的文件? 尚未统计媒体 — 打开聊天以生成图表 退出登录 此设备将清除 Telegram 会话、缓存的聊天和媒体 diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index d61d5fc7..0379ce18 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -300,7 +300,15 @@ Photos and files — chat list and history stay until you log out Clear media cache Clear - Delete %1$s of cached media? Chats and messages stay + Free %1$s on this device. Only downloaded files are removed + Free the selected %1$s on this device. Only downloaded files are removed + Maximum cache size + Unlimited + 500 MB + 1 GB + 2 GB + 5 GB + 10 GB Cleared %1$s Photos Videos @@ -309,7 +317,7 @@ GIFs Other Chats - Clear cache for this chat? + Clear downloaded files for this chat? No media indexed yet — open chats to build the chart Log Out This device will forget the Telegram session, cached chats, and media diff --git a/feature/settings/src/test/java/org/monogram/feature/settings/CacheClearTest.kt b/feature/settings/src/test/java/org/monogram/feature/settings/CacheClearTest.kt new file mode 100644 index 00000000..bbfa62b0 --- /dev/null +++ b/feature/settings/src/test/java/org/monogram/feature/settings/CacheClearTest.kt @@ -0,0 +1,33 @@ +package org.monogram.feature.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.network.http.FileCache + +class CacheClearTest { + private val usage = mapOf( + FileCache.KIND_PHOTOS to 40L, + FileCache.KIND_VIDEOS to 0L, + FileCache.KIND_FILES to 10L, + ) + + @Test + fun openingTheSheetSelectsOnlyNonEmptyCategoriesAndClearsNothing() { + val selected = selectableCacheKinds(usage).toSet() + assertEquals(setOf(FileCache.KIND_PHOTOS, FileCache.KIND_FILES), selected) + assertTrue(kindsToClear(selected, confirmed = false).isEmpty()) + assertNull(confirmedChatClear(7L, confirmed = false)) + } + + @Test + fun confirmClearsOnlyTheKindsStillChecked() { + val opened = selectableCacheKinds(usage).toSet() + val stillChecked = opened - FileCache.KIND_PHOTOS + val cleared = mutableListOf() + kindsToClear(stillChecked, confirmed = true).forEach(cleared::add) + assertEquals(listOf(FileCache.KIND_FILES), cleared) + assertEquals(10L, selectedCacheBytes(usage, stillChecked)) + } +} \ No newline at end of file diff --git a/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt b/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt index c5230e03..ef162f7b 100644 --- a/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt +++ b/feature/settings/src/test/java/org/monogram/feature/settings/SettingsStoreCacheTest.kt @@ -26,6 +26,7 @@ import org.monogram.core.models.Profile import org.monogram.network.bridge.MtprotoClient import org.monogram.network.bridge.MtprotoUpdate import org.monogram.network.bridge.UpdatesCursor +import org.monogram.network.http.FileCache @OptIn(ExperimentalCoroutinesApi::class) class SettingsStoreCacheTest { @@ -90,6 +91,34 @@ class SettingsStoreCacheTest { } } + @Test + fun clearKindsDeletesOnlyTheConfirmedSelection() { + val cleared = mutableListOf() + val store = SettingsStoreFactory( + DefaultStoreFactory(), + StubClient(), + sessionStore = null, + warmup = null, + mediaRepository = null, + appVersion = "test", + buildStamp = "test", + pushRegistration = null, + clearKindCache = { kind -> + cleared += kind + 1L + }, + ).create() + try { + val opened = setOf(FileCache.KIND_PHOTOS, FileCache.KIND_FILES) + assertTrue(kindsToClear(opened, confirmed = false).isEmpty()) + assertTrue(cleared.isEmpty()) + store.accept(SettingsStore.Intent.ClearKinds(setOf(FileCache.KIND_FILES))) + assertEquals(listOf(FileCache.KIND_FILES), cleared) + } finally { + store.dispose() + } + } + private class StubClient : MtprotoClient { override suspend fun connect() = Outcome.Ok(Unit) override suspend fun sendAuthCode(phone: String) = unused() diff --git a/native/mtproto-rs/src/client/auth.rs b/native/mtproto-rs/src/client/auth.rs index 3b1dc29a..a2bf2992 100644 --- a/native/mtproto-rs/src/client/auth.rs +++ b/native/mtproto-rs/src/client/auth.rs @@ -292,6 +292,7 @@ pub fn logout(handle: u64) -> Result<(), MtprotoError> { state.channel_pts.clear(); state.channel_recovery.clear(); state.seen_messages.clear(); + std::sync::Arc::make_mut(&mut state.journal).reset_account(); state.updates_started = false; state.session_dead = false; state.new_session = None; diff --git a/native/mtproto-rs/src/client/extras.rs b/native/mtproto-rs/src/client/extras.rs index eb0c7392..881a1ee9 100644 --- a/native/mtproto-rs/src/client/extras.rs +++ b/native/mtproto-rs/src/client/extras.rs @@ -416,6 +416,8 @@ pub fn get_sticker_pack( document_id, ) })?; + // Pack documents are the only file locations for stickers that have no message id. + persist(state)?; Ok(dto) }) } @@ -435,6 +437,7 @@ pub fn get_sticker_set( access_hash, ) })?; + persist(state)?; Ok(dto) }) } diff --git a/native/mtproto-rs/src/client/lanes.rs b/native/mtproto-rs/src/client/lanes.rs index b11f236a..9a1a84c8 100644 --- a/native/mtproto-rs/src/client/lanes.rs +++ b/native/mtproto-rs/src/client/lanes.rs @@ -286,6 +286,28 @@ pub(crate) fn try_lock_second_media_lane(client: &Client) -> Option usize { + VISIBLE_TRANSFER_WAITERS.load(std::sync::atomic::Ordering::Acquire) +} + +#[cfg(test)] +pub(crate) fn set_visible_transfer_waiters_for_test(count: usize) { + VISIBLE_TRANSFER_WAITERS.store(count, std::sync::atomic::Ordering::Release); +} + +struct VisibleWaitGuard(bool); + +impl Drop for VisibleWaitGuard { + fn drop(&mut self) { + if self.0 { + VISIBLE_TRANSFER_WAITERS.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); + } + } +} + pub(crate) fn acquire_lane_family<'a>( order: impl Iterator + Clone, class: scheduler::RequestClass, @@ -293,12 +315,31 @@ pub(crate) fn acquire_lane_family<'a>( wait_counter: &'static str, lane_at: impl Fn(usize) -> &'a Lane, ) -> Result, MtprotoError> { + let _visible = if class == scheduler::RequestClass::InteractiveMedia { + VISIBLE_TRANSFER_WAITERS.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + VisibleWaitGuard(true) + } else { + VisibleWaitGuard(false) + }; let wait = crate::perf::span(span); + let waiters: Vec<_> = order.clone().map(|index| { + let lane = lane_at(index); + (lane, lane.gate.enqueue(class)) + }).collect(); + if waiters.is_empty() { + return Err(MtprotoError::Message("no authorized request lane".into())); + } let mut counted = false; loop { crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; - for index in order.clone() { - if let Some(lease) = lane_at(index).try_lease(class) { + if let Some(promoted) = crate::request_control::promoted_class() { + for (_, waiter) in &waiters { + waiter.promote(promoted); + } + } + for (lane, waiter) in &waiters { + if let Some(gate) = waiter.try_acquire() { + let lease = LaneLease { _gate: gate, io: lane.io.lock() }; drop(wait); return Ok(lease); } @@ -307,9 +348,6 @@ pub(crate) fn acquire_lane_family<'a>( counted = true; crate::perf::count(wait_counter); } - let index = order.clone().last().unwrap_or(0); - let lease = lane_at(index).lease(class)?; - drop(wait); - return Ok(lease); + waiters[0].1.wait(); } } diff --git a/native/mtproto-rs/src/client/media_download.rs b/native/mtproto-rs/src/client/media_download.rs index bdb0cf53..d1772b3e 100644 --- a/native/mtproto-rs/src/client/media_download.rs +++ b/native/mtproto-rs/src/client/media_download.rs @@ -273,12 +273,29 @@ pub(crate) fn refresh_file_source( .get(&(chat_id, message_id)) .is_some_and(|media| matches!(media.location, media::MediaLocation::Document { .. })) { - let _ = crate::client::extras_rpc::get_saved_gifs( - &mut state.snapshot, - state.api_id, - &mut state.media, - 0, - ); + let sticker = state + .media + .get(&(chat_id, message_id)) + .and_then(sticker_set_of); + if let Some((set_id, access_hash)) = sticker { + // Pack stickers have no message id. messages.getStickerSet is the + // constructor that reissues their file_reference. + // https://core.telegram.org/api/file-references + let _ = crate::messages::sticker_rpc::get_sticker_set( + &mut state.snapshot, + state.api_id, + &mut state.media, + set_id, + access_hash, + ); + } else { + let _ = crate::client::extras_rpc::get_saved_gifs( + &mut state.snapshot, + state.api_id, + &mut state.media, + 0, + ); + } } else if state .media .get(&(chat_id, message_id)) @@ -570,26 +587,159 @@ pub fn download_custom_emoji( dest_path: String, ) -> Result { let client = get_client(handle)?; - let (api_id, home, media) = with_client_mut(handle, |state| { + let (api_id, fallback, media) = with_client_mut(handle, |state| { ensure_ready(state)?; if let Some(indexed) = state.media.get(&(document_id, 0)).cloned() { return Ok((state.api_id, state.snapshot.clone(), indexed)); } - let indexed = call_with_migrate(state, |state| { - media::fetch_custom_emoji(&mut state.snapshot, state.api_id, document_id) - })?; - state.media.insert((document_id, 0), indexed.clone()); - persist(state)?; + let indexed = index_missing_custom_emoji(state, document_id)?; Ok((state.api_id, state.snapshot.clone(), indexed)) })?; - download_media_on_lane( + // A bound temp key leaves snapshot.session_id != home_session_id. Media + // leases require the permanent id, the same source message downloads use. + let home = { + let data = client.data.lock(); + home_fork_source(&data).unwrap_or(fallback) + }; + match download_media_on_lane( handle, &client, &home, api_id, &media, Path::new(&dest_path), - ) + ) { + Err(error) if media::is_file_reference_error(&error) => { + let refreshed = with_client_mut(handle, |state| { + if !home_lease_ok(state, home.session_id) { + return Err(expired_session_lease()); + } + refresh_document_file_reference(state, document_id)?; + persist(state)?; + state.media.get(&(document_id, 0)).cloned().ok_or_else(|| { + MtprotoError::Message(format!( + "no media after file-ref refresh for document {document_id}" + )) + }) + })?; + download_media_on_lane( + handle, + &client, + &home, + api_id, + &refreshed, + Path::new(&dest_path), + ) + } + result => result, + } +} + +/// Re-fetch the constructor that issued this document's `file_reference`. +/// Stickers come from `messages.getStickerSet`; custom emoji and other +/// documents come from `messages.getCustomEmojiDocuments`. +/// Pack stickers carry the set that can reissue `file_reference`. Saved GIFs do not. +pub(crate) fn sticker_set_of(media: &media::MediaRef) -> Option<(i64, i64)> { + media.sticker_set_id.zip(media.sticker_set_access_hash) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CustomEmojiDocumentSource { + StickerSet { set_id: i64, access_hash: i64 }, + CustomEmojiDocuments, +} + +pub(crate) fn custom_emoji_document_source( + index: &media::MediaIndex, + document_id: i64, +) -> CustomEmojiDocumentSource { + match sticker_set_for_document(index, document_id) { + Some((set_id, access_hash)) => { + CustomEmojiDocumentSource::StickerSet { set_id, access_hash } + } + None => CustomEmojiDocumentSource::CustomEmojiDocuments, + } +} + +fn document_location_id(item: &media::MediaRef) -> Option { + match item.location { + media::MediaLocation::Document { id, .. } => Some(id), + _ => None, + } +} + +fn sticker_set_for_document( + index: &media::MediaIndex, + document_id: i64, +) -> Option<(i64, i64)> { + if let Some(set) = index.get(&(document_id, 0)).and_then(sticker_set_of) { + return Some(set); + } + index.iter().find_map(|(&(key_id, key_msg), item)| { + let same_document = (key_id == document_id && key_msg == 0) + || document_location_id(item) == Some(document_id); + if same_document { + sticker_set_of(item) + } else { + None + } + }) +} + +fn index_missing_custom_emoji( + state: &mut ClientState, + document_id: i64, +) -> Result { + match custom_emoji_document_source(&state.media, document_id) { + CustomEmojiDocumentSource::StickerSet { set_id, access_hash } => { + crate::messages::sticker_rpc::get_sticker_set( + &mut state.snapshot, + state.api_id, + &mut state.media, + set_id, + access_hash, + )?; + persist(state)?; + state.media.get(&(document_id, 0)).cloned().ok_or_else(|| { + MtprotoError::Message(format!( + "no sticker document {document_id} after getStickerSet" + )) + }) + } + CustomEmojiDocumentSource::CustomEmojiDocuments => { + let indexed = call_with_migrate(state, |state| { + media::fetch_custom_emoji(&mut state.snapshot, state.api_id, document_id) + })?; + state.media.insert((document_id, 0), indexed.clone()); + persist(state)?; + Ok(indexed) + } + } +} + +fn refresh_document_file_reference( + state: &mut ClientState, + document_id: i64, +) -> Result<(), MtprotoError> { + match custom_emoji_document_source(&state.media, document_id) { + CustomEmojiDocumentSource::StickerSet { set_id, access_hash } => { + crate::messages::sticker_rpc::get_sticker_set( + &mut state.snapshot, + state.api_id, + &mut state.media, + set_id, + access_hash, + )?; + Ok(()) + } + CustomEmojiDocumentSource::CustomEmojiDocuments => { + let indexed = call_with_migrate(state, |state| { + media::fetch_custom_emoji(&mut state.snapshot, state.api_id, document_id) + })?; + state.media.insert((document_id, 0), indexed); + Ok(()) + } + } } pub(crate) fn download_media_on_lane( @@ -884,6 +1034,7 @@ pub(crate) fn download_media_range_on_home_session( dest: &Path, offset: Option, ) -> Result { + loop { let _span = crate::perf::span("media_file_home"); let (session_id, dc) = { let data = client.data.lock(); @@ -919,7 +1070,18 @@ pub(crate) fn download_media_range_on_home_session( }) }, ); - finish_staged(staged, downloaded, client, session_id, dest) + if downloaded.as_ref().err().is_some_and(media::optional_paused) { + staged.keep(); + while media::should_pause_optional(Some(media::DownloadProfile::Background)) { + if media::download_cancelled(dest) || crate::request_control::check().is_err() { + return Err(MtprotoError::Message("cancelled".into())); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + continue; + } + return finish_staged(staged, downloaded, client, session_id, dest); + } } pub(crate) fn split_even_odd_request_indices( @@ -1116,6 +1278,7 @@ pub(crate) fn download_media_range_on_lane_dc( offset: Option, target_dc: i32, ) -> Result { + loop { let _span = crate::perf::span("media_file"); let mut io = lock_media_lane(client)?; if !session_lease_valid(&client.data.lock(), home.session_id) { @@ -1285,6 +1448,16 @@ pub(crate) fn download_media_range_on_lane_dc( } drop(io); drop(io2); + if path.as_ref().err().is_some_and(media::optional_paused) { + staged.keep(); + while media::should_pause_optional(Some(media::DownloadProfile::Background)) { + if media::download_cancelled(dest) || crate::request_control::check().is_err() { + return Err(MtprotoError::Message("cancelled".into())); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + continue; + } if let Ok(client) = get_client(handle) { schedule_file_cleanup(&client); } @@ -1343,7 +1516,8 @@ pub(crate) fn download_media_range_on_lane_dc( }); } } - finish_staged(staged, path, client, home.session_id, dest) + return finish_staged(staged, path, client, home.session_id, dest); + } } thread_local! { diff --git a/native/mtproto-rs/src/client/mod.rs b/native/mtproto-rs/src/client/mod.rs index d1078d47..d6547869 100644 --- a/native/mtproto-rs/src/client/mod.rs +++ b/native/mtproto-rs/src/client/mod.rs @@ -110,6 +110,7 @@ pub(crate) struct ClientData { /// Optional metadata previews; gaps remain durable in `channel_recovery`. pub(crate) lazy_channel_recovery: HashSet, pub(crate) lazy_retry_at: u64, + pub(crate) journal: Arc, } #[derive(Clone)] @@ -138,6 +139,7 @@ pub(crate) struct Client { pub(crate) media_gate: scheduler::LaneGate, pub(crate) persist_queued: AtomicBool, pub(crate) persist_running: AtomicBool, + pub(crate) persist_exhausted: AtomicBool, pub(crate) interactive_waiters: AtomicU64, } @@ -168,6 +170,7 @@ pub(crate) struct ClientState { pub(crate) perm_salt: i64, pub(crate) perm_session_id: i64, pub(crate) dc_auth_keys: HashMap, + pub(crate) journal: Arc, } /// In-memory bound temporary key. Deliberately not `Debug`: it holds key material. @@ -256,6 +259,7 @@ pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 session_dead, test_dc, dc_auth_keys, + journal, ) = match loaded { Some(session) => ( session.snapshot, @@ -270,6 +274,7 @@ pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 session.session_dead, session.test_dc, session.dc_auth_keys, + session.journal, ), None => ( Snapshot::new(DEFAULT_DC_ID, &mut OsRandom).expect("snapshot"), @@ -284,6 +289,7 @@ pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 load_failed, false, HashMap::new(), + Default::default(), ), }; crate::rpc::set_use_test_dc(test_dc); @@ -350,6 +356,7 @@ pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 lazy_sync_exceptions: HashSet::new(), lazy_channel_recovery: HashSet::new(), lazy_retry_at: 0, + journal: Arc::new(journal), }), main_gate: scheduler::LaneGate::new(), main: Mutex::new(SessionIo { @@ -397,6 +404,7 @@ pub fn create_client(api_id: i32, api_hash: String, session_path: String) -> u64 media_gate: scheduler::LaneGate::new(), persist_queued: AtomicBool::new(false), persist_running: AtomicBool::new(false), + persist_exhausted: AtomicBool::new(false), interactive_waiters: AtomicU64::new(0), }), ); @@ -555,6 +563,7 @@ pub(crate) fn with_client_mut( perm_salt: d.home_salt, perm_session_id: d.home_session_id, dc_auth_keys: d.dc_auth_keys.clone(), + journal: Arc::clone(&d.journal), } }; crate::rpc::set_use_test_dc(state.test_dc); @@ -620,6 +629,9 @@ pub(crate) fn with_client_mut( d.channel_recovery = state.channel_recovery; d.seen_messages = state.seen_messages; d.updates_started = state.updates_started; + if before_user_id.is_some() { + Arc::make_mut(&mut d.journal).reset_account(); + } true } else { let peers_changed = merge_changed_entries(&mut d.peers, &before_peers, state.peers); @@ -767,6 +779,7 @@ pub(crate) fn with_read_lane( perm_salt: d.home_salt, perm_session_id: d.home_session_id, dc_auth_keys: d.dc_auth_keys.clone(), + journal: Arc::clone(&d.journal), }) } }; diff --git a/native/mtproto-rs/src/client/persist.rs b/native/mtproto-rs/src/client/persist.rs index faf27b83..87fe606d 100644 --- a/native/mtproto-rs/src/client/persist.rs +++ b/native/mtproto-rs/src/client/persist.rs @@ -61,6 +61,33 @@ pub(crate) fn durable_snapshot(state: &ClientState) -> tellers_mtproto_session:: snapshot } +pub(crate) fn journal_to_save( + snapshot: crate::update_journal::UpdateJournal, + live: Option, +) -> crate::update_journal::UpdateJournal { + let Some(live) = live else { + return snapshot; + }; + if live.account_generation == snapshot.account_generation + && live.next_batch_id >= snapshot.next_batch_id + { + live + } else { + snapshot + } +} + +fn live_journal(path: &Path) -> Option { + let clients = CLIENTS.lock(); + for client in clients.values() { + let data = client.data.lock(); + if data.session_path == path { + return Some((*data.journal).clone()); + } + } + None +} + pub(crate) fn persist(state: &ClientState) -> Result<(), MtprotoError> { // Extra-lane snapshot_id must never replace the main session on disk. if EXTRA_READ_LANE.with(Cell::get) { @@ -80,6 +107,7 @@ pub(crate) fn persist(state: &ClientState) -> Result<(), MtprotoError> { session_dead: state.session_dead, test_dc: state.test_dc, dc_auth_keys: state.dc_auth_keys.clone(), + journal: journal_to_save((*state.journal).clone(), live_journal(&state.session_path)), }; let clock = persist_generation(&state.session_path); let _write = PERSIST_WRITE.lock(); @@ -98,11 +126,11 @@ pub(crate) fn expired_session_lease() -> MtprotoError { MtprotoError::Message("request cancelled: session changed".into()) } -pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result<(), MtprotoError> { +pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result { // Copy under the home lane, then release it before the disk write. Chat // opens, media, and channel info share that lane and were waiting out the // whole save. - let (path, session, epoch, generation) = { + let (path, session, journal, epoch, generation) = { let main = client.main.lock(); let d = client.data.lock(); if !session_lease_valid(&d, session_id) { @@ -130,15 +158,19 @@ pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result<( session_dead: d.session_dead, test_dc: d.test_dc, dc_auth_keys: d.dc_auth_keys.clone(), + journal: d.journal.header_only(), }; + let journal = Arc::clone(&d.journal); let path = d.session_path.clone(); let epoch = d.persist_epoch; drop(d); // Read the clock while the home lane is still held, so `persist` // cannot commit this file between the copy and the snapshot. let generation = persist_generation(&path).load(Ordering::Acquire); - (path, session, epoch, generation) + (path, session, journal, epoch, generation) }; + let mut session = session; + session.journal = (*journal).clone(); let clock = persist_generation(&path); let _write = PERSIST_WRITE.lock(); { @@ -154,9 +186,16 @@ pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result<( epoch, d.persist_epoch, ) { - return Ok(()); + return Ok(false); } } + #[cfg(test)] + if FORCE_STALE_SAVES + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |left| left.checked_sub(1)) + .is_ok() + { + return Ok(false); + } let store = FileSessionStore::new(&path); tcp::with_connection_control(&client.connections, || store.save(&session)) .map_err(|e| MtprotoError::Message(e.to_string()))?; @@ -165,9 +204,45 @@ pub(crate) fn persist_updates_data(client: &Client, session_id: i64) -> Result<( if session_lease_valid(&d, session_id) && d.persisted_epoch < epoch { d.persisted_epoch = epoch; } - Ok(()) + Ok(true) +} + +#[cfg(test)] +pub(crate) static FORCE_STALE_SAVES: AtomicU32 = AtomicU32::new(0); + +use std::sync::atomic::AtomicU32; + +pub(crate) fn persist_journal_sync(client: &Client, session_id: i64) -> Result<(), MtprotoError> { + let mut last = None; + for attempt in 0..JOURNAL_PERSIST_ATTEMPTS { + match persist_updates_data(client, session_id) { + Ok(true) => { + client.persist_exhausted.store(false, Ordering::Release); + return Ok(()); + } + Ok(false) => { + crate::perf::count("journal.persist_superseded"); + continue; + } + Err(error) if !session_lease_valid(&client.data.lock(), session_id) => { + return Err(error); + } + Err(error) => { + crate::perf::count("journal.persist_retry"); + last = Some(error); + if attempt + 1 < JOURNAL_PERSIST_ATTEMPTS { + std::thread::sleep(std::time::Duration::from_millis(25 << attempt)); + } + } + } + } + crate::perf::count("journal.persist_exhausted"); + client.persist_exhausted.store(true, Ordering::Release); + Err(last.unwrap_or_else(|| MtprotoError::Message("journal persistence failed".into()))) } +const JOURNAL_PERSIST_ATTEMPTS: u32 = 4; + /// Coalesce session writes so getHistory/getChats can return before disk I/O. /// In-memory state is already merged; this is crash recovery, not the RPC result. pub(crate) fn schedule_persist(client: &Arc, session_id: i64) { @@ -175,10 +250,12 @@ pub(crate) fn schedule_persist(client: &Arc, session_id: i64) { if client.persist_running.swap(true, Ordering::AcqRel) { return; } - let client = Arc::clone(client); - let _ = std::thread::Builder::new() + let worker_client = Arc::clone(client); + let spawned = std::thread::Builder::new() .name("mtproto-persist".into()) .spawn(move || { + let client = worker_client; + let mut failures = 0_u32; loop { client.persist_queued.store(false, Ordering::Release); let skip = { @@ -187,8 +264,28 @@ pub(crate) fn schedule_persist(client: &Arc, session_id: i64) { }; if !skip { let span = crate::perf::span("persist_session"); - let _ = persist_updates_data(&client, session_id); + let result = persist_updates_data(&client, session_id); drop(span); + if result.as_ref().ok() == Some(&true) { + client.persist_exhausted.store(false, Ordering::Release); + } + if result.as_ref().ok() == Some(&false) { + client.persist_queued.store(true, Ordering::Release); + continue; + } + if result.is_err() { + crate::perf::count("persist_session.failed"); + client.persist_queued.store(true, Ordering::Release); + failures += 1; + if failures >= 4 || !session_lease_valid(&client.data.lock(), session_id) { + client.persist_exhausted.store(true, Ordering::Release); + client.persist_running.store(false, Ordering::Release); + break; + } + std::thread::sleep(std::time::Duration::from_millis(100 * (1 << failures))); + continue; + } + failures = 0; } if client.persist_queued.load(Ordering::Acquire) { continue; @@ -202,6 +299,10 @@ pub(crate) fn schedule_persist(client: &Arc, session_id: i64) { break; } }); + if spawned.is_err() { + crate::perf::count("persist_session.spawn_failed"); + client.persist_running.store(false, Ordering::Release); + } } #[cfg(test)] diff --git a/native/mtproto-rs/src/client/updates.rs b/native/mtproto-rs/src/client/updates.rs index f8302913..b8a68265 100644 --- a/native/mtproto-rs/src/client/updates.rs +++ b/native/mtproto-rs/src/client/updates.rs @@ -4,7 +4,10 @@ use crate::peers::channel_id_from_chat_id; use crate::session_file::ChannelRecovery; use crate::tcp; use crate::updates::updates_rpc; -use crate::{MtprotoError, UpdateEventDto, UpdatesStateDto}; +use crate::update_journal::{DurableBatch, is_durable}; +use crate::{ + ChannelCursorDto, MtprotoError, UpdateBatchDto, UpdateDrainDto, UpdateEventDto, UpdatesStateDto, +}; use super::*; @@ -71,7 +74,7 @@ pub fn clear_active_dialog(handle: u64) -> Result<(), MtprotoError> { data.channel_recovery.retain(|entry| !entry.watching); data.home_session_id }; - persist_updates_data(&client, session_id) + persist_updates_data(&client, session_id).map(|_| ()) } pub(crate) fn lock_updates_lane( @@ -91,13 +94,95 @@ pub(crate) fn lock_updates_lane( Ok(client.main.try_lock().map(|io| (gate, io))) } -pub fn drain_updates(handle: u64) -> Result, MtprotoError> { +fn batch_dto(generation: i64, batch: &DurableBatch) -> UpdateBatchDto { + UpdateBatchDto { + account_generation: generation, + batch_id: batch.batch_id, + events: batch.events.clone(), + cursor_after: batch.cursor_after.clone(), + channel_pts_after: batch + .channel_pts_after + .iter() + .map(|(chat_id, pts)| ChannelCursorDto { + chat_id: *chat_id, + pts: *pts, + }) + .collect(), + } +} + +pub fn pending_update_batches(handle: u64) -> Result, MtprotoError> { + let client = get_client(handle)?; + let (session_id, dirty) = { + let d = client.data.lock(); + if d.journal.batches.is_empty() { + return Ok(Vec::new()); + } + (d.home_session_id, d.persist_epoch != d.persisted_epoch) + }; + if dirty { + persist_journal_sync(&client, session_id)?; + } + let mut d = client.data.lock(); + if !session_lease_valid(&d, session_id) { + return Err(expired_session_lease()); + } + let generation = d.journal.account_generation; + let batches = d + .journal + .pending() + .iter() + .map(|batch| batch_dto(generation, batch)) + .collect(); + std::sync::Arc::make_mut(&mut d.journal).mark_all_delivered(); + Ok(batches) +} + +pub fn acknowledge_update_batch( + handle: u64, + account_generation: i64, + batch_id: i64, +) -> Result<(), MtprotoError> { + let client = get_client(handle)?; + let (removed, session_id) = { + let mut d = client.data.lock(); + let removed = std::sync::Arc::make_mut(&mut d.journal) + .acknowledge(account_generation, batch_id); + if removed { + d.persist_epoch = d.persist_epoch.saturating_add(1); + } + (removed, d.home_session_id) + }; + if removed { + schedule_persist(&client, session_id); + } + Ok(()) +} + +pub fn drain_updates(handle: u64) -> Result { let client = get_client(handle)?; if interactive_request_pending(&client) { - return Ok(Vec::new()); + return Ok(UpdateDrainDto::default()); + } + { + let d = client.data.lock(); + if d.journal.has_undelivered() { + return Ok(UpdateDrainDto { + replay_required: true, + ..Default::default() + }); + } + if !d.journal.admission_open() { + crate::perf::count("journal.admission_closed"); + return Ok(UpdateDrainDto::default()); + } + } + if client.persist_exhausted.load(std::sync::atomic::Ordering::Acquire) { + let session_id = client.data.lock().home_session_id; + schedule_persist(&client, session_id); } let Some((gate, mut io)) = lock_updates_lane(&client)? else { - return Ok(Vec::new()); + return Ok(UpdateDrainDto::default()); }; let now = recovery_now(); let ( @@ -120,7 +205,7 @@ pub fn drain_updates(handle: u64) -> Result, MtprotoError> { ))); } if d.user_id.is_none() || !d.updates_started { - return Ok(Vec::new()); + return Ok(UpdateDrainDto::default()); } ( d.api_id, @@ -537,9 +622,14 @@ pub fn drain_updates(handle: u64) -> Result, MtprotoError> { } schedule_persist(&client, session_id); } - return Ok(events); + return Ok(UpdateDrainDto { + live_events: events, + ..Default::default() + }); }; let persist_needed; + let mut journal_batch = None; + let mut journal_generation = 0; let is_syncing_now; let should_emit_sync_state; { @@ -576,7 +666,27 @@ pub fn drain_updates(handle: u64) -> Result, MtprotoError> { is_syncing_now, ); d.is_syncing = is_syncing_now; - persist_needed = new_session.is_some() + let durable_events: Vec; + (durable_events, events) = std::mem::take(&mut events) + .into_iter() + .partition(is_durable); + if !durable_events.is_empty() { + let channel_pts_after: Vec<(i64, i32)> = applied + .channel_pts + .iter() + .filter(|(chat, pts)| d.channel_pts.get(*chat) != Some(*pts)) + .map(|(chat, pts)| (*chat, *pts)) + .collect(); + let journal = std::sync::Arc::make_mut(&mut d.journal); + journal_generation = journal.account_generation; + journal_batch = Some(journal.push( + durable_events, + next_cursor.clone(), + channel_pts_after, + )); + } + persist_needed = journal_batch.is_some() + || new_session.is_some() || peers_changed || media_changed || d.channel_pts != applied.channel_pts @@ -613,16 +723,29 @@ pub fn drain_updates(handle: u64) -> Result, MtprotoError> { drop(io); drop(gate); schedule_file_cleanup(&client); - if persist_needed { - crate::perf::count("updates.persist"); - schedule_persist(&client, session_id); - } if should_emit_sync_state { events.push(UpdateEventDto::SyncState { is_syncing: is_syncing_now, }); } - Ok(events) + let Some(batch) = journal_batch else { + if persist_needed { + crate::perf::count("updates.persist"); + schedule_persist(&client, session_id); + } + return Ok(UpdateDrainDto { + live_events: events, + ..Default::default() + }); + }; + crate::perf::count("updates.persist"); + persist_journal_sync(&client, session_id)?; + std::sync::Arc::make_mut(&mut client.data.lock().journal).mark_delivered(batch.batch_id); + Ok(UpdateDrainDto { + batches: vec![batch_dto(journal_generation, &batch)], + live_events: events, + replay_required: false, + }) } pub fn get_updates_state(handle: u64) -> Result { diff --git a/native/mtproto-rs/src/dto.rs b/native/mtproto-rs/src/dto.rs index af78ce75..4eeb9698 100644 --- a/native/mtproto-rs/src/dto.rs +++ b/native/mtproto-rs/src/dto.rs @@ -60,7 +60,7 @@ fn nonempty(value: String, fallback: &str) -> String { } } -#[derive(Debug, Clone, uniffi::Record)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct ChatDto { pub id: i64, pub title: String, @@ -140,7 +140,7 @@ pub struct ProfileDto { pub extra_json: Option, } -#[derive(Debug, Clone, uniffi::Record)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Record)] pub struct MessageDto { pub chat_id: i64, pub id: i32, @@ -232,7 +232,7 @@ pub struct ReactionChoiceDto { pub document_id: i64, } -#[derive(Debug, Clone, uniffi::Enum)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, uniffi::Enum)] pub enum UpdateEventDto { ChatsChanged, FoldersChanged, @@ -294,6 +294,28 @@ pub enum UpdateEventDto { }, } +#[derive(Debug, Clone, uniffi::Record)] +pub struct ChannelCursorDto { + pub chat_id: i64, + pub pts: i32, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct UpdateBatchDto { + pub account_generation: i64, + pub batch_id: i64, + pub events: Vec, + pub cursor_after: Option, + pub channel_pts_after: Vec, +} + +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct UpdateDrainDto { + pub batches: Vec, + pub live_events: Vec, + pub replay_required: bool, +} + #[derive(Debug, Clone, Default, uniffi::Record)] pub struct NotifySettingsDto { pub show_previews: Option, diff --git a/native/mtproto-rs/src/ffi.rs b/native/mtproto-rs/src/ffi.rs index feb56b1e..0676460c 100644 --- a/native/mtproto-rs/src/ffi.rs +++ b/native/mtproto-rs/src/ffi.rs @@ -1,4 +1,5 @@ use crate::client; +use crate::{UpdateBatchDto, UpdateDrainDto}; #[cfg(target_os = "android")] #[allow(unsafe_code)] @@ -76,6 +77,19 @@ pub fn release_request_control(id: u64) { request_control::release(id); } +#[uniffi::export] +pub fn promote_request_control(id: u64, class: i32) { + let class = match class { + 0 => scheduler::RequestClass::InteractiveRead, + 1 => scheduler::RequestClass::BackgroundRead, + 2 | 5 | 6 | 7 => scheduler::RequestClass::InteractiveMedia, + 3 => scheduler::RequestClass::BackgroundMedia, + 4 => scheduler::RequestClass::InteractiveWrite, + _ => return, + }; + request_control::promote(id, class); +} + /// 0 interactive read, 1 background read, 2 interactive media, 3 background media, /// 4 interactive write, 5 ordinary media, 6 visible media, 7 user media. #[uniffi::export] @@ -112,7 +126,7 @@ pub fn set_file_part_kib(kib: i32) { #[uniffi::export] pub fn set_download_chunk_kib(kib: i32) { - crate::media::set_chunk_size(kib * 1024); + crate::media::set_chunk_size(kib.checked_mul(1024).unwrap_or(0)); } #[uniffi::export(callback_interface)] @@ -858,10 +872,24 @@ pub fn clear_active_dialog(handle: u64) -> Result<(), MtprotoError> { } #[uniffi::export] -pub fn drain_updates(handle: u64) -> Result, MtprotoError> { +pub fn drain_updates(handle: u64) -> Result { client::drain_updates(handle) } +#[uniffi::export] +pub fn pending_update_batches(handle: u64) -> Result, MtprotoError> { + client::pending_update_batches(handle) +} + +#[uniffi::export] +pub fn acknowledge_update_batch( + handle: u64, + account_generation: i64, + batch_id: i64, +) -> Result<(), MtprotoError> { + client::acknowledge_update_batch(handle, account_generation, batch_id) +} + #[uniffi::export] pub fn animated_emoji_max(handle: u64) -> Result { client::animated_emoji_max(handle) diff --git a/native/mtproto-rs/src/lib.rs b/native/mtproto-rs/src/lib.rs index b814842b..d4152aef 100644 --- a/native/mtproto-rs/src/lib.rs +++ b/native/mtproto-rs/src/lib.rs @@ -34,6 +34,7 @@ mod stripped_jpeg; mod tcp; mod transfer_policy; mod update_buffer; +mod update_journal; mod updates; mod upload; #[allow(unsafe_code)] diff --git a/native/mtproto-rs/src/media/download.rs b/native/mtproto-rs/src/media/download.rs index fa93c862..4acf4e7d 100644 --- a/native/mtproto-rs/src/media/download.rs +++ b/native/mtproto-rs/src/media/download.rs @@ -42,6 +42,19 @@ pub struct DownloadWindow { /// Visible, playing, and user files use 512 KiB and 8 parts. Other files stay /// at 128 KiB, and a thumb is one request. /// https://core.telegram.org/api/files +pub(crate) fn window_candidate( + chunk: i32, + in_flight: usize, + alignment_proven: bool, +) -> Option { + let aligned = chunk > 0 && chunk % 4_096 == 0; + match (chunk, in_flight, alignment_proven && aligned) { + (131_072, 4, _) | (524_288, 8, _) => Some(DownloadWindow { chunk, in_flight }), + (1_048_576, 12, true) => Some(DownloadWindow { chunk, in_flight }), + _ => None, + } +} + pub fn download_window(profile: DownloadProfile) -> DownloadWindow { match profile { DownloadProfile::Thumb => DownloadWindow { @@ -493,6 +506,7 @@ pub(crate) fn keep_partial(err: &MtprotoError) -> bool { || upper.contains("TIMEOUT") || upper.contains("FILE_REFERENCE") || upper.contains("DOWNLOAD INCOMPLETE") + || upper.contains("OPTIONAL_PAUSED") || upper.contains("AUTH_KEY_UNREGISTERED") || upper.contains("AUTH_KEY_INVALID") || upper.contains("AUTH_KEY_DUPLICATED") @@ -781,6 +795,151 @@ fn store_ranges(staging: &Path, ranges: &MissingRanges) { let _ = fs::write(ranges_path(staging), bytes); } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RangeIdentity { + pub file_id: i64, + pub access_hash: i64, + pub dc_id: i32, + pub size: i64, + pub version: i32, +} + +impl RangeIdentity { + fn to_bytes(&self) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[0..8].copy_from_slice(&self.file_id.to_le_bytes()); + bytes[8..16].copy_from_slice(&self.access_hash.to_le_bytes()); + bytes[16..20].copy_from_slice(&self.dc_id.to_le_bytes()); + bytes[20..28].copy_from_slice(&self.size.to_le_bytes()); + bytes[28..32].copy_from_slice(&self.version.to_le_bytes()); + bytes + } + + fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() != 32 { + return None; + } + Some(Self { + file_id: i64::from_le_bytes(bytes[0..8].try_into().ok()?), + access_hash: i64::from_le_bytes(bytes[8..16].try_into().ok()?), + dc_id: i32::from_le_bytes(bytes[16..20].try_into().ok()?), + size: i64::from_le_bytes(bytes[20..28].try_into().ok()?), + version: i32::from_le_bytes(bytes[28..32].try_into().ok()?), + }) + } +} + +pub(crate) fn location_identity( + location: &crate::media::MediaLocation, + size: i64, + dc_fallback: i32, +) -> Option { + let (file_id, access_hash, dc_id) = match location { + crate::media::MediaLocation::Document { + id, + access_hash, + dc_id, + .. + } + | crate::media::MediaLocation::Photo { + id, + access_hash, + dc_id, + .. + } => (*id, *access_hash, *dc_id), + crate::media::MediaLocation::PeerPhoto { + photo_id, + access_hash, + dc_id, + .. + } => (*photo_id, *access_hash, *dc_id), + crate::media::MediaLocation::Inline { .. } => return None, + }; + Some(RangeIdentity { + file_id, + access_hash, + dc_id: if dc_id == 0 { dc_fallback } else { dc_id }, + size, + version: 0, + }) +} + +pub(crate) fn resume_allowed(stored: Option<&RangeIdentity>, current: &RangeIdentity) -> bool { + stored.is_some_and(|stored| stored == current) +} + +fn identity_path(staging: &Path) -> PathBuf { + let text = staging.to_string_lossy(); + if let Some(stripped) = text.strip_suffix(".part") { + PathBuf::from(format!("{stripped}.identity.part")) + } else { + PathBuf::from(format!("{text}.identity.part")) + } +} + +fn load_identity(staging: &Path) -> Option { + RangeIdentity::from_bytes(&fs::read(identity_path(staging)).ok()?) +} + +fn store_identity(staging: &Path, identity: &RangeIdentity) { + let _ = fs::write(identity_path(staging), identity.to_bytes()); +} + +pub(crate) fn dedup_offsets(offsets: &[i64], chunk: i64) -> Vec { + if chunk <= 0 { + return Vec::new(); + } + let mut ordered: Vec = offsets.iter().copied().filter(|off| *off >= 0).collect(); + ordered.sort_unstable(); + ordered.dedup(); + let mut kept = Vec::new(); + for offset in ordered { + if kept.iter().any(|previous: &i64| offset < previous + chunk && offset >= *previous) { + continue; + } + kept.push(offset); + } + kept +} + +static INFLIGHT_RANGES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashSet::new())); + +pub(crate) fn claim_offsets(file_id: i64, offsets: &[i64]) -> Vec { + let mut held = INFLIGHT_RANGES.lock().unwrap_or_else(|error| error.into_inner()); + offsets + .iter() + .copied() + .filter(|offset| held.insert((file_id, *offset))) + .collect() +} + +pub(crate) fn release_offsets(file_id: i64, offsets: &[i64]) { + let mut held = INFLIGHT_RANGES.lock().unwrap_or_else(|error| error.into_inner()); + for offset in offsets { + held.remove(&(file_id, *offset)); + } +} + +struct OffsetClaim(Option<(i64, Vec)>); + +impl Drop for OffsetClaim { + fn drop(&mut self) { + if let Some((file_id, offsets)) = self.0.take() { + release_offsets(file_id, &offsets); + } + } +} + +pub(crate) fn should_pause_optional(profile: Option) -> bool { + matches!(profile, Some(DownloadProfile::Background)) + && crate::client::lanes::visible_transfer_waiters() > 0 +} + +pub(crate) fn optional_paused(err: &MtprotoError) -> bool { + matches!(err, MtprotoError::Message(message) if message.contains("OPTIONAL_PAUSED")) +} + /// Where a full-file retry should request next. A range file wins. A missing or /// corrupt file falls back to the chunk-aligned length. Neither path panics. pub(crate) fn resume_start(ranges: Option<&MissingRanges>, file_len: i64, chunk: i32) -> i64 { @@ -1168,8 +1327,19 @@ pub(crate) fn download_media_range_batched_streaming_capped( } let result = (|| -> Result<(), MtprotoError> { let stream = offset.is_some(); - let file_len = fs::metadata(dest_path).map(|meta| meta.len() as i64).unwrap_or(0); - let loaded_ranges = if stream { None } else { load_ranges(dest_path) }; + let mut file_len = fs::metadata(dest_path).map(|meta| meta.len() as i64).unwrap_or(0); + let identity = location_identity(&media.location, media.file_size.unwrap_or(0), dc_id); + let mut loaded_ranges = if stream { None } else { load_ranges(dest_path) }; + if !stream { + if let Some(identity) = identity.clone() { + if file_len > 0 && !resume_allowed(load_identity(dest_path).as_ref(), &identity) { + loaded_ranges = None; + file_len = 0; + let _ = fs::remove_file(ranges_path(dest_path)); + } + store_identity(dest_path, &identity); + } + } let chunk_now = resolve_chunk(offset); let origin = if stream { offset.unwrap_or(0) @@ -1203,16 +1373,6 @@ pub(crate) fn download_media_range_batched_streaming_capped( file.seek(SeekFrom::Start(origin as u64)) .map_err(|e| MtprotoError::Message(e.to_string()))?; file - } else if origin > 0 { - use std::io::{Seek, SeekFrom}; - let mut file = fs::OpenOptions::new() - .read(true) - .write(true) - .open(dest_path) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - file.seek(SeekFrom::Start(origin as u64)) - .map_err(|e| MtprotoError::Message(e.to_string()))?; - file } else { fs::File::create(dest_path).map_err(|e| MtprotoError::Message(e.to_string()))? }; @@ -1266,7 +1426,7 @@ pub(crate) fn download_media_range_batched_streaming_capped( bound_download_window() .map(|window| window.in_flight) .unwrap_or(STREAM_WINDOW_CHUNKS) - .min(width.max(1)) + .min(width.max(1)).min((FAST_CHUNK / chunk).max(1) as usize) } else { width }; @@ -1274,6 +1434,9 @@ pub(crate) fn download_media_range_batched_streaming_capped( if download_cancelled(cancellation_path) { return Err(MtprotoError::Message("cancelled".into())); } + if should_pause_optional(request_profile()) { + return Err(MtprotoError::Message("OPTIONAL_PAUSED".into())); + } let mut offsets = if let Some(ranges) = missing.as_ref().filter(|_| !stream) { let planned = request_offsets(ranges, i64::from(chunk.max(1)), batch); if planned.is_empty() { @@ -1295,6 +1458,17 @@ pub(crate) fn download_media_range_batched_streaming_capped( offsets.retain(|off| !cancelled.contains(off)); } } + offsets = dedup_offsets(&offsets, i64::from(chunk.max(1))); + if let Some(identity) = identity.clone() { + offsets = claim_offsets(identity.file_id, &offsets); + } + if offsets.is_empty() { + std::thread::sleep(std::time::Duration::from_millis(5)); + continue; + } + let _release_claim = OffsetClaim( + identity.clone().map(|identity| (identity.file_id, offsets.clone())), + ); let (flags, cdn_supported) = cdn_fields_for_seek(origin); let requests: Vec = offsets .iter() @@ -1331,7 +1505,7 @@ pub(crate) fn download_media_range_batched_streaming_capped( let bytes = &file.bytes; if !bytes.is_empty() && bytes.len() <= chunk as usize { use std::io::{Seek, SeekFrom, Write}; - if out.seek(SeekFrom::Start(part_offset as u64)).is_ok() + if out.seek(SeekFrom::Start((if stream { part_offset - origin } else { part_offset }) as u64)).is_ok() && out.write_all(bytes).is_ok() { written_end = written_end.max(part_offset + bytes.len() as i64); @@ -1356,7 +1530,7 @@ pub(crate) fn download_media_range_batched_streaming_capped( } } } - if bytes.len() < chunk as usize || stream { + if bytes.len() < chunk as usize { hit_short = true; last_part = true; if index < short_or_empty.len() { @@ -1459,7 +1633,7 @@ pub(crate) fn download_media_range_batched_streaming_capped( break; } use std::io::{Seek, SeekFrom, Write}; - out.seek(SeekFrom::Start(part_offset as u64)) + out.seek(SeekFrom::Start((if stream { part_offset - origin } else { part_offset }) as u64)) .map_err(|e| MtprotoError::Message(e.to_string()))?; out.write_all(&bytes) .map_err(|e| MtprotoError::Message(e.to_string()))?; @@ -1468,7 +1642,7 @@ pub(crate) fn download_media_range_batched_streaming_capped( downloaded_bytes += bytes.len() as i64; notify_progress(&target_str, downloaded_bytes, 0); } - if bytes.len() < chunk as usize || stream { + if bytes.len() < chunk as usize { last_part = true; } } @@ -1548,7 +1722,7 @@ pub(crate) fn download_media_range_batched_streaming_capped( } } } else { - out.set_len(written_end as u64) + out.set_len((written_end - origin).max(0) as u64) .map_err(|e| MtprotoError::Message(e.to_string()))?; } out.flush() diff --git a/native/mtproto-rs/src/request_control.rs b/native/mtproto-rs/src/request_control.rs index 3b8c3d2f..3e5b71af 100644 --- a/native/mtproto-rs/src/request_control.rs +++ b/native/mtproto-rs/src/request_control.rs @@ -13,6 +13,7 @@ use tellers_mtproto_transport::Error; static NEXT: AtomicU64 = AtomicU64::new(1); static REQUESTS: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); +static PROMOTIONS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); thread_local! { static CURRENT: RefCell<(u64, Option>)> = const { RefCell::new((0, None)) }; static SHARED: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -65,6 +66,30 @@ pub fn cancel(id: u64) { pub fn release(id: u64) { REQUESTS.lock().remove(&id); + PROMOTIONS.lock().remove(&id); +} + +pub fn promote(id: u64, class: crate::scheduler::RequestClass) { + if !REQUESTS.lock().contains_key(&id) { + return; + } + let mut promotions = PROMOTIONS.lock(); + let entry = promotions.entry(id).or_insert(class.bits()); + let current = crate::scheduler::RequestClass::from_bits(*entry); + if current.is_none_or(|c| class.priority() < c.priority()) { + *entry = class.bits(); + } +} + +pub(crate) fn promoted_class() -> Option { + let id = current_id(); + if id == 0 { + return None; + } + PROMOTIONS + .lock() + .get(&id) + .and_then(|bits| crate::scheduler::RequestClass::from_bits(*bits)) } pub(crate) fn detach_sockets() { diff --git a/native/mtproto-rs/src/scheduler.rs b/native/mtproto-rs/src/scheduler.rs index 35e743e6..bd96be45 100644 --- a/native/mtproto-rs/src/scheduler.rs +++ b/native/mtproto-rs/src/scheduler.rs @@ -3,6 +3,7 @@ //! https://core.telegram.org/api/invoking use std::collections::VecDeque; +use std::sync::Arc; use std::time::Duration; use parking_lot::{Condvar, Mutex}; @@ -40,7 +41,7 @@ impl RequestClass { } } - fn from_bits(bits: u8) -> Option { + pub(crate) fn from_bits(bits: u8) -> Option { match bits { 0 => Some(RequestClass::InteractiveRead), 1 => Some(RequestClass::BackgroundRead), @@ -211,8 +212,33 @@ pub fn media_lane_order() -> std::ops::Range { pub struct LaneGate { state: Mutex, wake: Condvar, + clock: Arc, } +pub trait SchedulerClock: Send + Sync { + fn now_ms(&self) -> u64; +} + +pub struct SystemSchedulerClock { + origin: std::time::Instant, +} + +impl Default for SystemSchedulerClock { + fn default() -> Self { + Self { + origin: std::time::Instant::now(), + } + } +} + +impl SchedulerClock for SystemSchedulerClock { + fn now_ms(&self) -> u64 { + self.origin.elapsed().as_millis() as u64 + } +} + +const AGING_INTERVAL_MS: u64 = 5_000; + #[derive(Default)] struct GateState { busy: bool, @@ -223,6 +249,15 @@ struct GateState { struct Waiter { ticket: u64, priority: u8, + queued_at: u64, +} + +impl Waiter { + fn order_at(&self, now: u64) -> (u8, u64) { + let promotion = (now.saturating_sub(self.queued_at) / AGING_INTERVAL_MS).min(4) as u8; + let priority = if self.priority > 2 { self.priority.saturating_sub(promotion).max(1) } else { self.priority }; + (priority, self.ticket) + } } /// Holds a lane; releases it (and wakes the best waiter) on drop. @@ -235,17 +270,33 @@ pub(crate) struct LaneWaiter<'a> { ticket: u64, } -impl LaneWaiter<'_> { +impl<'a> LaneWaiter<'a> { + pub(crate) fn try_acquire(&self) -> Option> { + let mut state = self.gate.state.lock(); + let now = self.gate.clock.now_ms(); + let best = state.waiters.iter().min_by_key(|waiter| waiter.order_at(now)); + if state.busy || !best.is_some_and(|waiter| waiter.ticket == self.ticket) { + return None; + } + state.busy = true; + state.waiters.retain(|waiter| waiter.ticket != self.ticket); + Some(LaneGuard { gate: self.gate }) + } + pub(crate) fn is_next(&self) -> bool { self.gate .state .lock() .waiters .iter() - .min_by_key(|waiter| (waiter.priority, waiter.ticket)) + .min_by_key(|waiter| waiter.order_at(self.gate.clock.now_ms())) .is_some_and(|waiter| waiter.ticket == self.ticket) } + pub(crate) fn promote(&self, class: RequestClass) { + self.gate.promote(self.ticket, class); + } + pub(crate) fn wait(&self) { self.gate .wake @@ -269,6 +320,7 @@ impl Default for LaneGate { Self { state: Mutex::new(GateState::default()), wake: Condvar::new(), + clock: Arc::new(SystemSchedulerClock::default()), } } } @@ -278,6 +330,23 @@ impl LaneGate { Self::default() } + pub fn with_clock(clock: Arc) -> Self { + Self { + state: Mutex::new(GateState::default()), + wake: Condvar::new(), + clock, + } + } + + pub(crate) fn promote(&self, ticket: u64, class: RequestClass) { + let mut state = self.state.lock(); + if let Some(waiter) = state.waiters.iter_mut().find(|w| w.ticket == ticket) { + waiter.priority = waiter.priority.min(class.priority()); + } + drop(state); + self.wake.notify_all(); + } + pub(crate) fn enqueue(&self, class: RequestClass) -> LaneWaiter<'_> { let mut state = self.state.lock(); let ticket = state.next_ticket; @@ -285,6 +354,7 @@ impl LaneGate { state.waiters.push_back(Waiter { ticket, priority: class.priority(), + queued_at: self.clock.now_ms(), }); self.wake.notify_all(); LaneWaiter { gate: self, ticket } @@ -304,7 +374,7 @@ impl LaneGate { || state .waiters .iter() - .any(|waiter| waiter.priority < priority) + .any(|waiter| waiter.order_at(self.clock.now_ms()).0 <= priority) { return None; } @@ -319,7 +389,7 @@ impl LaneGate { let mut state = self.state.lock(); let ticket = state.next_ticket; state.next_ticket += 1; - state.waiters.push_back(Waiter { ticket, priority }); + state.waiters.push_back(Waiter { ticket, priority, queued_at: self.clock.now_ms() }); ticket }; loop { @@ -335,7 +405,7 @@ impl LaneGate { let best = state .waiters .iter() - .min_by_key(|waiter| (waiter.priority, waiter.ticket)) + .min_by_key(|waiter| waiter.order_at(self.clock.now_ms())) .map(|waiter| waiter.ticket); if !state.busy && best == Some(ticket) { state.busy = true; diff --git a/native/mtproto-rs/src/session_file.rs b/native/mtproto-rs/src/session_file.rs index d423fab7..d725b5a0 100644 --- a/native/mtproto-rs/src/session_file.rs +++ b/native/mtproto-rs/src/session_file.rs @@ -71,6 +71,8 @@ pub struct ClientSession { /// Imported master keys for foreign datacenters, keyed by DC id. #[serde(default)] pub dc_auth_keys: HashMap, + #[serde(default)] + pub(crate) journal: crate::update_journal::UpdateJournal, } pub fn media_key(chat_id: i64, message_id: i32) -> String { @@ -150,6 +152,7 @@ impl FileSessionStore { new_session: None, test_dc: false, dc_auth_keys: HashMap::new(), + journal: Default::default(), }; if !encrypted && key.is_some() { self.save(&session)?; diff --git a/native/mtproto-rs/src/tcp.rs b/native/mtproto-rs/src/tcp.rs index 58e91d80..12267702 100644 --- a/native/mtproto-rs/src/tcp.rs +++ b/native/mtproto-rs/src/tcp.rs @@ -178,6 +178,9 @@ impl ObfuscatedTcp { pub fn set_io_timeout_ms(&mut self, ms: u64) { self.read_timeout = Duration::from_millis(ms.max(50)); let read = self.read_timeout; + // Shutdown of a cloned socket can leave a Windows reader blocked. + #[cfg(windows)] + let read = read.min(Duration::from_millis(250)); let _ = match &mut self.inner { NativeTransportConnection::Obfuscated(connection) => connection.set_io_timeout_ms(ms), NativeTransportConnection::Http(connection) => connection.set_io_timeout_ms(ms), diff --git a/native/mtproto-rs/src/transfer_policy.rs b/native/mtproto-rs/src/transfer_policy.rs index be89ecd1..1724d093 100644 --- a/native/mtproto-rs/src/transfer_policy.rs +++ b/native/mtproto-rs/src/transfer_policy.rs @@ -91,6 +91,7 @@ impl TransferPolicy { priority, dc, large, + queued_at: std::time::Instant::now(), }); loop { if let Err(error) = crate::request_control::check() { @@ -105,7 +106,7 @@ impl TransferPolicy { .waiters .iter() .filter(|waiter| waiter.dc == dc && waiter.large == large) - .min_by_key(|waiter| (waiter.priority, waiter.ticket)) + .min_by_key(|waiter| waiter.order_at(std::time::Instant::now())) .map(|waiter| waiter.ticket); let recovering = self.floods.lock().recovering_download(dc); let dc_count: usize = active.counts.iter() @@ -265,6 +266,31 @@ struct DownloadWaiter { priority: u8, dc: i32, large: bool, + queued_at: std::time::Instant, +} + +impl DownloadWaiter { + fn order_at(&self, now: std::time::Instant) -> (u8, u64) { + let priority = if self.priority > 2 { + let age = (now.saturating_duration_since(self.queued_at).as_secs() / 5).min(255) as u8; + self.priority.saturating_sub(age).max(1) + } else { self.priority }; + (priority, self.ticket) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aging_preserves_fifo_and_never_overtakes_writes() { + let start = std::time::Instant::now(); + let waiter = DownloadWaiter { ticket: 7, priority: 4, dc: 2, large: false, queued_at: start }; + assert_eq!(waiter.order_at(start + std::time::Duration::from_secs(4)), (4, 7)); + assert_eq!(waiter.order_at(start + std::time::Duration::from_secs(5)), (3, 7)); + assert_eq!(waiter.order_at(start + std::time::Duration::from_secs(100)), (1, 7)); + } } fn valid_chunk(size: i32) -> bool { diff --git a/native/mtproto-rs/src/update_journal.rs b/native/mtproto-rs/src/update_journal.rs new file mode 100644 index 00000000..4f6ade04 --- /dev/null +++ b/native/mtproto-rs/src/update_journal.rs @@ -0,0 +1,167 @@ +//! Replayable journal of update batches. A batch lives in the encrypted session +//! file next to the cursor it advanced, and stays there until the application +//! acknowledges that it committed the batch. +use std::collections::VecDeque; + +use serde::{Deserialize, Serialize}; + +use crate::{UpdateEventDto, UpdatesStateDto}; + +/// Admission closes at either bound; recovery work stays in the journal. +pub(crate) const MAX_UNACKED_BATCHES: usize = 256; +pub(crate) const MAX_UNACKED_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct DurableBatch { + pub batch_id: i64, + pub events: Vec, + pub cursor_after: Option, + pub channel_pts_after: Vec<(i64, i32)>, + #[serde(default)] + pub approx_bytes: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct UpdateJournal { + /// Random per account so a recreated session file cannot collide with receipts + /// the application stored for an earlier journal. + pub account_generation: i64, + pub next_batch_id: i64, + pub batches: VecDeque, + /// Leading batches already handed to the application in this process. + #[serde(skip)] + delivered: usize, +} + +fn random_generation() -> i64 { + let mut bytes = [0_u8; 8]; + if tellers_mtproto_crypto::fill_random(&mut bytes).is_err() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + bytes = nanos.to_le_bytes(); + } + let value = (u64::from_le_bytes(bytes) >> 1) as i64; + value.max(1) +} + +impl Default for UpdateJournal { + fn default() -> Self { + Self { + account_generation: random_generation(), + next_batch_id: 1, + batches: VecDeque::new(), + delivered: 0, + } + } +} + +/// Typing, presence and sync edges are recomputed from live traffic; everything +/// that mutates stored application state must be replayable. +pub(crate) fn is_durable(event: &UpdateEventDto) -> bool { + !matches!( + event, + UpdateEventDto::PeerTyping { .. } + | UpdateEventDto::PeerStatus { .. } + | UpdateEventDto::PeerEmojiStatus { .. } + | UpdateEventDto::SyncState { .. } + | UpdateEventDto::SavedGifsChanged + ) && !matches!(event, UpdateEventDto::Ignored { kind } if !is_durable_ignored(kind)) +} + +/// `Ignored` carries a few state mutations encoded in `kind`; the rest are noise. +fn is_durable_ignored(kind: &str) -> bool { + [ + "dialog_unread_mark:", + "notify_settings:", + "unread_mentions_delta:", + "unread_reactions_delta:", + ] + .iter() + .any(|prefix| kind.starts_with(prefix)) +} + +impl UpdateJournal { + /// Drops every batch: they belong to an account that is gone. + pub(crate) fn header_only(&self) -> Self { + Self { + account_generation: self.account_generation, + next_batch_id: self.next_batch_id, + batches: VecDeque::new(), + delivered: 0, + } + } + + pub(crate) fn reset_account(&mut self) { + let previous = self.account_generation; + *self = Self::default(); + if self.account_generation == previous { + self.account_generation = previous.wrapping_add(1).max(1); + } + } + + fn unacked_bytes(&self) -> usize { + self.batches.iter().map(|b| b.approx_bytes).sum() + } + + pub(crate) fn admission_open(&self) -> bool { + self.batches.len() < MAX_UNACKED_BATCHES && self.unacked_bytes() < MAX_UNACKED_BYTES + } + + pub(crate) fn has_undelivered(&self) -> bool { + self.delivered < self.batches.len() + } + + pub(crate) fn push( + &mut self, + events: Vec, + cursor_after: Option, + channel_pts_after: Vec<(i64, i32)>, + ) -> DurableBatch { + let approx_bytes = serde_json::to_vec(&events).map(|v| v.len()).unwrap_or(0); + let batch = DurableBatch { + batch_id: self.next_batch_id, + events, + cursor_after, + channel_pts_after, + approx_bytes, + }; + self.next_batch_id = self.next_batch_id.saturating_add(1); + self.batches.push_back(batch.clone()); + batch + } + + pub(crate) fn mark_delivered(&mut self, batch_id: i64) { + if let Some(index) = self.batches.iter().position(|b| b.batch_id == batch_id) { + self.delivered = self.delivered.max(index + 1); + } + } + + pub(crate) fn mark_all_delivered(&mut self) { + self.delivered = self.batches.len(); + } + + pub(crate) fn pending(&self) -> Vec { + self.batches.iter().cloned().collect() + } + + /// Returns true when a batch was removed. + pub(crate) fn acknowledge(&mut self, account_generation: i64, batch_id: i64) -> bool { + if account_generation != self.account_generation { + return false; + } + let Some(index) = self.batches.iter().position(|b| b.batch_id == batch_id) else { + return false; + }; + self.batches.remove(index); + if index < self.delivered { + self.delivered -= 1; + } + true + } +} + +#[cfg(test)] +#[path = "../tests/unit/update_journal_tests.rs"] +mod tests; diff --git a/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs index 1ad0eeef..f750165f 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_channel_tests.rs @@ -83,6 +83,7 @@ fn begin_updates_preserves_the_unapplied_cursor_in_persistence() { perm_salt: 0, perm_session_id: 0, dc_auth_keys: HashMap::new(), + journal: Default::default(), }; begin_updates(&mut state); diff --git a/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs index 822ce12e..ffe2504d 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs @@ -132,7 +132,8 @@ fn updates_yield_to_main_rpc_and_media_keep_distinct_sessions() { let client = get_client(handle).expect("client"); let main = client.main.lock(); let main_id = main.snapshot.session_id; - assert!(drain_updates(handle).unwrap().is_empty()); + let drained = drain_updates(handle).unwrap(); + assert!(drained.batches.is_empty() && drained.live_events.is_empty()); let media_id = client.media[0].io.lock().snapshot.session_id; // Distinct session per lane, whatever the configured lane count. let media_ids: Vec = client @@ -652,6 +653,9 @@ fn parallel_media_does_not_refill_when_callback_stops() { #[test] fn extra_main_sessions_stay_zero_without_tmp_sessions() { + let _allowance = crate::scheduler::ALLOWANCE_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); assert_eq!(crate::scheduler::extra_main_sessions(), 0); } diff --git a/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs index 20288c3c..03b9744e 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_logout_tests.rs @@ -32,6 +32,7 @@ fn logout_clears_unauthorized_local_session_without_network() { perm_salt: 0, perm_session_id: 0, dc_auth_keys: HashMap::new(), + journal: Default::default(), }; persist(&state).expect("seed session"); assert!(path.exists()); @@ -114,6 +115,7 @@ fn cached_foreign_dc_key_skips_export_until_the_key_is_unusable() { perm_salt: 0, perm_session_id: 0, dc_auth_keys, + journal: Default::default(), }; let foreign = copy_authorization_to_dc(&mut state, 4).unwrap(); assert_eq!(foreign.dc_id, 4); @@ -208,6 +210,7 @@ fn identity_changes_drop_dc_auth_and_a_later_save_cannot_restore_them() { perm_salt: 0, perm_session_id: 0, dc_auth_keys: client.data.lock().dc_auth_keys.clone(), + journal: Default::default(), }; persist(&state).unwrap(); let loaded = FileSessionStore::new(&path).load().unwrap().unwrap(); @@ -388,7 +391,7 @@ fn empty_update_poll_skips_cache_clone() { } let _ = take_update_cache_clones(); let events = drain_updates(handle).unwrap(); - assert!(events.is_empty()); + assert!(events.batches.is_empty() && events.live_events.is_empty()); assert_eq!(take_update_cache_clones(), 0); destroy_client(handle); let _ = std::fs::remove_file(path); diff --git a/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs index a995092f..623c5f9f 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs @@ -32,6 +32,104 @@ fn password_required_normalizes_tl_and_text_errors() { ))); } +#[test] +fn sticker_document_refreshes_from_its_set_not_saved_gifs() { + let mut sticker = media_rpc::MediaRef { + file_size: Some(12), + kind: "sticker".into(), + cache_key: "emoji:42".into(), + location: media_rpc::MediaLocation::Document { + id: 42, + access_hash: 7, + file_reference: vec![1], + thumb_size: String::new(), + dc_id: 2, + mime_type: "image/webp".into(), + }, + thumb_cache_key: None, + thumb_location: None, + display_cache_key: None, + display_location: None, + sticker_set_id: Some(9), + sticker_set_access_hash: Some(11), + source_url: None, + }; + assert_eq!(sticker_set_of(&sticker), Some((9, 11))); + sticker.sticker_set_id = None; + sticker.sticker_set_access_hash = None; + assert_eq!(sticker_set_of(&sticker), None); +} + +#[test] +fn custom_emoji_miss_uses_sticker_set_not_custom_emoji_documents() { + let sticker = media_rpc::MediaRef { + file_size: Some(12), + kind: "sticker".into(), + cache_key: "emoji:42".into(), + location: media_rpc::MediaLocation::Document { + id: 42, + access_hash: 7, + file_reference: vec![1], + thumb_size: String::new(), + dc_id: 2, + mime_type: "image/webp".into(), + }, + thumb_cache_key: None, + thumb_location: None, + display_cache_key: None, + display_location: None, + sticker_set_id: Some(9), + sticker_set_access_hash: Some(11), + source_url: None, + }; + let mut index = media_rpc::MediaIndex::default(); + index.insert((-1004434463760, 106), sticker); + assert!(index.get(&(42, 0)).is_none()); + assert_eq!( + custom_emoji_document_source(&index, 42), + CustomEmojiDocumentSource::StickerSet { + set_id: 9, + access_hash: 11, + }, + ); + + let mut plain = sticker_without_set(99); + plain.sticker_set_id = None; + plain.sticker_set_access_hash = None; + index.insert((7, 3), plain); + assert_eq!( + custom_emoji_document_source(&index, 99), + CustomEmojiDocumentSource::CustomEmojiDocuments, + ); + assert_eq!( + custom_emoji_document_source(&index, 1000), + CustomEmojiDocumentSource::CustomEmojiDocuments, + ); +} + +fn sticker_without_set(id: i64) -> media_rpc::MediaRef { + media_rpc::MediaRef { + file_size: Some(4), + kind: "sticker".into(), + cache_key: format!("emoji:{id}"), + location: media_rpc::MediaLocation::Document { + id, + access_hash: 1, + file_reference: vec![2], + thumb_size: String::new(), + dc_id: 2, + mime_type: "image/webp".into(), + }, + thumb_cache_key: None, + thumb_location: None, + display_cache_key: None, + display_location: None, + sticker_set_id: None, + sticker_set_access_hash: None, + source_url: None, + } +} + #[test] fn file_reference_retry_requires_new_bytes() { let old = media_rpc::MediaLocation::Photo { diff --git a/native/mtproto-rs/tests/unit/lib_tests.rs b/native/mtproto-rs/tests/unit/lib_tests.rs index 26302fca..c3866655 100644 --- a/native/mtproto-rs/tests/unit/lib_tests.rs +++ b/native/mtproto-rs/tests/unit/lib_tests.rs @@ -34,6 +34,7 @@ fn session_file_persists_user_id() { session_dead: true, test_dc: false, dc_auth_keys: Default::default(), + journal: Default::default(), }) .expect("save"); let raw = std::fs::read(&path).expect("read compact"); diff --git a/native/mtproto-rs/tests/unit/media_download_tests.rs b/native/mtproto-rs/tests/unit/media_download_tests.rs index cfd03caf..57074b52 100644 --- a/native/mtproto-rs/tests/unit/media_download_tests.rs +++ b/native/mtproto-rs/tests/unit/media_download_tests.rs @@ -537,6 +537,33 @@ fn media_range_fetches_one_aligned_part_and_rejects_invalid_bounds() { fs::remove_file(&path).unwrap(); } } + for callback in [false, true] { + let origin = 2 * i64::from(CHUNK); + download_media_range_batched_streaming( + 2, &media, &path, &path, Some(origin), 8, + |_, requests, on_chunk| { + assert_eq!(requests.len(), 4); + let mut results = Vec::new(); + for (index, request) in requests.into_iter().enumerate() { + let result = Ok(UploadFile::UploadFile(UploadFileConstructor { + type_: Box::new(StorageFileType::StorageFileUnknown(StorageFileUnknownConstructor {})), + mtime: 0, + bytes: vec![(request.offset / i64::from(CHUNK)) as u8; CHUNK as usize], + })); + if callback { assert!(on_chunk(index, result.clone()).is_none()); } + results.push(result); + } + Ok(results) + }, + ).unwrap(); + let bytes = fs::read(&path).unwrap(); + assert_eq!(bytes.len(), 512 * 1024); + for index in 0..4 { + assert!(bytes[index * CHUNK as usize..(index + 1) * CHUNK as usize] + .iter().all(|byte| *byte == (index + 2) as u8)); + } + fs::remove_file(&path).unwrap(); + } for offset in [-1, 1, i64::from(CHUNK) + 1] { assert!( download_media_range_with_fetch(2, &media, &path, &path, Some(offset), |_| { @@ -967,6 +994,58 @@ fn one_mib_chunks_are_rejected() { set_chunk_size(512 * 1024); assert_eq!(chunk_size(), 512 * 1024); set_chunk_size(previous); + assert!(window_candidate(128 * 1024, 4, false).is_some()); + assert!(window_candidate(512 * 1024, 8, false).is_some()); + assert!(window_candidate(1024 * 1024, 12, false).is_none()); + assert!(window_candidate(1024 * 1024, 12, true).is_some()); + assert!(window_candidate(1024 * 1024, 4, true).is_none()); +} + +#[test] +fn overlapping_ranges_collapse_and_a_second_claim_waits() { + let offsets = dedup_offsets(&[0, 100, 128 * 1024, 50, 128 * 1024], 128 * 1024); + assert_eq!(offsets, vec![0, 128 * 1024]); + let first = claim_offsets(9, &[0, 128 * 1024]); + assert_eq!(first, vec![0, 128 * 1024]); + assert!(claim_offsets(9, &[0]).is_empty()); + assert_eq!(claim_offsets(9, &[256 * 1024]), vec![256 * 1024]); + release_offsets(9, &[0, 128 * 1024, 256 * 1024]); + assert_eq!(claim_offsets(9, &[0]), vec![0]); + release_offsets(9, &[0]); +} + +#[test] +fn resume_rejects_a_partial_whose_identity_does_not_match() { + let current = RangeIdentity { + file_id: 7, + access_hash: 8, + dc_id: 2, + size: 100, + version: 0, + }; + assert!(!resume_allowed(None, ¤t)); + let mut other = current.clone(); + other.file_id = 9; + assert!(!resume_allowed(Some(&other), ¤t)); + other.file_id = 7; + other.size = 90; + assert!(!resume_allowed(Some(&other), ¤t)); + assert!(resume_allowed(Some(¤t), ¤t)); +} + +#[test] +fn optional_transfer_pauses_only_while_visible_work_is_waiting() { + crate::client::lanes::set_visible_transfer_waiters_for_test(0); + assert!(!should_pause_optional(Some(DownloadProfile::Background))); + crate::client::lanes::set_visible_transfer_waiters_for_test(1); + assert!(should_pause_optional(Some(DownloadProfile::Background))); + assert!(!should_pause_optional(Some(DownloadProfile::Visible))); + assert!(!should_pause_optional(Some(DownloadProfile::User))); + crate::client::lanes::set_visible_transfer_waiters_for_test(0); + assert!(!should_pause_optional(Some(DownloadProfile::Background))); + assert!(keep_partial(&crate::MtprotoError::Message( + "OPTIONAL_PAUSED".into() + ))); } #[test] diff --git a/native/mtproto-rs/tests/unit/request_control_tests.rs b/native/mtproto-rs/tests/unit/request_control_tests.rs index df72d595..1a60b6b6 100644 --- a/native/mtproto-rs/tests/unit/request_control_tests.rs +++ b/native/mtproto-rs/tests/unit/request_control_tests.rs @@ -84,3 +84,27 @@ fn finished_request_cannot_close_socket_reused_by_next_request() { release(first); release(second); } + +#[test] +fn promotion_is_monotonic_scoped_to_the_request_and_cleared_on_release() { + use crate::scheduler::RequestClass; + let id = create(); + let other = create(); + let previous = bind(id); + assert_eq!(promoted_class(), None); + promote(id, RequestClass::InteractiveMedia); + assert_eq!(promoted_class(), Some(RequestClass::InteractiveMedia)); + promote(id, RequestClass::BackgroundMedia); + assert_eq!(promoted_class(), Some(RequestClass::InteractiveMedia), "never lowered"); + promote(id, RequestClass::InteractiveWrite); + assert_eq!(promoted_class(), Some(RequestClass::InteractiveWrite)); + bind(other); + assert_eq!(promoted_class(), None); + bind(previous); + release(id); + release(other); + promote(id, RequestClass::InteractiveRead); + bind(id); + assert_eq!(promoted_class(), None, "released requests ignore promotion"); + bind(previous); +} diff --git a/native/mtproto-rs/tests/unit/scheduler_tests.rs b/native/mtproto-rs/tests/unit/scheduler_tests.rs index 041559ac..664d7ed3 100644 --- a/native/mtproto-rs/tests/unit/scheduler_tests.rs +++ b/native/mtproto-rs/tests/unit/scheduler_tests.rs @@ -1,6 +1,114 @@ use super::*; use std::sync::mpsc; +#[test] +fn background_aging_never_overtakes_writes() { + let waiter = Waiter { ticket: 0, priority: 4, queued_at: 0 }; + assert_eq!(waiter.order_at(0).0, 4); + assert_eq!(waiter.order_at(5_000).0, 3); + assert_eq!(waiter.order_at(60_000).0, 1); + let write = Waiter { ticket: 1, priority: 0, queued_at: 0 }; + assert_eq!(write.order_at(60_000).0, 0); +} + +struct ManualClock(std::sync::atomic::AtomicU64); + +impl ManualClock { + fn advance(&self, ms: u64) { + self.0.fetch_add(ms, std::sync::atomic::Ordering::SeqCst); + } +} + +impl SchedulerClock for ManualClock { + fn now_ms(&self) -> u64 { + self.0.load(std::sync::atomic::Ordering::SeqCst) + } +} + +#[test] +fn injected_clock_ages_background_work_past_a_later_interactive_read_without_sleeping() { + let clock = Arc::new(ManualClock(std::sync::atomic::AtomicU64::new(0))); + let gate = LaneGate::with_clock(clock.clone()); + let background = gate.enqueue(RequestClass::BackgroundMedia); + clock.advance(4_999); + let read = gate.enqueue(RequestClass::InteractiveRead); + assert!(background.try_acquire().is_none(), "a fresh interactive read still wins"); + clock.advance(20_000); + let read_late = gate.enqueue(RequestClass::InteractiveRead); + assert!(read_late.try_acquire().is_none(), "ticket order decides equal effective priority"); + drop(read); + let lease = background.try_acquire().expect("aged background request is next"); + drop(lease); + assert!(read_late.try_acquire().is_some()); +} + +#[test] +fn aging_stops_below_interactive_write_priority() { + let clock = Arc::new(ManualClock(std::sync::atomic::AtomicU64::new(0))); + let gate = LaneGate::with_clock(clock.clone()); + let background = gate.enqueue(RequestClass::BackgroundMedia); + clock.advance(10 * 60_000); + let write = gate.enqueue(RequestClass::InteractiveWrite); + assert!(background.try_acquire().is_none()); + assert!(write.try_acquire().is_some()); +} + +#[test] +fn promotion_raises_a_queued_ticket_and_never_lowers_it() { + let gate = LaneGate::new(); + let read = gate.enqueue(RequestClass::InteractiveRead); + let media = gate.enqueue(RequestClass::BackgroundMedia); + media.promote(RequestClass::InteractiveWrite); + assert!(read.try_acquire().is_none()); + assert!(media.try_acquire().is_some()); + let other = LaneGate::new(); + let first = other.enqueue(RequestClass::InteractiveRead); + let second = other.enqueue(RequestClass::InteractiveRead); + second.promote(RequestClass::BackgroundMedia); + assert!(first.try_acquire().is_some(), "promotion to a lower class changes nothing"); + drop(second); +} + +#[test] +fn a_freed_sibling_lane_is_taken_without_waiting_for_the_first_lane() { + let first = LaneGate::new(); + let second = LaneGate::new(); + let busy = first.acquire(RequestClass::InteractiveRead).unwrap(); + let waiters = [ + first.enqueue(RequestClass::InteractiveMedia), + second.enqueue(RequestClass::InteractiveMedia), + ]; + let acquired = waiters.iter().find_map(|waiter| waiter.try_acquire()); + assert!(acquired.is_some(), "the free lane serves the request while another is busy"); + drop(busy); +} + +#[test] +fn cancelling_a_family_request_releases_every_reservation() { + let first = LaneGate::new(); + let second = LaneGate::new(); + let waiters = [ + first.enqueue(RequestClass::BackgroundMedia), + second.enqueue(RequestClass::BackgroundMedia), + ]; + assert_eq!(first.waiter_count() + second.waiter_count(), 2); + drop(waiters); + assert_eq!(first.waiter_count() + second.waiter_count(), 0); + assert!(first.try_acquire(RequestClass::BackgroundMedia).is_some()); +} + +#[test] +fn fast_admission_cannot_pass_an_equal_priority_waiter() { + let gate = LaneGate::new(); + let waiter = gate.enqueue(RequestClass::InteractiveRead); + assert!(gate.try_acquire(RequestClass::InteractiveRead).is_none()); + let lease = waiter.try_acquire().expect("queued owner acquires the lane"); + assert!(gate.try_acquire(RequestClass::InteractiveRead).is_none()); + drop(lease); + drop(waiter); + assert!(gate.try_acquire(RequestClass::InteractiveRead).is_some()); +} + #[test] fn cancelled_waiter_does_not_block_following_requests() { let gate = LaneGate::new(); @@ -50,6 +158,7 @@ fn dispatch_class_round_trips_through_the_thread_local() { #[test] fn lane_orders_cover_every_lane_exactly_once() { + let _serial = ALLOWANCE_TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner()); let reads: Vec = read_lane_order().collect(); assert_eq!(reads.len(), read_lanes()); let mut sorted = reads.clone(); diff --git a/native/mtproto-rs/tests/unit/update_journal_tests.rs b/native/mtproto-rs/tests/unit/update_journal_tests.rs new file mode 100644 index 00000000..9cbe0825 --- /dev/null +++ b/native/mtproto-rs/tests/unit/update_journal_tests.rs @@ -0,0 +1,299 @@ +use super::*; +use crate::client::{ + acknowledge_update_batch, create_client, destroy_client, flush_persist, get_client, + pending_update_batches, persist_journal_sync, +}; +use std::sync::Arc; + +fn message_event(id: i32) -> UpdateEventDto { + UpdateEventDto::MessagesDeleted { + chat_id: Some(7), + message_ids: vec![id], + } +} + +fn cursor(pts: i32) -> Option { + Some(UpdatesStateDto { + pts, + qts: 0, + date: 1, + seq: 1, + }) +} + +fn temp_session(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("monogram-journal-{name}-{}.session", std::process::id())); + let _ = std::fs::remove_file(&path); + path +} + +fn queue(handle: u64, id: i32) -> DurableBatch { + let client = get_client(handle).unwrap(); + let mut d = client.data.lock(); + let batch = Arc::make_mut(&mut d.journal).push(vec![message_event(id)], cursor(id), vec![(9, id)]); + d.updates = cursor(id); + d.persist_epoch += 1; + batch +} + +#[test] +fn batches_get_increasing_ids_and_ack_is_exact() { + let mut journal = UpdateJournal::default(); + let generation = journal.account_generation; + let first = journal.push(vec![message_event(1)], cursor(1), vec![]); + let second = journal.push(vec![message_event(2)], cursor(2), vec![]); + assert!(second.batch_id > first.batch_id); + assert!(!journal.acknowledge(generation + 1, first.batch_id)); + assert_eq!(journal.batches.len(), 2); + assert!(journal.acknowledge(generation, first.batch_id)); + assert!(!journal.acknowledge(generation, first.batch_id)); + assert_eq!(journal.pending().len(), 1); + assert_eq!(journal.pending()[0].batch_id, second.batch_id); +} + +#[test] +fn delivery_tracking_follows_acknowledgement() { + let mut journal = UpdateJournal::default(); + let generation = journal.account_generation; + let a = journal.push(vec![message_event(1)], None, vec![]); + let b = journal.push(vec![message_event(2)], None, vec![]); + assert!(journal.has_undelivered()); + journal.mark_delivered(a.batch_id); + assert!(journal.has_undelivered()); + journal.acknowledge(generation, a.batch_id); + assert!(journal.has_undelivered(), "b is still undelivered"); + journal.mark_delivered(b.batch_id); + assert!(!journal.has_undelivered()); +} + +#[test] +fn admission_closes_at_the_batch_bound_and_reopens_after_ack() { + let mut journal = UpdateJournal::default(); + let generation = journal.account_generation; + let mut first = 0; + for index in 0..MAX_UNACKED_BATCHES { + let batch = journal.push(vec![message_event(index as i32)], None, vec![]); + if index == 0 { + first = batch.batch_id; + } + } + assert!(!journal.admission_open()); + assert!(journal.acknowledge(generation, first)); + assert!(journal.admission_open()); +} + +#[test] +fn account_reset_drops_batches_and_changes_generation() { + let mut journal = UpdateJournal::default(); + let previous = journal.account_generation; + journal.push(vec![message_event(1)], None, vec![]); + journal.reset_account(); + assert_ne!(journal.account_generation, previous); + assert!(journal.batches.is_empty()); +} + +#[test] +fn only_state_mutations_are_durable() { + assert!(is_durable(&message_event(1))); + assert!(is_durable(&UpdateEventDto::ChatsChanged)); + assert!(is_durable(&UpdateEventDto::ReadInbox { + chat_id: 1, + max_id: 2, + still_unread: 0 + })); + assert!(!is_durable(&UpdateEventDto::SyncState { is_syncing: true })); + assert!(!is_durable(&UpdateEventDto::PeerTyping { + chat_id: 1, + user_id: 2, + typing: true, + action: String::new() + })); + assert!(!is_durable(&UpdateEventDto::Ignored { kind: "x".into() })); + assert!(is_durable(&UpdateEventDto::Ignored { + kind: "unread_mentions_delta:5:1".into() + })); +} + +#[test] +fn journal_survives_process_restart_and_is_replayable() { + let path = temp_session("restart"); + let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); + let batch = queue(handle, 5); + let session_id = get_client(handle).unwrap().data.lock().home_session_id; + persist_journal_sync(&get_client(handle).unwrap(), session_id).unwrap(); + destroy_client(handle); + + let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); + let drained = crate::client::drain_updates(restarted).unwrap(); + assert!(drained.replay_required, "unacknowledged batch must block new delivery"); + let pending = pending_update_batches(restarted).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].batch_id, batch.batch_id); + assert_eq!(pending[0].cursor_after.as_ref().map(|c| c.pts), Some(5)); + assert_eq!(pending[0].channel_pts_after[0].pts, 5); + assert!(matches!( + pending[0].events[0], + UpdateEventDto::MessagesDeleted { .. } + )); + destroy_client(restarted); + let _ = std::fs::remove_file(path); +} + +#[test] +fn crash_before_the_journal_write_loses_nothing_the_cursor_did_not_claim() { + let path = temp_session("before-save"); + let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); + let session_id = get_client(handle).unwrap().data.lock().home_session_id; + persist_journal_sync(&get_client(handle).unwrap(), session_id).unwrap(); + queue(handle, 3); + // A hard kill: no orderly shutdown flush. + crate::client::CLIENTS.lock().remove(&handle); + + let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); + assert!(pending_update_batches(restarted).unwrap().is_empty()); + let client = get_client(restarted).unwrap(); + assert!(client.data.lock().updates.is_none(), "cursor stays behind the unwritten batch"); + destroy_client(restarted); + let _ = std::fs::remove_file(path); +} + +#[test] +fn a_superseded_save_is_retried_until_the_batch_is_on_disk() { + let path = temp_session("stale-retry"); + let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); + let batch = queue(handle, 11); + let client = get_client(handle).unwrap(); + let session_id = client.data.lock().home_session_id; + crate::client::FORCE_STALE_SAVES.store(1, std::sync::atomic::Ordering::SeqCst); + persist_journal_sync(&client, session_id).expect("retry must write after a stale skip"); + assert_eq!( + crate::client::FORCE_STALE_SAVES.load(std::sync::atomic::Ordering::SeqCst), + 0 + ); + destroy_client(handle); + + let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); + let pending = pending_update_batches(restarted).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].batch_id, batch.batch_id); + destroy_client(restarted); + let _ = std::fs::remove_file(path); +} + +#[test] +fn an_older_snapshot_does_not_replace_a_newer_live_journal() { + let mut older = UpdateJournal::default(); + let generation = older.account_generation; + let mut newer = older.clone(); + newer.push(vec![message_event(1)], None, vec![]); + let saved = crate::client::journal_to_save(older, Some(newer.clone())); + assert_eq!(saved.next_batch_id, newer.next_batch_id); + assert_eq!(saved.batches.len(), 1); + let reset = UpdateJournal::default(); + assert_ne!(reset.account_generation, generation); + let explicit_reset = crate::client::journal_to_save(reset.clone(), Some(newer)); + assert_eq!(explicit_reset.account_generation, reset.account_generation); + assert!(explicit_reset.batches.is_empty()); +} + +#[test] +fn acknowledgement_survives_an_older_same_generation_snapshot() { + let mut live = UpdateJournal::default(); + let generation = live.account_generation; + let batch = live.push(vec![message_event(1)], cursor(1), vec![]); + let snapshot = live.clone(); + assert!(live.acknowledge(generation, batch.batch_id)); + let saved = crate::client::journal_to_save(snapshot, Some(live.clone())); + assert!(saved.batches.is_empty(), "an older snapshot must not restore an ack"); + assert_eq!(saved.next_batch_id, live.next_batch_id); +} + +#[test] +fn drain_before_updates_start_creates_no_batch() { + let path = temp_session("empty-drain"); + let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); + let drained = crate::client::drain_updates(handle).unwrap(); + assert!(drained.batches.is_empty()); + assert!(drained.live_events.is_empty()); + assert!(!drained.replay_required); + assert!(get_client(handle).unwrap().data.lock().journal.batches.is_empty()); + destroy_client(handle); + let _ = std::fs::remove_file(path); +} + +#[test] +fn acknowledgement_is_idempotent_and_compaction_is_persisted() { + let path = temp_session("ack"); + let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); + let client = get_client(handle).unwrap(); + let batch = queue(handle, 8); + let session_id = client.data.lock().home_session_id; + persist_journal_sync(&client, session_id).unwrap(); + let generation = pending_update_batches(handle).unwrap()[0].account_generation; + + acknowledge_update_batch(handle, generation + 1, batch.batch_id).unwrap(); + assert_eq!(client.data.lock().journal.batches.len(), 1, "foreign generation is ignored"); + acknowledge_update_batch(handle, generation, batch.batch_id).unwrap(); + acknowledge_update_batch(handle, generation, batch.batch_id).unwrap(); + flush_persist(&client); + destroy_client(handle); + + let restarted = create_client(1, "hash".into(), path.to_string_lossy().into()); + assert!(pending_update_batches(restarted).unwrap().is_empty()); + destroy_client(restarted); + let _ = std::fs::remove_file(path); +} + +#[test] +fn exhausted_persistence_reports_the_error_and_keeps_the_batch() { + let path = temp_session("exhausted"); + let handle = create_client(1, "hash".into(), path.to_string_lossy().into()); + let client = get_client(handle).unwrap(); + queue(handle, 4); + let session_id = client.data.lock().home_session_id; + let blocker = std::env::temp_dir().join(format!("monogram-journal-dir-{}", std::process::id())); + std::fs::create_dir_all(&blocker).unwrap(); + client.data.lock().session_path = blocker.clone(); + + assert!(persist_journal_sync(&client, session_id).is_err()); + assert!(client.persist_exhausted.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!(client.data.lock().journal.batches.len(), 1); + assert!( + pending_update_batches(handle).is_err(), + "an unwritten batch must not be handed out" + ); + + client.data.lock().session_path = path.clone(); + persist_journal_sync(&client, session_id).unwrap(); + assert!(!client.persist_exhausted.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!(pending_update_batches(handle).unwrap().len(), 1); + destroy_client(handle); + let _ = std::fs::remove_dir(blocker); + let _ = std::fs::remove_file(path); +} + +#[test] +fn old_session_files_without_a_journal_still_load() { + let session = crate::session_file::ClientSession { + snapshot: tellers_mtproto_session::Snapshot::new(2, &mut tellers_mtproto_session::OsRandom) + .unwrap(), + user_id: None, + peers: Default::default(), + updates: None, + media: Default::default(), + channel_pts: Default::default(), + channel_recovery: Default::default(), + seen_messages: Default::default(), + session_dead: false, + logout_tokens: Vec::new(), + new_session: None, + test_dc: false, + dc_auth_keys: Default::default(), + journal: Default::default(), + }; + let mut value = serde_json::to_value(&session).unwrap(); + value.as_object_mut().unwrap().remove("journal"); + let loaded: crate::session_file::ClientSession = serde_json::from_value(value).unwrap(); + assert!(loaded.journal.batches.is_empty()); + assert_eq!(loaded.journal.next_batch_id, 1); +} diff --git a/native/mtproto-transport/src/lib.rs b/native/mtproto-transport/src/lib.rs index bbd41775..8e495ee8 100644 --- a/native/mtproto-transport/src/lib.rs +++ b/native/mtproto-transport/src/lib.rs @@ -450,21 +450,37 @@ impl TcpConnection { secret: Option<[u8; 16]>, dc_id: Option, ) -> Result { + let obf = make_obfuscated_stream(secret, dc_id, |nonce| { + tellers_mtproto_crypto::fill_random(nonce) + .map_err(|error| ProxyError::Transport(error.to_string())) + })?; + let mut result = ObfuscatedTcp { inner: self, obf }; + let header = *result.obf.header(); + result.inner.stream.write_all(&header)?; + Ok(result) + } +} + +fn make_obfuscated_stream( + secret: Option<[u8; 16]>, + dc_id: Option, + mut fill: impl FnMut(&mut [u8; 64]) -> Result<(), ProxyError>, +) -> Result { + for _ in 0..16 { let mut nonce = [0_u8; 64]; - tellers_mtproto_crypto::fill_random(&mut nonce) - .map_err(|error| ProxyError::Transport(error.to_string()))?; - let obf = ObfuscatedStream::new_client( + fill(&mut nonce)?; + match ObfuscatedStream::new_client( nonce, ObfuscatedProtocol::PaddedIntermediate, dc_id, secret.as_ref().map(|secret| secret.as_slice()), - ) - .map_err(|error| ProxyError::Transport(error.to_string()))?; - let mut result = ObfuscatedTcp { inner: self, obf }; - let header = *result.obf.header(); - result.inner.stream.write_all(&header)?; - Ok(result) + ) { + Ok(stream) => return Ok(stream), + Err(TransportError::InvalidObfuscationNonce) => continue, + Err(error) => return Err(ProxyError::Transport(error.to_string())), + } } + Err(ProxyError::Transport("could not generate transport obfuscation nonce".into())) } impl Connection for TcpConnection { diff --git a/native/mtproto-transport/tests/unit/proxy_config_tests.rs b/native/mtproto-transport/tests/unit/proxy_config_tests.rs index 395ec755..a7693279 100644 --- a/native/mtproto-transport/tests/unit/proxy_config_tests.rs +++ b/native/mtproto-transport/tests/unit/proxy_config_tests.rs @@ -1,5 +1,37 @@ use super::*; +#[test] +fn obfuscation_retries_forbidden_nonce_prefixes() { + let mut calls = 0; + let stream = make_obfuscated_stream(None, None, |nonce| { + calls += 1; + nonce[4] = 1; + match calls { + 1 => nonce[0] = 0xef, + 2 => nonce[..4].copy_from_slice(b"POST"), + _ => nonce[0] = 1, + } + Ok(()) + }).unwrap(); + assert_eq!(calls, 3); + assert_eq!(stream.header()[0], 1); +} + +#[test] +fn obfuscation_does_not_retry_entropy_errors_and_bounds_rejection() { + let mut calls = 0; + let result = make_obfuscated_stream(None, None, |_| { + calls += 1; + Err(ProxyError::Transport("fixture entropy failure".into())) + }); + assert!(result.is_err()); + assert_eq!(calls, 1); + calls = 0; + let result = make_obfuscated_stream(None, None, |_| { calls += 1; Ok(()) }); + assert!(result.is_err()); + assert_eq!(calls, 16); +} + #[test] fn validates_proxy_variants_without_exposing_secrets() { let config = ProxyConfig { 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 962e0bcb..67ecdfe5 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt @@ -28,6 +28,8 @@ import uniffi.monogram_mtproto.SavedGifDto import uniffi.monogram_mtproto.StickerCatalogDto import uniffi.monogram_mtproto.StickerListDto import uniffi.monogram_mtproto.StickerPackDto +import uniffi.monogram_mtproto.UpdateBatchDto +import uniffi.monogram_mtproto.UpdateDrainDto import uniffi.monogram_mtproto.UpdateEventDto import uniffi.monogram_mtproto.UpdatesStateDto import uniffi.monogram_mtproto.UploadItemDto @@ -50,6 +52,8 @@ interface MtprotoNative { fun createRequestControl(): Long = 0L fun bindRequestControl(id: Long): Long = 0L fun cancelRequestControl(id: Long) = Unit + + fun promoteRequestControl(id: Long, dispatchClass: Int) = Unit fun releaseRequestControl(id: Long) = Unit /** Dispatch class for the request on this thread. */ @@ -518,6 +522,20 @@ interface MtprotoNative { @Throws(MtprotoException::class) fun drainUpdates(handle: Long): List + @Throws(MtprotoException::class) + 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 + @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 = 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 19a3931e..bf81a892 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt @@ -29,6 +29,8 @@ import uniffi.monogram_mtproto.SavedGifDto import uniffi.monogram_mtproto.StickerCatalogDto import uniffi.monogram_mtproto.StickerListDto import uniffi.monogram_mtproto.StickerPackDto +import uniffi.monogram_mtproto.UpdateBatchDto +import uniffi.monogram_mtproto.UpdateDrainDto import uniffi.monogram_mtproto.UpdateEventDto import uniffi.monogram_mtproto.UpdatesStateDto import uniffi.monogram_mtproto.UploadItemDto @@ -53,7 +55,9 @@ import uniffi.monogram_mtproto.destroyClient as nativeDestroyClient import uniffi.monogram_mtproto.downloadCustomEmoji as nativeDownloadCustomEmoji import uniffi.monogram_mtproto.downloadMessageMedia as nativeDownloadMessageMedia import uniffi.monogram_mtproto.downloadMessageThumb as nativeDownloadMessageThumb +import uniffi.monogram_mtproto.acknowledgeUpdateBatch as nativeAcknowledgeUpdateBatch import uniffi.monogram_mtproto.drainUpdates as nativeDrainUpdates +import uniffi.monogram_mtproto.pendingUpdateBatches as nativePendingUpdateBatches import uniffi.monogram_mtproto.editForumTopicHidden as nativeEditForumTopicHidden import uniffi.monogram_mtproto.editTextMessage as nativeEditTextMessage import uniffi.monogram_mtproto.forwardMessages as nativeForwardMessages @@ -147,6 +151,9 @@ object MtprotoNativeUniFfi : MtprotoNative { 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 releaseRequestControl(id: Long) = uniffi.monogram_mtproto.releaseRequestControl(id.toULong()) override fun setDispatchClass(classId: Int) = uniffi.monogram_mtproto.setDispatchClass(classId) @@ -720,8 +727,18 @@ object MtprotoNativeUniFfi : MtprotoNative { } override fun drainUpdates(handle: Long): List = + nativeDrainUpdates(handle.toULong()).let { it.batches.flatMap { batch -> batch.events } + it.liveEvents } + + override fun drainUpdateBatches(handle: Long): UpdateDrainDto = nativeDrainUpdates(handle.toULong()) + override fun pendingUpdateBatches(handle: Long): List = + nativePendingUpdateBatches(handle.toULong()) + + override fun acknowledgeUpdateBatch(handle: Long, accountGeneration: Long, batchId: Long) { + nativeAcknowledgeUpdateBatch(handle.toULong(), accountGeneration, batchId) + } + override fun downloadMessageMedia( handle: Long, chatId: Long, 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 32a0b022..6e2995e5 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 @@ -17,18 +17,13 @@ package uniffi.monogram_mtproto // compile the Rust component. The easiest way to ensure this is to bundle the Kotlin // helpers directly inline like we're doing here. -import com.sun.jna.Library -import com.sun.jna.IntegerType import com.sun.jna.Native import com.sun.jna.Pointer import com.sun.jna.Structure -import com.sun.jna.Callback -import com.sun.jna.ptr.* import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.CharBuffer import java.nio.charset.CodingErrorAction -import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.ConcurrentHashMap // This is a helper for safely working with byte buffers returned from the Rust code. @@ -56,7 +51,7 @@ open class RustBuffer : Structure() { } companion object { - internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> + internal fun alloc(size: ULong = 0UL) = uniffiRustCall { status -> // Note: need to convert the size to a `Long` value to make this work with JVM. UniffiLib.ffi_monogram_mtproto_rustbuffer_alloc(size.toLong(), status) }.also { @@ -65,15 +60,15 @@ open class RustBuffer : Structure() { } } - internal fun create(capacity: ULong, len: ULong, data: Pointer?): RustBuffer.ByValue { - var buf = RustBuffer.ByValue() + internal fun create(capacity: ULong, len: ULong, data: Pointer?): ByValue { + var buf = ByValue() buf.capacity = capacity.toLong() buf.len = len.toLong() buf.data = data return buf } - internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> + internal fun free(buf: ByValue) = uniffiRustCall { status -> UniffiLib.ffi_monogram_mtproto_rustbuffer_free(buf, status) } } @@ -111,28 +106,28 @@ internal open class ForeignBytes : Structure() { // stable native address that JNA can expose via `getDirectBufferPointer`. // The returned `ForeignBytes.ByValue` is only valid for the duration of // the FFI call; the Rust side treats it as a borrow. -internal object FfiConverterByRefBytes : FfiConverter { - override fun lower(value: java.nio.ByteBuffer): ForeignBytes.ByValue { +internal object FfiConverterByRefBytes : FfiConverter { + override fun lower(value: ByteBuffer): ForeignBytes.ByValue { require(value.isDirect) { "UniFFI zero-copy &[u8] requires a direct ByteBuffer. Use ByteBuffer.allocateDirect()." } val remaining = value.remaining() val fb = ForeignBytes.ByValue() fb.len = remaining // Zero-length direct buffers: skip getDirectBufferPointer (platform-variable behavior) // and pass null. The Rust side treats (null, 0) as &[]. - fb.data = if (remaining == 0) null else com.sun.jna.Native.getDirectBufferPointer(value) + fb.data = if (remaining == 0) null else Native.getDirectBufferPointer(value) return fb } - override fun lift(value: ForeignBytes.ByValue): java.nio.ByteBuffer = + override fun lift(value: ForeignBytes.ByValue): ByteBuffer = error("ByRef bytes cannot be lifted: zero-copy &[u8] only flows foreign->Rust") - override fun read(buf: java.nio.ByteBuffer): java.nio.ByteBuffer = + override fun read(buf: ByteBuffer): ByteBuffer = error("ByRef bytes cannot be read from a buffer: zero-copy &[u8] is only supported in argument position, not nested in records/options/etc.") - override fun write(value: java.nio.ByteBuffer, buf: java.nio.ByteBuffer): Unit = + override fun write(value: ByteBuffer, buf: ByteBuffer): Unit = error("ByRef bytes cannot be written to a buffer: zero-copy &[u8] is only supported in argument position, not nested in records/options/etc.") - override fun allocationSize(value: java.nio.ByteBuffer): ULong = + override fun allocationSize(value: ByteBuffer): ULong = error("ByRef bytes have no RustBuffer allocation size: zero-copy &[u8] is only supported in argument position, not nested in records/options/etc.") } /** @@ -143,7 +138,7 @@ internal object FfiConverterByRefBytes : FfiConverter { +interface FfiConverter { // Convert an FFI type to a Kotlin type fun lift(value: FfiType): KotlinType @@ -210,7 +205,7 @@ public interface FfiConverter { * * @suppress */ -public interface FfiConverterRustBuffer: FfiConverter { +interface FfiConverterRustBuffer : FfiConverter { override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) } @@ -241,8 +236,8 @@ internal open class UniffiRustCallStatus : Structure() { } companion object { - fun create(code: Byte, errorBuf: RustBuffer.ByValue): UniffiRustCallStatus.ByValue { - val callStatus = UniffiRustCallStatus.ByValue() + fun create(code: Byte, errorBuf: RustBuffer.ByValue): ByValue { + val callStatus = ByValue() callStatus.code = code callStatus.error_buf = errorBuf return callStatus @@ -250,7 +245,7 @@ internal open class UniffiRustCallStatus : Structure() { } } -class InternalException(message: String) : kotlin.Exception(message) +class InternalException(message: String) : Exception(message) /** * Each top-level error class has a companion object that can lift the error from the call status's rust buffer @@ -258,7 +253,7 @@ class InternalException(message: String) : kotlin.Exception(message) * @suppress */ interface UniffiRustCallStatusErrorHandler { - fun lift(error_buf: RustBuffer.ByValue): E; + fun lift(error_buf: RustBuffer.ByValue): E } // Helpers for calling Rust @@ -266,7 +261,10 @@ interface UniffiRustCallStatusErrorHandler { // synchronize itself // Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err -private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { +private inline fun uniffiRustCallWithError( + errorHandler: UniffiRustCallStatusErrorHandler, + callback: (UniffiRustCallStatus) -> U +): U { var status = UniffiRustCallStatus() val return_value = callback(status) uniffiCheckCallStatus(errorHandler, status) @@ -274,7 +272,10 @@ private inline fun uniffiRustCallWithError(errorHandler } // Check UniffiRustCallStatus and throw an error if the call wasn't successful -private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { +private fun uniffiCheckCallStatus( + errorHandler: UniffiRustCallStatusErrorHandler, + status: UniffiRustCallStatus +) { if (status.isSuccess()) { return } else if (status.isError()) { @@ -317,7 +318,7 @@ internal inline fun uniffiTraitInterfaceCall( ) { try { writeReturn(makeCall()) - } catch(e: kotlin.Exception) { + } catch (e: Exception) { val err = try { e.stackTraceToString() } catch(_: Throwable) { "" } callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR callStatus.error_buf = FfiConverterString.lower(err) @@ -332,7 +333,7 @@ internal inline fun uniffiTraitInterfaceCallWithError( ) { try { writeReturn(makeCall()) - } catch(e: kotlin.Exception) { + } catch (e: Exception) { if (e is E) { callStatus.code = UNIFFI_CALL_ERROR callStatus.error_buf = lowerError(e) @@ -416,7 +417,7 @@ internal open class UniffiForeignFutureDroppedCallbackStruct( class UniffiByValue( `handle`: Long = 0.toLong(), `free`: UniffiForeignFutureDroppedCallback? = null, - ): UniffiForeignFutureDroppedCallbackStruct(`handle`,`free`,), Structure.ByValue + ) : UniffiForeignFutureDroppedCallbackStruct(`handle`, `free`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureDroppedCallbackStruct) { `handle` = other.`handle` @@ -432,7 +433,7 @@ internal open class UniffiForeignFutureResultU8( class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU8(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultU8(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU8) { `returnValue` = other.`returnValue` @@ -451,7 +452,7 @@ internal open class UniffiForeignFutureResultI8( class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI8(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultI8(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI8) { `returnValue` = other.`returnValue` @@ -470,7 +471,7 @@ internal open class UniffiForeignFutureResultU16( class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU16(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultU16(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU16) { `returnValue` = other.`returnValue` @@ -489,7 +490,7 @@ internal open class UniffiForeignFutureResultI16( class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI16(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultI16(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI16) { `returnValue` = other.`returnValue` @@ -508,7 +509,7 @@ internal open class UniffiForeignFutureResultU32( class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU32(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultU32(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU32) { `returnValue` = other.`returnValue` @@ -527,7 +528,7 @@ internal open class UniffiForeignFutureResultI32( class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI32(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultI32(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI32) { `returnValue` = other.`returnValue` @@ -546,7 +547,7 @@ internal open class UniffiForeignFutureResultU64( class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU64(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultU64(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU64) { `returnValue` = other.`returnValue` @@ -565,7 +566,7 @@ internal open class UniffiForeignFutureResultI64( class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI64(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultI64(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI64) { `returnValue` = other.`returnValue` @@ -584,7 +585,7 @@ internal open class UniffiForeignFutureResultF32( class UniffiByValue( `returnValue`: Float = 0.0f, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultF32(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultF32(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultF32) { `returnValue` = other.`returnValue` @@ -603,7 +604,7 @@ internal open class UniffiForeignFutureResultF64( class UniffiByValue( `returnValue`: Double = 0.0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultF64(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultF64(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultF64) { `returnValue` = other.`returnValue` @@ -622,7 +623,7 @@ internal open class UniffiForeignFutureResultRustBuffer( class UniffiByValue( `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultRustBuffer(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultRustBuffer) { `returnValue` = other.`returnValue` @@ -639,7 +640,7 @@ internal open class UniffiForeignFutureResultVoid( ) : Structure() { class UniffiByValue( `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultVoid(`callStatus`,), Structure.ByValue + ) : UniffiForeignFutureResultVoid(`callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultVoid) { `callStatus` = other.`callStatus` @@ -662,7 +663,11 @@ internal open class UniffiVTableCallbackInterfaceDownloadProgressListener( `uniffiFree`: UniffiCallbackInterfaceFree? = null, `uniffiClone`: UniffiCallbackInterfaceClone? = null, `onProgress`: UniffiCallbackInterfaceDownloadProgressListenerMethod0? = null, - ): UniffiVTableCallbackInterfaceDownloadProgressListener(`uniffiFree`,`uniffiClone`,`onProgress`,), Structure.ByValue + ) : UniffiVTableCallbackInterfaceDownloadProgressListener( + `uniffiFree`, + `uniffiClone`, + `onProgress`, + ), ByValue internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceDownloadProgressListener) { `uniffiFree` = other.`uniffiFree` @@ -694,6 +699,8 @@ internal object IntegrityCheckingUniffiLib { uniffiCheckContractApiVersion(this) uniffiCheckApiChecksums(this) } + external fun uniffi_monogram_mtproto_checksum_func_acknowledge_update_batch( + ): Int external fun uniffi_monogram_mtproto_checksum_func_add_waveform_pcm( ): Int external fun uniffi_monogram_mtproto_checksum_func_animated_emoji_max( @@ -872,12 +879,16 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_peek_peer_inline_thumb( ): Int + external fun uniffi_monogram_mtproto_checksum_func_pending_update_batches( + ): Int external fun uniffi_monogram_mtproto_checksum_func_perf_set_enabled( ): Int external fun uniffi_monogram_mtproto_checksum_func_perf_snapshot( ): Int external fun uniffi_monogram_mtproto_checksum_func_ping_proxy( ): Int + external fun uniffi_monogram_mtproto_checksum_func_promote_request_control( + ): Int external fun uniffi_monogram_mtproto_checksum_func_read_discussion( ): Int external fun uniffi_monogram_mtproto_checksum_func_read_history( @@ -986,32 +997,63 @@ internal object UniffiLib { uniffiCallbackInterfaceDownloadProgressListener.register(this) } - external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener(`vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_add_waveform_pcm(`handle`: Long,`samples`: RustBuffer.ByValue,`sampleRate`: Int,`channels`: Int,`presentationTimeUs`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener( + `vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, + ) + + external fun uniffi_monogram_mtproto_fn_func_acknowledge_update_batch( + `handle`: Long, + `accountGeneration`: Long, + `batchId`: Long, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_add_waveform_pcm( + `handle`: Long, + `samples`: RustBuffer.ByValue, + `sampleRate`: Int, + `channels`: Int, + `presentationTimeUs`: Long, + uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_animated_emoji_max(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int - external fun uniffi_monogram_mtproto_fn_func_append_todo_items(`handle`: Long,`chatId`: Long,`messageId`: Int,`firstId`: Int,`titles`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_append_todo_items( + `handle`: Long, + `chatId`: Long, + `messageId`: Int, + `firstId`: Int, + `titles`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_bind_request_control(`id`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long - external fun uniffi_monogram_mtproto_fn_func_cancel_request_control(`id`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_cancel_request_control( + `id`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_check_password(`handle`: Long,`password`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_clear_active_dialog(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_clear_proxy(uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_clear_active_dialog( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_clear_proxy( + uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_client_api_id(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_client_exists(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte external fun uniffi_monogram_mtproto_fn_func_client_uses_test_dc(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte - external fun uniffi_monogram_mtproto_fn_func_connect(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_connect( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_contacts_search(`handle`: Long,`query`: RustBuffer.ByValue,`limit`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_create_client(`apiId`: Int,`apiHash`: RustBuffer.ByValue,`sessionPath`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1034,18 +1076,34 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_decrypt_push_payload(`secret`: RustBuffer.ByValue,`payload`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_delete_folder(`handle`: Long,`id`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_delete_message(`handle`: Long,`chatId`: Long,`messageId`: Int,`revoke`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_destroy_client(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_destroy_lottie(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_destroy_vpx_decoder(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_destroy_waveform(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_delete_folder( + `handle`: Long, `id`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_delete_message( + `handle`: Long, + `chatId`: Long, + `messageId`: Int, + `revoke`: Byte, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_destroy_client( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_destroy_lottie( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_destroy_vpx_decoder( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_destroy_waveform( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_download_chunk_kib(uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_download_concurrency(uniffi_out_err: UniffiRustCallStatus, @@ -1064,8 +1122,14 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_drain_updates(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_edit_forum_topic_hidden(`handle`: Long,`chatId`: Long,`topicId`: Int,`hidden`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_edit_forum_topic_hidden( + `handle`: Long, + `chatId`: Long, + `topicId`: Int, + `hidden`: Byte, + uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_edit_text_message(`handle`: Long,`chatId`: Long,`messageId`: Int,`text`: RustBuffer.ByValue,`entitiesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_finish_waveform(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1152,46 +1216,89 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_load_more_chats(`handle`: Long,`offsetDate`: Int,`offsetId`: Int,`offsetPeerId`: Long,`folderId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_logout(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_logout( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_lottie_frame_count(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_lottie_frame_rate(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Float external fun uniffi_monogram_mtproto_fn_func_lottie_size(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_mark_dialog_unread(`handle`: Long,`chatId`: Long,`unread`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_mark_dialog_unread( + `handle`: Long, `chatId`: Long, `unread`: Byte, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_peek_message_inline_thumb(`handle`: Long,`chatId`: Long,`messageId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_peek_peer_inline_thumb(`handle`: Long,`peerId`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_perf_set_enabled(`enabled`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_pending_update_batches( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + external fun uniffi_monogram_mtproto_fn_func_perf_set_enabled( + `enabled`: Byte, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_perf_snapshot(`reset`: Byte,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_ping_proxy(`kind`: RustBuffer.ByValue,`host`: RustBuffer.ByValue,`port`: Short,`username`: RustBuffer.ByValue,`password`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Long - external fun uniffi_monogram_mtproto_fn_func_read_discussion(`handle`: Long,`chatId`: Long,`msgId`: Int,`readMaxId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_read_history(`handle`: Long,`chatId`: Long,`maxId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_read_mentions(`handle`: Long,`chatId`: Long,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_read_message_contents(`handle`: Long,`chatId`: Long,`messageIds`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_read_reactions(`handle`: Long,`chatId`: Long,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_register_device(`handle`: Long,`tokenType`: Int,`token`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,`noMuted`: Byte,`appSandbox`: Byte,`otherUids`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_release_request_control(`id`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_promote_request_control( + `id`: Long, `class`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_read_discussion( + `handle`: Long, + `chatId`: Long, + `msgId`: Int, + `readMaxId`: Int, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_read_history( + `handle`: Long, `chatId`: Long, `maxId`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_read_mentions( + `handle`: Long, `chatId`: Long, `topMsgId`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_read_message_contents( + `handle`: Long, + `chatId`: Long, + `messageIds`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_read_reactions( + `handle`: Long, `chatId`: Long, `topMsgId`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_register_device( + `handle`: Long, + `tokenType`: Int, + `token`: RustBuffer.ByValue, + `secret`: RustBuffer.ByValue, + `noMuted`: Byte, + `appSandbox`: Byte, + `otherUids`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_release_request_control( + `id`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_render_lottie_frame(`handle`: Long,`frame`: Float,`width`: Int,`height`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_resend_auth_code(`handle`: Long,`phone`: RustBuffer.ByValue,`phoneCodeHash`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_reset_notify_settings(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_reset_notify_settings( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_resolve_username(`handle`: Long,`username`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_search_global(`handle`: Long,`query`: RustBuffer.ByValue,`offsetRate`: Int,`offsetPeerId`: Long,`offsetId`: Int,`limit`: Int,`folderId`: Int,uniffi_out_err: UniffiRustCallStatus, @@ -1206,14 +1313,36 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_send_inline_bot_result(`handle`: Long,`chatId`: Long,`queryId`: Long,`resultId`: RustBuffer.ByValue,`replyToMsgId`: Int,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_send_location(`handle`: Long,`chatId`: Long,`latitude`: Double,`longitude`: Double,`livePeriod`: Int,`heading`: Int,`replyToMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_send_location( + `handle`: Long, + `chatId`: Long, + `latitude`: Double, + `longitude`: Double, + `livePeriod`: Int, + `heading`: Int, + `replyToMsgId`: Int, + uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_send_photo_message(`handle`: Long,`chatId`: Long,`path`: RustBuffer.ByValue,`caption`: RustBuffer.ByValue,`replyToMsgId`: Int,`topMsgId`: Int,`entitiesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_send_poll_vote(`handle`: Long,`chatId`: Long,`messageId`: Int,`options`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_send_reaction(`handle`: Long,`chatId`: Long,`messageId`: Int,`emoticon`: RustBuffer.ByValue,`documentId`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_send_poll_vote( + `handle`: Long, + `chatId`: Long, + `messageId`: Int, + `options`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_send_reaction( + `handle`: Long, + `chatId`: Long, + `messageId`: Int, + `emoticon`: RustBuffer.ByValue, + `documentId`: Long, + uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_send_saved_gif(`handle`: Long,`chatId`: Long,`documentId`: Long,`replyToMsgId`: Int,`topMsgId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_send_text_message(`handle`: Long,`chatId`: Long,`text`: RustBuffer.ByValue,`replyToMsgId`: Int,`entitiesJson`: RustBuffer.ByValue,`topMsgId`: Int,`webpageUrl`: RustBuffer.ByValue,`clientRandomId`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1222,152 +1351,293 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_send_uploaded_media(`handle`: Long,`chatId`: Long,`item`: RustBuffer.ByValue,`replyToMsgId`: Int,`topMsgId`: Int,`entitiesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_set_client_test_dc(`handle`: Long,`test`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_contact_joined_silent(`handle`: Long,`silent`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_dispatch_class(`class`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_download_chunk_kib(`kib`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_download_concurrency(`lanes`: Int,`parts`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_download_progress_listener(`listener`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_file_part_kib(`kib`: Int,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_init_connection_info(`info`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_proxy(`kind`: RustBuffer.ByValue,`host`: RustBuffer.ByValue,`port`: Short,`username`: RustBuffer.ByValue,`password`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_transport_mode(`mode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_set_typing(`handle`: Long,`chatId`: Long,`typing`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_set_client_test_dc( + `handle`: Long, `test`: Byte, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_contact_joined_silent( + `handle`: Long, `silent`: Byte, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_dispatch_class( + `class`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_download_chunk_kib( + `kib`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_download_concurrency( + `lanes`: Int, `parts`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_download_progress_listener( + `listener`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_file_part_kib( + `kib`: Int, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_init_connection_info( + `info`: RustBuffer.ByValue, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_proxy( + `kind`: RustBuffer.ByValue, + `host`: RustBuffer.ByValue, + `port`: Short, + `username`: RustBuffer.ByValue, + `password`: RustBuffer.ByValue, + `secret`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_transport_mode( + `mode`: RustBuffer.ByValue, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_set_typing( + `handle`: Long, `chatId`: Long, `typing`: Byte, uniffi_out_err: UniffiRustCallStatus, + ) + external fun uniffi_monogram_mtproto_fn_func_sign_in(`handle`: Long,`phone`: RustBuffer.ByValue,`phoneCodeHash`: RustBuffer.ByValue,`phoneCode`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun uniffi_monogram_mtproto_fn_func_start_updates(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_toggle_todo_completed(`handle`: Long,`chatId`: Long,`messageId`: Int,`completed`: RustBuffer.ByValue,`incompleted`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_unblock_user(`handle`: Long,`chatId`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_unregister_device(`handle`: Long,`tokenType`: Int,`token`: RustBuffer.ByValue,`otherUids`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_update_folder(`handle`: Long,`folder`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_update_folder_order(`handle`: Long,`order`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_update_lazy_sync_config(`handle`: Long,`lazy`: Byte,`exceptions`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_update_notify_settings(`handle`: Long,`peerKind`: RustBuffer.ByValue,`chatId`: Long,`showPreviews`: RustBuffer.ByValue,`silent`: Byte,`muteUntil`: Int,`storiesMuted`: Byte,`sound`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_monogram_mtproto_fn_func_update_status(`handle`: Long,`offline`: Byte,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun uniffi_monogram_mtproto_fn_func_start_updates( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_toggle_todo_completed( + `handle`: Long, + `chatId`: Long, + `messageId`: Int, + `completed`: RustBuffer.ByValue, + `incompleted`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_unblock_user( + `handle`: Long, `chatId`: Long, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_unregister_device( + `handle`: Long, + `tokenType`: Int, + `token`: RustBuffer.ByValue, + `otherUids`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_update_folder( + `handle`: Long, `folder`: RustBuffer.ByValue, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_update_folder_order( + `handle`: Long, `order`: RustBuffer.ByValue, uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_update_lazy_sync_config( + `handle`: Long, + `lazy`: Byte, + `exceptions`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_update_notify_settings( + `handle`: Long, + `peerKind`: RustBuffer.ByValue, + `chatId`: Long, + `showPreviews`: RustBuffer.ByValue, + `silent`: Byte, + `muteUntil`: Int, + `storiesMuted`: Byte, + `sound`: RustBuffer.ByValue, + uniffi_out_err: UniffiRustCallStatus, + ) + + external fun uniffi_monogram_mtproto_fn_func_update_status( + `handle`: Long, `offline`: Byte, uniffi_out_err: UniffiRustCallStatus, + ) + external fun ffi_monogram_mtproto_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun ffi_monogram_mtproto_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun ffi_monogram_mtproto_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun ffi_monogram_mtproto_rustbuffer_free( + `buf`: RustBuffer.ByValue, uniffi_out_err: UniffiRustCallStatus, + ) + external fun ffi_monogram_mtproto_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun ffi_monogram_mtproto_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_u8(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_u8(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_u8( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_u8( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_u8( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int - external fun ffi_monogram_mtproto_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_i8(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_i8(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_i8( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_i8( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_i8( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte - external fun ffi_monogram_mtproto_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_u16(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_u16(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_u16( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_u16( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_u16( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int - external fun ffi_monogram_mtproto_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_i16(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_i16(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_i16( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_i16( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_i16( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Short - external fun ffi_monogram_mtproto_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_u32(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_u32(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_u32( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_u32( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_u32( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int - external fun ffi_monogram_mtproto_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_i32(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_i32(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_i32( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_i32( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_i32( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Int - external fun ffi_monogram_mtproto_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_u64(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_u64(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_u64( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_u64( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_u64( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long - external fun ffi_monogram_mtproto_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_i64(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_i64(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_i64( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_i64( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_i64( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long - external fun ffi_monogram_mtproto_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_f32(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_f32(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_f32( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_f32( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_f32( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Float - external fun ffi_monogram_mtproto_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_f64(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_f64(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_f64( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_f64( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_f64( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Double - external fun ffi_monogram_mtproto_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_rust_buffer(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_rust_buffer(`handle`: Long, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_rust_buffer( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_rust_buffer( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_rust_buffer( + `handle`: Long, + ) + external fun ffi_monogram_mtproto_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue - external fun ffi_monogram_mtproto_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_cancel_void(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_free_void(`handle`: Long, - ): Unit - external fun ffi_monogram_mtproto_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit + external fun ffi_monogram_mtproto_rust_future_poll_void( + `handle`: Long, `callback`: UniffiRustFutureContinuationCallback, `callbackData`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_cancel_void( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_free_void( + `handle`: Long, + ) + + external fun ffi_monogram_mtproto_rust_future_complete_void( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, + ) } @@ -1383,6 +1653,9 @@ private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { } @Suppress("UNUSED_PARAMETER") private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { + if ((lib.uniffi_monogram_mtproto_checksum_func_acknowledge_update_batch() and 0xFFFF) != 5843) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_add_waveform_pcm() and 0xFFFF) != 55780) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1494,7 +1767,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_download_wallpaper() and 0xFFFF) != 51704) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if ((lib.uniffi_monogram_mtproto_checksum_func_drain_updates() and 0xFFFF) != 44961) { + if ((lib.uniffi_monogram_mtproto_checksum_func_drain_updates() and 0xFFFF) != 3845) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_edit_forum_topic_hidden() and 0xFFFF) != 27878) { @@ -1650,6 +1923,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_peek_peer_inline_thumb() and 0xFFFF) != 63664) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_pending_update_batches() and 0xFFFF) != 10802) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_perf_set_enabled() and 0xFFFF) != 49824) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1659,6 +1935,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_ping_proxy() and 0xFFFF) != 46980) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_promote_request_control() and 0xFFFF) != 59110) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_read_discussion() and 0xFFFF) != 59067) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1805,7 +2084,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { /** * @suppress */ -public fun uniffiEnsureInitialized() { +fun uniffiEnsureInitialized() { IntegrityCheckingUniffiLib // UniffiLib() initialized as objects are used, but we still need to explicitly // reference it so initialization across crates works as expected. @@ -1902,7 +2181,8 @@ internal const val UNIFFI_CALLBACK_UNEXPECTED_ERROR = 2 /** * @suppress */ -public abstract class FfiConverterCallbackInterface: FfiConverter { +abstract class FfiConverterCallbackInterface : + FfiConverter { internal val handleMap = UniffiHandleMap() internal fun drop(handle: Long) { @@ -1927,7 +2207,7 @@ public abstract class FfiConverterCallbackInterface: Ffi /** * @suppress */ -public object FfiConverterUShort: FfiConverter { +object FfiConverterUShort : FfiConverter { override fun lift(value: Short): UShort { return value.toUShort() } @@ -1954,7 +2234,7 @@ public object FfiConverterUShort: FfiConverter { /** * @suppress */ -public object FfiConverterShort: FfiConverter { +object FfiConverterShort : FfiConverter { override fun lift(value: Short): Short { return value } @@ -1977,7 +2257,7 @@ public object FfiConverterShort: FfiConverter { /** * @suppress */ -public object FfiConverterUInt: FfiConverter { +object FfiConverterUInt : FfiConverter { override fun lift(value: Int): UInt { return value.toUInt() } @@ -2000,7 +2280,7 @@ public object FfiConverterUInt: FfiConverter { /** * @suppress */ -public object FfiConverterInt: FfiConverter { +object FfiConverterInt : FfiConverter { override fun lift(value: Int): Int { return value } @@ -2023,7 +2303,7 @@ public object FfiConverterInt: FfiConverter { /** * @suppress */ -public object FfiConverterULong: FfiConverter { +object FfiConverterULong : FfiConverter { override fun lift(value: Long): ULong { return value.toULong() } @@ -2046,7 +2326,7 @@ public object FfiConverterULong: FfiConverter { /** * @suppress */ -public object FfiConverterLong: FfiConverter { +object FfiConverterLong : FfiConverter { override fun lift(value: Long): Long { return value } @@ -2069,7 +2349,7 @@ public object FfiConverterLong: FfiConverter { /** * @suppress */ -public object FfiConverterFloat: FfiConverter { +object FfiConverterFloat : FfiConverter { override fun lift(value: Float): Float { return value } @@ -2092,7 +2372,7 @@ public object FfiConverterFloat: FfiConverter { /** * @suppress */ -public object FfiConverterDouble: FfiConverter { +object FfiConverterDouble : FfiConverter { override fun lift(value: Double): Double { return value } @@ -2115,7 +2395,7 @@ public object FfiConverterDouble: FfiConverter { /** * @suppress */ -public object FfiConverterBoolean: FfiConverter { +object FfiConverterBoolean : FfiConverter { override fun lift(value: Byte): Boolean { return value.toInt() != 0 } @@ -2138,7 +2418,7 @@ public object FfiConverterBoolean: FfiConverter { /** * @suppress */ -public object FfiConverterString: FfiConverter { +object FfiConverterString : FfiConverter { // Note: we don't inherit from FfiConverterRustBuffer, because we use a // special encoding when lowering/lifting. We can use `RustBuffer.len` to // store our length and avoid writing it out to the buffer. @@ -2195,7 +2475,7 @@ public object FfiConverterString: FfiConverter { /** * @suppress */ -public object FfiConverterByteArray: FfiConverterRustBuffer { +object FfiConverterByteArray : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ByteArray { val len = buf.getInt() val byteArr = ByteArray(len) @@ -2214,13 +2494,13 @@ public object FfiConverterByteArray: FfiConverterRustBuffer { data class AuthCodeSent ( - var `phone`: kotlin.String + var `phone`: String , - var `phoneCodeHash`: kotlin.String + var `phoneCodeHash`: String , - var `codeType`: kotlin.String + var `codeType`: String , - var `codeLength`: kotlin.Int + var `codeLength`: Int ){ @@ -2234,7 +2514,7 @@ data class AuthCodeSent ( /** * @suppress */ -public object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer { +object FfiConverterTypeAuthCodeSent : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthCodeSent { return AuthCodeSent( FfiConverterString.read(buf), @@ -2262,9 +2542,9 @@ public object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer data class AuthSignedIn ( - var `userId`: kotlin.Long + var `userId`: Long , - var `dcId`: kotlin.Int + var `dcId`: Int ){ @@ -2278,7 +2558,7 @@ data class AuthSignedIn ( /** * @suppress */ -public object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer { +object FfiConverterTypeAuthSignedIn : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthSignedIn { return AuthSignedIn( FfiConverterLong.read(buf), @@ -2300,13 +2580,13 @@ public object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer data class BotCallbackAnswerDto ( - var `alert`: kotlin.Boolean + var `alert`: Boolean , - var `message`: kotlin.String? + var `message`: String? , - var `url`: kotlin.String? + var `url`: String? , - var `cacheTime`: kotlin.Int + var `cacheTime`: Int ){ @@ -2320,7 +2600,7 @@ data class BotCallbackAnswerDto ( /** * @suppress */ -public object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { +object FfiConverterTypeBotCallbackAnswerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): BotCallbackAnswerDto { return BotCallbackAnswerDto( FfiConverterBoolean.read(buf), @@ -2346,99 +2626,135 @@ public object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): ChannelCursorDto { + return ChannelCursorDto( + FfiConverterLong.read(buf), + FfiConverterInt.read(buf), + ) + } + + override fun allocationSize(value: ChannelCursorDto) = ( + FfiConverterLong.allocationSize(value.`chatId`) + + FfiConverterInt.allocationSize(value.`pts`) + ) + + override fun write(value: ChannelCursorDto, buf: ByteBuffer) { + FfiConverterLong.write(value.`chatId`, buf) + FfiConverterInt.write(value.`pts`, buf) + } +} + + data class ChatDto ( - var `id`: kotlin.Long + var `id`: Long , - var `title`: kotlin.String + var `title`: String , - var `isChannel`: kotlin.Boolean + var `isChannel`: Boolean , - var `isGroup`: kotlin.Boolean + var `isGroup`: Boolean , - var `isForum`: kotlin.Boolean + var `isForum`: Boolean , /** * True if the current user is not a participant of this dialog. */ - var `left`: kotlin.Boolean + var `left`: Boolean , - var `unreadCount`: kotlin.Int + var `unreadCount`: Int , - var `lastMessagePreview`: kotlin.String? + var `lastMessagePreview`: String? , - var `lastMessageDate`: kotlin.Long? + var `lastMessageDate`: Long? , - var `archived`: kotlin.Boolean + var `archived`: Boolean , - var `muted`: kotlin.Boolean + var `muted`: Boolean , - var `isContact`: kotlin.Boolean + var `isContact`: Boolean , - var `isBot`: kotlin.Boolean + var `isBot`: Boolean , - var `isVerified`: kotlin.Boolean + var `isVerified`: Boolean , - var `photoCacheKey`: kotlin.String? + var `photoCacheKey`: String? , - var `pinned`: kotlin.Boolean + var `pinned`: Boolean , - var `readInboxMaxId`: kotlin.Int + var `readInboxMaxId`: Int , - var `readOutboxMaxId`: kotlin.Int + var `readOutboxMaxId`: Int , - var `peerStatus`: kotlin.String? + var `peerStatus`: String? , - var `peerStatusAt`: kotlin.Long? + var `peerStatusAt`: Long? , - var `lastMediaThumbCacheKey`: kotlin.String? + var `lastMediaThumbCacheKey`: String? , - var `lastMessageId`: kotlin.Int + var `lastMessageId`: Int , - var `canView`: kotlin.Boolean + var `canView`: Boolean , - var `canSendPlain`: kotlin.Boolean + var `canSendPlain`: Boolean , - var `canSendPhotos`: kotlin.Boolean + var `canSendPhotos`: Boolean , - var `canForward`: kotlin.Boolean + var `canForward`: Boolean , - var `canDeleteOthers`: kotlin.Boolean + var `canDeleteOthers`: Boolean , /** * Custom emoji / collectible document for the peer name. */ - var `emojiStatusDocumentId`: kotlin.Long? + var `emojiStatusDocumentId`: Long? , /** * Whether the last dialog message was outgoing. */ - var `lastMessageOutgoing`: kotlin.Boolean + var `lastMessageOutgoing`: Boolean , /** * Whether the dialog carries its own mute setting instead of inheriting the type default. */ - var `muteOverride`: kotlin.Boolean + var `muteOverride`: Boolean , /** * `dialog.unread_mark`: the user manually marked the dialog unread. */ - var `unreadMark`: kotlin.Boolean + var `unreadMark`: Boolean , /** * `dialog.unread_mentions_count`. */ - var `unreadMentionsCount`: kotlin.Int + var `unreadMentionsCount`: Int , /** * `dialog.unread_reactions_count`. */ - var `unreadReactionsCount`: kotlin.Int + var `unreadReactionsCount`: Int , /** * Creator or `manage_topics` admin right. */ - var `canManageTopics`: kotlin.Boolean + var `canManageTopics`: Boolean ){ @@ -2452,7 +2768,7 @@ data class ChatDto ( /** * @suppress */ -public object FfiConverterTypeChatDto: FfiConverterRustBuffer { +object FfiConverterTypeChatDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChatDto { return ChatDto( FfiConverterLong.read(buf), @@ -2586,7 +2902,7 @@ data class ContactsSearchDto ( /** * @suppress */ -public object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeContactsSearchDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ContactsSearchDto { return ContactsSearchDto( FfiConverterSequenceTypeSearchPeerDto.read(buf), @@ -2608,9 +2924,9 @@ public object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeDiscussionDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): DiscussionDto { return DiscussionDto( FfiConverterLong.read(buf), @@ -2646,36 +2962,36 @@ public object FfiConverterTypeDiscussionDto: FfiConverterRustBuffer + var `chatIds`: List , - var `excludeChatIds`: List + var `excludeChatIds`: List , - var `includeContacts`: kotlin.Boolean + var `includeContacts`: Boolean , - var `includeNonContacts`: kotlin.Boolean + var `includeNonContacts`: Boolean , - var `includeGroups`: kotlin.Boolean + var `includeGroups`: Boolean , - var `includeChannels`: kotlin.Boolean + var `includeChannels`: Boolean , - var `includeBots`: kotlin.Boolean + var `includeBots`: Boolean , - var `excludeMuted`: kotlin.Boolean + var `excludeMuted`: Boolean , - var `excludeRead`: kotlin.Boolean + var `excludeRead`: Boolean , - var `excludeArchived`: kotlin.Boolean + var `excludeArchived`: Boolean , - var `emoticon`: kotlin.String + var `emoticon`: String , /** * `dialogFilter.pinned_peers`, distinct from include peers in [chat_ids]. */ - var `pinnedChatIds`: List + var `pinnedChatIds`: List ){ @@ -2689,7 +3005,7 @@ data class FolderDto ( /** * @suppress */ -public object FfiConverterTypeFolderDto: FfiConverterRustBuffer { +object FfiConverterTypeFolderDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): FolderDto { return FolderDto( FfiConverterInt.read(buf), @@ -2747,37 +3063,37 @@ public object FfiConverterTypeFolderDto: FfiConverterRustBuffer { data class ForumTopicDto ( - var `id`: kotlin.Int + var `id`: Int , - var `title`: kotlin.String + var `title`: String , - var `iconColor`: kotlin.Int + var `iconColor`: Int , - var `iconEmojiId`: kotlin.Long? + var `iconEmojiId`: Long? , - var `topMessage`: kotlin.Int + var `topMessage`: Int , - var `date`: kotlin.Int + var `date`: Int , - var `unreadCount`: kotlin.Int + var `unreadCount`: Int , - var `unreadMentionsCount`: kotlin.Int + var `unreadMentionsCount`: Int , - var `unreadReactionsCount`: kotlin.Int + var `unreadReactionsCount`: Int , - var `readInboxMaxId`: kotlin.Int + var `readInboxMaxId`: Int , - var `pinned`: kotlin.Boolean + var `pinned`: Boolean , - var `closed`: kotlin.Boolean + var `closed`: Boolean , - var `hidden`: kotlin.Boolean + var `hidden`: Boolean , - var `short`: kotlin.Boolean + var `short`: Boolean , - var `deleted`: kotlin.Boolean + var `deleted`: Boolean , - var `lastMessagePreview`: kotlin.String? + var `lastMessagePreview`: String? ){ @@ -2791,7 +3107,7 @@ data class ForumTopicDto ( /** * @suppress */ -public object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicDto { return ForumTopicDto( FfiConverterInt.read(buf), @@ -2855,7 +3171,7 @@ public object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer @@ -2871,7 +3187,7 @@ data class ForumTopicsPageDto ( /** * @suppress */ -public object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicsPageDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicsPageDto { return ForumTopicsPageDto( FfiConverterInt.read(buf), @@ -2895,11 +3211,11 @@ public object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer , - var `nextRate`: kotlin.Int + var `nextRate`: Int , - var `nextPeerId`: kotlin.Long + var `nextPeerId`: Long , - var `nextOffsetId`: kotlin.Int + var `nextOffsetId`: Int ){ @@ -2913,7 +3229,8 @@ data class GlobalMessageSearchDto ( /** * @suppress */ -public object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeGlobalMessageSearchDto : + FfiConverterRustBuffer { override fun read(buf: ByteBuffer): GlobalMessageSearchDto { return GlobalMessageSearchDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -2945,17 +3262,17 @@ public object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeInitConnectionInfo : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InitConnectionInfo { return InitConnectionInfo( FfiConverterString.read(buf), @@ -3003,19 +3320,19 @@ public object FfiConverterTypeInitConnectionInfo: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultDto { return InlineBotResultDto( FfiConverterString.read(buf), @@ -3066,13 +3383,13 @@ public object FfiConverterTypeInlineBotResultDto: FfiConverterRustBuffer @@ -3088,7 +3405,7 @@ data class InlineBotResultsDto ( /** * @suppress */ -public object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultsDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultsDto { return InlineBotResultsDto( FfiConverterLong.read(buf), @@ -3119,31 +3436,31 @@ public object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { +object FfiConverterTypeInstantViewDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InstantViewDto { return InstantViewDto( FfiConverterString.read(buf), @@ -3212,11 +3529,11 @@ public object FfiConverterTypeInstantViewDto: FfiConverterRustBuffer { +object FfiConverterTypeJoinChatDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): JoinChatDto { return JoinChatDto( FfiConverterBoolean.read(buf), @@ -3255,9 +3572,9 @@ public object FfiConverterTypeJoinChatDto: FfiConverterRustBuffer { data class LottieSize ( - var `width`: kotlin.UInt + var `width`: UInt , - var `height`: kotlin.UInt + var `height`: UInt ){ @@ -3271,7 +3588,7 @@ data class LottieSize ( /** * @suppress */ -public object FfiConverterTypeLottieSize: FfiConverterRustBuffer { +object FfiConverterTypeLottieSize : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): LottieSize { return LottieSize( FfiConverterUInt.read(buf), @@ -3293,77 +3610,77 @@ public object FfiConverterTypeLottieSize: FfiConverterRustBuffer { data class MessageDto ( - var `chatId`: kotlin.Long + var `chatId`: Long , - var `id`: kotlin.Int + var `id`: Int , - var `senderId`: kotlin.Long? + var `senderId`: Long? , - var `text`: kotlin.String? + var `text`: String? , - var `date`: kotlin.Long + var `date`: Long , - var `editDate`: kotlin.Long? + var `editDate`: Long? , - var `outgoing`: kotlin.Boolean + var `outgoing`: Boolean , - var `mediaKind`: kotlin.String? + var `mediaKind`: String? , - var `mediaCacheKey`: kotlin.String? + var `mediaCacheKey`: String? , - var `thumbCacheKey`: kotlin.String? + var `thumbCacheKey`: String? , - var `mediaDuration`: kotlin.Int? + var `mediaDuration`: Int? , - var `mediaWidth`: kotlin.Int? + var `mediaWidth`: Int? , - var `mediaHeight`: kotlin.Int? + var `mediaHeight`: Int? , - var `replyQuote`: kotlin.String? + var `replyQuote`: String? , /** * Compact JSON list of `{kind,offset,length,url?}` message entities. */ - var `entitiesJson`: kotlin.String? + var `entitiesJson`: String? , - var `noforwards`: kotlin.Boolean + var `noforwards`: Boolean , - var `replyToMsgId`: kotlin.Int? + var `replyToMsgId`: Int? , - var `replyToTopId`: kotlin.Int? + var `replyToTopId`: Int? , - var `fwdFrom`: kotlin.String? + var `fwdFrom`: String? , - var `fwdFromId`: kotlin.Long? + var `fwdFromId`: Long? , - var `fwdDate`: kotlin.Long? + var `fwdDate`: Long? , - var `viaBot`: kotlin.String? + var `viaBot`: String? , - var `senderName`: kotlin.String? + var `senderName`: String? , - var `senderEmojiStatusDocumentId`: kotlin.Long? + var `senderEmojiStatusDocumentId`: Long? , - var `groupedId`: kotlin.Long? + var `groupedId`: Long? , - var `fileName`: kotlin.String? + var `fileName`: String? , - var `fileSize`: kotlin.Long? + var `fileSize`: Long? , - var `supportsStreaming`: kotlin.Boolean + var `supportsStreaming`: Boolean , - var `reactionsJson`: kotlin.String? + var `reactionsJson`: String? , - var `repliesCount`: kotlin.Int + var `repliesCount`: Int , - var `discussionPeerId`: kotlin.Long? + var `discussionPeerId`: Long? , /** * Compact JSON for reply/inline keyboards. Never log. */ - var `replyMarkupJson`: kotlin.String? + var `replyMarkupJson`: String? , - var `forumTopic`: kotlin.Boolean + var `forumTopic`: Boolean ){ @@ -3377,7 +3694,7 @@ data class MessageDto ( /** * @suppress */ -public object FfiConverterTypeMessageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessageDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessageDto { return MessageDto( FfiConverterLong.read(buf), @@ -3494,7 +3811,7 @@ public object FfiConverterTypeMessageDto: FfiConverterRustBuffer { data class MessagesPageDto ( var `messages`: List , - var `totalCount`: kotlin.Int + var `totalCount`: Int ){ @@ -3508,7 +3825,7 @@ data class MessagesPageDto ( /** * @suppress */ -public object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessagesPageDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessagesPageDto { return MessagesPageDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -3530,23 +3847,23 @@ public object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifyExceptionDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifyExceptionDto { return NotifyExceptionDto( FfiConverterString.read(buf), @@ -3603,17 +3920,17 @@ public object FfiConverterTypeNotifyExceptionDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifySettingsDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifySettingsDto { return NotifySettingsDto( FfiConverterOptionalBoolean.read(buf), @@ -3661,9 +3978,9 @@ public object FfiConverterTypeNotifySettingsDto: FfiConverterRustBuffer { +object FfiConverterTypeOutboxReadDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): OutboxReadDto { return OutboxReadDto( FfiConverterInt.read(buf), @@ -3699,13 +4016,13 @@ public object FfiConverterTypeOutboxReadDto: FfiConverterRustBuffer + var `options`: List ){ @@ -3719,7 +4036,7 @@ data class PollVoterDto ( /** * @suppress */ -public object FfiConverterTypePollVoterDto: FfiConverterRustBuffer { +object FfiConverterTypePollVoterDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVoterDto { return PollVoterDto( FfiConverterLong.read(buf), @@ -3749,7 +4066,7 @@ public object FfiConverterTypePollVoterDto: FfiConverterRustBuffer data class PollVotersDto ( var `voters`: List , - var `count`: kotlin.Int + var `count`: Int ){ @@ -3763,7 +4080,7 @@ data class PollVotersDto ( /** * @suppress */ -public object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { +object FfiConverterTypePollVotersDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVotersDto { return PollVotersDto( FfiConverterSequenceTypePollVoterDto.read(buf), @@ -3785,28 +4102,28 @@ public object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { +object FfiConverterTypeProfileDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ProfileDto { return ProfileDto( FfiConverterLong.read(buf), @@ -3866,9 +4183,9 @@ public object FfiConverterTypeProfileDto: FfiConverterRustBuffer { data class ReactionChoiceDto ( - var `emoticon`: kotlin.String + var `emoticon`: String , - var `documentId`: kotlin.Long + var `documentId`: Long ){ @@ -3882,7 +4199,7 @@ data class ReactionChoiceDto ( /** * @suppress */ -public object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionChoiceDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionChoiceDto { return ReactionChoiceDto( FfiConverterString.read(buf), @@ -3904,15 +4221,15 @@ public object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeerDto { return ReactionPeerDto( FfiConverterLong.read(buf), @@ -3959,7 +4276,7 @@ public object FfiConverterTypeReactionPeerDto: FfiConverterRustBuffer , - var `count`: kotlin.Int + var `count`: Int ){ @@ -3973,7 +4290,7 @@ data class ReactionPeersDto ( /** * @suppress */ -public object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeersDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeersDto { return ReactionPeersDto( FfiConverterSequenceTypeReactionPeerDto.read(buf), @@ -3995,9 +4312,9 @@ public object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantDto { return ReadParticipantDto( FfiConverterLong.read(buf), @@ -4035,7 +4352,7 @@ public object FfiConverterTypeReadParticipantDto: FfiConverterRustBuffer , - var `error`: kotlin.String? + var `error`: String? ){ @@ -4049,7 +4366,7 @@ data class ReadParticipantsDto ( /** * @suppress */ -public object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantsDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantsDto { return ReadParticipantsDto( FfiConverterSequenceTypeReadParticipantDto.read(buf), @@ -4071,13 +4388,13 @@ public object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { +object FfiConverterTypeReadReceiptConfigDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadReceiptConfigDto { return ReadReceiptConfigDto( FfiConverterInt.read(buf), @@ -4119,13 +4436,13 @@ public object FfiConverterTypeReadReceiptConfigDto: FfiConverterRustBuffer { +object FfiConverterTypeResolvedPeerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ResolvedPeerDto { return ResolvedPeerDto( FfiConverterLong.read(buf), @@ -4167,15 +4484,15 @@ public object FfiConverterTypeResolvedPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeSavedGifDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SavedGifDto { return SavedGifDto( FfiConverterLong.read(buf), @@ -4220,19 +4537,19 @@ public object FfiConverterTypeSavedGifDto: FfiConverterRustBuffer { data class SearchPeerDto ( - var `peerId`: kotlin.Long + var `peerId`: Long , - var `title`: kotlin.String + var `title`: String , - var `username`: kotlin.String? + var `username`: String? , - var `kind`: kotlin.String + var `kind`: String , - var `isBot`: kotlin.Boolean + var `isBot`: Boolean , - var `isGroup`: kotlin.Boolean + var `isGroup`: Boolean , - var `isChannel`: kotlin.Boolean + var `isChannel`: Boolean ){ @@ -4246,7 +4563,7 @@ data class SearchPeerDto ( /** * @suppress */ -public object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeSearchPeerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SearchPeerDto { return SearchPeerDto( FfiConverterLong.read(buf), @@ -4283,9 +4600,9 @@ public object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer @@ -4301,7 +4618,7 @@ data class StickerCatalogDto ( /** * @suppress */ -public object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerCatalogDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerCatalogDto { return StickerCatalogDto( FfiConverterLong.read(buf), @@ -4326,11 +4643,11 @@ public object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer + var `documentIds`: List ){ @@ -4344,7 +4661,7 @@ data class StickerListDto ( /** * @suppress */ -public object FfiConverterTypeStickerListDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerListDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerListDto { return StickerListDto( FfiConverterLong.read(buf), @@ -4369,19 +4686,19 @@ public object FfiConverterTypeStickerListDto: FfiConverterRustBuffer + var `previewDocumentIds`: List ){ @@ -4395,7 +4712,7 @@ data class StickerPackDto ( /** * @suppress */ -public object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerPackDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerPackDto { return StickerPackDto( FfiConverterLong.read(buf), @@ -4430,15 +4747,100 @@ public object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer, + var `cursorAfter`: UpdatesStateDto?, + var `channelPtsAfter`: List + +) { + + + companion object +} + +/** + * @suppress + */ +object FfiConverterTypeUpdateBatchDto : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UpdateBatchDto { + return UpdateBatchDto( + FfiConverterLong.read(buf), + FfiConverterLong.read(buf), + FfiConverterSequenceTypeUpdateEventDto.read(buf), + FfiConverterOptionalTypeUpdatesStateDto.read(buf), + FfiConverterSequenceTypeChannelCursorDto.read(buf), + ) + } + + override fun allocationSize(value: UpdateBatchDto) = ( + FfiConverterLong.allocationSize(value.`accountGeneration`) + + FfiConverterLong.allocationSize(value.`batchId`) + + FfiConverterSequenceTypeUpdateEventDto.allocationSize(value.`events`) + + FfiConverterOptionalTypeUpdatesStateDto.allocationSize(value.`cursorAfter`) + + FfiConverterSequenceTypeChannelCursorDto.allocationSize(value.`channelPtsAfter`) + ) + + override fun write(value: UpdateBatchDto, buf: ByteBuffer) { + FfiConverterLong.write(value.`accountGeneration`, buf) + FfiConverterLong.write(value.`batchId`, buf) + FfiConverterSequenceTypeUpdateEventDto.write(value.`events`, buf) + FfiConverterOptionalTypeUpdatesStateDto.write(value.`cursorAfter`, buf) + FfiConverterSequenceTypeChannelCursorDto.write(value.`channelPtsAfter`, buf) + } +} + + +data class UpdateDrainDto( + var `batches`: List, + var `liveEvents`: List, + var `replayRequired`: Boolean + +) { + + + + + + companion object +} + +/** + * @suppress + */ +object FfiConverterTypeUpdateDrainDto : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UpdateDrainDto { + return UpdateDrainDto( + FfiConverterSequenceTypeUpdateBatchDto.read(buf), + FfiConverterSequenceTypeUpdateEventDto.read(buf), + FfiConverterBoolean.read(buf), + ) + } + + override fun allocationSize(value: UpdateDrainDto) = ( + FfiConverterSequenceTypeUpdateBatchDto.allocationSize(value.`batches`) + + FfiConverterSequenceTypeUpdateEventDto.allocationSize(value.`liveEvents`) + + FfiConverterBoolean.allocationSize(value.`replayRequired`) + ) + + override fun write(value: UpdateDrainDto, buf: ByteBuffer) { + FfiConverterSequenceTypeUpdateBatchDto.write(value.`batches`, buf) + FfiConverterSequenceTypeUpdateEventDto.write(value.`liveEvents`, buf) + FfiConverterBoolean.write(value.`replayRequired`, buf) + } +} + + data class UpdatesStateDto ( - var `pts`: kotlin.Int + var `pts`: Int , - var `qts`: kotlin.Int + var `qts`: Int , - var `date`: kotlin.Int + var `date`: Int , - var `seq`: kotlin.Int + var `seq`: Int ){ @@ -4452,7 +4854,7 @@ data class UpdatesStateDto ( /** * @suppress */ -public object FfiConverterTypeUpdatesStateDto: FfiConverterRustBuffer { +object FfiConverterTypeUpdatesStateDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdatesStateDto { return UpdatesStateDto( FfiConverterInt.read(buf), @@ -4480,25 +4882,25 @@ public object FfiConverterTypeUpdatesStateDto: FfiConverterRustBuffer { +object FfiConverterTypeUploadItemDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UploadItemDto { return UploadItemDto( FfiConverterString.read(buf), @@ -4558,11 +4960,11 @@ public object FfiConverterTypeUploadItemDto: FfiConverterRustBuffer { +object FfiConverterTypeVpxAlphaFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame { return VpxAlphaFrame( FfiConverterUInt.read(buf), @@ -4601,11 +5003,11 @@ public object FfiConverterTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterTypeVpxFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame { return VpxFrame( FfiConverterUInt.read(buf), @@ -4644,9 +5046,9 @@ public object FfiConverterTypeVpxFrame: FfiConverterRustBuffer { data class WallpaperCatalogDto ( - var `hash`: kotlin.Long + var `hash`: Long , - var `notModified`: kotlin.Boolean + var `notModified`: Boolean , var `wallpapers`: List @@ -4662,7 +5064,7 @@ data class WallpaperCatalogDto ( /** * @suppress */ -public object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperCatalogDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperCatalogDto { return WallpaperCatalogDto( FfiConverterLong.read(buf), @@ -4687,29 +5089,29 @@ public object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer + var `colors`: List , - var `intensity`: kotlin.Int? + var `intensity`: Int? , - var `rotation`: kotlin.Int + var `rotation`: Int , - var `blur`: kotlin.Boolean + var `blur`: Boolean , - var `motion`: kotlin.Boolean + var `motion`: Boolean ){ @@ -4723,7 +5125,7 @@ data class WallpaperDto ( /** * @suppress */ -public object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperDto { return WallpaperDto( FfiConverterLong.read(buf), @@ -4773,32 +5175,26 @@ public object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer } +sealed class MtprotoException : Exception() { - - -sealed class MtprotoException: kotlin.Exception() { - - class UnknownClient( - ) : MtprotoException() { + class UnknownClient : MtprotoException() { override val message get() = "" } - class RegistrationRequired( - ) : MtprotoException() { + class RegistrationRequired : MtprotoException() { override val message get() = "" } - class PasswordRequired( - ) : MtprotoException() { + class PasswordRequired : MtprotoException() { override val message get() = "" } class Message( - val v1: kotlin.String + val v1: String ) : MtprotoException() { override val message get() = "v1=${ v1 }" @@ -4818,7 +5214,7 @@ sealed class MtprotoException: kotlin.Exception() { /** * @suppress */ -public object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { +object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MtprotoException { @@ -4872,7 +5268,6 @@ public object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { buf.putInt(4) FfiConverterString.write(value.v1, buf) - Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -4890,7 +5285,8 @@ sealed class UpdateEventDto { data class NewMessage( - val `message`: uniffi.monogram_mtproto.MessageDto) : UpdateEventDto() + val `message`: MessageDto + ) : UpdateEventDto() { @@ -4899,7 +5295,8 @@ sealed class UpdateEventDto { } data class MessageEdited( - val `message`: uniffi.monogram_mtproto.MessageDto) : UpdateEventDto() + val `message`: MessageDto + ) : UpdateEventDto() { @@ -4908,8 +5305,9 @@ sealed class UpdateEventDto { } data class MessagesDeleted( - val `chatId`: kotlin.Long?, - val `messageIds`: List) : UpdateEventDto() + val `chatId`: Long?, + val `messageIds`: List + ) : UpdateEventDto() { @@ -4918,10 +5316,11 @@ sealed class UpdateEventDto { } data class PeerTyping( - val `chatId`: kotlin.Long, - val `userId`: kotlin.Long, - val `typing`: kotlin.Boolean, - val `action`: kotlin.String) : UpdateEventDto() + val `chatId`: Long, + val `userId`: Long, + val `typing`: Boolean, + val `action`: String + ) : UpdateEventDto() { @@ -4930,9 +5329,10 @@ sealed class UpdateEventDto { } data class PeerStatus( - val `userId`: kotlin.Long, - val `status`: kotlin.String?, - val `statusAt`: kotlin.Long?) : UpdateEventDto() + val `userId`: Long, + val `status`: String?, + val `statusAt`: Long? + ) : UpdateEventDto() { @@ -4941,8 +5341,9 @@ sealed class UpdateEventDto { } data class PeerEmojiStatus( - val `userId`: kotlin.Long, - val `documentId`: kotlin.Long?) : UpdateEventDto() + val `userId`: Long, + val `documentId`: Long? + ) : UpdateEventDto() { @@ -4951,9 +5352,10 @@ sealed class UpdateEventDto { } data class ReadInbox( - val `chatId`: kotlin.Long, - val `maxId`: kotlin.Int, - val `stillUnread`: kotlin.Int) : UpdateEventDto() + val `chatId`: Long, + val `maxId`: Int, + val `stillUnread`: Int + ) : UpdateEventDto() { @@ -4962,8 +5364,9 @@ sealed class UpdateEventDto { } data class ReadOutbox( - val `chatId`: kotlin.Long, - val `maxId`: kotlin.Int) : UpdateEventDto() + val `chatId`: Long, + val `maxId`: Int + ) : UpdateEventDto() { @@ -4975,9 +5378,10 @@ sealed class UpdateEventDto { data class MessageReactions( - val `chatId`: kotlin.Long, - val `messageId`: kotlin.Int, - val `reactionsJson`: kotlin.String) : UpdateEventDto() + val `chatId`: Long, + val `messageId`: Int, + val `reactionsJson`: String + ) : UpdateEventDto() { @@ -4986,9 +5390,10 @@ sealed class UpdateEventDto { } data class DiscussionInbox( - val `channelId`: kotlin.Long, - val `topMessageId`: kotlin.Int, - val `readMaxId`: kotlin.Int) : UpdateEventDto() + val `channelId`: Long, + val `topMessageId`: Int, + val `readMaxId`: Int + ) : UpdateEventDto() { @@ -5000,7 +5405,8 @@ sealed class UpdateEventDto { * Dialog rows from a lazy `messages.getPeerDialogs` batch. Not a full chat refresh. */ data class DialogsPatched( - val `chats`: List) : UpdateEventDto() + val `chats`: List + ) : UpdateEventDto() { @@ -5012,7 +5418,8 @@ sealed class UpdateEventDto { * Edge-triggered catch-up signal. True while difference or channel recovery is in flight. */ data class SyncState( - val `isSyncing`: kotlin.Boolean) : UpdateEventDto() + val `isSyncing`: Boolean + ) : UpdateEventDto() { @@ -5021,7 +5428,8 @@ sealed class UpdateEventDto { } data class Ignored( - val `kind`: kotlin.String) : UpdateEventDto() + val `kind`: String + ) : UpdateEventDto() { @@ -5042,7 +5450,7 @@ sealed class UpdateEventDto { /** * @suppress */ -public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer{ +object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdateEventDto { return when(buf.getInt()) { 1 -> UpdateEventDto.ChatsChanged @@ -5244,18 +5652,15 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { buf.putInt(3) FfiConverterTypeMessageDto.write(value.`message`, buf) - Unit } is UpdateEventDto.MessageEdited -> { buf.putInt(4) FfiConverterTypeMessageDto.write(value.`message`, buf) - Unit } is UpdateEventDto.MessagesDeleted -> { buf.putInt(5) FfiConverterOptionalLong.write(value.`chatId`, buf) FfiConverterSequenceInt.write(value.`messageIds`, buf) - Unit } is UpdateEventDto.PeerTyping -> { buf.putInt(6) @@ -5263,33 +5668,28 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { buf.putInt(7) FfiConverterLong.write(value.`userId`, buf) FfiConverterOptionalString.write(value.`status`, buf) FfiConverterOptionalLong.write(value.`statusAt`, buf) - Unit } is UpdateEventDto.PeerEmojiStatus -> { buf.putInt(8) FfiConverterLong.write(value.`userId`, buf) FfiConverterOptionalLong.write(value.`documentId`, buf) - Unit } is UpdateEventDto.ReadInbox -> { buf.putInt(9) FfiConverterLong.write(value.`chatId`, buf) FfiConverterInt.write(value.`maxId`, buf) FfiConverterInt.write(value.`stillUnread`, buf) - Unit } is UpdateEventDto.ReadOutbox -> { buf.putInt(10) FfiConverterLong.write(value.`chatId`, buf) FfiConverterInt.write(value.`maxId`, buf) - Unit } is UpdateEventDto.SavedGifsChanged -> { buf.putInt(11) @@ -5300,43 +5700,33 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { buf.putInt(13) FfiConverterLong.write(value.`channelId`, buf) FfiConverterInt.write(value.`topMessageId`, buf) FfiConverterInt.write(value.`readMaxId`, buf) - Unit } is UpdateEventDto.DialogsPatched -> { buf.putInt(14) FfiConverterSequenceTypeChatDto.write(value.`chats`, buf) - Unit } is UpdateEventDto.SyncState -> { buf.putInt(15) FfiConverterBoolean.write(value.`isSyncing`, buf) - Unit } is UpdateEventDto.Ignored -> { buf.putInt(16) FfiConverterString.write(value.`kind`, buf) - Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } +interface DownloadProgressListener { - - - - -public interface DownloadProgressListener { - - fun `onProgress`(`path`: kotlin.String, `downloaded`: kotlin.Long, `total`: kotlin.Long) + fun `onProgress`(`path`: String, `downloaded`: Long, `total`: Long) companion object } @@ -5348,14 +5738,14 @@ internal object uniffiCallbackInterfaceDownloadProgressListener { internal object `onProgress`: UniffiCallbackInterfaceDownloadProgressListenerMethod0 { override fun callback(`uniffiHandle`: Long,`path`: RustBuffer.ByValue,`downloaded`: Long,`total`: Long,`uniffiOutReturn`: Pointer,uniffiCallStatus: UniffiRustCallStatus,) { val uniffiObj = FfiConverterTypeDownloadProgressListener.handleMap.get(uniffiHandle) - val makeCall = { -> + val makeCall = { uniffiObj.`onProgress`( FfiConverterString.lift(`path`), FfiConverterLong.lift(`downloaded`), FfiConverterLong.lift(`total`), ) } - val writeReturn = { _: Unit -> Unit } + val writeReturn = { _: Unit -> } uniffiTraitInterfaceCall(uniffiCallStatus, makeCall, writeReturn) } } @@ -5390,7 +5780,8 @@ internal object uniffiCallbackInterfaceDownloadProgressListener { * * @suppress */ -public object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInterface() +object FfiConverterTypeDownloadProgressListener : + FfiConverterCallbackInterface() @@ -5398,15 +5789,15 @@ public object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInte /** * @suppress */ -public object FfiConverterOptionalInt: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.Int? { +object FfiConverterOptionalInt : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Int? { if (buf.get().toInt() == 0) { return null } return FfiConverterInt.read(buf) } - override fun allocationSize(value: kotlin.Int?): ULong { + override fun allocationSize(value: Int?): ULong { if (value == null) { return 1UL } else { @@ -5414,7 +5805,7 @@ public object FfiConverterOptionalInt: FfiConverterRustBuffer { } } - override fun write(value: kotlin.Int?, buf: ByteBuffer) { + override fun write(value: Int?, buf: ByteBuffer) { if (value == null) { buf.put(0) } else { @@ -5430,15 +5821,15 @@ public object FfiConverterOptionalInt: FfiConverterRustBuffer { /** * @suppress */ -public object FfiConverterOptionalLong: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.Long? { +object FfiConverterOptionalLong : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Long? { if (buf.get().toInt() == 0) { return null } return FfiConverterLong.read(buf) } - override fun allocationSize(value: kotlin.Long?): ULong { + override fun allocationSize(value: Long?): ULong { if (value == null) { return 1UL } else { @@ -5446,7 +5837,7 @@ public object FfiConverterOptionalLong: FfiConverterRustBuffer { } } - override fun write(value: kotlin.Long?, buf: ByteBuffer) { + override fun write(value: Long?, buf: ByteBuffer) { if (value == null) { buf.put(0) } else { @@ -5462,15 +5853,15 @@ public object FfiConverterOptionalLong: FfiConverterRustBuffer { /** * @suppress */ -public object FfiConverterOptionalBoolean: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.Boolean? { +object FfiConverterOptionalBoolean : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): Boolean? { if (buf.get().toInt() == 0) { return null } return FfiConverterBoolean.read(buf) } - override fun allocationSize(value: kotlin.Boolean?): ULong { + override fun allocationSize(value: Boolean?): ULong { if (value == null) { return 1UL } else { @@ -5478,7 +5869,7 @@ public object FfiConverterOptionalBoolean: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.String? { +object FfiConverterOptionalString : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): String? { if (buf.get().toInt() == 0) { return null } return FfiConverterString.read(buf) } - override fun allocationSize(value: kotlin.String?): ULong { + override fun allocationSize(value: String?): ULong { if (value == null) { return 1UL } else { @@ -5510,7 +5901,7 @@ public object FfiConverterOptionalString: FfiConverterRustBuffer } } - override fun write(value: kotlin.String?, buf: ByteBuffer) { + override fun write(value: String?, buf: ByteBuffer) { if (value == null) { buf.put(0) } else { @@ -5526,15 +5917,15 @@ public object FfiConverterOptionalString: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterOptionalByteArray: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): kotlin.ByteArray? { +object FfiConverterOptionalByteArray : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): ByteArray? { if (buf.get().toInt() == 0) { return null } return FfiConverterByteArray.read(buf) } - override fun allocationSize(value: kotlin.ByteArray?): ULong { + override fun allocationSize(value: ByteArray?): ULong { if (value == null) { return 1UL } else { @@ -5542,7 +5933,7 @@ public object FfiConverterOptionalByteArray: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UpdatesStateDto? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeUpdatesStateDto.read(buf) + } + + override fun allocationSize(value: UpdatesStateDto?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeUpdatesStateDto.allocationSize(value) + } + } + + override fun write(value: UpdatesStateDto?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeUpdatesStateDto.write(value, buf) + } + } +} + + /** * @suppress */ -public object FfiConverterOptionalTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxAlphaFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame? { if (buf.get().toInt() == 0) { return null @@ -5590,7 +6011,7 @@ public object FfiConverterOptionalTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame? { if (buf.get().toInt() == 0) { return null @@ -5622,21 +6043,21 @@ public object FfiConverterOptionalTypeVpxFrame: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceShort : FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterShort.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterShort.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterShort.write(it, buf) @@ -5650,21 +6071,21 @@ public object FfiConverterSequenceShort: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceInt : FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterInt.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterInt.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterInt.write(it, buf) @@ -5678,21 +6099,21 @@ public object FfiConverterSequenceInt: FfiConverterRustBuffer> /** * @suppress */ -public object FfiConverterSequenceLong: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceLong : FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterLong.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterLong.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterLong.write(it, buf) @@ -5706,21 +6127,21 @@ public object FfiConverterSequenceLong: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceFloat: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceFloat : FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterFloat.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterFloat.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterFloat.write(it, buf) @@ -5734,21 +6155,21 @@ public object FfiConverterSequenceFloat: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceString : FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterString.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterString.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterString.write(it, buf) @@ -5762,21 +6183,21 @@ public object FfiConverterSequenceString: FfiConverterRustBuffer> { - override fun read(buf: ByteBuffer): List { +object FfiConverterSequenceByteArray : FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { val len = buf.getInt() - return List(len) { + return List(len) { FfiConverterByteArray.read(buf) } } - override fun allocationSize(value: List): ULong { + override fun allocationSize(value: List): ULong { val sizeForLength = 4UL val sizeForItems = value.map { FfiConverterByteArray.allocationSize(it) }.sum() return sizeForLength + sizeForItems } - override fun write(value: List, buf: ByteBuffer) { + override fun write(value: List, buf: ByteBuffer) { buf.putInt(value.size) value.iterator().forEach { FfiConverterByteArray.write(it, buf) @@ -5785,12 +6206,39 @@ public object FfiConverterSequenceByteArray: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeChannelCursorDto.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeChannelCursorDto.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeChannelCursorDto.write(it, buf) + } + } +} + + /** * @suppress */ -public object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeChatDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5818,7 +6266,7 @@ public object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeFolderDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5846,7 +6294,7 @@ public object FfiConverterSequenceTypeFolderDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeForumTopicDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5874,7 +6322,8 @@ public object FfiConverterSequenceTypeForumTopicDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeInlineBotResultDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5902,7 +6351,7 @@ public object FfiConverterSequenceTypeInlineBotResultDto: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypeMessageDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeMessageDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5930,7 +6379,8 @@ public object FfiConverterSequenceTypeMessageDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeNotifyExceptionDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5958,7 +6408,7 @@ public object FfiConverterSequenceTypeNotifyExceptionDto: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypePollVoterDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypePollVoterDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5986,7 +6436,8 @@ public object FfiConverterSequenceTypePollVoterDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReactionChoiceDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6014,7 +6465,8 @@ public object FfiConverterSequenceTypeReactionChoiceDto: FfiConverterRustBuffer< /** * @suppress */ -public object FfiConverterSequenceTypeReactionPeerDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReactionPeerDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6042,7 +6494,8 @@ public object FfiConverterSequenceTypeReactionPeerDto: FfiConverterRustBuffer
  • > { +object FfiConverterSequenceTypeReadParticipantDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6070,7 +6523,7 @@ public object FfiConverterSequenceTypeReadParticipantDto: FfiConverterRustBuffer /** * @suppress */ -public object FfiConverterSequenceTypeSavedGifDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeSavedGifDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6098,7 +6551,7 @@ public object FfiConverterSequenceTypeSavedGifDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeSearchPeerDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6126,7 +6579,8 @@ public object FfiConverterSequenceTypeSearchPeerDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeStickerPackDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6149,12 +6603,39 @@ public object FfiConverterSequenceTypeStickerPackDto: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeUpdateBatchDto.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeUpdateBatchDto.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeUpdateBatchDto.write(it, buf) + } + } +} + + /** * @suppress */ -public object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUploadItemDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6182,7 +6663,7 @@ public object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeWallpaperDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6210,7 +6691,8 @@ public object FfiConverterSequenceTypeWallpaperDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUpdateEventDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6231,7 +6713,32 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `sampleRate`: kotlin.UInt, `channels`: kotlin.UInt, `presentationTimeUs`: kotlin.ULong) + +@Throws(MtprotoException::class) +fun `acknowledgeUpdateBatch`( + `handle`: ULong, + `accountGeneration`: Long, + `batchId`: Long +) = + uniffiRustCallWithError(MtprotoException) { _status -> + UniffiLib.uniffi_monogram_mtproto_fn_func_acknowledge_update_batch( + + + FfiConverterULong.lower(`handle`), + FfiConverterLong.lower(`accountGeneration`), + FfiConverterLong.lower(`batchId`), _status + ) + } + + +@Throws(MtprotoException::class) +fun `addWaveformPcm`( + `handle`: ULong, + `samples`: List, + `sampleRate`: UInt, + `channels`: UInt, + `presentationTimeUs`: ULong +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_add_waveform_pcm( @@ -6245,8 +6752,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_animated_emoji_max( @@ -6258,7 +6765,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) +@Throws(MtprotoException::class) +fun `appendTodoItems`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `firstId`: Int, + `titles`: List +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_append_todo_items( @@ -6272,9 +6786,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_bind_request_control( @@ -6283,9 +6797,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_cancel_request_control( @@ -6293,8 +6807,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_check_password( @@ -6307,7 +6821,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_clear_active_dialog( @@ -6327,9 +6842,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_api_id( @@ -6338,9 +6853,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_exists( @@ -6349,9 +6864,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_uses_test_dc( @@ -6361,7 +6876,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_connect( @@ -6371,8 +6887,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_contacts_search( @@ -6385,9 +6905,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_client( @@ -6399,7 +6923,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_encrypted_client( @@ -6414,7 +6944,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_lottie( @@ -6425,9 +6956,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_request_control( _status) @@ -6436,7 +6967,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_vpx_decoder( @@ -6447,7 +6979,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_create_waveform( @@ -6459,7 +6992,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_custom_emoji_is_free( @@ -6472,7 +7006,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_alpha_packet( @@ -6485,7 +7020,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_packet( @@ -6498,7 +7034,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_decrypt_push_payload( @@ -6511,7 +7048,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_delete_folder( @@ -6522,8 +7060,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_delete_message( @@ -6536,9 +7079,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_client( @@ -6546,9 +7089,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_lottie( @@ -6556,9 +7099,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_vpx_decoder( @@ -6566,9 +7109,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_waveform( @@ -6576,9 +7119,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_chunk_kib( _status) @@ -6586,9 +7129,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +fun `downloadConcurrency`(): List { return FfiConverterSequenceInt.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_concurrency( _status) @@ -6597,7 +7140,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_custom_emoji( @@ -6611,7 +7159,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_display( @@ -6626,7 +7180,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media( @@ -6641,7 +7201,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media_chunk( @@ -6657,7 +7224,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_thumb( @@ -6672,7 +7245,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_download_wallpaper( @@ -6687,8 +7266,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { - return FfiConverterSequenceTypeUpdateEventDto.lift( +@Throws(MtprotoException::class) +fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { + return FfiConverterTypeUpdateDrainDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_drain_updates( @@ -6699,7 +7279,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_edit_forum_topic_hidden( @@ -6712,8 +7298,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_edit_text_message( @@ -6729,7 +7321,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `finishWaveform`(`handle`: ULong): List { return FfiConverterSequenceFloat.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_finish_waveform( @@ -6741,7 +7334,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `toChatId`: kotlin.Long, `dropAuthor`: kotlin.Boolean): List { +@Throws(MtprotoException::class) +fun `forwardMessages`( + `handle`: ULong, + `fromChatId`: Long, + `messageIds`: List, + `toChatId`: Long, + `dropAuthor`: Boolean +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_forward_messages( @@ -6757,7 +7357,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_all_stickers( @@ -6770,7 +7371,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_bot_callback_answer( @@ -6785,7 +7392,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getChats`(`handle`: ULong): List { return FfiConverterSequenceTypeChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_chats( @@ -6800,7 +7408,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_common_chats( @@ -6815,7 +7429,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_discussion_message( @@ -6829,7 +7448,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_emoji_stickers( @@ -6842,7 +7462,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getFolders`(`handle`: ULong): List { return FfiConverterSequenceTypeFolderDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_folders( @@ -6854,7 +7475,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_forum_topics( @@ -6871,7 +7500,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer): ForumTopicsPageDto { +@Throws(MtprotoException::class) +fun `getForumTopicsById`( + `handle`: ULong, + `chatId`: Long, + `topicIds`: List +): ForumTopicsPageDto { return FfiConverterTypeForumTopicsPageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_forum_topics_by_id( @@ -6885,7 +7519,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_group_admin_tags( @@ -6898,7 +7533,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getHistory`( + `handle`: ULong, + `chatId`: Long, + `limit`: Int +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_history( @@ -6912,7 +7552,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getHistoryPage`( + `handle`: ULong, + `chatId`: Long, + `limit`: Int, + `offsetId`: Int, + `offsetDate`: Int, + `addOffset`: Int +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_history_page( @@ -6929,7 +7577,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_inline_bot_results( @@ -6945,7 +7600,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_reactions_list( @@ -6959,7 +7619,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_read_participants( @@ -6973,7 +7638,11 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getNotifyExceptions`( + `handle`: ULong, + `compareSound`: Boolean +): List { return FfiConverterSequenceTypeNotifyExceptionDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_notify_exceptions( @@ -6986,7 +7655,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_notify_settings( @@ -7000,7 +7674,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_outbox_read_date( @@ -7017,7 +7696,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_participants( @@ -7034,7 +7721,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getPinnedMessages`( + `handle`: ULong, + `chatId`: Long, + `limit`: Int +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_pinned_messages( @@ -7048,7 +7740,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_poll_votes( @@ -7062,7 +7759,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_profile( @@ -7075,7 +7773,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_read_receipt_config( @@ -7087,7 +7786,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getRecentReactions`(`handle`: ULong): List { return FfiConverterSequenceTypeReactionChoiceDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_recent_reactions( @@ -7099,7 +7799,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getReplies`( + `handle`: ULong, + `chatId`: Long, + `msgId`: Int, + `limit`: Int, + `offsetId`: Int, + `addOffset`: Int +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_replies( @@ -7116,7 +7824,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `getSavedGifs`(`handle`: ULong): List { return FfiConverterSequenceTypeSavedGifDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_saved_gifs( @@ -7131,7 +7840,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer): kotlin.String { + @Throws(MtprotoException::class) + fun `getSearchCounters`( + `handle`: ULong, + `chatId`: Long, + `filters`: List + ): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_search_counters( @@ -7145,7 +7859,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_pack( @@ -7158,7 +7873,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_set( @@ -7172,7 +7892,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_stickers( @@ -7189,7 +7914,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) + fun `getUnreadMentions`( + `handle`: ULong, + `chatId`: Long, + `offsetId`: Int, + `addOffset`: Int, + `limit`: Int, + `topMsgId`: Int + ): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_unread_mentions( @@ -7209,7 +7942,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + @Throws(MtprotoException::class) + fun `getUnreadReactions`( + `handle`: ULong, + `chatId`: Long, + `offsetId`: Int, + `addOffset`: Int, + `limit`: Int, + `topMsgId`: Int + ): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_unread_reactions( @@ -7226,7 +7967,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_updates_state( @@ -7238,7 +7980,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_wallpapers( @@ -7251,7 +7994,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page( @@ -7265,7 +8009,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page_preview( @@ -7278,7 +8023,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_is_authorized( @@ -7290,7 +8036,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_join_chat( @@ -7302,9 +8049,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_library_version( _status) @@ -7313,7 +8060,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `loadMoreChats`( + `handle`: ULong, + `offsetDate`: Int, + `offsetId`: Int, + `offsetPeerId`: Long, + `folderId`: Int +): List { return FfiConverterSequenceTypeChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_load_more_chats( @@ -7329,7 +8083,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_logout( @@ -7339,8 +8094,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_count( @@ -7352,7 +8107,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_rate( @@ -7364,7 +8120,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_size( @@ -7379,7 +8136,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_mark_dialog_unread( @@ -7391,9 +8153,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_message_inline_thumb( @@ -7404,9 +8170,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_peer_inline_thumb( @@ -7417,11 +8183,26 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + return FfiConverterSequenceTypeUpdateBatchDto.lift( + uniffiRustCallWithError(MtprotoException) { _status -> + UniffiLib.uniffi_monogram_mtproto_fn_func_pending_update_batches( + + + FfiConverterULong.lower(`handle`), _status + ) + } + ) +} + + /** * Netcode timing spans on/off; off by default. - */ fun `perfSetEnabled`(`enabled`: kotlin.Boolean) + */ + fun `perfSetEnabled`(`enabled`: Boolean) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_perf_set_enabled( @@ -7432,9 +8213,10 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_perf_snapshot( @@ -7444,7 +8226,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_ping_proxy( @@ -7460,8 +8250,24 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_promote_request_control( - @Throws(MtprotoException::class) fun `readDiscussion`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `msgId`: kotlin.Int, `readMaxId`: kotlin.Int) + + FfiConverterULong.lower(`id`), + FfiConverterInt.lower(`class`), _status + ) + } + + +@Throws(MtprotoException::class) +fun `readDiscussion`( + `handle`: ULong, + `chatId`: Long, + `msgId`: Int, + `readMaxId`: Int +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_discussion( @@ -7474,8 +8280,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_read_history( @@ -7491,7 +8297,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_read_mentions( @@ -7508,7 +8315,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) + @Throws(MtprotoException::class) + fun `readMessageContents`( + `handle`: ULong, + `chatId`: Long, + `messageIds`: List + ) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_message_contents( @@ -7524,7 +8336,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_read_reactions( @@ -7536,8 +8349,16 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) +@Throws(MtprotoException::class) +fun `registerDevice`( + `handle`: ULong, + `tokenType`: Int, + `token`: String, + `secret`: ByteArray, + `noMuted`: Boolean, + `appSandbox`: Boolean, + `otherUids`: List +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_register_device( @@ -7553,9 +8374,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_release_request_control( @@ -7563,8 +8384,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_render_lottie_frame( @@ -7579,7 +8405,12 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_resend_auth_code( @@ -7593,7 +8424,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_reset_notify_settings( @@ -7603,8 +8435,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_resolve_username( @@ -7617,7 +8449,16 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_search_global( @@ -7635,7 +8476,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `searchMessages`( + `handle`: ULong, + `chatId`: Long, + `query`: String, + `limit`: Int +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages( @@ -7650,7 +8497,16 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { +@Throws(MtprotoException::class) +fun `searchMessagesFiltered`( + `handle`: ULong, + `chatId`: Long, + `query`: String, + `filter`: String, + `offsetId`: Int, + `addOffset`: Int, + `limit`: Int +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages_filtered( @@ -7668,7 +8524,16 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages_page( @@ -7686,7 +8551,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_auth_code( @@ -7699,7 +8565,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_inline_bot_result( @@ -7716,7 +8590,16 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_location( @@ -7732,8 +8615,16 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_photo_message( @@ -7751,7 +8642,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) +@Throws(MtprotoException::class) +fun `sendPollVote`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `options`: List +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_poll_vote( @@ -7764,8 +8661,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_reaction( @@ -7779,8 +8682,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_saved_gif( @@ -7796,7 +8705,17 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_text_message( @@ -7815,7 +8734,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `replyToMsgId`: kotlin.Int, `topMsgId`: kotlin.Int): List { +@Throws(MtprotoException::class) +fun `sendUploadedAlbum`( + `handle`: ULong, + `chatId`: Long, + `items`: List, + `replyToMsgId`: Int, + `topMsgId`: Int +): List { return FfiConverterSequenceTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_uploaded_album( @@ -7831,7 +8757,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_send_uploaded_media( @@ -7848,7 +8782,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_client_test_dc( @@ -7859,8 +8794,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_contact_joined_silent( @@ -7875,9 +8810,10 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_dispatch_class( @@ -7885,9 +8821,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_chunk_kib( @@ -7898,9 +8834,10 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_concurrency( @@ -7911,7 +8848,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_progress_listener( @@ -7919,9 +8856,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_file_part_kib( @@ -7931,7 +8868,7 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_init_connection_info( @@ -7939,8 +8876,15 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_proxy( @@ -7955,8 +8899,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_transport_mode( @@ -7966,8 +8910,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_set_typing( @@ -7979,8 +8923,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_sign_in( @@ -7995,7 +8944,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_start_updates( @@ -8005,8 +8955,14 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer, `incompleted`: List) +@Throws(MtprotoException::class) +fun `toggleTodoCompleted`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `completed`: List, + `incompleted`: List +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_toggle_todo_completed( @@ -8020,8 +8976,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_unblock_user( @@ -8032,8 +8988,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) +@Throws(MtprotoException::class) +fun `unregisterDevice`( + `handle`: ULong, + `tokenType`: Int, + `token`: String, + `otherUids`: List +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_unregister_device( @@ -8046,8 +9007,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder( @@ -8058,8 +9019,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) +@Throws(MtprotoException::class) +fun `updateFolderOrder`(`handle`: ULong, `order`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder_order( @@ -8070,9 +9031,13 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer) +fun `updateLazySyncConfig`( + `handle`: ULong, + `lazy`: Boolean, + `exceptions`: List +) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_lazy_sync_config( @@ -8082,8 +9047,17 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_update_notify_settings( @@ -8100,8 +9074,8 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_update_status( diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt b/network/bridge/src/main/java/org/monogram/network/bridge/MtprotoClient.kt index 71d26ee0..5b718bfd 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 @@ -23,9 +23,25 @@ data class UpdatesCursor( val seq: Int, ) +data class DurableUpdateBatch( + val accountGeneration: Long, + val batchId: Long, + val updates: List, + val cursor: UpdatesCursor?, + val channelCursors: Map, +) + +fun interface DurableUpdateSink { + suspend fun commit(batch: DurableUpdateBatch): Boolean +} + sealed class MtprotoUpdate { data class ChatsChanged(val chats: List) : MtprotoUpdate() - data class NewMessage(val message: Message) : MtprotoUpdate() + data class NewMessage( + val message: Message, + val accountGeneration: Long = 0, + val acceptedOrder: Long = 0, + ) : MtprotoUpdate() data class MessageEdited(val message: Message) : MtprotoUpdate() data class MessagesDeleted(val chatId: PeerId?, val messageIds: List) : MtprotoUpdate() data class FoldersChanged(val folders: List) : MtprotoUpdate() 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 6bbc0dba..2342e734 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 @@ -2,6 +2,7 @@ package org.monogram.network.bridge.media import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import org.monogram.core.common.AppLog import org.monogram.core.common.DebugStats import org.monogram.core.common.Outcome @@ -11,10 +12,12 @@ import org.monogram.core.models.PeerId import org.monogram.core.models.Wallpaper import org.monogram.core.models.WallpaperCatalog import org.monogram.network.bridge.session.DispatchClass +import org.monogram.network.bridge.session.NativeRequestTag import org.monogram.network.bridge.session.SessionCore 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.MediaPriority internal class MediaApi(private val core: SessionCore) : MediaOps { @@ -131,7 +134,10 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { priority: Int, ): Outcome { when (val connected = core.ensureConnected()) { - is Outcome.Err -> return connected + is Outcome.Err -> { + AppLog.api("media", "downloadCustomEmoji fail id=$documentId") + return connected + } is Outcome.Ok -> Unit } return retryShortFlood { @@ -143,6 +149,7 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { } catch (e: CancellationException) { throw e } catch (e: Exception) { + AppLog.api("media", "downloadCustomEmoji fail id=$documentId") Outcome.Err(e.message ?: "custom emoji download failed") } } @@ -177,93 +184,106 @@ internal class MediaApi(private val core: SessionCore) : MediaOps { ) return connected } + is Outcome.Ok -> Unit } val dispatchClass = mediaDispatchClass(priority) - return retryShortFlood { - perfOp("download:${kind.name.lowercase()}") { - try { - AppLog.api( - "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 - ) + return withContext(NativeRequestTag(destPath)) { + retryShortFlood { + perfOp("download:${kind.name.lowercase()}") { + try { + AppLog.api( + "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 + ) - 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 + ) + } + } + + val downloaded = try { + fetch() + } catch (e: Exception) { + val raw = nativeExceptionMessage(e) + if (messageId > 0 && raw.contains("no media for chat")) { + delay(400) + fetch() + } else { + throw e + } } - } - val downloaded = try { - fetch() + AppLog.api("download media", "ok") + val size = java.io.File(downloaded).length() + DebugStats.attachBytes("download:${kind.name.lowercase()}", size) + Outcome.Ok(downloaded) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { val raw = nativeExceptionMessage(e) - if (messageId > 0 && raw.contains("no media for chat")) { - delay(400) - fetch() - } else { - throw e + if (messageId == 0 && raw.contains("no media for chat")) { + return@retryShortFlood Outcome.Err("no avatar") } + if (kind == TelegramFileKind.Thumb && isMissingThumbError(raw)) { + return@retryShortFlood Outcome.Err("no downloadable thumb") + } + if (kind == TelegramFileKind.Display && raw.contains( + "no display size", + ignoreCase = true + ) + ) { + return@retryShortFlood Outcome.Err("no display size") + } + if (isFileMigrateError(raw)) { + return@retryShortFlood Outcome.Err("media deferred") + } + val outcome = core.fail(e, "download media failed", degradeHome = false) + val homeAlive = core.activeHandleOrZero().let { activeHandle -> + activeHandle == 0L || runCatching { + core.native.isAuthorized( + activeHandle + ) + }.getOrDefault( + true + ) + } + if (!homeAlive) { + core.markSessionDegraded(outcome.telegramError) + } + outcome } - AppLog.api("download media", "ok") - val size = java.io.File(downloaded).length() - DebugStats.attachBytes("download:${kind.name.lowercase()}", size) - Outcome.Ok(downloaded) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val raw = nativeExceptionMessage(e) - if (messageId == 0 && raw.contains("no media for chat")) { - return@retryShortFlood Outcome.Err("no avatar") - } - if (kind == TelegramFileKind.Thumb && isMissingThumbError(raw)) { - return@retryShortFlood Outcome.Err("no downloadable thumb") - } - if (kind == TelegramFileKind.Display && raw.contains( - "no display size", - ignoreCase = true - ) - ) { - return@retryShortFlood Outcome.Err("no display size") - } - if (isFileMigrateError(raw)) { - return@retryShortFlood Outcome.Err("media deferred") - } - val outcome = core.fail(e, "download media failed", degradeHome = false) - val homeAlive = core.activeHandleOrZero().let { activeHandle -> - activeHandle == 0L || runCatching { core.native.isAuthorized(activeHandle) }.getOrDefault( - true - ) - } - if (!homeAlive) { - core.markSessionDegraded(outcome.telegramError) - } - outcome } } } } + override fun promoteDownload(destPath: String, priority: Int) { + promoteTaggedRequest(core.native, destPath, mediaDispatchClass(priority)) + } + override fun peekMessageInlineThumb(chatId: PeerId, messageId: Int): ByteArray? { val handle = core.activeHandleOrZero() if (handle == 0L) return null 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 a669f6fb..e13a8b46 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 @@ -60,6 +60,8 @@ interface MediaOps { priority: Int, ): Outcome = downloadMessageDisplay(chatId, messageId, destPath) + fun promoteDownload(destPath: String, priority: Int) = Unit + suspend fun downloadCustomEmoji(documentId: Long, destPath: String): Outcome = Outcome.Err("unsupported") 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 cbbe9775..3d83451c 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 @@ -60,7 +60,31 @@ internal class MessageApi( is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("getHistoryPage failed") { activeHandle -> + return historyPage(chatId, limit, offsetId, offsetDate, addOffset, background = false) + } + + override suspend fun getHistoryPageBackground( + chatId: PeerId, + limit: Int, + offsetId: Int, + offsetDate: Int, + addOffset: Int, + ): Outcome> = + historyPage(chatId, limit, offsetId, offsetDate, addOffset, background = true) + + private suspend fun historyPage( + chatId: PeerId, + limit: Int, + offsetId: Int, + offsetDate: Int, + addOffset: Int, + background: Boolean, + ): Outcome> { + when (val connected = core.ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + val call: suspend (Long) -> List = { activeHandle -> core.native.getHistoryPage( activeHandle, chatId.value, @@ -70,6 +94,11 @@ internal class MessageApi( addOffset, ).map { it.toModel() } } + return if (background) { + core.rpcBackground("getHistoryPage failed", call) + } else { + core.rpc("getHistoryPage failed", call) + } } override suspend fun searchMessages( diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt index c4c44061..02b69191 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/message/MessageOps.kt @@ -27,6 +27,14 @@ interface MessageOps { addOffset: Int = 0, ): Outcome> + suspend fun getHistoryPageBackground( + chatId: PeerId, + limit: Int = 40, + offsetId: Int = 0, + offsetDate: Int = 0, + addOffset: Int = 0, + ): Outcome> = getHistoryPage(chatId, limit, offsetId, offsetDate, addOffset) + suspend fun searchMessages( chatId: PeerId, query: String, diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/profile/ProfileApi.kt b/network/bridge/src/main/java/org/monogram/network/bridge/profile/ProfileApi.kt index e53706c4..1a3e6b40 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/profile/ProfileApi.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/profile/ProfileApi.kt @@ -168,7 +168,7 @@ internal class ProfileApi(private val core: SessionCore) : ProfileOps { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("getStickerPack failed") { activeHandle -> + return core.rpcBackground("getStickerPack failed") { activeHandle -> core.native.getStickerPack(activeHandle, documentId).toModel() } } @@ -178,7 +178,7 @@ internal class ProfileApi(private val core: SessionCore) : ProfileOps { is Outcome.Err -> return connected is Outcome.Ok -> Unit } - return core.rpc("getStickerSet failed") { activeHandle -> + return core.rpcBackground("getStickerSet failed") { activeHandle -> core.native.getStickerSet(activeHandle, setId, accessHash).toModel() } } diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/NativeRequest.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/NativeRequest.kt index 467811e6..7cc3575a 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/NativeRequest.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/NativeRequest.kt @@ -4,10 +4,12 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ThreadContextElement +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import org.monogram.core.common.PerfLog import org.monogram.mtproto.MtprotoNative +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @@ -24,6 +26,16 @@ internal object DispatchClass { const val USER_MEDIA = 7 } +internal class NativeRequestTag(val tag: String) : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key +} + +private val taggedRequests = java.util.concurrent.ConcurrentHashMap() + +internal fun promoteTaggedRequest(native: MtprotoNative, tag: String, dispatchClass: Int) { + taggedRequests[tag]?.let { native.promoteRequestControl(it, dispatchClass) } +} + private class NativeRequestContext( private val native: MtprotoNative, private val id: Long, @@ -62,18 +74,26 @@ internal suspend fun nativeRequest( block: suspend () -> T, ): T { val startedAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L + val opId = PerfLog.currentOpId() + val tag = currentCoroutineContext()[NativeRequestTag]?.tag + val serviceStartedAt = AtomicLong(0L) var resultLabel = "ok" try { return suspendCancellableCoroutine { continuation -> val id = native.createRequestControl() + if (tag != null) taggedRequests[tag] = id val result = AtomicReference?>(null) val worker = - CoroutineScope(dispatcher + NativeRequestContext(native, id, dispatchClass)).launch( - start = CoroutineStart.LAZY - ) { + CoroutineScope( + dispatcher + NativeRequestContext(native, id, dispatchClass) + PerfLog.OpTrace( + opId + ) + ).launch(start = CoroutineStart.LAZY) { + if (startedAt != 0L) serviceStartedAt.set(System.nanoTime()) result.set(runCatching { block() }) } worker.invokeOnCompletion { failure -> + if (tag != null) taggedRequests.remove(tag, id) native.releaseRequestControl(id) continuation.resumeWith( result.get() ?: Result.failure( @@ -95,14 +115,20 @@ internal suspend fun nativeRequest( throw e } finally { if (startedAt != 0L) { - PerfLog.trace( - op = "nativeRequest", - phase = "dispatch", - elapsedMs = (System.nanoTime() - startedAt) / 1_000_000, - dispatchClass = dispatchClass, - result = resultLabel, - detail = "lane=${dispatchClassName(dispatchClass)}", - ) + val finishedAt = System.nanoTime() + val serviceAt = serviceStartedAt.get().takeIf { it != 0L } ?: finishedAt + PerfLog.withOpId(opId) { + PerfLog.trace( + op = "nativeRequest", + phase = "dispatch", + elapsedMs = (System.nanoTime() - startedAt) / 1_000_000, + dispatchClass = dispatchClass, + result = resultLabel, + detail = "lane=${dispatchClassName(dispatchClass)} " + + "queue_wait_ms=${(serviceAt - startedAt) / 1_000_000} " + + "service_ms=${(finishedAt - serviceAt) / 1_000_000}", + ) + } } } } 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 55ce08c7..c09e8c64 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 @@ -3,7 +3,9 @@ package org.monogram.network.bridge.session import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -27,10 +29,12 @@ import org.monogram.core.models.AuthState import org.monogram.core.models.PeerId import org.monogram.mtproto.MtprotoNative import org.monogram.network.bridge.ClientInitInfo +import org.monogram.network.bridge.DurableUpdateSink import org.monogram.network.bridge.MtprotoTransportMode import org.monogram.network.bridge.MtprotoUpdate import org.monogram.network.bridge.ProxyConfig import uniffi.monogram_mtproto.MtprotoException +import java.util.concurrent.atomic.AtomicBoolean internal class SessionCore( internal val credentials: TelegramCredentials, @@ -48,16 +52,23 @@ internal class SessionCore( /** Native bootstrap (including TXT config) is valid for this handle. */ @Volatile internal var connectedHandle: Long = 0L + @Volatile private var proxyState: ProxyConnectionStatus = ProxyConnectionStatus.Disabled + @Volatile private var updatesStartedHandle: Long = 0L private val updatesStartMutex = Mutex() internal val connectMutex = Mutex() private val inflightMutex = Mutex() - private val inflight = HashMap>() + + private class SharedRead(val deferred: Deferred<*>, var consumers: Int = 0) + + private val inflight = HashMap() + @Volatile internal var sessionDead: Boolean = false + @Volatile internal var closed: Boolean = false internal fun isAuthFallback(fallback: String): Boolean = @@ -75,12 +86,18 @@ internal class SessionCore( /** Set by [hibernate] while requests are in flight; applied on the last completion. */ @Volatile internal var hibernatePending = false + @Volatile internal var dialogForeground: Boolean = false internal val scope = CoroutineScope(SupervisorJob() + nativeDispatcher) internal val sessionLostFlow = MutableSharedFlow(extraBufferCapacity = 1) internal val localUpdates = MutableSharedFlow(extraBufferCapacity = 8) + @Volatile + internal var durableSink: DurableUpdateSink? = null + + internal val replayPending = AtomicBoolean(true) + /** Wakes the update loop when a handle is created, reconnected, or closed. */ internal val updatesWake = Channel(capacity = Channel.CONFLATED) internal val updatesEvents = MutableSharedFlow( @@ -194,7 +211,10 @@ internal class SessionCore( internal suspend fun rpc(fallback: String, block: suspend (Long) -> T): Outcome = rpc(fallback, DispatchClass.INTERACTIVE_READ, block) - internal suspend fun rpcBackground(fallback: String, block: suspend (Long) -> T): Outcome = + internal suspend fun rpcBackground( + fallback: String, + block: suspend (Long) -> T + ): Outcome = rpc(fallback, DispatchClass.BACKGROUND_READ, block) internal suspend fun rpcWrite(fallback: String, block: suspend (Long) -> T): Outcome = @@ -202,17 +222,28 @@ internal class SessionCore( /** One native call per key while identical reads overlap. */ internal suspend fun coalesce(key: String, block: suspend () -> T): T { - val deferred = inflightMutex.withLock { + 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 + } + try { @Suppress("UNCHECKED_CAST") - (inflight[key] as Deferred?) ?: scope.async { - try { - block() - } finally { - inflightMutex.withLock { inflight.remove(key) } + 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 (!shared.deferred.isCompleted) shared.deferred.cancel() + } } - }.also { inflight[key] = it } + } } - return deferred.await() } internal suspend fun rpc( @@ -398,7 +429,10 @@ internal class SessionCore( ) } if (result is Outcome.Ok) { - AppLog.api("auth.sendCode", "ok type=${result.value.codeType} length=${result.value.codeLength}") + AppLog.api( + "auth.sendCode", + "ok type=${result.value.codeType} length=${result.value.codeLength}" + ) } return result } @@ -418,7 +452,10 @@ internal class SessionCore( ) } if (result is Outcome.Ok) { - AppLog.api("auth.resendCode", "ok type=${result.value.codeType} length=${result.value.codeLength}") + AppLog.api( + "auth.resendCode", + "ok type=${result.value.codeType} length=${result.value.codeLength}" + ) } return result } @@ -591,7 +628,14 @@ internal class SessionCore( native.clearProxy() proxyState = ProxyConnectionStatus.Disabled } else { - native.setProxy(config.type.name.lowercase(), config.host, config.port, config.username, config.password, config.secret.copyOf()) + native.setProxy( + config.type.name.lowercase(), + config.host, + config.port, + config.username, + config.password, + config.secret.copyOf() + ) proxyState = ProxyConnectionStatus.Disconnected } resetHandle() @@ -666,7 +710,8 @@ internal class SessionCore( handle.also { handle = 0L } } connectedHandle = 0L - if (proxyState == ProxyConnectionStatus.Connected) proxyState = ProxyConnectionStatus.Disconnected + if (proxyState == ProxyConnectionStatus.Connected) proxyState = + ProxyConnectionStatus.Disconnected if (updatesStartedHandle == oldHandle) updatesStartedHandle = 0L if (oldHandle != 0L) native.destroyClient(oldHandle) updatesWake.trySend(Unit) diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt new file mode 100644 index 00000000..e1719f90 --- /dev/null +++ b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt @@ -0,0 +1,152 @@ +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.chat.toChatModels +import uniffi.monogram_mtproto.UpdateEventDto +import org.monogram.network.bridge.message.toModel as toMessageModel + +/** + * Domain updates for one native event. `ChatsChanged` and `FoldersChanged` only + * 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.NewMessage -> { + AppLog.api("updates", "newMessage chat=${event.message.chatId} id=${event.message.id}") + listOf(MtprotoUpdate.NewMessage(event.message.toMessageModel())) + } + + is UpdateEventDto.MessageEdited -> { + 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.ReadInbox -> { + AppLog.api( + "read-state", + "server chat=${event.chatId} max=${event.maxId} unread=${event.stillUnread}" + ) + listOf( + MtprotoUpdate.ReadInbox( + chatId = PeerId(event.chatId), + maxId = event.maxId, + stillUnread = event.stillUnread, + ), + ) + } + + is UpdateEventDto.ReadOutbox -> listOf( + MtprotoUpdate.ReadOutbox(chatId = PeerId(event.chatId), maxId = event.maxId), + ) + + is UpdateEventDto.Ignored -> ignoredUpdates(event.kind) + + is UpdateEventDto.SavedGifsChanged -> listOf(MtprotoUpdate.SavedGifsChanged) + + 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.DialogsPatched -> + if (event.chats.isEmpty()) { + emptyList() + } else { + listOf(MtprotoUpdate.ChatsChanged(event.chats.toChatModels(assignPinOrder = false))) + } + + is UpdateEventDto.SyncState -> listOf(MtprotoUpdate.SyncState(event.isSyncing)) +} + +private fun ignoredUpdates(kind: String): List { + val parts = kind.split(':') + return when { + kind.startsWith("dialog_unread_mark:") -> { + val chatId = parts.getOrNull(1)?.toLongOrNull() + val unread = parts.getOrNull(2) == "1" + listOfNotNull(chatId?.let { MtprotoUpdate.DialogUnreadMark(PeerId(it), unread) }) + } + + kind.startsWith("notify_settings:") -> { + val peerKind = parts.getOrNull(1).orEmpty() + val chatId = parts.getOrNull(2)?.toLongOrNull() ?: 0L + val muteUntil = parts.getOrNull(3)?.toIntOrNull() ?: 0 + if (peerKind.isEmpty()) { + emptyList() + } else { + listOf( + MtprotoUpdate.NotifySettingsChanged( + peerKind = peerKind, + chatId = PeerId(chatId), + muteUntil = muteUntil, + topicId = parts.getOrNull(4)?.toIntOrNull()?.takeIf { it > 0 }, + ), + ) + } + } + + kind.startsWith("UpdateMessageId:") -> { + AppLog.api("updates", "message id mapped") + listOf(MtprotoUpdate.Ignored(kind)) + } + + kind.startsWith("unread_mentions_delta:") -> { + val chatId = parts.getOrNull(1)?.toLongOrNull() + val delta = parts.getOrNull(2)?.toIntOrNull() ?: 1 + listOfNotNull(chatId?.let { MtprotoUpdate.UnreadMentionsDelta(PeerId(it), delta) }) + } + + kind.startsWith("unread_reactions_delta:") -> { + val chatId = parts.getOrNull(1)?.toLongOrNull() + val delta = parts.getOrNull(2)?.toIntOrNull() ?: 1 + listOfNotNull(chatId?.let { MtprotoUpdate.UnreadReactionsDelta(PeerId(it), delta) }) + } + + else -> { + AppLog.api("updates", "ignored $kind") + listOf(MtprotoUpdate.Ignored(kind)) + } + } +} diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesOps.kt b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesOps.kt index 270924bc..5ffcb43b 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesOps.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdatesOps.kt @@ -2,6 +2,7 @@ package org.monogram.network.bridge.updates import kotlinx.coroutines.flow.Flow import org.monogram.core.common.Outcome +import org.monogram.network.bridge.DurableUpdateSink import org.monogram.network.bridge.MtprotoUpdate import org.monogram.network.bridge.UpdatesCursor @@ -9,4 +10,10 @@ interface UpdatesOps { suspend fun getUpdatesState(): Outcome fun updates(): Flow fun sessionLost(): Flow + + /** + * Registers the single consumer that commits durable batches. Until one is + * installed, saved batches stay queued and are replayed on installation. + */ + fun installDurableUpdateSink(sink: DurableUpdateSink) = 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 97e15b9f..f1aba755 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 @@ -11,7 +11,8 @@ 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.PeerId +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 @@ -19,14 +20,21 @@ import org.monogram.network.bridge.session.DispatchClass import org.monogram.network.bridge.session.SessionCore import org.monogram.network.bridge.session.dispatchClassName import org.monogram.network.bridge.session.nativeRequest +import uniffi.monogram_mtproto.UpdateBatchDto +import uniffi.monogram_mtproto.UpdateDrainDto import uniffi.monogram_mtproto.UpdateEventDto import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import org.monogram.network.bridge.chat.toModel as toFolderModel -import org.monogram.network.bridge.message.toModel as toMessageModel 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 { + private var premiumRefreshPending = false + private var chatsRefreshPending = false + private var foldersRefreshPending = false init { core.scope.launch { drainUpdatesLoop() } @@ -50,9 +58,6 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { private suspend fun drainUpdatesLoop() { var drainDelayMs = 25L var reconnectHandle = 0L - var premiumRefreshPending = false - var chatsRefreshPending = false - var foldersRefreshPending = false val lastMetadataRefreshMs = AtomicLong(-1L) val lastChatsRefreshMs = AtomicLong(-1L) val lastPremiumRefreshMs = AtomicLong(-1L) @@ -92,19 +97,24 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { lastChatsRefreshMs.set(-1L) lastPremiumRefreshMs.set(-1L) } - val events = try { + val drain = try { + if (!replayPendingBatches(activeHandle)) { + delay(drainDelayMs) + continue + } val drainAt = if (PerfLog.isEnabled()) System.nanoTime() else 0L val drained = nativeRequest( core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ ) { - core.native.drainUpdates(activeHandle) + core.native.drainUpdateBatches(activeHandle) } - if (drained.isNotEmpty()) { - PerfLog.noteUpdateDrain(drained.size) + val count = drained.batches.sumOf { it.events.size } + drained.liveEvents.size + if (count > 0) { + PerfLog.noteUpdateDrain(count) } - if (drainAt != 0L && drained.isNotEmpty()) { + if (drainAt != 0L && count > 0) { PerfLog.trace( op = "updates", phase = "drain", @@ -112,12 +122,24 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { handle = activeHandle, dispatchClass = DispatchClass.BACKGROUND_READ, result = "ok", - detail = "count=${drained.size} lane=${dispatchClassName(DispatchClass.BACKGROUND_READ)}", + detail = "count=$count batches=${drained.batches.size} lane=${ + dispatchClassName( + DispatchClass.BACKGROUND_READ + ) + }", ) } drainDelayMs = 25L reconnectHandle = 0L - if (core.isCurrentHandle(activeHandle)) drained else emptyList() + 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) { @@ -130,177 +152,17 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } drainDelayMs = (drainDelayMs * 2).coerceIn(4_000L, 30_000L) } - emptyList() + UpdateDrainDto(emptyList(), emptyList(), false) } - for (event in events) { - when (event) { - is UpdateEventDto.ChatsChanged -> { - chatsRefreshPending = true - premiumRefreshPending = true - } - - is UpdateEventDto.FoldersChanged -> { - foldersRefreshPending = true - } - - is UpdateEventDto.NewMessage -> { - AppLog.api( - "updates", - "newMessage chat=${event.message.chatId} id=${event.message.id}", - ) - core.updatesEvents.emit(MtprotoUpdate.NewMessage(event.message.toMessageModel())) - } - - is UpdateEventDto.MessageEdited -> { - AppLog.api( - "updates", - "messageEdited chat=${event.message.chatId} id=${event.message.id}", - ) - core.updatesEvents.emit(MtprotoUpdate.MessageEdited(event.message.toMessageModel())) - } - - is UpdateEventDto.MessagesDeleted -> core.updatesEvents.emit( - MtprotoUpdate.MessagesDeleted( - chatId = event.chatId?.let(::PeerId), - messageIds = event.messageIds, - ), - ) - - is UpdateEventDto.PeerTyping -> core.updatesEvents.emit( - 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 -> core.updatesEvents.emit( - MtprotoUpdate.PeerEmojiStatus( - userId = PeerId(event.userId), - documentId = event.documentId, - ), - ) - - is UpdateEventDto.PeerStatus -> core.updatesEvents.emit( - 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}" - ) - core.updatesEvents.emit( - MtprotoUpdate.ReadInbox( - chatId = PeerId(event.chatId), - maxId = event.maxId, - stillUnread = event.stillUnread, - ) - ) - } - - is UpdateEventDto.ReadOutbox -> core.updatesEvents.emit( - MtprotoUpdate.ReadOutbox( - chatId = PeerId(event.chatId), - maxId = event.maxId, - ), - ) - - is UpdateEventDto.Ignored -> { - when { - event.kind.startsWith("dialog_unread_mark:") -> { - val parts = event.kind.split(':') - val chatId = parts.getOrNull(1)?.toLongOrNull() - val unread = parts.getOrNull(2) == "1" - if (chatId != null) { - core.updatesEvents.emit( - MtprotoUpdate.DialogUnreadMark(PeerId(chatId), unread), - ) - } - } - event.kind.startsWith("notify_settings:") -> { - val parts = event.kind.split(':') - val peerKind = parts.getOrNull(1).orEmpty() - val chatId = parts.getOrNull(2)?.toLongOrNull() ?: 0L - val muteUntil = parts.getOrNull(3)?.toIntOrNull() ?: 0 - if (peerKind.isNotEmpty()) { - core.updatesEvents.emit( - MtprotoUpdate.NotifySettingsChanged( - peerKind = peerKind, - chatId = PeerId(chatId), - muteUntil = muteUntil, - topicId = parts.getOrNull(4)?.toIntOrNull() - ?.takeIf { it > 0 }, - ), - ) - } - } - event.kind.startsWith("UpdateMessageId:") -> { - AppLog.api("updates", "message id mapped") - core.updatesEvents.emit(MtprotoUpdate.Ignored(event.kind)) - } - event.kind.startsWith("unread_mentions_delta:") -> { - val parts = event.kind.split(':') - val chatId = parts.getOrNull(1)?.toLongOrNull() - val delta = parts.getOrNull(2)?.toIntOrNull() ?: 1 - if (chatId != null) { - core.updatesEvents.emit( - MtprotoUpdate.UnreadMentionsDelta(PeerId(chatId), delta), - ) - } - } - event.kind.startsWith("unread_reactions_delta:") -> { - val parts = event.kind.split(':') - val chatId = parts.getOrNull(1)?.toLongOrNull() - val delta = parts.getOrNull(2)?.toIntOrNull() ?: 1 - if (chatId != null) { - core.updatesEvents.emit( - MtprotoUpdate.UnreadReactionsDelta(PeerId(chatId), delta), - ) - } - } - else -> { - AppLog.api("updates", "ignored ${event.kind}") - core.updatesEvents.emit(MtprotoUpdate.Ignored(event.kind)) - } - } - } - - is UpdateEventDto.SavedGifsChanged -> core.updatesEvents.emit(MtprotoUpdate.SavedGifsChanged) - is UpdateEventDto.MessageReactions -> core.updatesEvents.emit( - MtprotoUpdate.MessageReactions( - chatId = PeerId(event.chatId), - messageId = event.messageId, - reactionsJson = event.reactionsJson, - ), - ) - - is UpdateEventDto.DiscussionInbox -> core.updatesEvents.emit( - MtprotoUpdate.DiscussionInbox( - channelId = PeerId(event.channelId), - topMessageId = event.topMessageId, - readMaxId = event.readMaxId, - ), - ) - - is UpdateEventDto.DialogsPatched -> { - if (event.chats.isNotEmpty()) { - core.updatesEvents.emit( - MtprotoUpdate.ChatsChanged(event.chats.toChatModels(assignPinOrder = false)), - ) - } - } - - is UpdateEventDto.SyncState -> core.updatesEvents.emit( - MtprotoUpdate.SyncState(event.isSyncing), - ) + for (batch in drain.batches) { + deliverEvents(batch.events, batch.accountGeneration, batch.batchId) + } + deliverEvents(drain.liveEvents, 0L, 0L) + for (batch in drain.batches) { + rememberEmitted(batch) + if (!commitAndAcknowledge(activeHandle, batch)) { + core.replayPending.set(true) + break } } // Chat metadata events carry no self-user flags. Coalesce the fallback @@ -401,6 +263,105 @@ internal class UpdatesPump(private val core: SessionCore) : UpdatesOps { } } + private val emitted = LinkedHashSet>() + + private fun rememberEmitted(batch: UpdateBatchDto) { + emitted += batch.accountGeneration to batch.batchId + while (emitted.size > EMITTED_WINDOW) emitted.remove(emitted.first()) + } + + /** + * 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. + */ + private suspend fun commitAndAcknowledge(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 + } + if (!committed) return false + nativeRequest(core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ) { + core.native.acknowledgeUpdateBatch(handle, batch.accountGeneration, batch.batchId) + } + return true + } + + /** + * Applies batches saved by an earlier run (or a failed commit) before any new + * update is drained. Returns false when a batch could not be committed. + */ + private suspend fun replayPendingBatches(handle: Long): Boolean { + if (core.durableSink == null || !core.replayPending.get()) return true + val pending = + nativeRequest(core.native, core.nativeDispatcher, DispatchClass.BACKGROUND_READ) { + core.native.pendingUpdateBatches(handle) + } + for (batch in pending) { + if ((batch.accountGeneration to batch.batchId) !in emitted) { + deliverEvents(batch.events, batch.accountGeneration, batch.batchId) + rememberEmitted(batch) + } + if (!commitAndAcknowledge(handle, batch)) return false + } + core.replayPending.set(false) + return true + } + + private suspend fun deliverEvents( + events: List, + accountGeneration: Long, + acceptedOrder: Long, + ) { + for (event in events) { + when (event) { + is UpdateEventDto.ChatsChanged -> { + chatsRefreshPending = true + 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 + } + core.updatesEvents.emit(ordered) + } + } + } + } + + override fun installDurableUpdateSink(sink: DurableUpdateSink) { + core.durableSink = sink + core.replayPending.set(true) + core.updatesWake.trySend(Unit) + } + override fun updates(): Flow = merge(core.updatesEvents, core.localUpdates) override fun sessionLost(): Flow = core.sessionLostFlow.asSharedFlow() } diff --git a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt index ac74f3d6..fe9f2d72 100644 --- a/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt +++ b/network/bridge/src/test/java/org/monogram/network/bridge/BridgedMtprotoClientHistoryTest.kt @@ -867,6 +867,9 @@ class BridgedMtprotoClientHistoryTest { private val historyHeight: Int? = null, private val chatLeft: Boolean = false, ) : MtprotoNative by MtprotoNative.Stub { + override fun drainUpdateBatches(handle: Long) = + uniffi.monogram_mtproto.UpdateDrainDto(emptyList(), drainUpdates(handle), false) + var getChatsCalls = 0 var getHistoryCalls = 0 var downloadCalls = 0 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 ddff0a8f..2ceea2f9 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 @@ -24,6 +24,8 @@ import uniffi.monogram_mtproto.AuthSignedIn import uniffi.monogram_mtproto.ChatDto import uniffi.monogram_mtproto.MessageDto import uniffi.monogram_mtproto.MtprotoException +import uniffi.monogram_mtproto.UpdateBatchDto +import uniffi.monogram_mtproto.UpdateDrainDto import uniffi.monogram_mtproto.UpdateEventDto import uniffi.monogram_mtproto.UpdatesStateDto import java.util.concurrent.CountDownLatch @@ -937,6 +939,160 @@ class BridgedMtprotoClientLifecycleTest { } } + private class DurableNative : RecordingNative() { + val drainResults = ArrayDeque() + + /** Like the native journal: delivered batches stay until acknowledged. */ + val unacked = mutableListOf() + val calls = mutableListOf() + + override fun isAuthorized(handle: Long) = true + override fun drainUpdateBatches(handle: Long): UpdateDrainDto { + calls += "drain" + val result = + drainResults.removeFirstOrNull() ?: UpdateDrainDto(emptyList(), emptyList(), false) + unacked += result.batches + return result + } + + override fun pendingUpdateBatches(handle: Long): List { + calls += "pending" + return unacked.toList() + } + + override fun acknowledgeUpdateBatch(handle: Long, accountGeneration: Long, batchId: Long) { + calls += "ack:$accountGeneration:$batchId" + 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 suspend fun kotlinx.coroutines.test.TestScope.settle(ms: Long = 300) { + testScheduler.advanceTimeBy(ms) + testScheduler.runCurrent() + } + + @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() + } + } + + @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() + } + } + + @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 + 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() + } + } + + @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() + } + } + private fun client( native: MtprotoNative, dispatcher: kotlinx.coroutines.CoroutineDispatcher, @@ -991,6 +1147,9 @@ class BridgedMtprotoClientLifecycleTest { ) private open class RecordingNative : MtprotoNative by MtprotoNative.Stub { + 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() 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 new file mode 100644 index 00000000..86ea4df7 --- /dev/null +++ b/network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt @@ -0,0 +1,82 @@ +package org.monogram.network.bridge.session + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.common.TelegramCredentials +import org.monogram.mtproto.MtprotoNative + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class SessionCoreOwnershipTest { + @Test + fun cancellingOneReaderKeepsTheProducerForTheRemainingReader() = runTest { + val core = SessionCore( + TelegramCredentials(1, "fixture"), "fixture.session", MtprotoNative.Stub, + 1_000L, StandardTestDispatcher(testScheduler), {}, + ) + val result = CompletableDeferred() + var calls = 0 + var cancelled = false + try { + val first = async { + core.coalesce("history:42") { + calls++ + try { + result.await() + } finally { + cancelled = !result.isCompleted + } + } + } + runCurrent() + val second = async { core.coalesce("history:42") { error("duplicate producer") } } + runCurrent() + first.cancelAndJoin() + assertFalse(cancelled) + result.complete(7) + assertEquals(7, second.await()) + assertEquals(1, calls) + } finally { + core.close() + } + } + + @Test + fun finalReaderReleaseCancelsTheProducerAndRejoinStartsFreshWork() = runTest { + val core = SessionCore( + TelegramCredentials(1, "fixture"), "fixture.session", MtprotoNative.Stub, + 1_000L, StandardTestDispatcher(testScheduler), {}, + ) + var cancelled = false + var calls = 0 + try { + val first = async { + core.coalesce("history:42") { + calls++ + try { + awaitCancellation() + } finally { + cancelled = true + } + } + } + runCurrent() + first.cancelAndJoin() + runCurrent() + assertTrue(cancelled) + val second = async { core.coalesce("history:42") { calls++; 8 } } + assertEquals(8, second.await()) + assertEquals(2, calls) + } finally { + core.close() + } + } +} 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 5d094a4e..9087286e 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 @@ -1,6 +1,8 @@ package org.monogram.network.http import java.io.File +import java.io.FileOutputStream +import java.io.IOException import java.security.MessageDigest data class CacheRecord( @@ -8,18 +10,274 @@ data class CacheRecord( val chatId: Long?, val kind: String, val bytes: Long, + /** Explicit user download: never evicted or cleared as disposable cache. */ + val pinned: Boolean = false, ) +/** + * Mutually exclusive byte buckets. A byte is counted in exactly one of them: + * evictable completed files, protected (leased, viewport-protected or pinned) + * files, partial staging files, or reserved-but-unwritten space. + */ +data class StorageAccounting( + val completedBytes: Long, + val protectedBytes: Long, + val partialBytes: Long, + val reservedBytes: Long, +) { + val totalBytes: Long get() = completedBytes + protectedBytes + partialBytes + reservedBytes +} + class FileCache( private val root: File, - private val maxBytes: Long = 256L * 1024L * 1024L, + maxBytes: Long = 256L * 1024L * 1024L, private val maintainOnInit: Boolean = true, + private val nowMs: () -> Long = System::currentTimeMillis, + enforceSize: Boolean = true, ) { + private var maxBytes: Long = maxBytes + private var enforceSize: Boolean = enforceSize + enum class AdmissionFailure { Capacity, Storage, InUse } + 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() private var indexLoaded = false private val protectedKeys = HashMap>() + private val leases = HashMap() + private val pendingDeletes = HashSet() + private val reservations = HashMap() + private val partialBytes = HashMap() + private val lastAccess = HashMap() + private val diskNames = java.util.concurrent.ConcurrentHashMap() + private var reconciled = false + private var dirtyIndexMutations = 0 + 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) + 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() + } + } + } + + /** 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() + } + } + } + + private fun protected(key: String): Boolean = + (leases[diskName(key)] ?: 0) > 0 || records[key]?.pinned == true || + protectedKeys.values.any { keys -> keys.any { diskName(it) == diskName(key) } } + + private fun protectedNames(): Set { + val names = HashSet(leases.keys) + protectedKeys.values.forEach { keys -> keys.forEach { names.add(diskName(it)) } } + records.values.forEach { if (it.pinned) names.add(diskName(it.key)) } + return names + } + + /** Space held by transfers in flight for other keys; completed files cannot be evicted for it. */ + 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) } + 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) + } + 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) }, + ) + } + + 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() + } + } + + /** + * 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 = + synchronized(recordsLock) { + ensureIndexLoaded() + val name = diskName(key) + if (bytes < 0 || root.usableSpace - bytes < FREE_SPACE_RESERVE) { + throw AdmissionException(AdmissionFailure.Storage) + } + if (optional && exceedsCapacity(key, bytes)) { + throw AdmissionException(AdmissionFailure.Capacity) + } + reservations[name] = (reservations[name] ?: 0L) + bytes + Reservation { + synchronized(recordsLock) { + val left = (reservations[name] ?: 0L) - bytes + if (left <= 0L) reservations.remove(name) else reservations[name] = left + } + } + } + + /** Records the current size of this key's `.part` staging file; zero clears it. */ + fun notePartial(key: String, bytes: Long) = synchronized(recordsLock) { + val name = diskName(key) + if (bytes <= 0L) partialBytes.remove(name) else partialBytes[name] = bytes + } + + /** Deletes staging files nobody owns and that were idle for [maxIdleMs]. */ + fun expireInactivePartials(maxIdleMs: Long = PARTIAL_IDLE_MS): Long = + synchronized(recordsLock) { + val now = nowMs() + var freed = 0L + root.listFiles()?.forEach { file -> + if (!file.isFile || !file.name.endsWith(".part")) return@forEach + val name = file.name.removeSuffix(".part") + if (reservations.containsKey(name) || (leases[name] ?: 0) > 0) return@forEach + if (now - file.lastModified() < maxIdleMs) return@forEach + val length = file.length() + if (file.delete()) { + freed += length + partialBytes.remove(name) + } + } + freed + } + + /** 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() + } + + private fun reconcileLocked() { + dropMissing() + backfillUnindexed() + partialBytes.clear() + root.listFiles()?.forEach { file -> + if (file.isFile && file.name.endsWith(".part") && !file.name.startsWith(STAGING_PUBLISH) && + !file.name.startsWith(STAGING_INDEX) + ) { + partialBytes[file.name.removeSuffix(".part")] = file.length() + } + } + reconciled = true + } + + private fun completeBytesOnDisk(): Long = + 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 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() + } + + private fun markIndexDirty(force: Boolean = false) { + if (dirtyIndexMutations == 0) firstDirtyAt = nowMs() + dirtyIndexMutations++ + if (force || dirtyIndexMutations >= INDEX_BATCH || nowMs() - firstDirtyAt >= INDEX_BATCH_MS) { + writeIndex() + } + } + + private fun writeIndex() { + try { + persistIndex() + dirtyIndexMutations = 0 + } catch (_: IOException) { + // The files stay valid; startup reconciliation recovers unindexed ones. + indexFailures++ + } + } + + private fun pendingDeletion(key: String): Boolean = + pendingDeletes.any { diskName(it) == diskName(key) } + + private fun flushPendingDeletes() { + val ready = pendingDeletes.filterNot(::protected) + if (ready.isEmpty()) return + deleteKeys(ready) + } init { root.mkdirs() @@ -28,8 +286,8 @@ class FileCache( internal fun maintain() = synchronized(recordsLock) { ensureIndexLoaded() - dropMissing() - backfillUnindexed() + reconcileLocked() + expireInactivePartials() } private fun ensureIndexLoaded() { @@ -40,30 +298,75 @@ class FileCache( fun fileFor(key: String): File = File(root, diskName(key)) - fun get(key: String): File? { + fun get(key: String): File? = synchronized(recordsLock) { + if (pendingDeletion(key)) return@synchronized null val file = fileFor(key) - if (!file.exists()) return null - file.setLastModified(System.currentTimeMillis()) - return file + if (!file.isFile) return@synchronized null + val now = nowMs() + val name = diskName(key) + 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() + flushPendingDeletes() } + fun publishLeased( + key: String, + source: File, + chatId: Long? = null, + kind: String? = null + ): Lease = + synchronized(recordsLock) { + val owner = "publication:${java.util.UUID.randomUUID()}" + protectedKeys[owner] = setOf(key) + try { + putFileInternal(key, source, chatId, kind, temporary = true) + checkNotNull(acquire(key)).also { + if (exceedsCapacity(key, source.length())) pendingDeletes.add(key) + } + } finally { + protectedKeys.remove(owner) + } + } + fun put(key: String, bytes: ByteArray, chatId: Long? = null, kind: String? = null): File = synchronized(recordsLock) { ensureIndexLoaded() val file = fileFor(key) file.parentFile?.mkdirs() - file.writeBytes(bytes) + checkAdmission(key, bytes.size.toLong()) + val staging = File.createTempFile("publish-", ".part", root) + try { + FileOutputStream(staging).use { output -> + output.write(bytes) + output.fd.sync() + } + replace(staging, file) + } finally { + staging.delete() + } remember(key, chatId, kind ?: inferKind(key), file.length()) trimIfNeeded() + if (!file.isFile) throw IOException("cache admission failed") file } 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 + ): File = synchronized(recordsLock) { ensureIndexLoaded() val target = fileFor(key) @@ -71,19 +374,80 @@ class FileCache( if (!source.exists()) { error("media cache source missing") } - if (source.canonicalFile != target.canonicalFile) { - source.copyTo(target, overwrite = true) + val sameFile = source.canonicalFile == target.canonicalFile + checkAdmission(key, source.length(), temporary, if (sameFile) 0 else source.length()) + if (!sameFile) { + val staging = File.createTempFile("publish-", ".part", root) + try { + source.inputStream().use { input -> + FileOutputStream(staging).use { output -> + input.copyTo(output) + output.fd.sync() + } + } + replace(staging, target) + } finally { + staging.delete() + } } remember(key, chatId, kind ?: inferKind(key), target.length()) trimIfNeeded() + if (!target.isFile) throw IOException("cache admission failed") target } + private fun exceedsCapacity(key: String, bytes: Long): Boolean { + if (!enforceSize) return false + if (bytes > maxBytes) return true + return protectedBytesExcept(key) + inFlightBytesExcept(key) > maxBytes - bytes + } + + private fun protectedBytesExcept(key: String): Long { + val names = protectedNames() + return names.asSequence().distinct().filter { it != diskName(key) } + .sumOf { File(root, it).length() } + } + + private fun checkAdmission( + key: String, + bytes: Long, + temporary: Boolean = false, + additionalBytes: Long = bytes + ) { + if ((leases[diskName(key)] ?: 0) > 0 || pendingDeletion(key)) { + throw AdmissionException(AdmissionFailure.InUse) + } + if (!temporary && exceedsCapacity(key, bytes)) { + throw AdmissionException(AdmissionFailure.Capacity) + } + if (bytes < 0 || root.usableSpace - additionalBytes < FREE_SPACE_RESERVE) { + throw AdmissionException(AdmissionFailure.Storage) + } + } + + 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 + ) + return + } + if (!source.renameTo(target)) throw IOException("cache atomic replacement failed") + } + fun remember(key: String, chatId: Long?, kind: String, bytes: Long) { synchronized(recordsLock) { ensureIndexLoaded() - records[key] = normalized(key, chatId, kind, bytes) - persistIndex() + 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) + lastAccess[diskName(key)] = nowMs() + partialBytes.remove(diskName(key)) + markIndexDirty() } } @@ -93,7 +457,7 @@ class FileCache( val existing = records[key] ?: return if (existing.chatId == chatId && existing.kind == kind) return records[key] = existing.copy(chatId = chatId, kind = kind) - persistIndex() + markIndexDirty() } } @@ -140,59 +504,78 @@ class FileCache( } fun remove(key: String): Boolean { - deleteKeys(listOf(key)) + unpin(key) + deleteKeys(listOf(key), force = true) return !fileFor(key).exists() } /** Deletes all cached files. Used by settings "clear media cache". */ fun clear(): Long = synchronized(recordsLock) { ensureIndexLoaded() - var freed = 0L - root.walkTopDown().filter { it.isFile && it.name != INDEX_NAME }.forEach { file -> - val len = file.length() - if (file.delete()) freed += len - } - records.clear() - persistIndex() - freed + backfillUnindexed() + deleteKeys(records.keys.toList()) } fun totalBytes(): Long = 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 + } + trimIfNeeded() + } + fun trimIfNeeded() = synchronized(recordsLock) { ensureIndexLoaded() - val files = root.walkTopDown().filter { it.isFile && it.name != INDEX_NAME && !isStaging(it.name) }.toList() - var total = files.sumOf { it.length() } + if (!reconciled) reconcileLocked() + if (!enforceSize) return@synchronized + var total = accounting().totalBytes if (total <= maxBytes) return@synchronized - val ordered = files.sortedBy { it.lastModified() } - for (file in ordered) { + 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 recordKey = - records.keys.firstOrNull { fileFor(it).canonicalFile == file.canonicalFile } - if (protectedKeys.values.any { recordKey in it }) continue - val len = file.length() - if (file.delete()) { - total -= len - val drop = records.filter { fileFor(it.key).canonicalFile == file.canonicalFile }.keys - drop.forEach { records.remove(it) } + 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(records::remove) + lastAccess.remove(name) } } - persistIndex() + markIndexDirty(force = true) } - private fun deleteKeys(keys: List): Long = synchronized(recordsLock) { + 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 records[key]?.bytes ?: 0L + val len = if (file.exists()) file.length() else 0L if (!file.exists() || file.delete()) { freed += len records.remove(key) + pendingDeletes.remove(key) + lastAccess.remove(diskName(key)) } } - persistIndex() + markIndexDirty(force = true) freed } @@ -200,7 +583,7 @@ class FileCache( val gone = records.filter { !fileFor(it.key).exists() }.keys if (gone.isEmpty()) return gone.forEach { records.remove(it) } - persistIndex() + markIndexDirty(force = true) } private fun backfillUnindexed() { @@ -220,7 +603,7 @@ class FileCache( ) added = true } - if (added) persistIndex() + if (added) markIndexDirty(force = true) } private fun loadIndex() { @@ -233,11 +616,11 @@ class FileCache( val chatId = parts[1].toLongOrNull() val kind = parts[2].ifBlank { inferKind(key) } val bytes = parts[3].toLongOrNull() ?: 0L - val row = normalized(key, chatId, kind, bytes) + val row = normalized(key, chatId, kind, bytes).copy(pinned = parts.getOrNull(4) == "1") if (row.chatId != chatId || row.kind != kind) changed = true records[key] = row } - if (changed) persistIndex() + if (changed) markIndexDirty(force = true) } private fun normalized(key: String, chatId: Long?, kind: String, bytes: Long): CacheRecord { @@ -255,23 +638,53 @@ class FileCache( 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}" + "${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 -> + output.write(text.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + replace(staging, indexFile) + } finally { + staging.delete() } - indexFile.writeText(text) } private fun isStaging(name: String): Boolean = name.endsWith(".part") || name.endsWith(".cancel") - private fun diskName(key: String): String = - 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 hash(key: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(key.toByteArray()) - return digest.joinToString("") { "%02x".format(it) } + val digest = checkNotNull(sha256.get()) + digest.reset() + val bytes = digest.digest(key.toByteArray(Charsets.UTF_8)) + val chars = CharArray(bytes.size * 2) + for (index in bytes.indices) { + val value = bytes[index].toInt() and 0xff + chars[index * 2] = HEX[value ushr 4] + chars[index * 2 + 1] = HEX[value and 0x0f] + } + return String(chars) } companion object { + const val FREE_SPACE_RESERVE = 128L * 1024 * 1024 + const val PARTIAL_IDLE_MS = 24L * 60 * 60 * 1000 + private const val INDEX_BATCH = 16 + 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 const val STAGING_PUBLISH = "publish-" + private const val STAGING_INDEX = "index-" + const val INDEX_NAME = "index.tsv" const val ORPHAN_PREFIX = "orphan:" const val KIND_PHOTOS = "photos" diff --git a/network/http/src/main/java/org/monogram/network/http/LeasedFileDataSource.kt b/network/http/src/main/java/org/monogram/network/http/LeasedFileDataSource.kt new file mode 100644 index 00000000..26b5e40f --- /dev/null +++ b/network/http/src/main/java/org/monogram/network/http/LeasedFileDataSource.kt @@ -0,0 +1,44 @@ +package org.monogram.network.http + +import android.net.Uri +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.FileDataSource +import androidx.media3.datasource.TransferListener +import java.io.IOException + +@androidx.media3.common.util.UnstableApi +internal class LeasedFileDataSource(private val cache: FileCache, private val key: String) : + DataSource { + private val delegate = FileDataSource() + private var lease: FileCache.Lease? = null + + override fun addTransferListener(listener: TransferListener) = + delegate.addTransferListener(listener) + + override fun open(dataSpec: DataSpec): Long { + close() + val acquired = cache.acquire(key) ?: throw IOException("Cached media unavailable") + lease = acquired + try { + return delegate.open(dataSpec.buildUpon().setUri(Uri.fromFile(acquired.file)).build()) + } catch (error: Exception) { + close() + throw error + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + delegate.read(buffer, offset, length) + + override fun getUri(): Uri? = delegate.uri + + override fun close() { + try { + delegate.close() + } finally { + lease?.close() + lease = null + } + } +} 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 8fefb7f2..9cc33abd 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -28,9 +29,12 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.monogram.core.common.AppLog +import org.monogram.core.common.MediaCacheLimit import org.monogram.core.common.Outcome import org.monogram.core.common.PerfLog +import org.monogram.core.common.WorkOwnership import org.monogram.core.common.perfOp import org.monogram.core.common.telegram.TelegramError import org.monogram.core.models.INSTANT_VIEW_MEDIA_MSG_ID @@ -57,7 +61,7 @@ internal fun streamPrefixPart( ): File = FileCache(directory, maintainOnInit = false).fileFor(streamPartKey(cacheKey, offset)) -internal fun streamPartKey(cacheKey: String, offset: Long): String = "$cacheKey:stream:$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}" @@ -70,7 +74,7 @@ internal fun reusableStreamPrefix( 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() - offset == expected } + return file?.takeIf { expected > 0 && it.isFile && it.length() == expected } } fun mediaThumbCacheKey(mediaCacheKey: String): String = @@ -92,6 +96,9 @@ fun interface TelegramMediaFetcher { kind: MediaFetchKind, priority: Int, ): Outcome + + /** A visible consumer joined the transfer writing [destPath]. */ + fun promote(destPath: String, priority: Int) = Unit } fun interface TelegramInlineThumbPeek { @@ -111,6 +118,7 @@ class MediaRepository( private val telegramChunkFetcher: TelegramChunkFetcher? = null, private val inlineThumbPeek: TelegramInlineThumbPeek? = null, private val peerInlineThumbPeek: TelegramPeerInlineThumbPeek? = null, + cacheLimitBytes: Long? = MediaCacheLimit.DEFAULT.bytes, ) { private val streamRoot = cacheRoot @@ -120,16 +128,32 @@ class MediaRepository( @OptIn(UnstableApi::class) fun createMessageDataSourceFactory(message: Message): androidx.media3.datasource.DataSource.Factory = TelegramVideoDataSource.Factory(message, streamRoot, telegramChunkFetcher, cache) - private val cache = FileCache(cacheRoot, maintainOnInit = false) + + @OptIn(UnstableApi::class) + fun createCachedDataSourceFactory(key: String): androidx.media3.datasource.DataSource.Factory = + androidx.media3.datasource.DataSource.Factory { LeasedFileDataSource(cache, key) } + 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) private val keyedGenerations = ConcurrentHashMap() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Volatile + internal var cacheWorkThread: Thread? = null + init { progressSink = { path, downloaded -> onNativeProgress(path, downloaded) } - scope.launch { cache.maintain() } + scope.launch { + cacheWorkThread = Thread.currentThread() + cache.maintain() + cache.trimIfNeeded() + } } private val telegramMutex = Mutex() @@ -239,7 +263,21 @@ class MediaRepository( fun cachedFile(key: String): File? = cache.get(key) - suspend fun removeCachedFile(key: String): Boolean = kotlinx.coroutines.withContext(Dispatchers.IO) { + fun acquireCachedFile(key: String): FileCache.Lease? = cache.acquire(key) + + 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() + + /** Lease by physical file, for consumers that only hold the resolved path. */ + fun leaseFile(file: File): AutoCloseable? { + val lease = cache.acquireFile(file) + if (lease != null) usedBytes.addAndGet(file.length()) + return lease + } + + suspend fun removeCachedFile(key: String): Boolean = withContext(Dispatchers.IO) { cache.remove(key).also { removed -> if (removed) markCached(key) } } @@ -266,8 +304,22 @@ class MediaRepository( return freed } + fun setCacheLimit(bytes: Long?) { + scope.launch { + cacheWorkThread = Thread.currentThread() + cache.setMaxBytes(bytes) + } + } + fun cacheSizeBytes(): Long = cache.totalBytes() + fun storageAccounting(): StorageAccounting = cache.accounting() + + fun inflightBytes(): Long = cache.inflightBytes() + + /** Releases an explicit download so ordinary cache trimming may reclaim it. */ + fun unpinDownload(key: String) = cache.unpin(key) + fun cacheByKind(): Map = cache.usageByKind() fun cacheByChat(): Map = cache.usageByChat() @@ -283,6 +335,7 @@ class MediaRepository( suspend fun ensureLocalMessageMedia( message: Message, priority: Int = MediaPriority.DEFAULT, + retain: Boolean = false, ): Outcome { val key = message.mediaCacheKey ?: return Outcome.Err("message has no media key") @@ -295,6 +348,7 @@ class MediaRepository( priority = priority, name = message.fileName, totalBytes = message.fileSize, + retain = retain, ) } @@ -351,7 +405,7 @@ class MediaRepository( is Outcome.Ok -> { val fetched = File(result.value) - val bytes = fetched.length() - offset + val bytes = fetched.length() if (fetched.canonicalFile != temporary.canonicalFile || bytes <= 0L || bytes > STREAM_PART || (message.fileSize?.takeIf { it > 0 } != null && @@ -361,19 +415,18 @@ class MediaRepository( ?: Outcome.Err("invalid stream prefix") } java.io.RandomAccessFile(fetched, "rw").use { it.fd.sync() } - if (!fetched.renameTo(dest)) { + 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("stream prefix publish") + ?: Outcome.Err(error.message ?: "stream prefix publish", error) } - val file = dest last = file - cache.remember( - streamPartKey(key, offset), - message.id.chatId.value, - FileCache.KIND_VIDEOS, - file.length() - ) - cache.trimIfNeeded() if (bytes < STREAM_PART) return Outcome.Ok(file) } } @@ -382,9 +435,98 @@ class MediaRepository( } offset += STREAM_PART } + if (last != null && message.mediaKind == "video") { + try { + prefetchMp4Metadata(message, key, fetcher) + } catch (error: CancellationException) { + throw error + } catch (_: Exception) { + PerfLog.event("stream_metadata", "result=fallback") + } + } return last?.let { Outcome.Ok(it) } ?: Outcome.Err("empty stream prefix") } + private suspend fun prefetchMp4Metadata( + 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) + protected.add(partKey) + cache.setProtectedKeys(owner, protected) + val dest = streamPrefixPart(streamRoot, key, aligned) + reusableStreamPrefix(dest, aligned, size)?.let { return it } + val charge = minOf(STREAM_PART, size - aligned) + if (charge <= 0 || charge > remaining) return null + remaining -= charge + val temporary = File.createTempFile("metadata-", ".part", streamRoot) + try { + 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 + } + if (reusableStreamPrefix(temporary, aligned, size) == null) return null + java.io.RandomAccessFile(temporary, "rw").use { it.fd.sync() } + return runCatching { + cache.putFile( + partKey, + temporary, + message.id.chatId.value, + FileCache.KIND_VIDEOS + ) + }.getOrNull() + } finally { + temporary.delete() + } + } + try { + preloadMp4Metadata(size, read = { offset, count -> + val bytes = ByteArray(count) + var copied = 0 + while (copied < count) { + val position = offset + copied + val file = part(position) ?: break + 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) + input.readFully(bytes, copied, take) + } + copied += take + } + bytes.takeIf { copied == count } + }, cover = { offset, length -> + var position = offset + var complete = true + while (position < offset + length) { + if (part(position) == null) { + complete = false; break + } + position = (position / STREAM_PART + 1) * STREAM_PART + } + complete + }) + } finally { + cache.setProtectedKeys(owner, emptySet()) + cache.trimIfNeeded() + } + } + suspend fun ensureLocalMessageThumb( message: Message, priority: Int = MediaPriority.THUMB, @@ -508,7 +650,7 @@ class MediaRepository( ): Outcome { val key = "emoji:$documentId" cache.get(key)?.let { return Outcome.Ok(it) } - return enqueueTelegram(key, priority, chatId = null) { activePriority -> + val result = enqueueTelegram(key, priority, chatId = null) { activePriority -> cache.get(key)?.let { return@enqueueTelegram Outcome.Ok(it) } val dest = cache.fileFor(key) val fetcher = customEmojiFetcher @@ -538,6 +680,10 @@ class MediaRepository( is Outcome.Err -> fetched } } + if (result is Outcome.Err) { + AppLog.api("media", "err kind=Emoji key=emoji:$documentId ${result.message}") + } + return result } private suspend fun ensureLocalTelegramMedia( @@ -549,6 +695,7 @@ class MediaRepository( priority: Int, name: String? = null, totalBytes: Long? = null, + retain: Boolean = false, ): Outcome { val storedChatId = if (mediaKind == "gif") null else chatId.value if (mediaKind == "gif") cache.attribute(key, chatId = null, kind = FileCache.KIND_GIFS) @@ -558,7 +705,7 @@ class MediaRepository( "media", "hit kind=${kind.name} key=$key bytes=${it.length()} chat=${chatId.value} id=$messageId", ) - return Outcome.Ok(it) + return Outcome.Ok(if (retain) keptDownload(key) ?: it else it) } // Negative ids other than instant-view media are not Telegram file slots. // Page photos and documents are indexed as (id, -1) and must still download. @@ -568,7 +715,7 @@ class MediaRepository( } PerfLog.event("cache_miss", kind.name.lowercase()) val queuedAt = PerfLog.nowMs() - return enqueueTelegram( + val fetched = enqueueTelegram( key, priority, chatId = chatId.value, @@ -591,6 +738,8 @@ class MediaRepository( ?: 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, @@ -602,6 +751,8 @@ class MediaRepository( } finally { progressPaths.remove(destPath) cancelMarker(key).delete() + cache.notePartial(key, part.length()) + reservation?.close() } when (fetched) { is Outcome.Ok -> { @@ -642,6 +793,8 @@ class MediaRepository( } } } + if (!retain || fetched !is Outcome.Ok) return fetched + return Outcome.Ok(keptDownload(key) ?: fetched.value) } fun cancel(key: String) { @@ -687,6 +840,7 @@ class MediaRepository( fun shutdown() { telegramStopped = true + cache.flushIndex() telegramWorkers.forEach { it.cancel() } telegramWorkers.clear() telegramWake.close() @@ -730,12 +884,6 @@ class MediaRepository( telegramPending += QueuedTelegram(job) } - private fun hasInteractivePendingLocked(): Boolean = - hasPendingAtLeastLocked(MediaPriority.IDLE + 1) - - private fun hasDisplayPendingLocked(): Boolean = - hasPendingAtLeastLocked(MediaPriority.DISPLAY) - private fun hasPendingAtLeastLocked(minPriority: Int, thumbnail: Boolean? = null): Boolean = telegramPending.any { queued -> val job = queued.job @@ -769,6 +917,14 @@ class MediaRepository( 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) { @@ -793,25 +949,36 @@ class MediaRepository( } created }.also { + it.consumers++ + WorkOwnership.acquire("media:${it.key}", it.priority) ensureTelegramWorkerLocked() } } telegramWake.trySend(Unit) thumbWake.trySend(Unit) - if (job.cancelled) { - job.deferred.await() - return enqueueTelegram( - key, - priority, - chatId = chatId, - name = name, - totalBytes = totalBytes, - large = large, - thumbnail = thumbnail, - work = work, - ) + try { + if (job.cancelled) { + job.deferred.await() + return enqueueTelegram( + key, priority, chatId = chatId, name = name, totalBytes = totalBytes, + large = large, thumbnail = thumbnail, work = work, + ) + } + return job.deferred.await() + } finally { + withContext(NonCancellable) { + telegramMutex.withLock { + job.consumers-- + WorkOwnership.release("media:${job.key}") + if (job.consumers == 0) { + WorkOwnership.finish("media:${job.key}") + } + if (job.consumers == 0 && job.priority < MediaPriority.USER && !job.deferred.isCompleted) { + cancelJobLocked(job) + } + } + } } - return job.deferred.await() } private fun ensureTelegramWorkerLocked() { @@ -878,7 +1045,9 @@ class MediaRepository( val result = perfOp("telegram_job") { try { coroutineScope { - val runner = async(start = CoroutineStart.LAZY) { job.work(job.priority) } + val runner = + async(PerfLog.OpTrace(job.opId.takeIf { it != 0L } ?: PerfLog.newOpId()), + start = CoroutineStart.LAZY) { job.work(job.priority) } job.runner = runner try { runner.start() @@ -922,6 +1091,25 @@ class MediaRepository( private fun runningLargeLocked(): Int = telegramJobs.values.count { it.running && it.large } + /** + * File the explicit-download UI opens. It lives outside the evictable cache, + * and the cache entry is pinned so a clear cannot delete a path still in use. + */ + private fun keptDownload(key: String): File? { + 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 + } + private fun trackUserDownloadLocked(key: String, name: String?, totalBytes: Long?) { val label = name?.trim().orEmpty().ifEmpty { key } userDownloadsState.update { current -> @@ -951,23 +1139,16 @@ class MediaRepository( chatId: Long?, mediaKind: String?, ): Outcome { - if (source.canonicalFile != dest.canonicalFile) { - dest.parentFile?.mkdirs() - dest.delete() - if (!source.renameTo(dest)) { - source.copyTo(dest, overwrite = true) - source.delete() - } - } return runCatching { cache.putFile( key, - dest, + source, chatId = chatId, kind = FileCache.inferKind(key, mediaKind), ) }.fold( onSuccess = { + if (source.canonicalFile != dest.canonicalFile) source.delete() markCached(key) Outcome.Ok(it) }, @@ -1001,11 +1182,13 @@ class MediaRepository( var priority: Int, var sequence: Long, val work: suspend (Int) -> Outcome, + val opId: Long = PerfLog.currentOpId(), var large: Boolean = false, val thumbnail: Boolean = false, val deferred: CompletableDeferred> = CompletableDeferred(), var generation: Int = 0, var running: Boolean = false, + var consumers: Int = 0, @Volatile var cancelled: Boolean = false, @Volatile var preempted: Boolean = false, @Volatile var runner: Job? = null, diff --git a/network/http/src/main/java/org/monogram/network/http/Mp4MetadataPreload.kt b/network/http/src/main/java/org/monogram/network/http/Mp4MetadataPreload.kt new file mode 100644 index 00000000..632eea74 --- /dev/null +++ b/network/http/src/main/java/org/monogram/network/http/Mp4MetadataPreload.kt @@ -0,0 +1,37 @@ +package org.monogram.network.http + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal suspend fun preloadMp4Metadata( + fileSize: Long, + read: suspend (Long, Int) -> ByteArray?, + cover: suspend (Long, Long) -> Boolean, +) { + if (fileSize < 8) return + var offset = 0L + repeat(128) { + currentCoroutineContext().ensureActive() + if (offset > fileSize - 8) return + val header = read(offset, minOf(16L, fileSize - offset).toInt()) ?: return + if (header.size < 8) return + val buffer = ByteBuffer.wrap(header).order(ByteOrder.BIG_ENDIAN) + val shortSize = buffer.int.toLong() and 0xffffffffL + val type = buffer.int + val headerSize = if (shortSize == 1L) 16L else 8L + val size = when (shortSize) { + 0L -> fileSize - offset + 1L -> if (header.size >= 16) buffer.long else return + else -> shortSize + } + if (size < headerSize || size > fileSize - offset) return + if (offset == 0L && type != 0x66747970) return + if (type == 0x6d6f6f76) { + if (size <= 2L * 1024 * 1024) cover(offset, size) + return + } + offset += size + } +} 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 ef0e89f2..2a607a54 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 @@ -47,7 +47,7 @@ class TelegramVideoDataSource private constructor( private var partFile: File? = null private var partIsPrefix = false private var partHandle: RandomAccessFile? = null - private val cacheOwner = "stream:${java.util.UUID.randomUUID()}" + private var partLease: FileCache.Lease? = null @Volatile private var request: Job? = null override fun open(dataSpec: DataSpec): Long { @@ -83,7 +83,7 @@ class TelegramVideoDataSource private constructor( val take = if (remaining < 0) Long.MAX_VALUE else remaining val count = minOf(length.toLong(), partLength - position, take).toInt().coerceAtLeast(1) val handle = partHandle ?: throw IOException("Media part unavailable") - handle.seek(position) + handle.seek(position - partOffset) val read = handle.read(buffer, offset, count) if (read <= 0) { if (remaining > 0) throw IOException("Unexpected end of media") @@ -99,13 +99,10 @@ class TelegramVideoDataSource private constructor( if (!directory.isDirectory && !directory.mkdirs()) throw IOException("Cannot create media buffer") releasePart() val mediaKey = streamMediaKey(message) - val prefix = mediaKey?.takeIf { offset < MediaRepository.STREAM_PREFIX_BYTES } - ?.let { streamPrefixPart(directory, it, offset) } - if (prefix != null) cache?.setProtectedKeys( - cacheOwner, - listOf(streamPartKey(checkNotNull(mediaKey), offset)) - ) - val reused = reusableStreamPrefix(prefix, offset, message.fileSize) + val key = mediaKey?.let { streamPartKey(it, offset) } + val acquired = key?.let { cache?.acquire(it) } + val reused = reusableStreamPrefix(acquired?.file, offset, message.fileSize) + if (reused == null) acquired?.close() else partLease = acquired val file = reused ?: File.createTempFile("video-", ".part", directory) partIsPrefix = reused != null if (reused != null) { @@ -126,9 +123,12 @@ class TelegramVideoDataSource private constructor( } } } + if (reused == null && file.length() > PART_SIZE) { + RandomAccessFile(file, "rw").use { it.setLength(PART_SIZE) } + } val length = file.length() - if (length <= offset || length - offset > PART_SIZE) throw IOException("Invalid media part") - if (message.fileSize?.takeIf { it > 0 } != null && reusableStreamPrefix( + if (length <= 0) throw IOException("Invalid media part: length=$length offset=$offset") + if (key != null && message.fileSize?.takeIf { it > 0 } != null && reusableStreamPrefix( file, offset, message.fileSize @@ -136,31 +136,35 @@ class TelegramVideoDataSource private constructor( throw IOException("Incomplete media part") } if (reused == null) RandomAccessFile(file, "rw").use { it.fd.sync() } - if (prefix != null && file.canonicalFile != prefix.canonicalFile && !file.renameTo( - prefix - ) - ) throw IOException("Media prefix publish") - val published = prefix ?: file - partIsPrefix = prefix != null - if (partIsPrefix) cache?.setProtectedKeys( - cacheOwner, - listOf(streamPartKey(checkNotNull(mediaKey), offset)) - ) - partFile = published - if (prefix != null) { - cache?.remember( - streamPartKey(checkNotNull(mediaKey), offset), - message.id.chatId.value, - FileCache.KIND_VIDEOS, - length - ) + if (reused == null && key != null && cache != null) { + try { + 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) + if (reusableStreamPrefix(concurrent?.file, offset, message.fileSize) != null) { + partLease = concurrent + } else { + concurrent?.close() + } + } } - partLength = length + val published = partLease?.file ?: file + partIsPrefix = partLease != null + partFile = published + if (partIsPrefix && reused == null) file.delete() + partLength = offset + length partHandle = RandomAccessFile(published, "r") partOffset = offset } catch (e: Exception) { + partLease?.close() + partLease = null if (reused == null) file.delete() - cache?.setProtectedKeys(cacheOwner, emptySet()) partOffset = -1 throw IOException("Media part unavailable: ${e.message}", e) } finally { @@ -173,9 +177,10 @@ class TelegramVideoDataSource private constructor( private fun releasePart() { runCatching { partHandle?.close() } partHandle = null + partLease?.close() + partLease = null if (!partIsPrefix) runCatching { partFile?.delete() } partFile = null - cache?.setProtectedKeys(cacheOwner, emptySet()) cache?.trimIfNeeded() partIsPrefix = false partLength = 0L diff --git a/network/http/src/test/java/org/monogram/network/http/FileCacheAccountingTest.kt b/network/http/src/test/java/org/monogram/network/http/FileCacheAccountingTest.kt new file mode 100644 index 00000000..14065bc4 --- /dev/null +++ b/network/http/src/test/java/org/monogram/network/http/FileCacheAccountingTest.kt @@ -0,0 +1,263 @@ +package org.monogram.network.http + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +class FileCacheAccountingTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun filesystemBytes(root: File): Long = + root.listFiles().orEmpty() + .filter { it.isFile && it.name != FileCache.INDEX_NAME && !it.name.endsWith(".part") } + .sumOf { it.length() } + + @Test + fun bucketsAreMutuallyExclusiveAndMatchTheFilesystem() { + val root = tmp.newFolder("buckets") + val cache = FileCache(root, maxBytes = 1024) + cache.put("a", ByteArray(10)) + cache.put("b", ByteArray(20)) + cache.put("c", ByteArray(30)) + cache.setProtectedKeys("viewport", listOf("a")) + val lease = checkNotNull(cache.acquire("b")) + cache.notePartial("p", 7) + val reservation = cache.reserve("q", 50) + + val snapshot = cache.accounting() + assertEquals(30L, snapshot.completedBytes) + assertEquals(30L, snapshot.protectedBytes) + assertEquals(7L, snapshot.partialBytes) + assertEquals(50L, snapshot.reservedBytes) + assertEquals(60L + 7L + 50L, snapshot.totalBytes) + assertEquals(60L, filesystemBytes(root)) + + lease.close() + reservation.close() + cache.setProtectedKeys("viewport", emptyList()) + cache.notePartial("p", 0) + val after = cache.accounting() + assertEquals(StorageAccounting(60, 0, 0, 0), after) + } + + @Test + fun reservationIsReplacedByPartialBytesAsTheyAreWritten() { + val cache = FileCache(tmp.newFolder("reserve"), maxBytes = 1024) + val reservation = cache.reserve("x", 100) + cache.notePartial("x", 40) + val snapshot = cache.accounting() + assertEquals(40L, snapshot.partialBytes) + assertEquals(60L, snapshot.reservedBytes) + reservation.close() + assertEquals(40L, cache.accounting().partialBytes) + } + + @Test + fun optionalReservationIsRefusedWhenInFlightWorkLeavesNoRoom() { + val cache = FileCache(tmp.newFolder("room"), maxBytes = 100) + val first = cache.reserve("a", 80) + val failure = + assertThrows(FileCache.AdmissionException::class.java) { cache.reserve("b", 40) } + assertEquals(FileCache.AdmissionFailure.Capacity, failure.reason) + cache.reserve("b", 40, optional = false).close() + first.close() + cache.reserve("b", 40).close() + } + + @Test + fun inFlightBytesForceEvictionOfCompletedFilesOnly() { + val cache = FileCache(tmp.newFolder("evict"), maxBytes = 100) + cache.put("old", ByteArray(40)) + cache.setProtectedKeys("v", listOf("held")) + cache.put("held", ByteArray(30)) + cache.notePartial("p", 60) + cache.trimIfNeeded() + assertNull(cache.get("old")) + assertNotNull(cache.get("held")) + } + + private fun part(cache: FileCache, root: File, key: String, size: Int, modified: Long) = + File(root, cache.fileFor(key).name + ".part").apply { + writeBytes(ByteArray(size)) + setLastModified(modified) + } + + @Test + fun partialsExpireAfterADayUnlessOwnedOrRecent() { + var now = 1_000_000_000L + val root = tmp.newFolder("expiry") + val cache = FileCache(root, nowMs = { now }) + val idle = part(cache, root, "idle", 9, now) + val owned = part(cache, root, "owned", 5, now) + val recent = part(cache, root, "recent", 3, now + FileCache.PARTIAL_IDLE_MS) + val ownerReservation = cache.reserve("owned", 5) + + now += FileCache.PARTIAL_IDLE_MS - 1 + assertEquals(0L, cache.expireInactivePartials()) + now += 2 + FileCache.PARTIAL_IDLE_MS / 2 + assertEquals(9L, cache.expireInactivePartials()) + assertFalse(idle.exists()) + assertTrue(owned.exists()) + assertTrue(recent.exists()) + + ownerReservation.close() + now += FileCache.PARTIAL_IDLE_MS + assertEquals(8L, cache.expireInactivePartials()) + assertFalse(owned.exists()) + } + + @Test + fun reconcileRebuildsPartialCountersAndRecoversUnindexedFiles() { + val root = tmp.newFolder("reconcile") + val first = FileCache(root, maxBytes = 1024) + first.put("a", ByteArray(10)) + first.put("b", ByteArray(5)) + File(root, "leftover").writeBytes(ByteArray(7)) + part(first, root, "x", 11, System.currentTimeMillis()) + File(root, first.fileFor("b").name).delete() + // The new instance has no index (never flushed) and a stale view of nothing. + val restarted = FileCache(root, maxBytes = 1024) + assertEquals(0L, restarted.reconcile()) + val snapshot = restarted.accounting() + assertEquals(10L + 7L, snapshot.completedBytes) + assertEquals(11L, snapshot.partialBytes) + assertEquals(filesystemBytes(root), snapshot.completedBytes + snapshot.protectedBytes) + } + + @Test + fun interruptedIndexWriteKeepsFilesUsableAndIsRecovered() { + val root = tmp.newFolder("index-fail") + val cache = FileCache(root, maxBytes = 1024) + // A directory where the index belongs makes every index replacement fail. + File(root, FileCache.INDEX_NAME).mkdirs() + File(File(root, FileCache.INDEX_NAME), "blocker").writeBytes(byteArrayOf(1)) + cache.put("a", ByteArray(4)) + cache.flushIndex() + assertNotNull(cache.get("a")) + File(File(root, FileCache.INDEX_NAME), "blocker").delete() + File(root, FileCache.INDEX_NAME).delete() + cache.flushIndex() + val restored = FileCache(root, maxBytes = 1024) + assertEquals(4L, restored.accounting().completedBytes) + assertNotNull(restored.get("a")) + } + + @Test + fun indexWritesAreBatchedAndFlushedOnDemand() { + val root = tmp.newFolder("batched") + val cache = FileCache(root, maxBytes = 1024 * 1024) + cache.put("one", ByteArray(1)) + assertFalse( + "a single publication must not rewrite the index", + File(root, FileCache.INDEX_NAME).exists() + ) + cache.flushIndex() + assertTrue(File(root, FileCache.INDEX_NAME).exists()) + repeat(40) { cache.put("k$it", ByteArray(1)) } + val lines = File(root, FileCache.INDEX_NAME).readLines().size + assertTrue("batch threshold must have flushed, lines=$lines", lines > 1) + } + + @Test + fun pinnedDownloadsSurviveTrimAndClearButNotExplicitRemoval() { + val cache = FileCache(tmp.newFolder("pinned"), maxBytes = 10) + cache.put("doc:1", ByteArray(8)) + cache.pin("doc:1") + cache.put("photo:2", ByteArray(2)) + cache.trimIfNeeded() + assertEquals(8L, cache.accounting().protectedBytes) + assertEquals(2L, cache.clear()) + assertNotNull(cache.get("doc:1")) + assertEquals(0L, cache.clearKind(FileCache.KIND_FILES)) + assertTrue(cache.remove("doc:1")) + assertNull(cache.get("doc:1")) + } + + @Test + fun clearReportsOnlyBytesActuallyFreed() { + val cache = FileCache(tmp.newFolder("freed")) + cache.put("a", ByteArray(10)) + cache.put("b", ByteArray(20)) + val lease = checkNotNull(cache.acquire("b")) + assertEquals(10L, cache.clear()) + assertTrue(lease.file.exists()) + lease.close() + assertFalse(lease.file.exists()) + } + + @Test + fun orphanAliasBytesAreCountedOnceAndProtectedByPhysicalFile() { + val root = tmp.newFolder("orphans") + File(root, "leftover").writeBytes(ByteArray(6)) + val cache = FileCache(root) + val key = FileCache.ORPHAN_PREFIX + "leftover" + val lease = checkNotNull(cache.acquire(key)) + val snapshot = cache.accounting() + assertEquals(6L, snapshot.protectedBytes) + assertEquals(0L, snapshot.completedBytes) + lease.close() + assertEquals(6L, cache.accounting().completedBytes) + } + + @Test + fun concurrentPublishReadTrimAndClearKeepAccountingConsistent() { + val root = tmp.newFolder("concurrent") + val cache = FileCache(root, maxBytes = 200) + val pool = Executors.newFixedThreadPool(6) + val start = CountDownLatch(1) + val failures = java.util.concurrent.ConcurrentLinkedQueue() + repeat(6) { worker -> + pool.execute { + start.await() + try { + repeat(150) { i -> + val key = "k${(worker * 7 + i) % 25}" + when ((worker + i) % 4) { + 0 -> runCatching { cache.put(key, ByteArray(10 + i % 5)) } + 1 -> cache.acquire(key)?.use { assertTrue(it.file.isFile) } + 2 -> cache.trimIfNeeded() + else -> if (i % 40 == 0) cache.clear() else cache.get(key) + } + } + } catch (error: Throwable) { + failures += error + } + } + } + start.countDown() + pool.shutdown() + assertTrue(pool.awaitTermination(30, java.util.concurrent.TimeUnit.SECONDS)) + assertTrue("failures=$failures", failures.isEmpty()) + cache.trimIfNeeded() + val snapshot = cache.accounting() + assertEquals(0L, snapshot.protectedBytes) + assertEquals(0L, cache.reconcile()) + assertTrue(snapshot.completedBytes <= 200L) + } + + @Test + fun fileLeaseProtectsTheResolvedPathUntilTheConsumerFinishes() { + val root = tmp.newFolder("file-lease") + val cache = FileCache(root) + val file = cache.put("photo:1", ByteArray(5)) + val lease = checkNotNull(cache.acquireFile(file)) + assertEquals(0L, cache.clear()) + assertTrue(file.exists()) + assertNull( + "files outside the cache cannot be leased", + cache.acquireFile(tmp.newFile("elsewhere")) + ) + lease.close() + assertFalse(file.exists()) + } +} diff --git a/network/http/src/test/java/org/monogram/network/http/FileCacheTest.kt b/network/http/src/test/java/org/monogram/network/http/FileCacheTest.kt index 9ff25ed9..37e8d89e 100644 --- a/network/http/src/test/java/org/monogram/network/http/FileCacheTest.kt +++ b/network/http/src/test/java/org/monogram/network/http/FileCacheTest.kt @@ -1,7 +1,12 @@ package org.monogram.network.http +import kotlinx.coroutines.delay +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.Rule import org.junit.Test @@ -9,6 +14,150 @@ import org.junit.rules.TemporaryFolder import java.io.File class FileCacheTest { + @Test + fun acquiredUnindexedFileSurvivesTrimBeforeStartupReconciliation() { + val root = tmp.newFolder("unindexed-lease") + val cache = FileCache(root, maxBytes = 4, maintainOnInit = false) + cache.fileFor("held").writeBytes(byteArrayOf(1, 2, 3, 4)) + val lease = checkNotNull(cache.acquire("held")) + try { + cache.fileFor("other").writeBytes(byteArrayOf(5, 6, 7, 8)) + cache.trimIfNeeded() + assertTrue(lease.file.exists()) + assertEquals(4L, cache.totalBytes()) + try { + cache.put("optional", byteArrayOf(9)) + org.junit.Assert.fail("optional publication must account for an unindexed lease") + } catch (error: FileCache.AdmissionException) { + assertEquals(FileCache.AdmissionFailure.Capacity, error.reason) + } + } finally { + lease.close() + } + } + + @Test + fun optionalPublicationCannotExceedCapacityThroughProtection() { + val cache = FileCache(tmp.newFolder("optional-admission"), maxBytes = 4) + cache.setProtectedKeys("viewport", listOf("photo:oversized")) + val failure = org.junit.Assert.assertThrows(FileCache.AdmissionException::class.java) { + cache.put("photo:oversized", ByteArray(8)) + } + assertEquals(FileCache.AdmissionFailure.Capacity, failure.reason) + assertEquals(null, cache.get("photo:oversized")) + } + + @Test + fun leasedPublicationOverProtectedBudgetIsTemporary() { + val cache = FileCache(tmp.newFolder("protected-budget"), maxBytes = 6) + cache.put("photo:visible", ByteArray(4)) + val visible = checkNotNull(cache.acquire("photo:visible")) + val source = tmp.newFile("protected-source").apply { writeBytes(ByteArray(4)) } + val temporary = cache.publishLeased("photo:demand", source) + assertTrue(visible.file.exists()) + assertTrue(temporary.file.exists()) + assertEquals(null, cache.acquire("photo:demand")) + temporary.close() + assertFalse(temporary.file.exists()) + assertTrue(visible.file.exists()) + visible.close() + } + + @Test + fun orphanAliasCannotEvictOrClearAnAcquiredFile() { + val root = tmp.newFolder("orphan-lease") + val cache = FileCache(root, maxBytes = 6, maintainOnInit = false) + val file = cache.fileFor("photo:late").apply { writeBytes(byteArrayOf(1, 2, 3)) } + cache.maintain() + val lease = checkNotNull(cache.acquire("photo:late")) + try { + file.setLastModified(1) + File(root, "another-orphan").writeBytes(ByteArray(5)) + cache.maintain() + cache.trimIfNeeded() + assertTrue(file.exists()) + assertEquals(0L, cache.clear()) + assertEquals(null, cache.acquire("photo:late")) + assertTrue(file.exists()) + } finally { + lease.close() + } + assertFalse(file.exists()) + } + + @Test + fun rememberingTheRealKeyReplacesOrphanAccounting() { + val root = tmp.newFolder("orphan-accounting") + val cache = FileCache(root, maintainOnInit = false) + val file = cache.fileFor("photo:late").apply { writeBytes(ByteArray(3)) } + cache.maintain() + cache.remember("photo:late", 42L, FileCache.KIND_PHOTOS, file.length()) + assertEquals(listOf("photo:late"), cache.records().map { it.key }) + assertEquals(mapOf(FileCache.KIND_PHOTOS to 3L), cache.usageByKind()) + assertEquals(3L, cache.clear()) + } + + @Test + fun oversizedLeasedPublicationIsRemovedAfterRelease() { + val root = tmp.newFolder("temporary-publication") + val cache = FileCache(root, maxBytes = 64) + val source = tmp.newFile("temporary-source").apply { writeBytes(ByteArray(128)) } + val lease = cache.publishLeased("video:temporary", source) + cache.trimIfNeeded() + assertTrue(lease.file.exists()) + assertEquals(null, cache.acquire("video:temporary")) + lease.close() + assertFalse(lease.file.exists()) + assertTrue(source.exists()) + } + + @Test + fun publicationCannotReplaceALeasedFile() { + val cache = FileCache(tmp.newFolder("leased-replacement")) + cache.put("photo:1", byteArrayOf(1, 2, 3)) + val lease = checkNotNull(cache.acquire("photo:1")) + org.junit.Assert.assertThrows(java.io.IOException::class.java) { + cache.put("photo:1", byteArrayOf(4, 5)) + } + org.junit.Assert.assertArrayEquals(byteArrayOf(1, 2, 3), lease.file.readBytes()) + lease.close() + cache.put("photo:1", byteArrayOf(4, 5)) + org.junit.Assert.assertArrayEquals(byteArrayOf(4, 5), cache.get("photo:1")!!.readBytes()) + } + + @Test + fun clearDefersDeletionUntilTheFinalLeaseIsReleased() { + val cache = FileCache(tmp.newFolder("leases")) + cache.put("photo:1", byteArrayOf(1, 2, 3)) + val first = checkNotNull(cache.acquire("photo:1")) + val second = checkNotNull(cache.acquire("photo:1")) + assertEquals(0L, cache.clear()) + assertEquals(null, cache.acquire("photo:1")) + first.close() + assertTrue(second.file.exists()) + second.close() + second.close() + assertFalse(second.file.exists()) + assertEquals(0L, cache.totalBytes()) + } + + @Test + fun oversizedPublicationFailsWithoutReplacingAnExistingEntry() { + val root = tmp.newFolder("oversized") + val cache = FileCache(root, maxBytes = 64) + cache.put("photo:1", byteArrayOf(1, 2, 3)) + org.junit.Assert.assertThrows(java.io.IOException::class.java) { + cache.put("photo:1", ByteArray(128)) + } + org.junit.Assert.assertArrayEquals(byteArrayOf(1, 2, 3), cache.get("photo:1")!!.readBytes()) + val source = File(root.parentFile, "oversized-source").apply { writeBytes(ByteArray(128)) } + org.junit.Assert.assertThrows(java.io.IOException::class.java) { + cache.putFile("doc:2", source) + } + assertTrue(source.exists()) + assertEquals(null, cache.get("doc:2")) + } + @Test fun removeDocumentPreservesOtherFilesAndPersistsIndex() { val root = tmp.newFolder("remove-document") @@ -105,17 +254,82 @@ class FileCacheTest { @Test fun indexSurvivesRelaunch() { val root = tmp.newFolder("durable") - FileCache(root, maxBytes = 1024 * 1024).put( + val first = FileCache(root, maxBytes = 1024 * 1024) + first.put( "photo:7", byteArrayOf(1, 2, 3, 4, 5), chatId = 99L, kind = FileCache.KIND_PHOTOS, ) + first.flushIndex() val again = FileCache(root, maxBytes = 1024 * 1024) assertEquals(mapOf(FileCache.KIND_PHOTOS to 5L), again.usageByKind()) assertEquals(mapOf(99L to 5L), again.usageByChat()) } + @Test + fun finiteLimitEvictsOldestUnpinnedAndKeepsPinnedAndLeased() { + var now = 1L + val cache = FileCache( + tmp.newFolder("limit"), + maxBytes = 200, + nowMs = { now }, + maintainOnInit = false + ) + cache.put("old", ByteArray(40)) + now = 2 + cache.put("pinned", ByteArray(40)) + cache.pin("pinned") + now = 3 + cache.put("leased", ByteArray(40)) + val lease = checkNotNull(cache.acquire("leased")) + try { + cache.setMaxBytes(50) + assertNull(cache.get("old")) + assertNotNull(cache.get("pinned")) + assertNotNull(cache.get("leased")) + } finally { + lease.close() + } + } + + @Test + fun unlimitedDoesNotSizeEvict() { + val cache = FileCache(tmp.newFolder("unlimited"), maxBytes = 10, maintainOnInit = false) + cache.setMaxBytes(null) + cache.put("a", ByteArray(40)) + cache.put("b", ByteArray(40)) + cache.trimIfNeeded() + assertNotNull(cache.get("a")) + assertNotNull(cache.get("b")) + cache.setMaxBytes(40) + assertNull(cache.get("a")) + assertNotNull(cache.get("b")) + } + + @Test + fun repositoryAppliesTheLimitOffTheCallerThread() = runBlocking { + val root = tmp.newFolder("repo-limit") + val seeded = FileCache(root, maxBytes = 10_000, maintainOnInit = false) + seeded.put("old", ByteArray(80)) + seeded.put("keep", ByteArray(80)) + seeded.pin("keep") + seeded.flushIndex() + val caller = Thread.currentThread() + val repository = MediaRepository(root, cacheLimitBytes = 100) + try { + withTimeout(5_000) { + while (repository.cacheWorkThread == null || repository.cacheSizeBytes() > 100) delay( + 20 + ) + } + assertTrue(repository.cacheWorkThread != caller) + assertTrue(repository.cacheSizeBytes() <= 100) + } finally { + repository.shutdown() + } + } + @Test fun inferKindFromKeyAndMediaHint() { assertEquals(FileCache.KIND_PHOTOS, FileCache.inferKind("photo:1")) diff --git a/network/http/src/test/java/org/monogram/network/http/MediaRepositoryInflightTest.kt b/network/http/src/test/java/org/monogram/network/http/MediaRepositoryInflightTest.kt index 905d7d09..f8d5ac40 100644 --- a/network/http/src/test/java/org/monogram/network/http/MediaRepositoryInflightTest.kt +++ b/network/http/src/test/java/org/monogram/network/http/MediaRepositoryInflightTest.kt @@ -2,6 +2,8 @@ package org.monogram.network.http import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -20,6 +22,77 @@ import java.io.FileOutputStream import java.util.concurrent.atomic.AtomicInteger class MediaRepositoryInflightTest { + @Test + fun finalConsumerReleaseCancelsTransferAndRejoinDownloadsAgain() = runBlocking { + val started = CompletableDeferred() + val cancelled = CompletableDeferred() + val fetches = AtomicInteger(0) + val repo = MediaRepository( + tmp.newFolder("last-consumer"), + telegramFetcher = TelegramMediaFetcher { _, _, path, _, _ -> + if (fetches.incrementAndGet() == 1) { + File(path).writeBytes(byteArrayOf(1)) + started.complete(Unit) + try { + awaitCancellation() + } finally { + cancelled.complete(Unit) + } + } + File(path).writeBytes(byteArrayOf(2, 3)) + Outcome.Ok(path) + }) + try { + val first = async { repo.ensureIndexedMedia(PeerId(42), 1, "photo:released") } + withTimeout(2_000) { started.await() } + first.cancelAndJoin() + withTimeout(2_000) { cancelled.await() } + assertNull(repo.cachedFile("photo:released")) + val rejoined = + withTimeout(2_000) { repo.ensureIndexedMedia(PeerId(42), 1, "photo:released") } + assertTrue(rejoined is Outcome.Ok) + assertEquals(listOf(2, 3), (rejoined as Outcome.Ok).value.readBytes().toList()) + assertEquals(2, fetches.get()) + } finally { + repo.shutdown() + } + } + + @Test + fun cancellingOneConsumerPreservesTheSharedProducer() = runBlocking { + val started = CompletableDeferred() + val finish = CompletableDeferred() + val fetches = AtomicInteger(0) + val repo = MediaRepository( + tmp.newFolder("shared-consumers"), + telegramFetcher = TelegramMediaFetcher { _, _, path, _, _ -> + fetches.incrementAndGet() + started.complete(Unit) + finish.await() + File(path).writeBytes(byteArrayOf(1, 2, 3)) + Outcome.Ok(path) + }) + try { + val first = async { repo.ensureIndexedMedia(PeerId(42), 1, "photo:shared") } + started.await() + val second = async(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) { + repo.ensureIndexedMedia( + PeerId(42), + 1, + "photo:shared", + priority = MediaPriority.VISIBLE + ) + } + first.cancel() + first.join() + finish.complete(Unit) + assertTrue(withTimeout(2_000) { second.await() } is Outcome.Ok) + assertEquals(1, fetches.get()) + } finally { + repo.shutdown() + } + } + @get:Rule val tmp = TemporaryFolder() diff --git a/network/http/src/test/java/org/monogram/network/http/Mp4MetadataPreloadTest.kt b/network/http/src/test/java/org/monogram/network/http/Mp4MetadataPreloadTest.kt new file mode 100644 index 00000000..f7b25196 --- /dev/null +++ b/network/http/src/test/java/org/monogram/network/http/Mp4MetadataPreloadTest.kt @@ -0,0 +1,70 @@ +package org.monogram.network.http + +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.ByteBuffer + +class Mp4MetadataPreloadTest { + @Test + fun findsMetadataInThePrefix() = runBlocking { + var covered: Pair? = null + preloadMp4Metadata(80, read = { offset, _ -> + when (offset) { + 0L -> atom(16, 0x66747970); 16L -> atom(64, 0x6d6f6f76); else -> null + } + }, cover = { offset, size -> covered = offset to size; true }) + assertEquals(16L to 64L, covered) + } + + @Test + fun rejectsOversizedMetadataAndInvalidExtendedLengths() = runBlocking { + var covers = 0 + for (size in listOf(-1L, Long.MAX_VALUE, 8L, 3L * 1024 * 1024)) { + preloadMp4Metadata(4L * 1024 * 1024, read = { offset, _ -> + if (offset == 0L) atom(16, 0x66747970) + else ByteBuffer.allocate(16).putInt(1).putInt(0x6d6f6f76).putLong(size).array() + }, cover = { _, _ -> covers++; true }) + } + assertEquals(0, covers) + } + + @Test + fun truncatedExtendedHeaderFallsBack() = runBlocking { + var covers = 0 + preloadMp4Metadata(64, read = { offset, _ -> + if (offset == 0L) atom(16, 0x66747970) + else ByteBuffer.allocate(8).putInt(1).putInt(0x6d6f6f76).array() + }, cover = { _, _ -> covers++; true }) + assertEquals(0, covers) + } + + @Test + fun findsMetadataAfterMediaWithoutReadingMediaPayload() = runBlocking { + val headers = mapOf( + 0L to atom(16, 0x66747970), + 16L to atom(4 * 1024 * 1024, 0x6d646174), + (16L + 4 * 1024 * 1024) to atom(64, 0x6d6f6f76), + ) + val reads = mutableListOf() + var covered: Pair? = null + preloadMp4Metadata(16L + 4 * 1024 * 1024 + 64, read = { offset, count -> + reads += offset + headers[offset]?.copyOf(count) + }, cover = { offset, size -> covered = offset to size; true }) + assertEquals(headers.keys.toList(), reads) + assertEquals((16L + 4 * 1024 * 1024) to 64L, covered) + } + + @Test + fun rejectsAtomOutsideTheFile() = runBlocking { + var covers = 0 + preloadMp4Metadata( + 32, read = { _, _ -> atom(128, 0x66747970) }, + cover = { _, _ -> covers++; true }) + assertEquals(0, covers) + } + + private fun atom(size: Int, type: Int): ByteArray = + ByteBuffer.allocate(16).putInt(size).putInt(type).array() +} diff --git a/network/http/src/test/java/org/monogram/network/http/ProtectedFileCacheTest.kt b/network/http/src/test/java/org/monogram/network/http/ProtectedFileCacheTest.kt index 1ca5f6f3..007ee383 100644 --- a/network/http/src/test/java/org/monogram/network/http/ProtectedFileCacheTest.kt +++ b/network/http/src/test/java/org/monogram/network/http/ProtectedFileCacheTest.kt @@ -19,9 +19,12 @@ class ProtectedFileCacheTest { cache.setProtectedKeys("list", listOf("avatar:1")) cache.setProtectedKeys("dialog", listOf("avatar:1")) cache.setProtectedKeys("dialog", emptyList()) - cache.put("doc:2", byteArrayOf(4, 5, 6, 7)) + org.junit.Assert.assertThrows(java.io.IOException::class.java) { + cache.put("doc:2", byteArrayOf(4, 5, 6, 7)) + } assertNotNull(cache.get("avatar:1")) assertNull(cache.get("doc:2")) + cache.flushIndex() val restored = FileCache(root, maxBytes = 6) assertNotNull(restored.get("avatar:1")) assertNull(restored.get("doc:2")) diff --git a/network/http/src/test/java/org/monogram/network/http/StreamingPrefixTest.kt b/network/http/src/test/java/org/monogram/network/http/StreamingPrefixTest.kt index 12c3bf2d..e53efa6f 100644 --- a/network/http/src/test/java/org/monogram/network/http/StreamingPrefixTest.kt +++ b/network/http/src/test/java/org/monogram/network/http/StreamingPrefixTest.kt @@ -23,6 +23,72 @@ class StreamingPrefixTest { fileSize = 20L * 1024 * 1024, ) + @Test + fun metadataOutsidePrefixUsesRangesWithinTheAdditionalBudget() = runBlocking { + val part = MediaRepository.STREAM_PART + val metadataOffset = 16L + 4L * 1024 * 1024 + val headers = mapOf( + 0L to java.nio.ByteBuffer.allocate(16).putInt(16).putInt(0x66747970).array(), + 16L to java.nio.ByteBuffer.allocate(8).putInt(4 * 1024 * 1024).putInt(0x6d646174) + .array(), + metadataOffset to java.nio.ByteBuffer.allocate(8).putInt(64).putInt(0x6d6f6f76).array(), + ) + val offsets = mutableListOf() + val root = tmp.newFolder() + val repository = MediaRepository( + root, + telegramChunkFetcher = TelegramChunkFetcher { _, _, dest, offset -> + offsets += offset + val bytes = ByteArray(part.toInt()) + headers.forEach { (position, header) -> + if (position >= offset && position + header.size <= offset + part) { + header.copyInto(bytes, (position - offset).toInt()) + } + } + File(dest).writeBytes(bytes) + Outcome.Ok(dest) + }) + try { + val message = video() + assertTrue(repository.ensureStreamingPrefix(message) is Outcome.Ok) + assertEquals(listOf(0L, part, part * 2, part * 3, part * 8), offsets) + val additional = offsets.count { it >= MediaRepository.STREAM_PREFIX_BYTES } * part + assertTrue(additional <= 2L * 1024 * 1024) + val metadata = streamPrefixPart(root, checkNotNull(streamMediaKey(message)), part * 8) + assertTrue(metadata.isFile) + val calls = offsets.size + assertTrue(repository.ensureStreamingPrefix(message) is Outcome.Ok) + assertEquals(calls, offsets.size) + } finally { + repository.shutdown() + } + } + + @Test + fun metadataSearchCannotFetchBeyondTwoMiBExtra() = runBlocking { + val part = MediaRepository.STREAM_PART + val offsets = mutableListOf() + val repository = MediaRepository( + tmp.newFolder(), + telegramChunkFetcher = TelegramChunkFetcher { _, _, dest, offset -> + offsets += offset + val bytes = ByteArray(part.toInt()) + java.nio.ByteBuffer.wrap(bytes).putInt(part.toInt()) + .putInt(if (offset == 0L) 0x66747970 else 0x66726565) + File(dest).writeBytes(bytes) + Outcome.Ok(dest) + }) + try { + assertTrue(repository.ensureStreamingPrefix(video()) is Outcome.Ok) + assertEquals((0L..7L).map { it * part }, offsets) + assertEquals( + 2L * 1024 * 1024, + offsets.count { it >= MediaRepository.STREAM_PREFIX_BYTES } * part) + } finally { + repository.shutdown() + } + } + @Test fun incompletePartIsNotPublished() = runBlocking { val root = tmp.newFolder() @@ -63,10 +129,10 @@ class StreamingPrefixTest { } @Test - fun sparsePartMustContainTheWholeRequestedRange() { + fun compactPartMustContainTheWholeRequestedRange() { val offset = MediaRepository.STREAM_PART val file = tmp.newFile() - java.io.RandomAccessFile(file, "rw").use { it.setLength(offset + 7) } + java.io.RandomAccessFile(file, "rw").use { it.setLength(7) } assertEquals(null, reusableStreamPrefix(file, offset, offset + MediaRepository.STREAM_PART)) assertEquals(file, reusableStreamPrefix(file, offset, offset + 7)) } @@ -92,7 +158,7 @@ class StreamingPrefixTest { offsets += offset val file = File(dest) java.io.RandomAccessFile(file, "rw").use { part -> - part.seek(offset) + part.setLength(0) part.write(ByteArray(MediaRepository.STREAM_PART.toInt())) } Outcome.Ok(dest)