From 10640c52c983e422ac624d1bb6d9edb02b969d7e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:27:24 +0300 Subject: [PATCH 1/7] refactor cache, parts, streaming, database usage --- .../java/org/monogram/MediaStreamingTest.kt | 92 +- .../java/org/monogram/MessageMergeTest.kt | 223 ++++ .../java/org/monogram/TabletStateTest.kt | 1 + .../6.json | 1052 ++++++++++++++++ .../7.json | 1059 +++++++++++++++++ .../core/database/DatabaseProvider.kt | 23 +- .../monogram/core/database/ModelMappers.kt | 2 + .../core/database/MonogramDatabase.kt | 5 +- .../monogram/core/database/OfflineWarmup.kt | 117 +- .../monogram/core/database/dao/MessageDao.kt | 24 +- .../core/database/dao/MessageTombstoneDao.kt | 19 + .../core/database/entity/MessageEntity.kt | 2 + .../database/entity/MessageTombstoneEntity.kt | 6 + .../core/database/MessageMediaFieldsTest.kt | 2 +- .../core/ui/media/MediaPlaybackSession.kt | 6 +- .../core/ui/media/MediaViewerModel.kt | 2 +- .../org/monogram/feature/chats/ui/ChatRows.kt | 6 +- .../feature/dialog/DialogMediaPreload.kt | 14 +- .../feature/dialog/DialogMediaPreloader.kt | 12 + .../feature/dialog/store/DialogHistory.kt | 24 +- .../feature/dialog/store/DialogSend.kt | 9 +- .../feature/dialog/ui/AudioMessagePlayback.kt | 17 +- .../feature/dialog/ui/MessageMediaViewer.kt | 4 +- .../feature/dialog/ui/VideoNoteBubble.kt | 9 +- .../feature/dialog/DialogMediaPreloadTest.kt | 25 + native/mtproto-rs/src/client/lanes.rs | 17 +- native/mtproto-rs/src/client/persist.rs | 24 +- native/mtproto-rs/src/ffi.rs | 2 +- native/mtproto-rs/src/media/download.rs | 22 +- native/mtproto-rs/src/scheduler.rs | 32 +- native/mtproto-rs/src/tcp.rs | 3 + native/mtproto-rs/src/transfer_policy.rs | 28 +- .../tests/unit/client_mgr_lane_tests.rs | 3 + .../tests/unit/media_download_tests.rs | 27 + .../mtproto-rs/tests/unit/scheduler_tests.rs | 22 + native/mtproto-transport/src/lib.rs | 34 +- .../tests/unit/proxy_config_tests.rs | 32 + .../network/bridge/session/SessionCore.kt | 34 +- .../session/SessionCoreOwnershipTest.kt | 82 ++ .../org/monogram/network/http/FileCache.kt | 187 ++- .../network/http/LeasedFileDataSource.kt | 44 + .../monogram/network/http/MediaRepository.kt | 166 ++- .../network/http/Mp4MetadataPreload.kt | 37 + .../network/http/TelegramVideoDataSource.kt | 69 +- .../monogram/network/http/FileCacheTest.kt | 144 +++ .../http/MediaRepositoryInflightTest.kt | 73 ++ .../network/http/Mp4MetadataPreloadTest.kt | 70 ++ .../network/http/ProtectedFileCacheTest.kt | 4 +- .../network/http/StreamingPrefixTest.kt | 6 +- 49 files changed, 3722 insertions(+), 195 deletions(-) create mode 100644 app/src/androidTest/java/org/monogram/MessageMergeTest.kt create mode 100644 core/database/schemas/org.monogram.core.database.MonogramDatabase/6.json create mode 100644 core/database/schemas/org.monogram.core.database.MonogramDatabase/7.json create mode 100644 core/database/src/main/java/org/monogram/core/database/dao/MessageTombstoneDao.kt create mode 100644 core/database/src/main/java/org/monogram/core/database/entity/MessageTombstoneEntity.kt create mode 100644 network/bridge/src/test/java/org/monogram/network/bridge/session/SessionCoreOwnershipTest.kt create mode 100644 network/http/src/main/java/org/monogram/network/http/LeasedFileDataSource.kt create mode 100644 network/http/src/main/java/org/monogram/network/http/Mp4MetadataPreload.kt create mode 100644 network/http/src/test/java/org/monogram/network/http/Mp4MetadataPreloadTest.kt 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..4688a4f0 --- /dev/null +++ b/app/src/androidTest/java/org/monogram/MessageMergeTest.kt @@ -0,0 +1,223 @@ +package org.monogram + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.Rule +import org.monogram.core.database.DatabaseProvider +import org.monogram.core.database.SessionMetadataStore +import org.monogram.core.database.MonogramDatabase +import org.monogram.core.database.OfflineWarmup +import org.monogram.core.database.toModel +import org.monogram.core.models.Message +import org.monogram.core.models.MessageId +import org.monogram.core.models.PeerId + +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) + } + } +} 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/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/src/main/java/org/monogram/core/database/DatabaseProvider.kt b/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt index fca38e27..aabeded5 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 @@ -16,7 +16,7 @@ object DatabaseProvider { @Volatile private var instance: MonogramDatabase? = null - const val SCHEMA_VERSION = 5 + const val SCHEMA_VERSION = 7 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -49,6 +49,18 @@ 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") + } + } + /** SQL stays off Dispatchers.IO, which media downloads also use. */ private val roomWorker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "monogram-room").apply { isDaemon = true } @@ -117,7 +129,14 @@ 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 + ) .setJournalMode(androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setQueryExecutor(roomExecutor) .setTransactionExecutor(roomExecutor) 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..5be80300 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 @@ -33,6 +34,7 @@ import org.monogram.core.database.entity.UpdateCursorEntity ChatEntity::class, FolderEntity::class, MessageEntity::class, + org.monogram.core.database.entity.MessageTombstoneEntity::class, MessageHoleEntity::class, UpdateCursorEntity::class, PeerEntity::class, @@ -42,7 +44,7 @@ import org.monogram.core.database.entity.UpdateCursorEntity ProfileCommonEntity::class, SponsorEntity::class, ], - version = 5, + version = 7, exportSchema = true, ) abstract class MonogramDatabase : RoomDatabase() { @@ -50,6 +52,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..e2ce75d7 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 @@ -191,8 +192,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) + } } } @@ -279,19 +281,44 @@ open class OfflineWarmup( } } - /** 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() @@ -330,6 +357,9 @@ open class OfflineWarmup( return withContext(DatabaseProvider.roomDispatcher) { ensureStartupCleanup() val rows = database.messageDao().olderThan(chatId.value, beforeId, limit) + if (database.metaDao() + .get("history_coverage:${chatId.value}") == null + ) return@withContext emptyList() if (rows.isEmpty()) return@withContext emptyList() if (database.messageHoleDao().overlaps( chatId.value, rows.minOf { it.id }, beforeId - 1, @@ -348,6 +378,9 @@ open class OfflineWarmup( return withContext(DatabaseProvider.roomDispatcher) { ensureStartupCleanup() val rows = database.messageDao().newerThan(chatId.value, afterId, limit) + if (database.metaDao() + .get("history_coverage:${chatId.value}") == null + ) return@withContext emptyList() if (rows.isEmpty()) return@withContext emptyList() if (database.messageHoleDao().overlaps( chatId.value, afterId + 1, rows.maxOf { it.id }, @@ -370,6 +403,7 @@ open class OfflineWarmup( reachesOldest: Boolean = false, olderBoundary: Int? = null, ) = roomIo { + recentHistory.invalidate(chatId.value) val database = db if (database == null) { upsertMessages(messages) @@ -377,17 +411,28 @@ 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) } + } } } @@ -480,21 +525,48 @@ 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 { 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 messages.chunked(100)) { + kotlinx.coroutines.currentCoroutineContext().ensureActive() + database.withTransaction { mergeMessages(batch) } + kotlinx.coroutines.yield() + } + } + + 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 +579,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 { @@ -557,16 +632,21 @@ open class OfflineWarmup( 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) - ) { + if (existed || message.outgoing || message.id.id <= (stored?.readInboxMaxId ?: 0)) { stored?.unreadCount ?: 0 } else { (stored?.unreadCount ?: 0) + 1 } + if (stored != null && message.id.id < stored.lastMessageId) { + database.chatDao().upsertAll(listOf(stored.copy(unreadCount = unread).toEntity())) + return@withTransaction + } val chat = (stored ?: placeholderChat(message.id.chatId)).copy( lastMessagePreview = message.chatListPreviewSource(), lastMessageOutgoing = message.outgoing, @@ -589,6 +669,7 @@ 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() 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..84dcbefd 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,9 @@ interface MessageDao { @Query("DELETE FROM messages WHERE chatId = :chatId") suspend fun clearChat(chatId: Long) + @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..7e9210b5 --- /dev/null +++ b/core/database/src/main/java/org/monogram/core/database/dao/MessageTombstoneDao.kt @@ -0,0 +1,19 @@ +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") + suspend fun clear() +} 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/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt b/core/database/src/test/java/org/monogram/core/database/MessageMediaFieldsTest.kt index 7f11c60f..c367f965 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(7, DatabaseProvider.SCHEMA_VERSION) } @Test 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/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/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/store/DialogHistory.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/store/DialogHistory.kt index 57b652d3..9827ae07 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 @@ -307,6 +307,20 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { return@launch } if (!ownsRequest()) return@launch + if (historyTop <= 0) { + val cached = warmup?.olderMessages(chatId, boundaryId, HISTORY_PAGE_LIMIT).orEmpty() + if (!ownsRequest()) return@launch + if (cached.size == HISTORY_PAGE_LIMIT) { + emit(Msg.AppendOlder(cached)) + serverHistoryBoundaryId = cached.minOf { it.id.id } + emit(Msg.HasOlder(true)) + emit(Msg.LoadingOlder(false)) + activeOlderRequestId = null + continueUnreadPaging() + resolveSendersLater(cached) + return@launch + } + } when ( val result = if (historyTop > 0) { client.getReplies( @@ -343,11 +357,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() 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/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/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/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/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/native/mtproto-rs/src/client/lanes.rs b/native/mtproto-rs/src/client/lanes.rs index b11f236a..f7bb22a1 100644 --- a/native/mtproto-rs/src/client/lanes.rs +++ b/native/mtproto-rs/src/client/lanes.rs @@ -294,11 +294,19 @@ pub(crate) fn acquire_lane_family<'a>( lane_at: impl Fn(usize) -> &'a Lane, ) -> Result, MtprotoError> { 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) { + 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 +315,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/persist.rs b/native/mtproto-rs/src/client/persist.rs index faf27b83..cd4aa58c 100644 --- a/native/mtproto-rs/src/client/persist.rs +++ b/native/mtproto-rs/src/client/persist.rs @@ -175,10 +175,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 +189,20 @@ 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.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_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 +216,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/ffi.rs b/native/mtproto-rs/src/ffi.rs index feb56b1e..7f0900c3 100644 --- a/native/mtproto-rs/src/ffi.rs +++ b/native/mtproto-rs/src/ffi.rs @@ -112,7 +112,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)] diff --git a/native/mtproto-rs/src/media/download.rs b/native/mtproto-rs/src/media/download.rs index fa93c862..8ff4566c 100644 --- a/native/mtproto-rs/src/media/download.rs +++ b/native/mtproto-rs/src/media/download.rs @@ -1203,16 +1203,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 +1256,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 }; @@ -1331,7 +1321,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 +1346,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 +1449,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 +1458,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 +1538,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/scheduler.rs b/native/mtproto-rs/src/scheduler.rs index 35e743e6..8cd842f5 100644 --- a/native/mtproto-rs/src/scheduler.rs +++ b/native/mtproto-rs/src/scheduler.rs @@ -223,6 +223,15 @@ struct GateState { struct Waiter { ticket: u64, priority: u8, + queued_at: std::time::Instant, +} + +impl Waiter { + fn order_at(&self, now: std::time::Instant) -> (u8, u64) { + let promotion = (now.saturating_duration_since(self.queued_at).as_secs() / 5).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,14 +244,26 @@ 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 = std::time::Instant::now(); + 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(std::time::Instant::now())) .is_some_and(|waiter| waiter.ticket == self.ticket) } @@ -285,6 +306,7 @@ impl LaneGate { state.waiters.push_back(Waiter { ticket, priority: class.priority(), + queued_at: std::time::Instant::now(), }); self.wake.notify_all(); LaneWaiter { gate: self, ticket } @@ -304,7 +326,7 @@ impl LaneGate { || state .waiters .iter() - .any(|waiter| waiter.priority < priority) + .any(|waiter| waiter.order_at(std::time::Instant::now()).0 <= priority) { return None; } @@ -319,7 +341,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: std::time::Instant::now() }); ticket }; loop { @@ -335,7 +357,7 @@ impl LaneGate { let best = state .waiters .iter() - .min_by_key(|waiter| (waiter.priority, waiter.ticket)) + .min_by_key(|waiter| waiter.order_at(std::time::Instant::now())) .map(|waiter| waiter.ticket); if !state.busy && best == Some(ticket) { state.busy = true; 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/tests/unit/client_mgr_lane_tests.rs b/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs index 822ce12e..06027a8c 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_lane_tests.rs @@ -652,6 +652,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/media_download_tests.rs b/native/mtproto-rs/tests/unit/media_download_tests.rs index cfd03caf..74de142d 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), |_| { diff --git a/native/mtproto-rs/tests/unit/scheduler_tests.rs b/native/mtproto-rs/tests/unit/scheduler_tests.rs index 041559ac..95eca7a1 100644 --- a/native/mtproto-rs/tests/unit/scheduler_tests.rs +++ b/native/mtproto-rs/tests/unit/scheduler_tests.rs @@ -1,6 +1,27 @@ use super::*; use std::sync::mpsc; +#[test] +fn background_aging_never_overtakes_writes() { + let queued_at = std::time::Instant::now(); + let waiter = Waiter { ticket: 0, priority: 4, queued_at }; + assert_eq!(waiter.order_at(queued_at).0, 4); + assert_eq!(waiter.order_at(queued_at + Duration::from_secs(5)).0, 3); + assert_eq!(waiter.order_at(queued_at + Duration::from_secs(60)).0, 1); +} + +#[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 +71,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-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/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..1b5eed30 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 @@ -4,6 +4,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -55,7 +57,10 @@ internal class SessionCore( 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 @@ -202,17 +207,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( 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..4db8f306 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( @@ -15,11 +17,56 @@ class FileCache( private val maxBytes: Long = 256L * 1024L * 1024L, private val maintainOnInit: Boolean = true, ) { + 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() + + 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() + } + } + } + + private fun protected(key: String): Boolean = + (leases[diskName(key)] ?: 0) > 0 || protectedKeys.values.any { keys -> + keys.any { diskName(it) == diskName(key) } + } + + 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() @@ -40,30 +87,73 @@ 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 + if (!file.isFile) return@synchronized null file.setLastModified(System.currentTimeMillis()) - return file + 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 (source.length() + protectedBytesExcept(key) > maxBytes) 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,17 +161,69 @@ 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 protectedBytesExcept(key: String): Long { + val names = leases.keys + protectedKeys.values.flatten().map(::diskName) + 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 && (bytes > maxBytes || protectedBytesExcept(key) > maxBytes - 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.keys.filter { it != key && diskName(it) == diskName(key) } + .forEach(records::remove) records[key] = normalized(key, chatId, kind, bytes) persistIndex() } @@ -147,14 +289,8 @@ class FileCache( /** 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 = @@ -168,9 +304,7 @@ class FileCache( val ordered = files.sortedBy { it.lastModified() } for (file in ordered) { if (total <= maxBytes) break - val recordKey = - records.keys.firstOrNull { fileFor(it).canonicalFile == file.canonicalFile } - if (protectedKeys.values.any { recordKey in it }) continue + if (protected(ORPHAN_PREFIX + file.name)) continue val len = file.length() if (file.delete()) { total -= len @@ -185,11 +319,16 @@ class FileCache( ensureIndexLoaded() var freed = 0L keys.forEach { key -> + 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) } } persistIndex() @@ -257,7 +396,16 @@ class FileCache( val chat = row.chatId?.toString().orEmpty() "${row.key}\t$chat\t${row.kind}\t${row.bytes}" } - indexFile.writeText(text) + 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() + } } private fun isStaging(name: String): Boolean = @@ -272,6 +420,7 @@ class FileCache( } companion object { + const val FREE_SPACE_RESERVE = 128L * 1024 * 1024 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..8f4c4e5e 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,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -57,7 +59,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 +72,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 = @@ -120,6 +122,10 @@ class MediaRepository( @OptIn(UnstableApi::class) fun createMessageDataSourceFactory(message: Message): androidx.media3.datasource.DataSource.Factory = TelegramVideoDataSource.Factory(message, streamRoot, telegramChunkFetcher, cache) + + @OptIn(UnstableApi::class) + fun createCachedDataSourceFactory(key: String): androidx.media3.datasource.DataSource.Factory = + androidx.media3.datasource.DataSource.Factory { LeasedFileDataSource(cache, key) } private val cache = FileCache(cacheRoot, maintainOnInit = false) private val progress = MutableStateFlow>(emptyMap()) private val generation = MutableStateFlow(0L) @@ -239,6 +245,8 @@ class MediaRepository( fun cachedFile(key: String): File? = cache.get(key) + fun acquireCachedFile(key: String): FileCache.Lease? = cache.acquire(key) + suspend fun removeCachedFile(key: String): Boolean = kotlinx.coroutines.withContext(Dispatchers.IO) { cache.remove(key).also { removed -> if (removed) markCached(key) } } @@ -351,7 +359,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 +369,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 +389,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, @@ -793,25 +889,31 @@ class MediaRepository( } created }.also { + it.consumers++ 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-- + if (job.consumers == 0 && job.priority < MediaPriority.USER && !job.deferred.isCompleted) { + cancelJobLocked(job) + } + } + } } - return job.deferred.await() } private fun ensureTelegramWorkerLocked() { @@ -951,23 +1053,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) }, @@ -1006,6 +1101,7 @@ class MediaRepository( 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/FileCacheTest.kt b/network/http/src/test/java/org/monogram/network/http/FileCacheTest.kt index 9ff25ed9..9f1c593f 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 @@ -9,6 +9,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") 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..23fe1d71 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,7 +19,9 @@ 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")) val restored = FileCache(root, maxBytes = 6) 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..d567f84f 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 @@ -63,10 +63,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 +92,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) From 4f9737d8c5f22ab3524ae18825679a582330ba2c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:29:32 +0300 Subject: [PATCH 2/7] refactor file leases --- .../java/org/monogram/MessageMergeTest.kt | 121 ++ app/src/main/java/org/monogram/MonogramApp.kt | 94 +- .../java/org/monogram/root/RootComponent.kt | 162 ++- .../java/org/monogram/root/RootContent.kt | 6 +- .../java/org/monogram/core/common/PerfLog.kt | 51 +- .../monogram/core/common/RuntimePressure.kt | 18 + .../org/monogram/core/common/WorkOwnership.kt | 44 + .../org/monogram/core/common/PerfLogTest.kt | 35 + .../monogram/core/common/WorkOwnershipTest.kt | 30 + .../8.json | 1090 +++++++++++++++++ .../core/database/DatabaseProvider.kt | 87 +- .../monogram/core/database/HistorySlice.kt | 42 + .../core/database/MonogramDatabase.kt | 4 +- .../monogram/core/database/OfflineWarmup.kt | 178 ++- .../core/database/SessionMetadataStore.kt | 1 + .../core/database/dao/UpdateCursorDao.kt | 20 + .../database/entity/UpdateCursorEntity.kt | 10 + .../core/database/HistorySliceTest.kt | 69 ++ .../core/database/MessageMediaFieldsTest.kt | 2 +- .../monogram/core/database/RoomBoundsTest.kt | 78 ++ .../monogram/core/models/ChatIncomingMerge.kt | 64 + .../core/models/ChatIncomingMergeTest.kt | 64 + .../java/org/monogram/core/ui/FileLeases.kt | 34 + .../java/org/monogram/core/ui/ImageCache.kt | 27 + .../monogram/core/ui/RememberEnsuredFile.kt | 1 + .../core/ui/media/MediaViewerPages.kt | 27 +- .../core/ui/media/VoiceWaveformExtraction.kt | 5 +- .../monogram/feature/chats/ChatsExecutor.kt | 30 +- .../org/monogram/feature/chats/ChatsMerge.kt | 44 +- .../monogram/feature/dialog/DialogStore.kt | 12 +- .../monogram/feature/dialog/PickerCache.kt | 11 + .../feature/dialog/SelectedTargetWarmup.kt | 93 ++ .../feature/dialog/store/DialogExecutor.kt | 20 +- .../feature/dialog/store/DialogHistory.kt | 72 +- .../feature/dialog/store/DialogStickers.kt | 27 +- .../feature/dialog/ui/MediaShareActions.kt | 14 + .../feature/dialog/ui/MessageMedia.kt | 6 +- .../monogram/feature/dialog/ui/VideoPlayer.kt | 1 + .../feature/dialog/ui/VpxStickerPlayer.kt | 1 + .../feature/dialog/DialogForumStoreTest.kt | 30 + .../dialog/SelectedTargetWarmupTest.kt | 99 ++ native/mtproto-rs/src/client/auth.rs | 1 + native/mtproto-rs/src/client/extras.rs | 3 + native/mtproto-rs/src/client/lanes.rs | 33 + .../mtproto-rs/src/client/media_download.rs | 118 +- native/mtproto-rs/src/client/mod.rs | 13 + native/mtproto-rs/src/client/persist.rs | 93 +- native/mtproto-rs/src/client/updates.rs | 149 ++- native/mtproto-rs/src/dto.rs | 28 +- native/mtproto-rs/src/ffi.rs | 30 +- native/mtproto-rs/src/lib.rs | 1 + native/mtproto-rs/src/media/download.rs | 188 ++- native/mtproto-rs/src/request_control.rs | 25 + native/mtproto-rs/src/scheduler.rs | 68 +- native/mtproto-rs/src/session_file.rs | 3 + native/mtproto-rs/src/update_journal.rs | 167 +++ .../tests/unit/client_mgr_channel_tests.rs | 1 + .../tests/unit/client_mgr_lane_tests.rs | 3 +- .../tests/unit/client_mgr_logout_tests.rs | 5 +- .../tests/unit/client_mgr_session_tests.rs | 28 + native/mtproto-rs/tests/unit/lib_tests.rs | 1 + .../tests/unit/media_download_tests.rs | 52 + .../tests/unit/request_control_tests.rs | 24 + .../mtproto-rs/tests/unit/scheduler_tests.rs | 97 +- .../tests/unit/update_journal_tests.rs | 274 +++++ .../org/monogram/mtproto/MtprotoNative.kt | 18 + .../monogram/mtproto/MtprotoNativeUniFfi.kt | 17 + .../monogram_mtproto/monogram_mtproto.kt | 280 ++++- .../monogram/network/bridge/MtprotoClient.kt | 18 +- .../monogram/network/bridge/media/MediaApi.kt | 160 +-- .../monogram/network/bridge/media/MediaOps.kt | 2 + .../network/bridge/message/MessageApi.kt | 31 +- .../network/bridge/message/MessageOps.kt | 8 + .../network/bridge/session/NativeRequest.kt | 48 +- .../network/bridge/session/SessionCore.kt | 41 +- .../bridge/updates/UpdateEventMapping.kt | 152 +++ .../network/bridge/updates/UpdatesOps.kt | 7 + .../network/bridge/updates/UpdatesPump.kt | 325 +++-- .../bridge/BridgedMtprotoClientHistoryTest.kt | 3 + .../BridgedMtprotoClientLifecycleTest.kt | 135 ++ .../org/monogram/network/http/FileCache.kt | 281 ++++- .../monogram/network/http/MediaRepository.kt | 80 +- .../network/http/FileCacheAccountingTest.kt | 263 ++++ .../monogram/network/http/FileCacheTest.kt | 4 +- .../network/http/ProtectedFileCacheTest.kt | 1 + .../network/http/StreamingPrefixTest.kt | 66 + 86 files changed, 5628 insertions(+), 541 deletions(-) create mode 100644 core/common/src/main/java/org/monogram/core/common/RuntimePressure.kt create mode 100644 core/common/src/main/java/org/monogram/core/common/WorkOwnership.kt create mode 100644 core/common/src/test/java/org/monogram/core/common/WorkOwnershipTest.kt create mode 100644 core/database/schemas/org.monogram.core.database.MonogramDatabase/8.json create mode 100644 core/database/src/main/java/org/monogram/core/database/HistorySlice.kt create mode 100644 core/database/src/test/java/org/monogram/core/database/HistorySliceTest.kt create mode 100644 core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt create mode 100644 core/models/src/main/java/org/monogram/core/models/ChatIncomingMerge.kt create mode 100644 core/models/src/test/java/org/monogram/core/models/ChatIncomingMergeTest.kt create mode 100644 core/ui/src/main/java/org/monogram/core/ui/FileLeases.kt create mode 100644 feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt create mode 100644 feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt create mode 100644 native/mtproto-rs/src/update_journal.rs create mode 100644 native/mtproto-rs/tests/unit/update_journal_tests.rs create mode 100644 network/bridge/src/main/java/org/monogram/network/bridge/updates/UpdateEventMapping.kt create mode 100644 network/http/src/test/java/org/monogram/network/http/FileCacheAccountingTest.kt diff --git a/app/src/androidTest/java/org/monogram/MessageMergeTest.kt b/app/src/androidTest/java/org/monogram/MessageMergeTest.kt index 4688a4f0..22614e11 100644 --- a/app/src/androidTest/java/org/monogram/MessageMergeTest.kt +++ b/app/src/androidTest/java/org/monogram/MessageMergeTest.kt @@ -220,4 +220,125 @@ class MessageMergeTest { 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() + } + } } diff --git a/app/src/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 20c367ba..369fa80f 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 @@ -19,6 +20,7 @@ import org.monogram.core.common.DebugLog import org.monogram.core.common.DebugStats 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 +33,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 +130,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 +138,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) @@ -263,33 +281,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) }, @@ -301,6 +330,7 @@ class MonogramApp : Application() { }, ) } + FileLeases.leaser = mediaRepository::leaseFile client.setDownloadProgressListener { path, downloaded, _ -> mediaRepository.onNativeProgress(path, downloaded) } @@ -421,11 +451,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..75a45bc9 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,40 @@ 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 accounting = mediaRepository?.storageAccounting() + val storagePressure = accounting != null && + accounting.partialBytes + accounting.reservedBytes > 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 +131,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 +186,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 +291,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 +377,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 +390,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 +416,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 +438,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 +559,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/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/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/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/DatabaseProvider.kt b/core/database/src/main/java/org/monogram/core/database/DatabaseProvider.kt index aabeded5..7e68cb43 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 @@ -5,18 +5,21 @@ import android.database.sqlite.SQLiteDatabase import androidx.room.Room import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import kotlinx.coroutines.ThreadContextElement import kotlinx.coroutines.asCoroutineDispatcher 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 = 7 + const val SCHEMA_VERSION = 8 val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { @@ -61,28 +64,68 @@ object DatabaseProvider { } } + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS update_batch_receipts (" + + "accountGeneration INTEGER NOT NULL, batchId INTEGER NOT NULL, " + + "appliedAtMs INTEGER NOT NULL, PRIMARY KEY(accountGeneration, batchId))" + ) + } + } + /** 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 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() try { - runnable.run() + PerfLog.withOpId(opId) { runnable.run() } } finally { 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 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" @@ -135,7 +178,8 @@ object DatabaseProvider { MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, - MIGRATION_6_7 + MIGRATION_6_7, + MIGRATION_7_8 ) .setJournalMode(androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setQueryExecutor(roomExecutor) @@ -200,3 +244,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/MonogramDatabase.kt b/core/database/src/main/java/org/monogram/core/database/MonogramDatabase.kt index 5be80300..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 @@ -26,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( @@ -37,6 +38,7 @@ import org.monogram.core.database.entity.UpdateCursorEntity org.monogram.core.database.entity.MessageTombstoneEntity::class, MessageHoleEntity::class, UpdateCursorEntity::class, + UpdateBatchReceiptEntity::class, PeerEntity::class, ProfileTabsEntity::class, ProfileMemberEntity::class, @@ -44,7 +46,7 @@ import org.monogram.core.database.entity.UpdateCursorEntity ProfileCommonEntity::class, SponsorEntity::class, ], - version = 7, + version = 8, exportSchema = true, ) abstract class MonogramDatabase : RoomDatabase() { diff --git a/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt b/core/database/src/main/java/org/monogram/core/database/OfflineWarmup.kt index e2ce75d7..bc7b8eab 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 @@ -15,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 @@ -22,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 /** @@ -35,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 @@ -327,7 +333,8 @@ open class OfflineWarmup( return withContext(DatabaseProvider.roomDispatcher) { 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 } } @@ -349,7 +356,11 @@ open class OfflineWarmup( } } - 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() @@ -370,7 +381,11 @@ open class OfflineWarmup( } } - 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() @@ -391,6 +406,37 @@ open class OfflineWarmup( } } + 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 withContext(DatabaseProvider.roomDispatcher) { + 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 @@ -502,7 +548,8 @@ open class OfflineWarmup( if (chats.isEmpty()) return withContext(DatabaseProvider.roomDispatcher) { 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() @@ -531,12 +578,16 @@ open class OfflineWarmup( } } - 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 val database = db ?: return@roomIo - for (batch in messages.chunked(100)) { - kotlinx.coroutines.currentCoroutineContext().ensureActive() + for (batch in chunkMessages(messages)) { + currentCoroutineContext().ensureActive() database.withTransaction { mergeMessages(batch) } kotlinx.coroutines.yield() } @@ -628,7 +679,11 @@ 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 { @@ -637,27 +692,48 @@ open class OfflineWarmup( .isNotEmpty() if (mergeMessages(listOf(message)).isEmpty()) return@withTransaction val stored = database.chatDao().get(message.id.chatId.value)?.toModel() - val unread = - if (existed || message.outgoing || message.id.id <= (stored?.readInboxMaxId ?: 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)) } - if (stored != null && message.id.id < stored.lastMessageId) { - database.chatDao().upsertAll(listOf(stored.copy(unreadCount = unread).toEntity())) - return@withTransaction } - 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 } } @@ -674,6 +750,7 @@ open class OfflineWarmup( database.folderDao().clear() database.peerDao().clear() database.updateCursorDao().clear() + database.updateCursorDao().clearReceipts() database.profileTabsDao().clear() database.profileMembersDao().clear() database.profileMediaDao().clear() @@ -740,6 +817,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/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/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/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 c367f965..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(7, 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..0d6e1baf --- /dev/null +++ b/core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt @@ -0,0 +1,78 @@ +package org.monogram.core.database + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +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 + +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) + } +} 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/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/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/SelectedTargetWarmup.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt new file mode 100644 index 00000000..fe61161a --- /dev/null +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt @@ -0,0 +1,93 @@ +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) { + launch() + } else if (job?.isActive != true) { + 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) { + Unit + } 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..1b728be2 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,24 @@ 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 coveredLatest = + slice != null && slice.covered && slice.confirmedEnd && slice.rows.isNotEmpty() + if (coveredLatest) { + 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 9827ae07..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) @@ -308,16 +326,21 @@ internal fun DialogExecutor.loadOlder(prefetch: Boolean = false) { } if (!ownsRequest()) return@launch if (historyTop <= 0) { - val cached = warmup?.olderMessages(chatId, boundaryId, HISTORY_PAGE_LIMIT).orEmpty() + val slice = + warmup?.historySlice(chatId, beforeId = boundaryId, limit = HISTORY_PAGE_LIMIT) if (!ownsRequest()) return@launch - if (cached.size == HISTORY_PAGE_LIMIT) { - emit(Msg.AppendOlder(cached)) - serverHistoryBoundaryId = cached.minOf { it.id.id } - emit(Msg.HasOlder(true)) + 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() - resolveSendersLater(cached) return@launch } } @@ -464,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, @@ -518,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 -> @@ -533,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/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/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/VideoPlayer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoPlayer.kt index 92de86d8..2d71d194 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 @@ -108,6 +108,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 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/SelectedTargetWarmupTest.kt b/feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt new file mode 100644 index 00000000..88f5a936 --- /dev/null +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt @@ -0,0 +1,99 @@ +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) + 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/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 f7bb22a1..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,6 +315,12 @@ 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); @@ -304,6 +332,11 @@ pub(crate) fn acquire_lane_family<'a>( let mut counted = false; loop { crate::request_control::check().map_err(|e| MtprotoError::Message(e.to_string()))?; + 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() }; diff --git a/native/mtproto-rs/src/client/media_download.rs b/native/mtproto-rs/src/client/media_download.rs index bdb0cf53..ab2c5602 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)) @@ -582,14 +599,71 @@ pub fn download_custom_emoji( persist(state)?; Ok((state.api_id, state.snapshot.clone(), indexed)) })?; - download_media_on_lane( + 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) +} + +fn refresh_document_file_reference( + state: &mut ClientState, + document_id: i64, +) -> Result<(), MtprotoError> { + let sticker = state + .media + .get(&(document_id, 0)) + .and_then(sticker_set_of); + if let Some((set_id, access_hash)) = sticker { + crate::messages::sticker_rpc::get_sticker_set( + &mut state.snapshot, + state.api_id, + &mut state.media, + set_id, + access_hash, + )?; + return Ok(()); + } + 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 +958,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 +994,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 +1202,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 +1372,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 +1440,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 cd4aa58c..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) { @@ -191,11 +266,19 @@ pub(crate) fn schedule_persist(client: &Arc, session_id: i64) { let span = crate::perf::span("persist_session"); 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; } 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 7f0900c3..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] @@ -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 8ff4566c..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) @@ -1264,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() { @@ -1285,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() 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 8cd842f5..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,12 +249,12 @@ struct GateState { struct Waiter { ticket: u64, priority: u8, - queued_at: std::time::Instant, + queued_at: u64, } impl Waiter { - fn order_at(&self, now: std::time::Instant) -> (u8, u64) { - let promotion = (now.saturating_duration_since(self.queued_at).as_secs() / 5).min(4) as u8; + 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) } @@ -247,7 +273,7 @@ pub(crate) struct LaneWaiter<'a> { impl<'a> LaneWaiter<'a> { pub(crate) fn try_acquire(&self) -> Option> { let mut state = self.gate.state.lock(); - let now = std::time::Instant::now(); + 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; @@ -263,10 +289,14 @@ impl<'a> LaneWaiter<'a> { .lock() .waiters .iter() - .min_by_key(|waiter| waiter.order_at(std::time::Instant::now())) + .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 @@ -290,6 +320,7 @@ impl Default for LaneGate { Self { state: Mutex::new(GateState::default()), wake: Condvar::new(), + clock: Arc::new(SystemSchedulerClock::default()), } } } @@ -299,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; @@ -306,7 +354,7 @@ impl LaneGate { state.waiters.push_back(Waiter { ticket, priority: class.priority(), - queued_at: std::time::Instant::now(), + queued_at: self.clock.now_ms(), }); self.wake.notify_all(); LaneWaiter { gate: self, ticket } @@ -326,7 +374,7 @@ impl LaneGate { || state .waiters .iter() - .any(|waiter| waiter.order_at(std::time::Instant::now()).0 <= priority) + .any(|waiter| waiter.order_at(self.clock.now_ms()).0 <= priority) { return None; } @@ -341,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, queued_at: std::time::Instant::now() }); + state.waiters.push_back(Waiter { ticket, priority, queued_at: self.clock.now_ms() }); ticket }; loop { @@ -357,7 +405,7 @@ impl LaneGate { let best = state .waiters .iter() - .min_by_key(|waiter| waiter.order_at(std::time::Instant::now())) + .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/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 06027a8c..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 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..c99ad5bd 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,34 @@ 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 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 74de142d..57074b52 100644 --- a/native/mtproto-rs/tests/unit/media_download_tests.rs +++ b/native/mtproto-rs/tests/unit/media_download_tests.rs @@ -994,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 95eca7a1..664d7ed3 100644 --- a/native/mtproto-rs/tests/unit/scheduler_tests.rs +++ b/native/mtproto-rs/tests/unit/scheduler_tests.rs @@ -3,11 +3,98 @@ use std::sync::mpsc; #[test] fn background_aging_never_overtakes_writes() { - let queued_at = std::time::Instant::now(); - let waiter = Waiter { ticket: 0, priority: 4, queued_at }; - assert_eq!(waiter.order_at(queued_at).0, 4); - assert_eq!(waiter.order_at(queued_at + Duration::from_secs(5)).0, 3); - assert_eq!(waiter.order_at(queued_at + Duration::from_secs(60)).0, 1); + 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] 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..e52b7c58 --- /dev/null +++ b/native/mtproto-rs/tests/unit/update_journal_tests.rs @@ -0,0 +1,274 @@ +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_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/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..200ece54 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 @@ -694,6 +694,9 @@ 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 +875,18 @@ 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( @@ -988,6 +997,12 @@ internal object UniffiLib { } external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener(`vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, ): Unit + external fun uniffi_monogram_mtproto_fn_func_acknowledge_update_batch( + `handle`: Long, + `accountGeneration`: Long, + `batchId`: Long, + uniffi_out_err: UniffiRustCallStatus, + ): 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_func_animated_emoji_max(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1166,12 +1181,18 @@ internal object UniffiLib { ): 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_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, ): Unit 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_promote_request_control( + `id`: Long, `class`: Int, uniffi_out_err: UniffiRustCallStatus, + ): Unit 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, @@ -1383,6 +1404,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 +1518,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 +1674,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 +1686,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") } @@ -2346,6 +2376,39 @@ 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 @@ -4430,6 +4493,88 @@ public object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer, + var `cursorAfter`: UpdatesStateDto?, + var `channelPtsAfter`: List + +) { + + + companion object +} + +/** + * @suppress + */ +public 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`: kotlin.Boolean + +) { + + + companion object +} + +/** + * @suppress + */ +public 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 @@ -5553,6 +5698,36 @@ 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) + } + } +} + + /** @@ -5785,6 +5960,33 @@ 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) + } + } +} + + /** @@ -6149,6 +6351,33 @@ 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) + } + } +} + + /** @@ -6231,6 +6460,25 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + 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`: kotlin.ULong, `samples`: List, `sampleRate`: kotlin.UInt, `channels`: kotlin.UInt, `presentationTimeUs`: kotlin.ULong) = uniffiRustCallWithError(MtprotoException) { _status -> @@ -6687,8 +6935,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { - return FfiConverterSequenceTypeUpdateEventDto.lift( +@Throws(MtprotoException::class) +fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { + return FfiConverterTypeUpdateDrainDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_drain_updates( @@ -7417,6 +7666,20 @@ 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) @@ -7460,6 +7723,17 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_promote_request_control( + + + FfiConverterULong.lower(`id`), + FfiConverterInt.lower(`class`), _status + ) + } + + @Throws(MtprotoException::class) fun `readDiscussion`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `msgId`: kotlin.Int, `readMaxId`: kotlin.Int) = 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..be16cc4b 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 { @@ -177,93 +180,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/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 1b5eed30..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,8 +3,8 @@ package org.monogram.network.bridge.session import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async @@ -29,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, @@ -50,8 +52,10 @@ 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() @@ -61,8 +65,10 @@ internal class SessionCore( 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 = @@ -80,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( @@ -199,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 = @@ -414,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 } @@ -434,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 } @@ -607,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() @@ -682,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..b92b57fd 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,136 @@ 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 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 +1123,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/http/src/main/java/org/monogram/network/http/FileCache.kt b/network/http/src/main/java/org/monogram/network/http/FileCache.kt index 4db8f306..760f703b 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 @@ -10,12 +10,29 @@ 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, private val maintainOnInit: Boolean = true, + private val nowMs: () -> Long = System::currentTimeMillis, ) { enum class AdmissionFailure { Capacity, Storage, InUse } class AdmissionException(val reason: AdmissionFailure) : IOException( @@ -32,6 +49,13 @@ class FileCache( 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 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 { @@ -54,10 +78,186 @@ class FileCache( } } + /** 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 || protectedKeys.values.any { keys -> - keys.any { diskName(it) == diskName(key) } + (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 + } + + /** 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 && (bytes > maxBytes || + protectedBytesExcept(key) + inFlightBytesExcept(key) > maxBytes - 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) } @@ -75,8 +275,8 @@ class FileCache( internal fun maintain() = synchronized(recordsLock) { ensureIndexLoaded() - dropMissing() - backfillUnindexed() + reconcileLocked() + expireInactivePartials() } private fun ensureIndexLoaded() { @@ -91,7 +291,9 @@ class FileCache( if (pendingDeletion(key)) return@synchronized null val file = fileFor(key) if (!file.isFile) return@synchronized null - file.setLastModified(System.currentTimeMillis()) + val now = nowMs() + file.setLastModified(now) + lastAccess[diskName(key)] = now file } @@ -184,7 +386,7 @@ class FileCache( } private fun protectedBytesExcept(key: String): Long { - val names = leases.keys + protectedKeys.values.flatten().map(::diskName) + val names = protectedNames() return names.asSequence().distinct().filter { it != diskName(key) } .sumOf { File(root, it).length() } } @@ -198,7 +400,9 @@ class FileCache( if ((leases[diskName(key)] ?: 0) > 0 || pendingDeletion(key)) { throw AdmissionException(AdmissionFailure.InUse) } - if (!temporary && (bytes > maxBytes || protectedBytesExcept(key) > maxBytes - bytes)) { + if (!temporary && (bytes > maxBytes || + protectedBytesExcept(key) + inFlightBytesExcept(key) > maxBytes - bytes) + ) { throw AdmissionException(AdmissionFailure.Capacity) } if (bytes < 0 || root.usableSpace - additionalBytes < FREE_SPACE_RESERVE) { @@ -224,8 +428,11 @@ class FileCache( ensureIndexLoaded() records.keys.filter { it != key && diskName(it) == diskName(key) } .forEach(records::remove) - records[key] = normalized(key, chatId, kind, bytes) - persistIndex() + 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() } } @@ -235,7 +442,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() } } @@ -282,7 +489,8 @@ class FileCache( } fun remove(key: String): Boolean { - deleteKeys(listOf(key)) + unpin(key) + deleteKeys(listOf(key), force = true) return !fileFor(key).exists() } @@ -298,27 +506,35 @@ class FileCache( 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() + 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 - if (protected(ORPHAN_PREFIX + file.name)) 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 @@ -329,9 +545,10 @@ class FileCache( freed += len records.remove(key) pendingDeletes.remove(key) + lastAccess.remove(diskName(key)) } } - persistIndex() + markIndexDirty(force = true) freed } @@ -339,7 +556,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() { @@ -359,7 +576,7 @@ class FileCache( ) added = true } - if (added) persistIndex() + if (added) markIndexDirty(force = true) } private fun loadIndex() { @@ -372,11 +589,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 { @@ -394,7 +611,7 @@ 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 { @@ -421,6 +638,12 @@ class FileCache( 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 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/MediaRepository.kt b/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt index 8f4c4e5e..24f5bfb0 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 @@ -33,6 +33,7 @@ import kotlinx.coroutines.sync.withLock import org.monogram.core.common.AppLog 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 @@ -94,6 +95,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 { @@ -247,7 +251,19 @@ class MediaRepository( fun acquireCachedFile(key: String): FileCache.Lease? = cache.acquire(key) - suspend fun removeCachedFile(key: String): Boolean = kotlinx.coroutines.withContext(Dispatchers.IO) { + 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) } } @@ -276,6 +292,11 @@ class MediaRepository( fun cacheSizeBytes(): Long = cache.totalBytes() + fun storageAccounting(): StorageAccounting = cache.accounting() + + /** 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() @@ -291,6 +312,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") @@ -303,6 +325,7 @@ class MediaRepository( priority = priority, name = message.fileName, totalBytes = message.fileSize, + retain = retain, ) } @@ -645,6 +668,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) @@ -654,7 +678,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. @@ -664,7 +688,7 @@ class MediaRepository( } PerfLog.event("cache_miss", kind.name.lowercase()) val queuedAt = PerfLog.nowMs() - return enqueueTelegram( + val fetched = enqueueTelegram( key, priority, chatId = chatId.value, @@ -687,6 +711,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, @@ -698,6 +724,8 @@ class MediaRepository( } finally { progressPaths.remove(destPath) cancelMarker(key).delete() + cache.notePartial(key, part.length()) + reservation?.close() } when (fetched) { is Outcome.Ok -> { @@ -738,6 +766,8 @@ class MediaRepository( } } } + if (!retain || fetched !is Outcome.Ok) return fetched + return Outcome.Ok(keptDownload(key) ?: fetched.value) } fun cancel(key: String) { @@ -783,6 +813,7 @@ class MediaRepository( fun shutdown() { telegramStopped = true + cache.flushIndex() telegramWorkers.forEach { it.cancel() } telegramWorkers.clear() telegramWake.close() @@ -826,12 +857,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 @@ -865,6 +890,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) { @@ -890,6 +923,7 @@ class MediaRepository( created }.also { it.consumers++ + WorkOwnership.acquire("media:${it.key}", it.priority) ensureTelegramWorkerLocked() } } @@ -908,6 +942,10 @@ class MediaRepository( 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) } @@ -980,7 +1018,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() @@ -1024,6 +1064,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 -> @@ -1096,6 +1155,7 @@ 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(), 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 9f1c593f..69e4fcc0 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 @@ -249,12 +249,14 @@ 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()) 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 23fe1d71..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 @@ -24,6 +24,7 @@ class ProtectedFileCacheTest { } 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 d567f84f..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() From 3c61c75226f0db7c672494b2031594249951b6a9 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:55:25 +0300 Subject: [PATCH 3/7] feat: avoid redundant dispatch when already on thread --- .../core/database/DatabaseProvider.kt | 7 ++++ .../monogram/core/database/OfflineWarmup.kt | 42 +++++++++---------- 2 files changed, 28 insertions(+), 21 deletions(-) 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 7e68cb43..80455a59 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 @@ -7,6 +7,7 @@ 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 @@ -76,6 +77,7 @@ object DatabaseProvider { /** SQL stays off Dispatchers.IO, which media downloads also use. */ private val queueLock = Object() + private val onRoomThread = ThreadLocal() private val foregroundQueue = ArrayDeque() private val maintenanceQueue = ArrayDeque() private val roomThread = Thread({ @@ -99,9 +101,11 @@ object DatabaseProvider { val maintenance = RoomLane.current() == RoomLane.Kind.MAINTENANCE val task = Runnable { val startedAt = PerfLog.nowMs() + onRoomThread.set(true) try { PerfLog.withOpId(opId) { runnable.run() } } finally { + onRoomThread.remove() if (PerfLog.isEnabled()) { PerfLog.withOpId(opId) { PerfLog.event( @@ -122,6 +126,9 @@ object DatabaseProvider { private val keepRoomThread = roomThread internal val roomDispatcher = roomExecutor.asCoroutineDispatcher() + internal suspend fun onRoom(block: suspend () -> T): T = + if (onRoomThread.get() == true) block() else withContext(roomDispatcher) { block() } + fun roomQueueDepth(): Int = synchronized(queueLock) { foregroundQueue.size + maintenanceQueue.size } 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 bc7b8eab..affb471b 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 @@ -101,7 +101,7 @@ 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 @@ -235,7 +235,7 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().observeAll().map { it.toModel() } } @@ -246,7 +246,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) @@ -259,7 +259,7 @@ open class OfflineWarmup( ensureStartupCleanup() return 0 } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.chatDao().mainListCount() } @@ -270,7 +270,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() } @@ -281,7 +281,7 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.folderDao().observeAll().map { it.toModel() } } @@ -330,7 +330,7 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val pending = database.messageDao().pendingForChat(chatId.value).map { it.toModel() } val latest = @@ -345,11 +345,11 @@ 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] } @@ -365,18 +365,18 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().olderThan(chatId.value, beforeId, limit) if (database.metaDao() .get("history_coverage:${chatId.value}") == null - ) return@withContext emptyList() - if (rows.isEmpty()) return@withContext emptyList() + ) return@onRoom emptyList() + if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( chatId.value, rows.minOf { it.id }, beforeId - 1, MessageHoleEntity.SOURCE_HISTORY ) - ) return@withContext emptyList() + ) return@onRoom emptyList() rows.map { it.toModel() } } } @@ -390,18 +390,18 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() val rows = database.messageDao().newerThan(chatId.value, afterId, limit) if (database.metaDao() .get("history_coverage:${chatId.value}") == null - ) return@withContext emptyList() - if (rows.isEmpty()) return@withContext emptyList() + ) return@onRoom emptyList() + if (rows.isEmpty()) return@onRoom emptyList() if (database.messageHoleDao().overlaps( chatId.value, afterId + 1, rows.maxOf { it.id }, MessageHoleEntity.SOURCE_HISTORY ) - ) return@withContext emptyList() + ) return@onRoom emptyList() rows.map { it.toModel() } } } @@ -414,7 +414,7 @@ open class OfflineWarmup( ): HistorySlice { val database = db ?: return HistorySlice(emptyList(), false, emptyList(), false, false, false) - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { val rows = when { beforeId != null -> database.messageDao().olderThan(chatId.value, beforeId, limit) afterId != null -> database.messageDao().newerThan(chatId.value, afterId, limit) @@ -517,7 +517,7 @@ open class OfflineWarmup( ensureStartupCleanup() return emptyList() } - return withContext(DatabaseProvider.roomDispatcher) { + return DatabaseProvider.onRoom { ensureStartupCleanup() database.messageDao() .atOrBeforeDate(chatId.value, beforeDate.toLong(), limit) @@ -546,7 +546,7 @@ 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 } @@ -644,7 +644,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 From ff493dd610e2c52b22f82dfa671f0d2721ae0fbe Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:52:49 +0300 Subject: [PATCH 4/7] add cache ttl pruning and media cache size limit --- .../java/org/monogram/CacheTtlRoomTest.kt | 133 ++ .../java/org/monogram/MessageMergeTest.kt | 43 +- app/src/main/java/org/monogram/MonogramApp.kt | 3 + .../monogram/core/common/MediaCacheLimit.kt | 48 + .../core/common/MediaCacheLimitTest.kt | 34 + .../org/monogram/core/database/CacheTtl.kt | 84 + .../core/database/DatabaseProvider.kt | 31 +- .../monogram/core/database/OfflineWarmup.kt | 84 + .../monogram/core/database/dao/MessageDao.kt | 33 + .../core/database/dao/MessageTombstoneDao.kt | 3 + .../monogram/core/database/dao/ProfileDao.kt | 6 + .../monogram/core/database/CacheTtlTest.kt | 53 + .../monogram/core/database/RoomBoundsTest.kt | 33 + .../feature/dialog/DialogComponent.kt | 2 + .../feature/dialog/SelectedTargetWarmup.kt | 3 +- .../feature/dialog/store/DialogExecutor.kt | 8 +- .../dialog/SelectedTargetWarmupTest.kt | 1 + .../monogram/feature/settings/CacheClear.kt | 26 + .../feature/settings/SettingsComponent.kt | 10 +- .../feature/settings/SettingsStore.kt | 13 +- .../feature/settings/ui/SettingsContent.kt | 78 +- .../feature/settings/ui/SettingsData.kt | 208 ++- .../src/main/res/values-es/strings.xml | 12 +- .../src/main/res/values-hy/strings.xml | 12 +- .../src/main/res/values-ja/strings.xml | 12 +- .../src/main/res/values-pt-rBR/strings.xml | 12 +- .../src/main/res/values-ru/strings.xml | 12 +- .../src/main/res/values-sk/strings.xml | 12 +- .../src/main/res/values-tr/strings.xml | 12 +- .../src/main/res/values-uk/strings.xml | 12 +- .../src/main/res/values-zh/strings.xml | 12 +- .../settings/src/main/res/values/strings.xml | 12 +- .../feature/settings/CacheClearTest.kt | 33 + .../settings/SettingsStoreCacheTest.kt | 29 + .../tests/unit/update_journal_tests.rs | 25 + .../monogram_mtproto/monogram_mtproto.kt | 1554 +++++++++-------- .../BridgedMtprotoClientLifecycleTest.kt | 24 + .../org/monogram/network/http/FileCache.kt | 35 +- .../monogram/network/http/MediaRepository.kt | 27 +- .../monogram/network/http/FileCacheTest.kt | 68 + 40 files changed, 2005 insertions(+), 847 deletions(-) create mode 100644 app/src/androidTest/java/org/monogram/CacheTtlRoomTest.kt create mode 100644 core/common/src/main/java/org/monogram/core/common/MediaCacheLimit.kt create mode 100644 core/common/src/test/java/org/monogram/core/common/MediaCacheLimitTest.kt create mode 100644 core/database/src/main/java/org/monogram/core/database/CacheTtl.kt create mode 100644 core/database/src/test/java/org/monogram/core/database/CacheTtlTest.kt create mode 100644 feature/settings/src/main/java/org/monogram/feature/settings/CacheClear.kt create mode 100644 feature/settings/src/test/java/org/monogram/feature/settings/CacheClearTest.kt 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/MessageMergeTest.kt b/app/src/androidTest/java/org/monogram/MessageMergeTest.kt index 22614e11..c3fdbcfb 100644 --- a/app/src/androidTest/java/org/monogram/MessageMergeTest.kt +++ b/app/src/androidTest/java/org/monogram/MessageMergeTest.kt @@ -3,19 +3,26 @@ 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.Test import org.junit.Rule +import org.junit.Test import org.monogram.core.database.DatabaseProvider -import org.monogram.core.database.SessionMetadataStore 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 @@ -341,4 +348,36 @@ class MessageMergeTest { 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/main/java/org/monogram/MonogramApp.kt b/app/src/main/java/org/monogram/MonogramApp.kt index 369fa80f..d0750e26 100644 --- a/app/src/main/java/org/monogram/MonogramApp.kt +++ b/app/src/main/java/org/monogram/MonogramApp.kt @@ -18,6 +18,7 @@ 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 @@ -272,6 +273,7 @@ class MonogramApp : Application() { perfSpan("app:downloadConcurrency") { DownloadSettings.install(this, ::applyDownloadSettings) DownloadSettings.setPremium(sessionStore.readPremium()) + MediaCacheLimitStore.install(filesDir) } }, createMedia = { @@ -328,6 +330,7 @@ class MonogramApp : Application() { peerInlineThumbPeek = TelegramPeerInlineThumbPeek { peerId -> client.peekPeerInlineThumb(peerId) }, + cacheLimitBytes = MediaCacheLimitStore.current.value.bytes, ) } FileLeases.leaser = mediaRepository::leaseFile 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/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/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 80455a59..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,6 +2,7 @@ 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 @@ -13,6 +14,7 @@ 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 @@ -124,11 +126,34 @@ object DatabaseProvider { @Suppress("unused") private val keepRoomThread = roomThread - internal val roomDispatcher = roomExecutor.asCoroutineDispatcher() + 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() + } + } + } + } + fun roomQueueDepth(): Int = synchronized(queueLock) { foregroundQueue.size + maintenanceQueue.size } @@ -189,8 +214,8 @@ object DatabaseProvider { 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") 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 affb471b..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 @@ -68,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) { @@ -106,6 +113,81 @@ open class OfflineWarmup( @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. */ @@ -480,6 +562,7 @@ open class OfflineWarmup( holes.forEach { holeDao.insert(it) } } } + pruneExpiredCache() } open suspend fun markHistoryHole(chatId: PeerId, startId: Int, endId: Int) = roomIo { @@ -591,6 +674,7 @@ open class OfflineWarmup( database.withTransaction { mergeMessages(batch) } kotlinx.coroutines.yield() } + pruneExpiredCache() } private suspend fun mergeMessages(messages: List): List { 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 84dcbefd..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 @@ -106,6 +106,39 @@ 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) 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 index 7e9210b5..f9fb412d 100644 --- 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 @@ -14,6 +14,9 @@ interface MessageTombstoneDao { @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/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/RoomBoundsTest.kt b/core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt index 0d6e1baf..3ad51f7f 100644 --- a/core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt +++ b/core/database/src/test/java/org/monogram/core/database/RoomBoundsTest.kt @@ -1,8 +1,10 @@ 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 @@ -11,6 +13,8 @@ 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( @@ -75,4 +79,33 @@ class RoomBoundsTest { 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/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/SelectedTargetWarmup.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt index fe61161a..34114de7 100644 --- a/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt +++ b/feature/dialog/src/main/java/org/monogram/feature/dialog/SelectedTargetWarmup.kt @@ -51,8 +51,10 @@ class SelectedTargetWarmup( priority = MediaPriority.VISIBLE WorkOwnership.promote(ownerKey(peer), MediaPriority.VISIBLE) if (job?.isActive == true && raised) { + generation++ launch() } else if (job?.isActive != true) { + generation++ launch() } } @@ -81,7 +83,6 @@ class SelectedTargetWarmup( } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { - Unit } finally { WorkOwnership.release(key) if (gen == generation) WorkOwnership.finish(key) 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 1b728be2..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 @@ -723,9 +723,15 @@ internal class DialogExecutor( generation = gen ) } + val sliceNewestId = slice?.rows?.maxOfOrNull { it.id.id } ?: newestCachedId val coveredLatest = - slice != null && slice.covered && slice.confirmedEnd && slice.rows.isNotEmpty() + 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() 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 index 88f5a936..046e36e8 100644 --- a/feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt +++ b/feature/dialog/src/test/java/org/monogram/feature/dialog/SelectedTargetWarmupTest.kt @@ -82,6 +82,7 @@ class SelectedTargetWarmupTest { testScheduler.runCurrent() started.await() warmup.promote(4L) + assertEquals(listOf(MediaPriority.IDLE), seen) testScheduler.runCurrent() assertEquals(listOf(MediaPriority.IDLE, MediaPriority.VISIBLE), seen) } 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..f95e15c1 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 @@ -15,8 +15,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 +24,7 @@ interface SettingsStore : Store) : Intent data object Logout : Intent data object ExportDebugStats : Intent data object ClearDebugStats : Intent @@ -66,6 +66,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 +113,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() 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..faaea814 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 @@ -27,11 +27,13 @@ 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.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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 @@ -42,6 +44,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource 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 +52,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 +67,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,9 +178,10 @@ 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, @@ -342,35 +352,40 @@ internal fun LazyListScope.dataItems( } } 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 { + 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 { 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 - }, + SettingsCard( position = when (index) { 0 -> ItemPosition.TOP - kinds.lastIndex -> ItemPosition.BOTTOM + storageKinds.lastIndex -> ItemPosition.BOTTOM else -> ItemPosition.MIDDLE }, - enabled = !loading && bytes > 0L, - onClick = { onClearKind(kind) }, - ) + ) { + SettingsCardRow( + icon = storageCategoryIcon(kind), + title = stringResource(storageKindLabel(kind)), + subtitle = formatBytes(bytes), + iconColor = storageCategoryColor(kind), + titleColor = if (bytes > 0L) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } } item { Spacer(Modifier.height(8.dp)) } @@ -382,7 +397,7 @@ internal fun LazyListScope.dataItems( iconColor = MaterialTheme.colorScheme.error, titleColor = MaterialTheme.colorScheme.error, position = ItemPosition.STANDALONE, - enabled = !loading, + enabled = !loading && selectableCacheKinds(cacheByKind).isNotEmpty(), onClick = onClear, trailingContent = { MonogramLoading( @@ -424,6 +439,149 @@ internal fun LazyListScope.dataItems( } } +internal fun storageKindLabel(kind: String): Int = when (kind) { + FileCache.KIND_PHOTOS -> R.string.settings_storage_photos + FileCache.KIND_VIDEOS -> R.string.settings_storage_videos + FileCache.KIND_FILES -> R.string.settings_storage_files + FileCache.KIND_STICKERS -> R.string.settings_storage_stickers + FileCache.KIND_GIFS -> R.string.settings_storage_gifs + else -> R.string.settings_storage_other +} + +@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.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + MediaCacheLimit.entries.forEach { limit -> + SettingsCardRow( + icon = Icons.Outlined.Storage, + title = cacheLimitLabel(limit), + iconColor = MaterialTheme.colorScheme.primary, + selected = limit == current, + onClick = { onSelect(limit) }, + ) + } + Spacer(Modifier.height(12.dp)) + } +} + +@Composable +internal fun CacheClearDialog( + byKind: Map, + selected: Set, + onToggle: (String) -> Unit, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AppModalSheet(onDismissRequest = onDismiss) { + Text( + text = stringResource(R.string.settings_clear_cache), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + selectableCacheKinds(byKind).forEach { kind -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle(kind) } + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = kind in selected, + onCheckedChange = { onToggle(kind) }, + ) + Text( + text = stringResource(storageKindLabel(kind)), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = formatBytes(byKind[kind] ?: 0L), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 12.dp), + ) + } + } + Text( + text = stringResource( + R.string.settings_clear_cache_selected, + formatBytes(selectedCacheBytes(byKind, selected)), + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + TextButton( + onClick = onConfirm, + enabled = selected.isNotEmpty(), + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 8.dp), + ) { + Text( + stringResource(R.string.settings_clear_cache_action), + color = if (selected.isNotEmpty()) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } +} + +@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.titleLarge, + 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), + ) + TextButton( + onClick = onConfirm, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(bottom = 8.dp), + ) { + Text( + stringResource(R.string.settings_clear_cache_action), + color = MaterialTheme.colorScheme.error, + ) + } + } +} + internal fun formatBytes(bytes: Long): String { if (bytes < 1024) return "$bytes B" val kb = bytes / 1024.0 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/tests/unit/update_journal_tests.rs b/native/mtproto-rs/tests/unit/update_journal_tests.rs index e52b7c58..9cbe0825 100644 --- a/native/mtproto-rs/tests/unit/update_journal_tests.rs +++ b/native/mtproto-rs/tests/unit/update_journal_tests.rs @@ -196,6 +196,31 @@ fn an_older_snapshot_does_not_replace_a_newer_live_journal() { 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"); 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 200ece54..dd317ae9 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,7 @@ 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 +269,7 @@ 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 +312,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 +327,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 +411,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 +427,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 +446,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 +465,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 +484,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 +503,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 +522,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 +541,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 +560,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 +579,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 +598,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 +617,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 +634,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 +657,7 @@ 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,7 +689,6 @@ 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( @@ -875,7 +869,6 @@ 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( @@ -884,7 +877,6 @@ internal object IntegrityCheckingUniffiLib { ): 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( @@ -996,29 +988,36 @@ internal object UniffiLib { } external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener(`vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, - ): Unit + ) + external fun uniffi_monogram_mtproto_fn_func_acknowledge_update_batch( `handle`: Long, `accountGeneration`: Long, `batchId`: Long, uniffi_out_err: UniffiRustCallStatus, - ): 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_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_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_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_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, @@ -1026,7 +1025,8 @@ internal object UniffiLib { 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_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, @@ -1050,17 +1050,23 @@ internal object UniffiLib { 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_download_chunk_kib(uniffi_out_err: UniffiRustCallStatus, ): Int external fun uniffi_monogram_mtproto_fn_func_download_concurrency(uniffi_out_err: UniffiRustCallStatus, @@ -1080,7 +1086,8 @@ internal object UniffiLib { 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_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, @@ -1168,7 +1175,8 @@ internal object UniffiLib { 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_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, @@ -1176,7 +1184,8 @@ internal object UniffiLib { 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_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, @@ -1185,34 +1194,44 @@ internal object UniffiLib { `handle`: 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_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_promote_request_control( `id`: Long, `class`: Int, uniffi_out_err: UniffiRustCallStatus, - ): Unit + ) + 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_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_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, @@ -1228,13 +1247,16 @@ internal object UniffiLib { 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_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_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, @@ -1244,151 +1266,208 @@ internal object UniffiLib { 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_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 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_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_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_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_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_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_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_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_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_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_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_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_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 + ) } @@ -1835,7 +1914,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. @@ -1932,7 +2011,7 @@ 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) { @@ -1957,7 +2036,7 @@ public abstract class FfiConverterCallbackInterface: Ffi /** * @suppress */ -public object FfiConverterUShort: FfiConverter { +object FfiConverterUShort: FfiConverter { override fun lift(value: Short): UShort { return value.toUShort() } @@ -1984,7 +2063,7 @@ public object FfiConverterUShort: FfiConverter { /** * @suppress */ -public object FfiConverterShort: FfiConverter { +object FfiConverterShort: FfiConverter { override fun lift(value: Short): Short { return value } @@ -2007,7 +2086,7 @@ public object FfiConverterShort: FfiConverter { /** * @suppress */ -public object FfiConverterUInt: FfiConverter { +object FfiConverterUInt: FfiConverter { override fun lift(value: Int): UInt { return value.toUInt() } @@ -2030,7 +2109,7 @@ public object FfiConverterUInt: FfiConverter { /** * @suppress */ -public object FfiConverterInt: FfiConverter { +object FfiConverterInt: FfiConverter { override fun lift(value: Int): Int { return value } @@ -2053,7 +2132,7 @@ public object FfiConverterInt: FfiConverter { /** * @suppress */ -public object FfiConverterULong: FfiConverter { +object FfiConverterULong: FfiConverter { override fun lift(value: Long): ULong { return value.toULong() } @@ -2076,7 +2155,7 @@ public object FfiConverterULong: FfiConverter { /** * @suppress */ -public object FfiConverterLong: FfiConverter { +object FfiConverterLong: FfiConverter { override fun lift(value: Long): Long { return value } @@ -2099,7 +2178,7 @@ public object FfiConverterLong: FfiConverter { /** * @suppress */ -public object FfiConverterFloat: FfiConverter { +object FfiConverterFloat: FfiConverter { override fun lift(value: Float): Float { return value } @@ -2122,7 +2201,7 @@ public object FfiConverterFloat: FfiConverter { /** * @suppress */ -public object FfiConverterDouble: FfiConverter { +object FfiConverterDouble: FfiConverter { override fun lift(value: Double): Double { return value } @@ -2145,7 +2224,7 @@ public object FfiConverterDouble: FfiConverter { /** * @suppress */ -public object FfiConverterBoolean: FfiConverter { +object FfiConverterBoolean: FfiConverter { override fun lift(value: Byte): Boolean { return value.toInt() != 0 } @@ -2168,7 +2247,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. @@ -2225,7 +2304,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) @@ -2244,13 +2323,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 ){ @@ -2264,7 +2343,7 @@ data class AuthCodeSent ( /** * @suppress */ -public object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer { +object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthCodeSent { return AuthCodeSent( FfiConverterString.read(buf), @@ -2292,9 +2371,9 @@ public object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer data class AuthSignedIn ( - var `userId`: kotlin.Long + var `userId`: Long , - var `dcId`: kotlin.Int + var `dcId`: Int ){ @@ -2308,7 +2387,7 @@ data class AuthSignedIn ( /** * @suppress */ -public object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer { +object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthSignedIn { return AuthSignedIn( FfiConverterLong.read(buf), @@ -2330,13 +2409,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 ){ @@ -2350,7 +2429,7 @@ data class BotCallbackAnswerDto ( /** * @suppress */ -public object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { +object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): BotCallbackAnswerDto { return BotCallbackAnswerDto( FfiConverterBoolean.read(buf), @@ -2377,8 +2456,8 @@ public object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { +object FfiConverterTypeChannelCursorDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChannelCursorDto { return ChannelCursorDto( FfiConverterLong.read(buf), @@ -2411,97 +2490,97 @@ public object FfiConverterTypeChannelCursorDto : FfiConverterRustBuffer { +object FfiConverterTypeChatDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChatDto { return ChatDto( FfiConverterLong.read(buf), @@ -2649,7 +2728,7 @@ data class ContactsSearchDto ( /** * @suppress */ -public object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ContactsSearchDto { return ContactsSearchDto( FfiConverterSequenceTypeSearchPeerDto.read(buf), @@ -2671,9 +2750,9 @@ public object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeDiscussionDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): DiscussionDto { return DiscussionDto( FfiConverterLong.read(buf), @@ -2709,36 +2788,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 ){ @@ -2752,7 +2831,7 @@ data class FolderDto ( /** * @suppress */ -public object FfiConverterTypeFolderDto: FfiConverterRustBuffer { +object FfiConverterTypeFolderDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): FolderDto { return FolderDto( FfiConverterInt.read(buf), @@ -2810,37 +2889,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? ){ @@ -2854,7 +2933,7 @@ data class ForumTopicDto ( /** * @suppress */ -public object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicDto { return ForumTopicDto( FfiConverterInt.read(buf), @@ -2918,7 +2997,7 @@ public object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer @@ -2934,7 +3013,7 @@ data class ForumTopicsPageDto ( /** * @suppress */ -public object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicsPageDto { return ForumTopicsPageDto( FfiConverterInt.read(buf), @@ -2958,11 +3037,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 ){ @@ -2976,7 +3055,7 @@ data class GlobalMessageSearchDto ( /** * @suppress */ -public object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): GlobalMessageSearchDto { return GlobalMessageSearchDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -3008,17 +3087,17 @@ public object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeInitConnectionInfo: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InitConnectionInfo { return InitConnectionInfo( FfiConverterString.read(buf), @@ -3066,19 +3145,19 @@ public object FfiConverterTypeInitConnectionInfo: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultDto { return InlineBotResultDto( FfiConverterString.read(buf), @@ -3129,13 +3208,13 @@ public object FfiConverterTypeInlineBotResultDto: FfiConverterRustBuffer @@ -3151,7 +3230,7 @@ data class InlineBotResultsDto ( /** * @suppress */ -public object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultsDto { return InlineBotResultsDto( FfiConverterLong.read(buf), @@ -3182,31 +3261,31 @@ public object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { +object FfiConverterTypeInstantViewDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InstantViewDto { return InstantViewDto( FfiConverterString.read(buf), @@ -3275,11 +3354,11 @@ public object FfiConverterTypeInstantViewDto: FfiConverterRustBuffer { +object FfiConverterTypeJoinChatDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): JoinChatDto { return JoinChatDto( FfiConverterBoolean.read(buf), @@ -3318,9 +3397,9 @@ public object FfiConverterTypeJoinChatDto: FfiConverterRustBuffer { data class LottieSize ( - var `width`: kotlin.UInt + var `width`: UInt , - var `height`: kotlin.UInt + var `height`: UInt ){ @@ -3334,7 +3413,7 @@ data class LottieSize ( /** * @suppress */ -public object FfiConverterTypeLottieSize: FfiConverterRustBuffer { +object FfiConverterTypeLottieSize: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): LottieSize { return LottieSize( FfiConverterUInt.read(buf), @@ -3356,77 +3435,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 ){ @@ -3440,7 +3519,7 @@ data class MessageDto ( /** * @suppress */ -public object FfiConverterTypeMessageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessageDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessageDto { return MessageDto( FfiConverterLong.read(buf), @@ -3557,7 +3636,7 @@ public object FfiConverterTypeMessageDto: FfiConverterRustBuffer { data class MessagesPageDto ( var `messages`: List , - var `totalCount`: kotlin.Int + var `totalCount`: Int ){ @@ -3571,7 +3650,7 @@ data class MessagesPageDto ( /** * @suppress */ -public object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessagesPageDto { return MessagesPageDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -3593,23 +3672,23 @@ public object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifyExceptionDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifyExceptionDto { return NotifyExceptionDto( FfiConverterString.read(buf), @@ -3666,17 +3745,17 @@ public object FfiConverterTypeNotifyExceptionDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifySettingsDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifySettingsDto { return NotifySettingsDto( FfiConverterOptionalBoolean.read(buf), @@ -3724,9 +3803,9 @@ public object FfiConverterTypeNotifySettingsDto: FfiConverterRustBuffer { +object FfiConverterTypeOutboxReadDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): OutboxReadDto { return OutboxReadDto( FfiConverterInt.read(buf), @@ -3762,13 +3841,13 @@ public object FfiConverterTypeOutboxReadDto: FfiConverterRustBuffer + var `options`: List ){ @@ -3782,7 +3861,7 @@ data class PollVoterDto ( /** * @suppress */ -public object FfiConverterTypePollVoterDto: FfiConverterRustBuffer { +object FfiConverterTypePollVoterDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVoterDto { return PollVoterDto( FfiConverterLong.read(buf), @@ -3812,7 +3891,7 @@ public object FfiConverterTypePollVoterDto: FfiConverterRustBuffer data class PollVotersDto ( var `voters`: List , - var `count`: kotlin.Int + var `count`: Int ){ @@ -3826,7 +3905,7 @@ data class PollVotersDto ( /** * @suppress */ -public object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { +object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVotersDto { return PollVotersDto( FfiConverterSequenceTypePollVoterDto.read(buf), @@ -3848,28 +3927,28 @@ public object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { +object FfiConverterTypeProfileDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ProfileDto { return ProfileDto( FfiConverterLong.read(buf), @@ -3929,9 +4008,9 @@ public object FfiConverterTypeProfileDto: FfiConverterRustBuffer { data class ReactionChoiceDto ( - var `emoticon`: kotlin.String + var `emoticon`: String , - var `documentId`: kotlin.Long + var `documentId`: Long ){ @@ -3945,7 +4024,7 @@ data class ReactionChoiceDto ( /** * @suppress */ -public object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionChoiceDto { return ReactionChoiceDto( FfiConverterString.read(buf), @@ -3967,15 +4046,15 @@ public object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeerDto { return ReactionPeerDto( FfiConverterLong.read(buf), @@ -4022,7 +4101,7 @@ public object FfiConverterTypeReactionPeerDto: FfiConverterRustBuffer , - var `count`: kotlin.Int + var `count`: Int ){ @@ -4036,7 +4115,7 @@ data class ReactionPeersDto ( /** * @suppress */ -public object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeersDto { return ReactionPeersDto( FfiConverterSequenceTypeReactionPeerDto.read(buf), @@ -4058,9 +4137,9 @@ public object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantDto { return ReadParticipantDto( FfiConverterLong.read(buf), @@ -4098,7 +4177,7 @@ public object FfiConverterTypeReadParticipantDto: FfiConverterRustBuffer , - var `error`: kotlin.String? + var `error`: String? ){ @@ -4112,7 +4191,7 @@ data class ReadParticipantsDto ( /** * @suppress */ -public object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantsDto { return ReadParticipantsDto( FfiConverterSequenceTypeReadParticipantDto.read(buf), @@ -4134,13 +4213,13 @@ public object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { +object FfiConverterTypeReadReceiptConfigDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadReceiptConfigDto { return ReadReceiptConfigDto( FfiConverterInt.read(buf), @@ -4182,13 +4261,13 @@ public object FfiConverterTypeReadReceiptConfigDto: FfiConverterRustBuffer { +object FfiConverterTypeResolvedPeerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ResolvedPeerDto { return ResolvedPeerDto( FfiConverterLong.read(buf), @@ -4230,15 +4309,15 @@ public object FfiConverterTypeResolvedPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeSavedGifDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SavedGifDto { return SavedGifDto( FfiConverterLong.read(buf), @@ -4283,19 +4362,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 ){ @@ -4309,7 +4388,7 @@ data class SearchPeerDto ( /** * @suppress */ -public object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SearchPeerDto { return SearchPeerDto( FfiConverterLong.read(buf), @@ -4346,9 +4425,9 @@ public object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer @@ -4364,7 +4443,7 @@ data class StickerCatalogDto ( /** * @suppress */ -public object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerCatalogDto { return StickerCatalogDto( FfiConverterLong.read(buf), @@ -4389,11 +4468,11 @@ public object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer + var `documentIds`: List ){ @@ -4407,7 +4486,7 @@ data class StickerListDto ( /** * @suppress */ -public object FfiConverterTypeStickerListDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerListDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerListDto { return StickerListDto( FfiConverterLong.read(buf), @@ -4432,19 +4511,19 @@ public object FfiConverterTypeStickerListDto: FfiConverterRustBuffer + var `previewDocumentIds`: List ){ @@ -4458,7 +4537,7 @@ data class StickerPackDto ( /** * @suppress */ -public object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerPackDto { return StickerPackDto( FfiConverterLong.read(buf), @@ -4494,8 +4573,8 @@ public object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer, var `cursorAfter`: UpdatesStateDto?, var `channelPtsAfter`: List @@ -4509,7 +4588,7 @@ data class UpdateBatchDto( /** * @suppress */ -public object FfiConverterTypeUpdateBatchDto : FfiConverterRustBuffer { +object FfiConverterTypeUpdateBatchDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdateBatchDto { return UpdateBatchDto( FfiConverterLong.read(buf), @@ -4541,7 +4620,7 @@ public object FfiConverterTypeUpdateBatchDto : FfiConverterRustBuffer, var `liveEvents`: List, - var `replayRequired`: kotlin.Boolean + var `replayRequired`: Boolean ) { @@ -4552,7 +4631,7 @@ data class UpdateDrainDto( /** * @suppress */ -public object FfiConverterTypeUpdateDrainDto : FfiConverterRustBuffer { +object FfiConverterTypeUpdateDrainDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdateDrainDto { return UpdateDrainDto( FfiConverterSequenceTypeUpdateBatchDto.read(buf), @@ -4577,13 +4656,13 @@ public object FfiConverterTypeUpdateDrainDto : FfiConverterRustBuffer { +object FfiConverterTypeUpdatesStateDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdatesStateDto { return UpdatesStateDto( FfiConverterInt.read(buf), @@ -4625,25 +4704,25 @@ public object FfiConverterTypeUpdatesStateDto: FfiConverterRustBuffer { +object FfiConverterTypeUploadItemDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UploadItemDto { return UploadItemDto( FfiConverterString.read(buf), @@ -4703,11 +4782,11 @@ public object FfiConverterTypeUploadItemDto: FfiConverterRustBuffer { +object FfiConverterTypeVpxAlphaFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame { return VpxAlphaFrame( FfiConverterUInt.read(buf), @@ -4746,11 +4825,11 @@ public object FfiConverterTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterTypeVpxFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame { return VpxFrame( FfiConverterUInt.read(buf), @@ -4789,9 +4868,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 @@ -4807,7 +4886,7 @@ data class WallpaperCatalogDto ( /** * @suppress */ -public object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperCatalogDto { return WallpaperCatalogDto( FfiConverterLong.read(buf), @@ -4832,29 +4911,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 ){ @@ -4868,7 +4947,7 @@ data class WallpaperDto ( /** * @suppress */ -public object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperDto { return WallpaperDto( FfiConverterLong.read(buf), @@ -4921,29 +5000,26 @@ public object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer -sealed class MtprotoException: kotlin.Exception() { +sealed class MtprotoException: 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 }" @@ -4963,7 +5039,7 @@ sealed class MtprotoException: kotlin.Exception() { /** * @suppress */ -public object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { +object FfiConverterTypeMtprotoError : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MtprotoException { @@ -5017,7 +5093,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 */ } } @@ -5035,7 +5110,7 @@ sealed class UpdateEventDto { data class NewMessage( - val `message`: uniffi.monogram_mtproto.MessageDto) : UpdateEventDto() + val `message`: MessageDto) : UpdateEventDto() { @@ -5044,7 +5119,7 @@ sealed class UpdateEventDto { } data class MessageEdited( - val `message`: uniffi.monogram_mtproto.MessageDto) : UpdateEventDto() + val `message`: MessageDto) : UpdateEventDto() { @@ -5053,8 +5128,8 @@ sealed class UpdateEventDto { } data class MessagesDeleted( - val `chatId`: kotlin.Long?, - val `messageIds`: List) : UpdateEventDto() + val `chatId`: Long?, + val `messageIds`: List) : UpdateEventDto() { @@ -5063,10 +5138,10 @@ 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() { @@ -5075,9 +5150,9 @@ 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() { @@ -5086,8 +5161,8 @@ sealed class UpdateEventDto { } data class PeerEmojiStatus( - val `userId`: kotlin.Long, - val `documentId`: kotlin.Long?) : UpdateEventDto() + val `userId`: Long, + val `documentId`: Long?) : UpdateEventDto() { @@ -5096,9 +5171,9 @@ 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() { @@ -5107,8 +5182,8 @@ sealed class UpdateEventDto { } data class ReadOutbox( - val `chatId`: kotlin.Long, - val `maxId`: kotlin.Int) : UpdateEventDto() + val `chatId`: Long, + val `maxId`: Int) : UpdateEventDto() { @@ -5120,9 +5195,9 @@ 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() { @@ -5131,9 +5206,9 @@ 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() { @@ -5145,7 +5220,7 @@ 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() { @@ -5157,7 +5232,7 @@ 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() { @@ -5166,7 +5241,7 @@ sealed class UpdateEventDto { } data class Ignored( - val `kind`: kotlin.String) : UpdateEventDto() + val `kind`: String) : UpdateEventDto() { @@ -5187,7 +5262,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 @@ -5389,18 +5464,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) @@ -5408,33 +5480,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) @@ -5445,29 +5512,24 @@ 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 */ } } @@ -5479,9 +5541,9 @@ public object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer + val makeCall = { uniffiObj.`onProgress`( FfiConverterString.lift(`path`), FfiConverterLong.lift(`downloaded`), FfiConverterLong.lift(`total`), ) } - val writeReturn = { _: Unit -> Unit } + val writeReturn = { _: Unit -> } uniffiTraitInterfaceCall(uniffiCallStatus, makeCall, writeReturn) } } @@ -5535,7 +5597,7 @@ internal object uniffiCallbackInterfaceDownloadProgressListener { * * @suppress */ -public object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInterface() +object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInterface() @@ -5543,15 +5605,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 { @@ -5559,7 +5621,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 { @@ -5575,15 +5637,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 { @@ -5591,7 +5653,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 { @@ -5607,15 +5669,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 { @@ -5623,7 +5685,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 { @@ -5655,7 +5717,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 { @@ -5671,15 +5733,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 { @@ -5687,7 +5749,7 @@ public object FfiConverterOptionalByteArray: FfiConverterRustBuffer { +object FfiConverterOptionalTypeUpdatesStateDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdatesStateDto? { if (buf.get().toInt() == 0) { return null @@ -5733,7 +5795,7 @@ public object FfiConverterOptionalTypeUpdatesStateDto : FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxAlphaFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame? { if (buf.get().toInt() == 0) { return null @@ -5765,7 +5827,7 @@ public object FfiConverterOptionalTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxFrame: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame? { if (buf.get().toInt() == 0) { return null @@ -5797,21 +5859,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) @@ -5825,21 +5887,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) @@ -5853,21 +5915,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) @@ -5881,21 +5943,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) @@ -5909,21 +5971,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) @@ -5937,21 +5999,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) @@ -5963,7 +6025,7 @@ public object FfiConverterSequenceByteArray: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() @@ -5992,7 +6054,7 @@ public object FfiConverterSequenceTypeChannelCursorDto : /** * @suppress */ -public object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6020,7 +6082,7 @@ public object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeFolderDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6048,7 +6110,7 @@ public object FfiConverterSequenceTypeFolderDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeForumTopicDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6076,7 +6138,7 @@ public object FfiConverterSequenceTypeForumTopicDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeInlineBotResultDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6104,7 +6166,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) { @@ -6132,7 +6194,7 @@ public object FfiConverterSequenceTypeMessageDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeNotifyExceptionDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6160,7 +6222,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) { @@ -6188,7 +6250,7 @@ public object FfiConverterSequenceTypePollVoterDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReactionChoiceDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6216,7 +6278,7 @@ 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) { @@ -6244,7 +6306,7 @@ public object FfiConverterSequenceTypeReactionPeerDto: FfiConverterRustBuffer
  • > { +object FfiConverterSequenceTypeReadParticipantDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6272,7 +6334,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) { @@ -6300,7 +6362,7 @@ public object FfiConverterSequenceTypeSavedGifDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeSearchPeerDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6328,7 +6390,7 @@ public object FfiConverterSequenceTypeSearchPeerDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeStickerPackDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6354,7 +6416,7 @@ public object FfiConverterSequenceTypeStickerPackDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() @@ -6383,7 +6445,7 @@ public object FfiConverterSequenceTypeUpdateBatchDto : /** * @suppress */ -public object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6411,7 +6473,7 @@ public object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeWallpaperDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6439,7 +6501,7 @@ public object FfiConverterSequenceTypeWallpaperDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6463,9 +6525,9 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer UniffiLib.uniffi_monogram_mtproto_fn_func_acknowledge_update_batch( @@ -6479,7 +6541,7 @@ fun `acknowledgeUpdateBatch`( - @Throws(MtprotoException::class) fun `addWaveformPcm`(`handle`: kotlin.ULong, `samples`: List, `sampleRate`: kotlin.UInt, `channels`: kotlin.UInt, `presentationTimeUs`: kotlin.ULong) + @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( @@ -6494,7 +6556,7 @@ fun `acknowledgeUpdateBatch`( - @Throws(MtprotoException::class) fun `animatedEmojiMax`(`handle`: kotlin.ULong): kotlin.Int { + @Throws(MtprotoException::class) fun `animatedEmojiMax`(`handle`: ULong): Int { return FfiConverterInt.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_animated_emoji_max( @@ -6506,7 +6568,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `appendTodoItems`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `firstId`: kotlin.Int, `titles`: List) + @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( @@ -6520,9 +6582,9 @@ fun `acknowledgeUpdateBatch`( } - fun `bindRequestControl`(`id`: kotlin.ULong): kotlin.ULong { + fun `bindRequestControl`(`id`: ULong): ULong { return FfiConverterULong.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_bind_request_control( @@ -6531,9 +6593,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `cancelRequestControl`(`id`: kotlin.ULong) + fun `cancelRequestControl`(`id`: ULong) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_cancel_request_control( @@ -6542,7 +6604,7 @@ fun `acknowledgeUpdateBatch`( - @Throws(MtprotoException::class) fun `checkPassword`(`handle`: kotlin.ULong, `password`: kotlin.String): AuthSignedIn { + @Throws(MtprotoException::class) fun `checkPassword`(`handle`: ULong, `password`: String): AuthSignedIn { return FfiConverterTypeAuthSignedIn.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_check_password( @@ -6555,7 +6617,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `clearActiveDialog`(`handle`: kotlin.ULong) + @Throws(MtprotoException::class) fun `clearActiveDialog`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_clear_active_dialog( @@ -6575,9 +6637,9 @@ fun `acknowledgeUpdateBatch`( } - fun `clientApiId`(`handle`: kotlin.ULong): kotlin.Int { + fun `clientApiId`(`handle`: ULong): Int { return FfiConverterInt.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_api_id( @@ -6586,9 +6648,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `clientExists`(`handle`: kotlin.ULong): kotlin.Boolean { + fun `clientExists`(`handle`: ULong): Boolean { return FfiConverterBoolean.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_exists( @@ -6597,9 +6659,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `clientUsesTestDc`(`handle`: kotlin.ULong): kotlin.Boolean { + fun `clientUsesTestDc`(`handle`: ULong): Boolean { return FfiConverterBoolean.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_uses_test_dc( @@ -6609,7 +6671,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `connect`(`handle`: kotlin.ULong) + @Throws(MtprotoException::class) fun `connect`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_connect( @@ -6620,7 +6682,7 @@ fun `acknowledgeUpdateBatch`( - @Throws(MtprotoException::class) fun `contactsSearch`(`handle`: kotlin.ULong, `query`: kotlin.String, `limit`: kotlin.Int): ContactsSearchDto { + @Throws(MtprotoException::class) fun `contactsSearch`(`handle`: ULong, `query`: String, `limit`: Int): ContactsSearchDto { return FfiConverterTypeContactsSearchDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_contacts_search( @@ -6633,9 +6695,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `createClient`(`apiId`: kotlin.Int, `apiHash`: kotlin.String, `sessionPath`: kotlin.String): kotlin.ULong { + fun `createClient`(`apiId`: Int, `apiHash`: String, `sessionPath`: String): ULong { return FfiConverterULong.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_client( @@ -6647,7 +6709,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createEncryptedClient`(`apiId`: kotlin.Int, `apiHash`: kotlin.String, `sessionPath`: kotlin.String, `key`: kotlin.ByteArray): kotlin.ULong { + @Throws(MtprotoException::class) fun `createEncryptedClient`(`apiId`: Int, `apiHash`: String, `sessionPath`: String, `key`: ByteArray): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_encrypted_client( @@ -6662,7 +6724,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createLottie`(`data`: kotlin.ByteArray): kotlin.ULong { + @Throws(MtprotoException::class) fun `createLottie`(`data`: ByteArray): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_lottie( @@ -6673,9 +6735,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `createRequestControl`(): kotlin.ULong { + fun `createRequestControl`(): ULong { return FfiConverterULong.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_request_control( _status) @@ -6684,7 +6746,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createVpxDecoder`(): kotlin.ULong { + @Throws(MtprotoException::class) fun `createVpxDecoder`(): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_vpx_decoder( @@ -6695,7 +6757,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createWaveform`(`durationUs`: kotlin.ULong): kotlin.ULong { + @Throws(MtprotoException::class) fun `createWaveform`(`durationUs`: ULong): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_waveform( @@ -6707,7 +6769,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `customEmojiIsFree`(`handle`: kotlin.ULong, `documentId`: kotlin.Long): kotlin.Boolean { + @Throws(MtprotoException::class) fun `customEmojiIsFree`(`handle`: ULong, `documentId`: Long): Boolean { return FfiConverterBoolean.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_custom_emoji_is_free( @@ -6720,7 +6782,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `decodeVpxAlphaPacket`(`handle`: kotlin.ULong, `data`: kotlin.ByteArray): VpxAlphaFrame? { + @Throws(MtprotoException::class) fun `decodeVpxAlphaPacket`(`handle`: ULong, `data`: ByteArray): VpxAlphaFrame? { return FfiConverterOptionalTypeVpxAlphaFrame.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_alpha_packet( @@ -6733,7 +6795,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `decodeVpxPacket`(`handle`: kotlin.ULong, `data`: kotlin.ByteArray): VpxFrame? { + @Throws(MtprotoException::class) fun `decodeVpxPacket`(`handle`: ULong, `data`: ByteArray): VpxFrame? { return FfiConverterOptionalTypeVpxFrame.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_packet( @@ -6746,7 +6808,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `decryptPushPayload`(`secret`: kotlin.ByteArray, `payload`: kotlin.String): kotlin.String { + @Throws(MtprotoException::class) fun `decryptPushPayload`(`secret`: ByteArray, `payload`: String): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_decrypt_push_payload( @@ -6759,7 +6821,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `deleteFolder`(`handle`: kotlin.ULong, `id`: kotlin.Int) + @Throws(MtprotoException::class) fun `deleteFolder`(`handle`: ULong, `id`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_delete_folder( @@ -6771,7 +6833,7 @@ fun `acknowledgeUpdateBatch`( - @Throws(MtprotoException::class) fun `deleteMessage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `revoke`: kotlin.Boolean) + @Throws(MtprotoException::class) fun `deleteMessage`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `revoke`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_delete_message( @@ -6784,9 +6846,9 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyClient`(`handle`: kotlin.ULong) + fun `destroyClient`(`handle`: ULong) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_client( @@ -6794,9 +6856,9 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyLottie`(`handle`: kotlin.ULong) + fun `destroyLottie`(`handle`: ULong) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_lottie( @@ -6804,9 +6866,9 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyVpxDecoder`(`handle`: kotlin.ULong) + fun `destroyVpxDecoder`(`handle`: ULong) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_vpx_decoder( @@ -6814,9 +6876,9 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyWaveform`(`handle`: kotlin.ULong) + fun `destroyWaveform`(`handle`: ULong) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_waveform( @@ -6824,9 +6886,9 @@ fun `acknowledgeUpdateBatch`( } - fun `downloadChunkKib`(): kotlin.Int { + fun `downloadChunkKib`(): Int { return FfiConverterInt.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_chunk_kib( _status) @@ -6834,9 +6896,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `downloadConcurrency`(): List { + fun `downloadConcurrency`(): List { return FfiConverterSequenceInt.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_concurrency( _status) @@ -6845,7 +6907,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadCustomEmoji`(`handle`: kotlin.ULong, `documentId`: kotlin.Long, `destPath`: kotlin.String): kotlin.String { + @Throws(MtprotoException::class) fun `downloadCustomEmoji`(`handle`: ULong, `documentId`: Long, `destPath`: String): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_custom_emoji( @@ -6859,7 +6921,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageDisplay`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `destPath`: kotlin.String): kotlin.String { + @Throws(MtprotoException::class) fun `downloadMessageDisplay`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_display( @@ -6874,7 +6936,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageMedia`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `destPath`: kotlin.String): kotlin.String { + @Throws(MtprotoException::class) fun `downloadMessageMedia`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media( @@ -6889,7 +6951,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageMediaChunk`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `destPath`: kotlin.String, `offset`: kotlin.Long): kotlin.String { + @Throws(MtprotoException::class) fun `downloadMessageMediaChunk`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String, `offset`: Long): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media_chunk( @@ -6905,7 +6967,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageThumb`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `destPath`: kotlin.String): kotlin.String { + @Throws(MtprotoException::class) fun `downloadMessageThumb`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_thumb( @@ -6920,7 +6982,7 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadWallpaper`(`handle`: kotlin.ULong, `id`: kotlin.Long, `accessHash`: kotlin.Long, `destPath`: kotlin.String): kotlin.String { + @Throws(MtprotoException::class) fun `downloadWallpaper`(`handle`: ULong, `id`: Long, `accessHash`: Long, `destPath`: String): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_wallpaper( @@ -6936,7 +6998,7 @@ fun `acknowledgeUpdateBatch`( @Throws(MtprotoException::class) -fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { +fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { return FfiConverterTypeUpdateDrainDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_drain_updates( @@ -6948,7 +7010,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `editForumTopicHidden`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `topicId`: kotlin.Int, `hidden`: kotlin.Boolean) + @Throws(MtprotoException::class) fun `editForumTopicHidden`(`handle`: ULong, `chatId`: Long, `topicId`: Int, `hidden`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_edit_forum_topic_hidden( @@ -6962,7 +7024,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { - @Throws(MtprotoException::class) fun `editTextMessage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `text`: kotlin.String, `entitiesJson`: kotlin.String?): MessageDto { + @Throws(MtprotoException::class) fun `editTextMessage`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `text`: String, `entitiesJson`: String?): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_edit_text_message( @@ -6978,7 +7040,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `finishWaveform`(`handle`: kotlin.ULong): List { + @Throws(MtprotoException::class) fun `finishWaveform`(`handle`: ULong): List { return FfiConverterSequenceFloat.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_finish_waveform( @@ -6990,7 +7052,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `forwardMessages`(`handle`: kotlin.ULong, `fromChatId`: kotlin.Long, `messageIds`: List, `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( @@ -7006,7 +7068,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getAllStickers`(`handle`: kotlin.ULong, `hash`: kotlin.Long): StickerCatalogDto { + @Throws(MtprotoException::class) fun `getAllStickers`(`handle`: ULong, `hash`: Long): StickerCatalogDto { return FfiConverterTypeStickerCatalogDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_all_stickers( @@ -7019,7 +7081,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getBotCallbackAnswer`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `dataHex`: kotlin.String): BotCallbackAnswerDto { + @Throws(MtprotoException::class) fun `getBotCallbackAnswer`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `dataHex`: String): BotCallbackAnswerDto { return FfiConverterTypeBotCallbackAnswerDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_bot_callback_answer( @@ -7034,7 +7096,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getChats`(`handle`: kotlin.ULong): List { + @Throws(MtprotoException::class) fun `getChats`(`handle`: ULong): List { return FfiConverterSequenceTypeChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_chats( @@ -7049,7 +7111,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { /** * Groups and channels shared with a user, as compact JSON. */ - @Throws(MtprotoException::class) fun `getCommonChats`(`handle`: kotlin.ULong, `userId`: kotlin.Long, `maxId`: kotlin.Long, `limit`: kotlin.Int): kotlin.String { + @Throws(MtprotoException::class) fun `getCommonChats`(`handle`: ULong, `userId`: Long, `maxId`: Long, `limit`: Int): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_common_chats( @@ -7064,7 +7126,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getDiscussionMessage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int): DiscussionDto { + @Throws(MtprotoException::class) fun `getDiscussionMessage`(`handle`: ULong, `chatId`: Long, `messageId`: Int): DiscussionDto { return FfiConverterTypeDiscussionDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_discussion_message( @@ -7078,7 +7140,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getEmojiStickers`(`handle`: kotlin.ULong, `hash`: kotlin.Long): StickerCatalogDto { + @Throws(MtprotoException::class) fun `getEmojiStickers`(`handle`: ULong, `hash`: Long): StickerCatalogDto { return FfiConverterTypeStickerCatalogDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_emoji_stickers( @@ -7091,7 +7153,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getFolders`(`handle`: kotlin.ULong): List { + @Throws(MtprotoException::class) fun `getFolders`(`handle`: ULong): List { return FfiConverterSequenceTypeFolderDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_folders( @@ -7103,7 +7165,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getForumTopics`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `offsetDate`: kotlin.Int, `offsetId`: kotlin.Int, `offsetTopic`: kotlin.Int, `limit`: kotlin.Int): ForumTopicsPageDto { + @Throws(MtprotoException::class) fun `getForumTopics`(`handle`: ULong, `chatId`: Long, `offsetDate`: Int, `offsetId`: Int, `offsetTopic`: Int, `limit`: Int): ForumTopicsPageDto { return FfiConverterTypeForumTopicsPageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_forum_topics( @@ -7120,7 +7182,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getForumTopicsById`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `topicIds`: List): 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( @@ -7134,7 +7196,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getGroupAdminTags`(`handle`: kotlin.ULong, `chatId`: kotlin.Long): kotlin.String { + @Throws(MtprotoException::class) fun `getGroupAdminTags`(`handle`: ULong, `chatId`: Long): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_group_admin_tags( @@ -7147,7 +7209,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getHistory`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `limit`: kotlin.Int): List { + @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( @@ -7161,7 +7223,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getHistoryPage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `limit`: kotlin.Int, `offsetId`: kotlin.Int, `offsetDate`: kotlin.Int, `addOffset`: kotlin.Int): List { + @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( @@ -7178,7 +7240,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getInlineBotResults`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `botId`: kotlin.Long, `query`: kotlin.String, `offset`: kotlin.String): InlineBotResultsDto { + @Throws(MtprotoException::class) fun `getInlineBotResults`(`handle`: ULong, `chatId`: Long, `botId`: Long, `query`: String, `offset`: String): InlineBotResultsDto { return FfiConverterTypeInlineBotResultsDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_inline_bot_results( @@ -7194,7 +7256,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getMessageReactionsList`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int): ReactionPeersDto { + @Throws(MtprotoException::class) fun `getMessageReactionsList`(`handle`: ULong, `chatId`: Long, `messageId`: Int): ReactionPeersDto { return FfiConverterTypeReactionPeersDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_reactions_list( @@ -7208,7 +7270,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getMessageReadParticipants`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `msgId`: kotlin.Int): ReadParticipantsDto { + @Throws(MtprotoException::class) fun `getMessageReadParticipants`(`handle`: ULong, `chatId`: Long, `msgId`: Int): ReadParticipantsDto { return FfiConverterTypeReadParticipantsDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_read_participants( @@ -7222,7 +7284,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getNotifyExceptions`(`handle`: kotlin.ULong, `compareSound`: kotlin.Boolean): List { + @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( @@ -7235,7 +7297,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getNotifySettings`(`handle`: kotlin.ULong, `peerKind`: kotlin.String, `chatId`: kotlin.Long): NotifySettingsDto { + @Throws(MtprotoException::class) fun `getNotifySettings`(`handle`: ULong, `peerKind`: String, `chatId`: Long): NotifySettingsDto { return FfiConverterTypeNotifySettingsDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_notify_settings( @@ -7249,7 +7311,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getOutboxReadDate`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `msgId`: kotlin.Int): OutboxReadDto { + @Throws(MtprotoException::class) fun `getOutboxReadDate`(`handle`: ULong, `chatId`: Long, `msgId`: Int): OutboxReadDto { return FfiConverterTypeOutboxReadDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_outbox_read_date( @@ -7266,7 +7328,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { /** * Participant page with roles for groups and channels, as compact JSON. */ - @Throws(MtprotoException::class) fun `getParticipants`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `filter`: kotlin.String, `query`: kotlin.String, `offset`: kotlin.Int, `limit`: kotlin.Int): kotlin.String { + @Throws(MtprotoException::class) fun `getParticipants`(`handle`: ULong, `chatId`: Long, `filter`: String, `query`: String, `offset`: Int, `limit`: Int): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_participants( @@ -7283,7 +7345,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getPinnedMessages`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `limit`: kotlin.Int): List { + @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( @@ -7297,7 +7359,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getPollVotes`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int): PollVotersDto { + @Throws(MtprotoException::class) fun `getPollVotes`(`handle`: ULong, `chatId`: Long, `messageId`: Int): PollVotersDto { return FfiConverterTypePollVotersDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_poll_votes( @@ -7311,7 +7373,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getProfile`(`handle`: kotlin.ULong, `peerId`: kotlin.Long): ProfileDto { + @Throws(MtprotoException::class) fun `getProfile`(`handle`: ULong, `peerId`: Long): ProfileDto { return FfiConverterTypeProfileDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_profile( @@ -7324,7 +7386,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getReadReceiptConfig`(`handle`: kotlin.ULong): ReadReceiptConfigDto { + @Throws(MtprotoException::class) fun `getReadReceiptConfig`(`handle`: ULong): ReadReceiptConfigDto { return FfiConverterTypeReadReceiptConfigDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_read_receipt_config( @@ -7336,7 +7398,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getRecentReactions`(`handle`: kotlin.ULong): List { + @Throws(MtprotoException::class) fun `getRecentReactions`(`handle`: ULong): List { return FfiConverterSequenceTypeReactionChoiceDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_recent_reactions( @@ -7348,7 +7410,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getReplies`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `msgId`: kotlin.Int, `limit`: kotlin.Int, `offsetId`: kotlin.Int, `addOffset`: kotlin.Int): List { + @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( @@ -7365,7 +7427,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getSavedGifs`(`handle`: kotlin.ULong): List { + @Throws(MtprotoException::class) fun `getSavedGifs`(`handle`: ULong): List { return FfiConverterSequenceTypeSavedGifDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_saved_gifs( @@ -7380,7 +7442,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { /** * `messages.getSearchCounters` counts per filter (photo_video/document/url/gif/voice/music/...). */ - @Throws(MtprotoException::class) fun `getSearchCounters`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `filters`: List): 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( @@ -7394,7 +7456,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getStickerPack`(`handle`: kotlin.ULong, `documentId`: kotlin.Long): StickerPackDto { + @Throws(MtprotoException::class) fun `getStickerPack`(`handle`: ULong, `documentId`: Long): StickerPackDto { return FfiConverterTypeStickerPackDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_pack( @@ -7407,7 +7469,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getStickerSet`(`handle`: kotlin.ULong, `setId`: kotlin.Long, `accessHash`: kotlin.Long): StickerPackDto { + @Throws(MtprotoException::class) fun `getStickerSet`(`handle`: ULong, `setId`: Long, `accessHash`: Long): StickerPackDto { return FfiConverterTypeStickerPackDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_set( @@ -7421,7 +7483,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getStickers`(`handle`: kotlin.ULong, `emoticon`: kotlin.String, `hash`: kotlin.Long): StickerListDto { + @Throws(MtprotoException::class) fun `getStickers`(`handle`: ULong, `emoticon`: String, `hash`: Long): StickerListDto { return FfiConverterTypeStickerListDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_stickers( @@ -7438,7 +7500,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { /** * https://core.telegram.org/method/messages.getUnreadMentions */ - @Throws(MtprotoException::class) fun `getUnreadMentions`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `offsetId`: kotlin.Int, `addOffset`: kotlin.Int, `limit`: kotlin.Int, `topMsgId`: kotlin.Int): List { + @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( @@ -7458,7 +7520,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { /** * https://core.telegram.org/method/messages.getUnreadReactions */ - @Throws(MtprotoException::class) fun `getUnreadReactions`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `offsetId`: kotlin.Int, `addOffset`: kotlin.Int, `limit`: kotlin.Int, `topMsgId`: kotlin.Int): List { + @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( @@ -7475,7 +7537,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getUpdatesState`(`handle`: kotlin.ULong): UpdatesStateDto { + @Throws(MtprotoException::class) fun `getUpdatesState`(`handle`: ULong): UpdatesStateDto { return FfiConverterTypeUpdatesStateDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_updates_state( @@ -7487,7 +7549,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getWallpapers`(`handle`: kotlin.ULong, `hash`: kotlin.Long): WallpaperCatalogDto { + @Throws(MtprotoException::class) fun `getWallpapers`(`handle`: ULong, `hash`: Long): WallpaperCatalogDto { return FfiConverterTypeWallpaperCatalogDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_wallpapers( @@ -7500,7 +7562,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getWebPage`(`handle`: kotlin.ULong, `url`: kotlin.String, `hash`: kotlin.Int): InstantViewDto { + @Throws(MtprotoException::class) fun `getWebPage`(`handle`: ULong, `url`: String, `hash`: Int): InstantViewDto { return FfiConverterTypeInstantViewDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page( @@ -7514,7 +7576,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getWebPagePreview`(`handle`: kotlin.ULong, `message`: kotlin.String): InstantViewDto { + @Throws(MtprotoException::class) fun `getWebPagePreview`(`handle`: ULong, `message`: String): InstantViewDto { return FfiConverterTypeInstantViewDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page_preview( @@ -7527,7 +7589,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `isAuthorized`(`handle`: kotlin.ULong): kotlin.Boolean { + @Throws(MtprotoException::class) fun `isAuthorized`(`handle`: ULong): Boolean { return FfiConverterBoolean.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_is_authorized( @@ -7539,7 +7601,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `joinChat`(`handle`: kotlin.ULong, `chatId`: kotlin.Long): JoinChatDto { + @Throws(MtprotoException::class) fun `joinChat`(`handle`: ULong, `chatId`: Long): JoinChatDto { return FfiConverterTypeJoinChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_join_chat( @@ -7551,9 +7613,9 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { ) } - fun `libraryVersion`(): kotlin.String { + fun `libraryVersion`(): String { return FfiConverterString.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_library_version( _status) @@ -7562,7 +7624,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `loadMoreChats`(`handle`: kotlin.ULong, `offsetDate`: kotlin.Int, `offsetId`: kotlin.Int, `offsetPeerId`: kotlin.Long, `folderId`: kotlin.Int): List { + @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( @@ -7578,7 +7640,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `logout`(`handle`: kotlin.ULong) + @Throws(MtprotoException::class) fun `logout`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_logout( @@ -7589,7 +7651,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { - @Throws(MtprotoException::class) fun `lottieFrameCount`(`handle`: kotlin.ULong): kotlin.UInt { + @Throws(MtprotoException::class) fun `lottieFrameCount`(`handle`: ULong): UInt { return FfiConverterUInt.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_count( @@ -7601,7 +7663,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `lottieFrameRate`(`handle`: kotlin.ULong): kotlin.Float { + @Throws(MtprotoException::class) fun `lottieFrameRate`(`handle`: ULong): Float { return FfiConverterFloat.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_rate( @@ -7613,7 +7675,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `lottieSize`(`handle`: kotlin.ULong): LottieSize { + @Throws(MtprotoException::class) fun `lottieSize`(`handle`: ULong): LottieSize { return FfiConverterTypeLottieSize.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_size( @@ -7628,7 +7690,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { /** * https://core.telegram.org/method/messages.markDialogUnread */ - @Throws(MtprotoException::class) fun `markDialogUnread`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `unread`: kotlin.Boolean) + @Throws(MtprotoException::class) fun `markDialogUnread`(`handle`: ULong, `chatId`: Long, `unread`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_mark_dialog_unread( @@ -7640,9 +7702,9 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { } - fun `peekMessageInlineThumb`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int): kotlin.ByteArray? { + fun `peekMessageInlineThumb`(`handle`: ULong, `chatId`: Long, `messageId`: Int): ByteArray? { return FfiConverterOptionalByteArray.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_message_inline_thumb( @@ -7653,9 +7715,9 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { ) } - fun `peekPeerInlineThumb`(`handle`: kotlin.ULong, `peerId`: kotlin.Long): kotlin.ByteArray? { + fun `peekPeerInlineThumb`(`handle`: ULong, `peerId`: Long): ByteArray? { return FfiConverterOptionalByteArray.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_peer_inline_thumb( @@ -7667,7 +7729,7 @@ fun `drainUpdates`(`handle`: kotlin.ULong): UpdateDrainDto { @Throws(MtprotoException::class) -fun `pendingUpdateBatches`(`handle`: kotlin.ULong): List { +fun `pendingUpdateBatches`(`handle`: ULong): List { return FfiConverterSequenceTypeUpdateBatchDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_pending_update_batches( @@ -7682,9 +7744,9 @@ fun `pendingUpdateBatches`(`handle`: kotlin.ULong): List { /** * 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( @@ -7695,9 +7757,9 @@ fun `pendingUpdateBatches`(`handle`: kotlin.ULong): List { /** * JSON timing snapshot; `reset` clears the window. - */ fun `perfSnapshot`(`reset`: kotlin.Boolean): kotlin.String { + */ fun `perfSnapshot`(`reset`: Boolean): String { return FfiConverterString.lift( - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_perf_snapshot( @@ -7707,7 +7769,7 @@ fun `pendingUpdateBatches`(`handle`: kotlin.ULong): List { } - @Throws(MtprotoException::class) fun `pingProxy`(`kind`: kotlin.String, `host`: kotlin.String, `port`: kotlin.UShort, `username`: kotlin.String?, `password`: kotlin.String?, `secret`: kotlin.ByteArray): kotlin.Long { + @Throws(MtprotoException::class) fun `pingProxy`(`kind`: String, `host`: String, `port`: UShort, `username`: String?, `password`: String?, `secret`: ByteArray): Long { return FfiConverterLong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_ping_proxy( @@ -7723,8 +7785,8 @@ fun `pendingUpdateBatches`(`handle`: kotlin.ULong): List { ) } -fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - uniffiRustCall() { _status -> +fun `promoteRequestControl`(`id`: ULong, `class`: Int) = + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_promote_request_control( @@ -7735,7 +7797,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `readDiscussion`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `msgId`: kotlin.Int, `readMaxId`: kotlin.Int) + @Throws(MtprotoException::class) fun `readDiscussion`(`handle`: ULong, `chatId`: Long, `msgId`: Int, `readMaxId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_discussion( @@ -7749,7 +7811,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `readHistory`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `maxId`: kotlin.Int) + @Throws(MtprotoException::class) fun `readHistory`(`handle`: ULong, `chatId`: Long, `maxId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_history( @@ -7765,7 +7827,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = /** * https://core.telegram.org/method/messages.readMentions */ - @Throws(MtprotoException::class) fun `readMentions`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `topMsgId`: kotlin.Int) + @Throws(MtprotoException::class) fun `readMentions`(`handle`: ULong, `chatId`: Long, `topMsgId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_mentions( @@ -7782,7 +7844,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = * https://core.telegram.org/method/messages.readMessageContents * https://core.telegram.org/method/channels.readMessageContents */ - @Throws(MtprotoException::class) fun `readMessageContents`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageIds`: List) + @Throws(MtprotoException::class) fun `readMessageContents`(`handle`: ULong, `chatId`: Long, `messageIds`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_message_contents( @@ -7798,7 +7860,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = /** * https://core.telegram.org/method/messages.readReactions */ - @Throws(MtprotoException::class) fun `readReactions`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `topMsgId`: kotlin.Int) + @Throws(MtprotoException::class) fun `readReactions`(`handle`: ULong, `chatId`: Long, `topMsgId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_reactions( @@ -7811,7 +7873,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `registerDevice`(`handle`: kotlin.ULong, `tokenType`: kotlin.Int, `token`: kotlin.String, `secret`: kotlin.ByteArray, `noMuted`: kotlin.Boolean, `appSandbox`: kotlin.Boolean, `otherUids`: List) + @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( @@ -7827,9 +7889,9 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - fun `releaseRequestControl`(`id`: kotlin.ULong) + fun `releaseRequestControl`(`id`: ULong) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_release_request_control( @@ -7838,7 +7900,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `renderLottieFrame`(`handle`: kotlin.ULong, `frame`: kotlin.Float, `width`: kotlin.UInt, `height`: kotlin.UInt): kotlin.ByteArray { + @Throws(MtprotoException::class) fun `renderLottieFrame`(`handle`: ULong, `frame`: Float, `width`: UInt, `height`: UInt): ByteArray { return FfiConverterByteArray.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_render_lottie_frame( @@ -7853,7 +7915,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `resendAuthCode`(`handle`: kotlin.ULong, `phone`: kotlin.String, `phoneCodeHash`: kotlin.String): AuthCodeSent { + @Throws(MtprotoException::class) fun `resendAuthCode`(`handle`: ULong, `phone`: String, `phoneCodeHash`: String): AuthCodeSent { return FfiConverterTypeAuthCodeSent.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_resend_auth_code( @@ -7867,7 +7929,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `resetNotifySettings`(`handle`: kotlin.ULong) + @Throws(MtprotoException::class) fun `resetNotifySettings`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_reset_notify_settings( @@ -7878,7 +7940,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `resolveUsername`(`handle`: kotlin.ULong, `username`: kotlin.String): ResolvedPeerDto { + @Throws(MtprotoException::class) fun `resolveUsername`(`handle`: ULong, `username`: String): ResolvedPeerDto { return FfiConverterTypeResolvedPeerDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_resolve_username( @@ -7891,7 +7953,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `searchGlobal`(`handle`: kotlin.ULong, `query`: kotlin.String, `offsetRate`: kotlin.Int, `offsetPeerId`: kotlin.Long, `offsetId`: kotlin.Int, `limit`: kotlin.Int, `folderId`: kotlin.Int): GlobalMessageSearchDto { + @Throws(MtprotoException::class) fun `searchGlobal`(`handle`: ULong, `query`: String, `offsetRate`: Int, `offsetPeerId`: Long, `offsetId`: Int, `limit`: Int, `folderId`: Int): GlobalMessageSearchDto { return FfiConverterTypeGlobalMessageSearchDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_global( @@ -7909,7 +7971,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `searchMessages`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `query`: kotlin.String, `limit`: kotlin.Int): List { + @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( @@ -7924,7 +7986,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `searchMessagesFiltered`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `query`: kotlin.String, `filter`: kotlin.String, `offsetId`: kotlin.Int, `addOffset`: kotlin.Int, `limit`: kotlin.Int): List { + @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( @@ -7942,7 +8004,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `searchMessagesPage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `query`: kotlin.String, `filter`: kotlin.String, `offsetId`: kotlin.Int, `addOffset`: kotlin.Int, `limit`: kotlin.Int): MessagesPageDto { + @Throws(MtprotoException::class) fun `searchMessagesPage`(`handle`: ULong, `chatId`: Long, `query`: String, `filter`: String, `offsetId`: Int, `addOffset`: Int, `limit`: Int): MessagesPageDto { return FfiConverterTypeMessagesPageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages_page( @@ -7960,7 +8022,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `sendAuthCode`(`handle`: kotlin.ULong, `phone`: kotlin.String): AuthCodeSent { + @Throws(MtprotoException::class) fun `sendAuthCode`(`handle`: ULong, `phone`: String): AuthCodeSent { return FfiConverterTypeAuthCodeSent.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_auth_code( @@ -7973,7 +8035,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `sendInlineBotResult`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `queryId`: kotlin.Long, `resultId`: kotlin.String, `replyToMsgId`: kotlin.Int, `topMsgId`: kotlin.Int): MessageDto { + @Throws(MtprotoException::class) fun `sendInlineBotResult`(`handle`: ULong, `chatId`: Long, `queryId`: Long, `resultId`: String, `replyToMsgId`: Int, `topMsgId`: Int): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_inline_bot_result( @@ -7990,7 +8052,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `sendLocation`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `latitude`: kotlin.Double, `longitude`: kotlin.Double, `livePeriod`: kotlin.Int, `heading`: kotlin.Int, `replyToMsgId`: kotlin.Int) + @Throws(MtprotoException::class) fun `sendLocation`(`handle`: ULong, `chatId`: Long, `latitude`: Double, `longitude`: Double, `livePeriod`: Int, `heading`: Int, `replyToMsgId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_location( @@ -8007,7 +8069,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `sendPhotoMessage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `path`: kotlin.String, `caption`: kotlin.String, `replyToMsgId`: kotlin.Int, `topMsgId`: kotlin.Int, `entitiesJson`: kotlin.String?): MessageDto { + @Throws(MtprotoException::class) fun `sendPhotoMessage`(`handle`: ULong, `chatId`: Long, `path`: String, `caption`: String, `replyToMsgId`: Int, `topMsgId`: Int, `entitiesJson`: String?): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_photo_message( @@ -8025,7 +8087,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `sendPollVote`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `options`: List) + @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( @@ -8039,7 +8101,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `sendReaction`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `emoticon`: kotlin.String, `documentId`: kotlin.Long) + @Throws(MtprotoException::class) fun `sendReaction`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `emoticon`: String, `documentId`: Long) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_reaction( @@ -8054,7 +8116,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `sendSavedGif`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `documentId`: kotlin.Long, `replyToMsgId`: kotlin.Int, `topMsgId`: kotlin.Int): MessageDto { + @Throws(MtprotoException::class) fun `sendSavedGif`(`handle`: ULong, `chatId`: Long, `documentId`: Long, `replyToMsgId`: Int, `topMsgId`: Int): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_saved_gif( @@ -8070,7 +8132,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `sendTextMessage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `text`: kotlin.String, `replyToMsgId`: kotlin.Int, `entitiesJson`: kotlin.String?, `topMsgId`: kotlin.Int, `webpageUrl`: kotlin.String?, `clientRandomId`: kotlin.Long): MessageDto { + @Throws(MtprotoException::class) fun `sendTextMessage`(`handle`: ULong, `chatId`: Long, `text`: String, `replyToMsgId`: Int, `entitiesJson`: String?, `topMsgId`: Int, `webpageUrl`: String?, `clientRandomId`: Long): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_text_message( @@ -8089,7 +8151,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `sendUploadedAlbum`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `items`: List, `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( @@ -8105,7 +8167,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `sendUploadedMedia`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `item`: UploadItemDto, `replyToMsgId`: kotlin.Int, `topMsgId`: kotlin.Int, `entitiesJson`: kotlin.String?): MessageDto { + @Throws(MtprotoException::class) fun `sendUploadedMedia`(`handle`: ULong, `chatId`: Long, `item`: UploadItemDto, `replyToMsgId`: Int, `topMsgId`: Int, `entitiesJson`: String?): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_uploaded_media( @@ -8122,7 +8184,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `setClientTestDc`(`handle`: kotlin.ULong, `test`: kotlin.Boolean) + @Throws(MtprotoException::class) fun `setClientTestDc`(`handle`: ULong, `test`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_client_test_dc( @@ -8134,7 +8196,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `setContactJoinedSilent`(`handle`: kotlin.ULong, `silent`: kotlin.Boolean) + @Throws(MtprotoException::class) fun `setContactJoinedSilent`(`handle`: ULong, `silent`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_contact_joined_silent( @@ -8149,9 +8211,9 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = /** * 0 interactive read, 1 background read, 2 interactive media, 3 background media, * 4 interactive write, 5 ordinary media, 6 visible media, 7 user media. - */ fun `setDispatchClass`(`class`: kotlin.Int) + */ fun `setDispatchClass`(`class`: Int) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_dispatch_class( @@ -8159,9 +8221,9 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - fun `setDownloadChunkKib`(`kib`: kotlin.Int) + fun `setDownloadChunkKib`(`kib`: Int) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_chunk_kib( @@ -8172,9 +8234,9 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = /** * "Faster downloads" setting: media lanes (1..=8) and parts in flight (1..=16). - */ fun `setDownloadConcurrency`(`lanes`: kotlin.Int, `parts`: kotlin.Int) + */ fun `setDownloadConcurrency`(`lanes`: Int, `parts`: Int) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_concurrency( @@ -8185,7 +8247,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = fun `setDownloadProgressListener`(`listener`: DownloadProgressListener) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_progress_listener( @@ -8193,9 +8255,9 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - fun `setFilePartKib`(`kib`: kotlin.Int) + fun `setFilePartKib`(`kib`: Int) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_file_part_kib( @@ -8205,7 +8267,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = fun `setInitConnectionInfo`(`info`: InitConnectionInfo) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_init_connection_info( @@ -8214,7 +8276,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `setProxy`(`kind`: kotlin.String, `host`: kotlin.String, `port`: kotlin.UShort, `username`: kotlin.String?, `password`: kotlin.String?, `secret`: kotlin.ByteArray) + @Throws(MtprotoException::class) fun `setProxy`(`kind`: String, `host`: String, `port`: UShort, `username`: String?, `password`: String?, `secret`: ByteArray) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_proxy( @@ -8230,7 +8292,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `setTransportMode`(`mode`: kotlin.String) + @Throws(MtprotoException::class) fun `setTransportMode`(`mode`: String) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_transport_mode( @@ -8241,7 +8303,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `setTyping`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `typing`: kotlin.Boolean) + @Throws(MtprotoException::class) fun `setTyping`(`handle`: ULong, `chatId`: Long, `typing`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_typing( @@ -8254,7 +8316,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `signIn`(`handle`: kotlin.ULong, `phone`: kotlin.String, `phoneCodeHash`: kotlin.String, `phoneCode`: kotlin.String): AuthSignedIn { + @Throws(MtprotoException::class) fun `signIn`(`handle`: ULong, `phone`: String, `phoneCodeHash`: String, `phoneCode`: String): AuthSignedIn { return FfiConverterTypeAuthSignedIn.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_sign_in( @@ -8269,7 +8331,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - @Throws(MtprotoException::class) fun `startUpdates`(`handle`: kotlin.ULong) + @Throws(MtprotoException::class) fun `startUpdates`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_start_updates( @@ -8280,7 +8342,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `toggleTodoCompleted`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int, `completed`: List, `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( @@ -8295,7 +8357,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `unblockUser`(`handle`: kotlin.ULong, `chatId`: kotlin.Long) + @Throws(MtprotoException::class) fun `unblockUser`(`handle`: ULong, `chatId`: Long) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_unblock_user( @@ -8307,7 +8369,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `unregisterDevice`(`handle`: kotlin.ULong, `tokenType`: kotlin.Int, `token`: kotlin.String, `otherUids`: List) + @Throws(MtprotoException::class) fun `unregisterDevice`(`handle`: ULong, `tokenType`: Int, `token`: String, `otherUids`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_unregister_device( @@ -8321,7 +8383,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `updateFolder`(`handle`: kotlin.ULong, `folder`: FolderDto) + @Throws(MtprotoException::class) fun `updateFolder`(`handle`: ULong, `folder`: FolderDto) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder( @@ -8333,7 +8395,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `updateFolderOrder`(`handle`: kotlin.ULong, `order`: List) + @Throws(MtprotoException::class) fun `updateFolderOrder`(`handle`: ULong, `order`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder_order( @@ -8344,9 +8406,9 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = } - fun `updateLazySyncConfig`(`handle`: kotlin.ULong, `lazy`: kotlin.Boolean, `exceptions`: List) + fun `updateLazySyncConfig`(`handle`: ULong, `lazy`: Boolean, `exceptions`: List) = - uniffiRustCall() { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_lazy_sync_config( @@ -8357,7 +8419,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `updateNotifySettings`(`handle`: kotlin.ULong, `peerKind`: kotlin.String, `chatId`: kotlin.Long, `showPreviews`: kotlin.Boolean?, `silent`: kotlin.Boolean, `muteUntil`: kotlin.Int, `storiesMuted`: kotlin.Boolean, `sound`: kotlin.String) + @Throws(MtprotoException::class) fun `updateNotifySettings`(`handle`: ULong, `peerKind`: String, `chatId`: Long, `showPreviews`: Boolean?, `silent`: Boolean, `muteUntil`: Int, `storiesMuted`: Boolean, `sound`: String) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_notify_settings( @@ -8375,7 +8437,7 @@ fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = - @Throws(MtprotoException::class) fun `updateStatus`(`handle`: kotlin.ULong, `offline`: kotlin.Boolean) + @Throws(MtprotoException::class) fun `updateStatus`(`handle`: ULong, `offline`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_status( 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 b92b57fd..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 @@ -1049,6 +1049,30 @@ class BridgedMtprotoClientLifecycleTest { } } + @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() 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 760f703b..ce6d8a03 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 @@ -30,10 +30,13 @@ data class StorageAccounting( 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) { @@ -154,9 +157,7 @@ class FileCache( if (bytes < 0 || root.usableSpace - bytes < FREE_SPACE_RESERVE) { throw AdmissionException(AdmissionFailure.Storage) } - if (optional && (bytes > maxBytes || - protectedBytesExcept(key) + inFlightBytesExcept(key) > maxBytes - bytes) - ) { + if (optional && exceedsCapacity(key, bytes)) { throw AdmissionException(AdmissionFailure.Capacity) } reservations[name] = (reservations[name] ?: 0L) + bytes @@ -315,9 +316,7 @@ class FileCache( try { putFileInternal(key, source, chatId, kind, temporary = true) checkNotNull(acquire(key)).also { - if (source.length() + protectedBytesExcept(key) > maxBytes) pendingDeletes.add( - key - ) + if (exceedsCapacity(key, source.length())) pendingDeletes.add(key) } } finally { protectedKeys.remove(owner) @@ -385,6 +384,12 @@ class FileCache( 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) } @@ -400,9 +405,7 @@ class FileCache( if ((leases[diskName(key)] ?: 0) > 0 || pendingDeletion(key)) { throw AdmissionException(AdmissionFailure.InUse) } - if (!temporary && (bytes > maxBytes || - protectedBytesExcept(key) + inFlightBytesExcept(key) > maxBytes - bytes) - ) { + if (!temporary && exceedsCapacity(key, bytes)) { throw AdmissionException(AdmissionFailure.Capacity) } if (bytes < 0 || root.usableSpace - additionalBytes < FREE_SPACE_RESERVE) { @@ -504,9 +507,21 @@ class FileCache( 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() if (!reconciled) reconcileLocked() + if (!enforceSize) return@synchronized var total = accounting().totalBytes if (total <= maxBytes) return@synchronized val protectedSet = protectedNames() 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 24f5bfb0..0977ce6b 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 @@ -9,7 +9,6 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.withContext import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -30,7 +29,9 @@ 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 @@ -117,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 @@ -130,16 +132,28 @@ class MediaRepository( @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, maintainOnInit = false) + 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() @@ -290,6 +304,13 @@ 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() 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 69e4fcc0..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 @@ -262,6 +267,69 @@ class FileCacheTest { 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")) From d6d3ba2f3a9d8051b367277b323cc85d1bce585f Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:23:11 +0300 Subject: [PATCH 5/7] pause media during scroll and cut cache overhead --- .../java/org/monogram/root/RootComponent.kt | 4 +- .../core/ui/components/LoopingVideo.kt | 17 +- .../monogram/core/ui/components/LottieLoop.kt | 4 + .../core/ui/components/MediaAnimation.kt | 11 + .../feature/chats/ui/ChatsLazyList.kt | 14 + .../feature/dialog/PickerMediaPreloader.kt | 9 +- .../feature/dialog/ui/ChatInlineVideo.kt | 4 +- .../monogram/feature/dialog/ui/CustomEmoji.kt | 3 +- .../feature/dialog/ui/DialogHistoryPane.kt | 11 + .../feature/dialog/ui/StickerPlayer.kt | 5 + .../monogram/feature/dialog/ui/VideoPlayer.kt | 13 +- .../feature/settings/SettingsStore.kt | 20 +- .../feature/settings/ui/SettingsData.kt | 479 +++--- .../feature/settings/ui/StorageRing.kt | 20 +- .../monogram_mtproto/monogram_mtproto.kt | 1418 ++++++++++++----- .../org/monogram/network/http/FileCache.kt | 38 +- .../monogram/network/http/MediaRepository.kt | 2 + 17 files changed, 1445 insertions(+), 627 deletions(-) diff --git a/app/src/main/java/org/monogram/root/RootComponent.kt b/app/src/main/java/org/monogram/root/RootComponent.kt index 75a45bc9..e6be31e7 100644 --- a/app/src/main/java/org/monogram/root/RootComponent.kt +++ b/app/src/main/java/org/monogram/root/RootComponent.kt @@ -83,9 +83,7 @@ class RootComponent( getSystem().configuration.smallestScreenWidthDp >= 600 }, underPressure = { - val accounting = mediaRepository?.storageAccounting() - val storagePressure = accounting != null && - accounting.partialBytes + accounting.reservedBytes > 32L * 1024 * 1024 + 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 || 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/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/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/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/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/VideoPlayer.kt b/feature/dialog/src/main/java/org/monogram/feature/dialog/ui/VideoPlayer.kt index 2d71d194..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 @@ -309,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/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsStore.kt index f95e15c1..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 @@ -154,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/SettingsData.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsData.kt index faaea814..e63df58f 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,9 +37,12 @@ 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 @@ -43,6 +56,7 @@ 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 @@ -186,6 +200,151 @@ internal fun LazyListScope.dataItems( 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 { @@ -311,132 +470,7 @@ 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 } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.extraLarge) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - 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, - ) - } - } - } - 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 { - 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)), - subtitle = formatBytes(bytes), - iconColor = storageCategoryColor(kind), - titleColor = if (bytes > 0L) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } - 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 && selectableCacheKinds(cacheByKind).isNotEmpty(), - onClick = onClear, - trailingContent = { - MonogramLoading( - visible = loading, - size = MonogramLoadingInlineSize, - color = MaterialTheme.colorScheme.error, - ) - }, - ) - } - 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 { - 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) }, - ) - } - } - } + } internal fun storageKindLabel(kind: String): Int = when (kind) { @@ -464,22 +498,39 @@ internal fun CacheLimitPickerDialog( onSelect: (MediaCacheLimit) -> Unit, onDismiss: () -> Unit, ) { - AppModalSheet(onDismissRequest = onDismiss) { + AppModalSheet(onDismissRequest = onDismiss, maxWidth = 560.dp) { Text( text = stringResource(R.string.settings_cache_limit), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineSmallEmphasized, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) - MediaCacheLimit.entries.forEach { limit -> - SettingsCardRow( - icon = Icons.Outlined.Storage, - title = cacheLimitLabel(limit), - iconColor = MaterialTheme.colorScheme.primary, - selected = limit == current, - onClick = { onSelect(limit) }, - ) + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .selectableGroup() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + MediaCacheLimit.entries.forEach { limit -> + 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) + }, + ) + } + } } - Spacer(Modifier.height(12.dp)) } } @@ -491,62 +542,87 @@ internal fun CacheClearDialog( onDismiss: () -> Unit, onConfirm: () -> Unit, ) { - AppModalSheet(onDismissRequest = onDismiss) { - Text( - text = stringResource(R.string.settings_clear_cache), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) - selectableCacheKinds(byKind).forEach { kind -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onToggle(kind) } - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = kind in selected, - onCheckedChange = { onToggle(kind) }, - ) - Text( - text = stringResource(storageKindLabel(kind)), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = formatBytes(byKind[kind] ?: 0L), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 12.dp), - ) - } + val selectedBytes = selectedCacheBytes(byKind, selected) + AppModalSheet(onDismissRequest = onDismiss, maxWidth = 560.dp) { + Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { + Text( + text = stringResource(R.string.settings_clear_cache), + style = MaterialTheme.typography.headlineSmallEmphasized, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = formatBytes(selectedBytes), + style = MaterialTheme.typography.headlineLargeEmphasized, + color = MaterialTheme.colorScheme.primary, + ) } - Text( - text = stringResource( - R.string.settings_clear_cache_selected, - formatBytes(selectedCacheBytes(byKind, selected)), - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) - TextButton( - onClick = onConfirm, - enabled = selected.isNotEmpty(), + Column( modifier = Modifier - .padding(horizontal = 12.dp) - .padding(bottom = 8.dp), + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), ) { + val kinds = selectableCacheKinds(byKind) + kinds.forEachIndexed { index, kind -> + val checked = kind in selected + SettingsCard( + selected = checked, + position = when { + kinds.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == kinds.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + }, + ) { + 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( - stringResource(R.string.settings_clear_cache_action), - color = if (selected.isNotEmpty()) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + 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)) + } + } } } @@ -556,10 +632,10 @@ internal fun CacheChatClearSheet( onDismiss: () -> Unit, onConfirm: () -> Unit, ) { - AppModalSheet(onDismissRequest = onDismiss) { + AppModalSheet(onDismissRequest = onDismiss, maxWidth = 560.dp) { Text( text = stringResource(R.string.settings_storage_clear_chat), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.headlineSmallEmphasized, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) Text( @@ -568,16 +644,25 @@ internal fun CacheChatClearSheet( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) - TextButton( - onClick = onConfirm, + Row( modifier = Modifier - .padding(horizontal = 12.dp) - .padding(bottom = 8.dp), + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - stringResource(R.string.settings_clear_cache_action), - color = MaterialTheme.colorScheme.error, - ) + 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/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt index dd317ae9..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 @@ -205,7 +205,7 @@ interface FfiConverter { * * @suppress */ -interface FfiConverterRustBuffer: FfiConverter { +interface FfiConverterRustBuffer : FfiConverter { override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) } @@ -261,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) @@ -269,7 +272,10 @@ private inline fun uniffiRustCallWithError(errorHandler: Uniff } // 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()) { @@ -312,7 +318,7 @@ internal inline fun uniffiTraitInterfaceCall( ) { try { writeReturn(makeCall()) - } catch(e: Exception) { + } catch (e: Exception) { val err = try { e.stackTraceToString() } catch(_: Throwable) { "" } callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR callStatus.error_buf = FfiConverterString.lower(err) @@ -327,7 +333,7 @@ internal inline fun uniffiTraitInterfaceCallWithError( ) { try { writeReturn(makeCall()) - } catch(e: Exception) { + } catch (e: Exception) { if (e is E) { callStatus.code = UNIFFI_CALL_ERROR callStatus.error_buf = lowerError(e) @@ -411,7 +417,7 @@ internal open class UniffiForeignFutureDroppedCallbackStruct( class UniffiByValue( `handle`: Long = 0.toLong(), `free`: UniffiForeignFutureDroppedCallback? = null, - ): UniffiForeignFutureDroppedCallbackStruct(`handle`,`free`,), ByValue + ) : UniffiForeignFutureDroppedCallbackStruct(`handle`, `free`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureDroppedCallbackStruct) { `handle` = other.`handle` @@ -427,7 +433,7 @@ internal open class UniffiForeignFutureResultU8( class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU8(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultU8(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU8) { `returnValue` = other.`returnValue` @@ -446,7 +452,7 @@ internal open class UniffiForeignFutureResultI8( class UniffiByValue( `returnValue`: Byte = 0.toByte(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI8(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultI8(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI8) { `returnValue` = other.`returnValue` @@ -465,7 +471,7 @@ internal open class UniffiForeignFutureResultU16( class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU16(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultU16(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU16) { `returnValue` = other.`returnValue` @@ -484,7 +490,7 @@ internal open class UniffiForeignFutureResultI16( class UniffiByValue( `returnValue`: Short = 0.toShort(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI16(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultI16(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI16) { `returnValue` = other.`returnValue` @@ -503,7 +509,7 @@ internal open class UniffiForeignFutureResultU32( class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU32(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultU32(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU32) { `returnValue` = other.`returnValue` @@ -522,7 +528,7 @@ internal open class UniffiForeignFutureResultI32( class UniffiByValue( `returnValue`: Int = 0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI32(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultI32(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI32) { `returnValue` = other.`returnValue` @@ -541,7 +547,7 @@ internal open class UniffiForeignFutureResultU64( class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultU64(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultU64(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultU64) { `returnValue` = other.`returnValue` @@ -560,7 +566,7 @@ internal open class UniffiForeignFutureResultI64( class UniffiByValue( `returnValue`: Long = 0.toLong(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultI64(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultI64(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultI64) { `returnValue` = other.`returnValue` @@ -579,7 +585,7 @@ internal open class UniffiForeignFutureResultF32( class UniffiByValue( `returnValue`: Float = 0.0f, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultF32(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultF32(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultF32) { `returnValue` = other.`returnValue` @@ -598,7 +604,7 @@ internal open class UniffiForeignFutureResultF64( class UniffiByValue( `returnValue`: Double = 0.0, `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultF64(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultF64(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultF64) { `returnValue` = other.`returnValue` @@ -617,7 +623,7 @@ internal open class UniffiForeignFutureResultRustBuffer( class UniffiByValue( `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultRustBuffer(`returnValue`,`callStatus`,), ByValue + ) : UniffiForeignFutureResultRustBuffer(`returnValue`, `callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultRustBuffer) { `returnValue` = other.`returnValue` @@ -634,7 +640,7 @@ internal open class UniffiForeignFutureResultVoid( ) : Structure() { class UniffiByValue( `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), - ): UniffiForeignFutureResultVoid(`callStatus`,), ByValue + ) : UniffiForeignFutureResultVoid(`callStatus`), ByValue internal fun uniffiSetValue(other: UniffiForeignFutureResultVoid) { `callStatus` = other.`callStatus` @@ -657,7 +663,11 @@ internal open class UniffiVTableCallbackInterfaceDownloadProgressListener( `uniffiFree`: UniffiCallbackInterfaceFree? = null, `uniffiClone`: UniffiCallbackInterfaceClone? = null, `onProgress`: UniffiCallbackInterfaceDownloadProgressListenerMethod0? = null, - ): UniffiVTableCallbackInterfaceDownloadProgressListener(`uniffiFree`,`uniffiClone`,`onProgress`,), ByValue + ) : UniffiVTableCallbackInterfaceDownloadProgressListener( + `uniffiFree`, + `uniffiClone`, + `onProgress`, + ), ByValue internal fun uniffiSetValue(other: UniffiVTableCallbackInterfaceDownloadProgressListener) { `uniffiFree` = other.`uniffiFree` @@ -987,7 +997,8 @@ internal object UniffiLib { uniffiCallbackInterfaceDownloadProgressListener.register(this) } - external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener(`vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, + external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener( + `vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, ) external fun uniffi_monogram_mtproto_fn_func_acknowledge_update_batch( @@ -997,25 +1008,40 @@ internal object UniffiLib { 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_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, + 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, + 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, + 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_clear_proxy( + uniffi_out_err: UniffiRustCallStatus, ) external fun uniffi_monogram_mtproto_fn_func_client_api_id(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1024,7 +1050,8 @@ internal object UniffiLib { ): 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, + 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, @@ -1049,22 +1076,32 @@ 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, + 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_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_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_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_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_destroy_waveform( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, ) external fun uniffi_monogram_mtproto_fn_func_download_chunk_kib(uniffi_out_err: UniffiRustCallStatus, @@ -1085,7 +1122,12 @@ 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, + 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, @@ -1174,7 +1216,8 @@ 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, + 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, @@ -1183,7 +1226,8 @@ internal object UniffiLib { ): 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, + 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, @@ -1193,7 +1237,8 @@ internal object UniffiLib { 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_set_enabled( + `enabled`: Byte, uniffi_out_err: UniffiRustCallStatus, ) external fun uniffi_monogram_mtproto_fn_func_perf_snapshot(`reset`: Byte,uniffi_out_err: UniffiRustCallStatus, @@ -1204,32 +1249,54 @@ internal object UniffiLib { `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_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_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_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_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_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_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_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, + 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, @@ -1246,15 +1313,34 @@ 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, + 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, + 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_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, @@ -1265,208 +1351,292 @@ 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, + 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_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_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_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_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_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_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_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_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_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_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, + 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_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_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_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( + `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_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_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_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 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, + 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, + 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_cancel_u8( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_i8( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_u16( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_i16( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_u32( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_i32( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_u64( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_i64( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_f32( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_f64( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_rust_buffer( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + 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_cancel_void( + `handle`: Long, ) - external fun ffi_monogram_mtproto_rust_future_free_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, + external fun ffi_monogram_mtproto_rust_future_complete_void( + `handle`: Long, uniffi_out_err: UniffiRustCallStatus, ) @@ -2011,7 +2181,8 @@ internal const val UNIFFI_CALLBACK_UNEXPECTED_ERROR = 2 /** * @suppress */ -abstract class FfiConverterCallbackInterface: FfiConverter { +abstract class FfiConverterCallbackInterface : + FfiConverter { internal val handleMap = UniffiHandleMap() internal fun drop(handle: Long) { @@ -2036,7 +2207,7 @@ abstract class FfiConverterCallbackInterface: FfiConvert /** * @suppress */ -object FfiConverterUShort: FfiConverter { +object FfiConverterUShort : FfiConverter { override fun lift(value: Short): UShort { return value.toUShort() } @@ -2063,7 +2234,7 @@ object FfiConverterUShort: FfiConverter { /** * @suppress */ -object FfiConverterShort: FfiConverter { +object FfiConverterShort : FfiConverter { override fun lift(value: Short): Short { return value } @@ -2086,7 +2257,7 @@ object FfiConverterShort: FfiConverter { /** * @suppress */ -object FfiConverterUInt: FfiConverter { +object FfiConverterUInt : FfiConverter { override fun lift(value: Int): UInt { return value.toUInt() } @@ -2109,7 +2280,7 @@ object FfiConverterUInt: FfiConverter { /** * @suppress */ -object FfiConverterInt: FfiConverter { +object FfiConverterInt : FfiConverter { override fun lift(value: Int): Int { return value } @@ -2132,7 +2303,7 @@ object FfiConverterInt: FfiConverter { /** * @suppress */ -object FfiConverterULong: FfiConverter { +object FfiConverterULong : FfiConverter { override fun lift(value: Long): ULong { return value.toULong() } @@ -2155,7 +2326,7 @@ object FfiConverterULong: FfiConverter { /** * @suppress */ -object FfiConverterLong: FfiConverter { +object FfiConverterLong : FfiConverter { override fun lift(value: Long): Long { return value } @@ -2178,7 +2349,7 @@ object FfiConverterLong: FfiConverter { /** * @suppress */ -object FfiConverterFloat: FfiConverter { +object FfiConverterFloat : FfiConverter { override fun lift(value: Float): Float { return value } @@ -2201,7 +2372,7 @@ object FfiConverterFloat: FfiConverter { /** * @suppress */ -object FfiConverterDouble: FfiConverter { +object FfiConverterDouble : FfiConverter { override fun lift(value: Double): Double { return value } @@ -2224,7 +2395,7 @@ object FfiConverterDouble: FfiConverter { /** * @suppress */ -object FfiConverterBoolean: FfiConverter { +object FfiConverterBoolean : FfiConverter { override fun lift(value: Byte): Boolean { return value.toInt() != 0 } @@ -2247,7 +2418,7 @@ object FfiConverterBoolean: FfiConverter { /** * @suppress */ -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. @@ -2304,7 +2475,7 @@ object FfiConverterString: FfiConverter { /** * @suppress */ -object FfiConverterByteArray: FfiConverterRustBuffer { +object FfiConverterByteArray : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ByteArray { val len = buf.getInt() val byteArr = ByteArray(len) @@ -2343,7 +2514,7 @@ data class AuthCodeSent ( /** * @suppress */ -object FfiConverterTypeAuthCodeSent: FfiConverterRustBuffer { +object FfiConverterTypeAuthCodeSent : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthCodeSent { return AuthCodeSent( FfiConverterString.read(buf), @@ -2387,7 +2558,7 @@ data class AuthSignedIn ( /** * @suppress */ -object FfiConverterTypeAuthSignedIn: FfiConverterRustBuffer { +object FfiConverterTypeAuthSignedIn : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AuthSignedIn { return AuthSignedIn( FfiConverterLong.read(buf), @@ -2429,7 +2600,7 @@ data class BotCallbackAnswerDto ( /** * @suppress */ -object FfiConverterTypeBotCallbackAnswerDto: FfiConverterRustBuffer { +object FfiConverterTypeBotCallbackAnswerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): BotCallbackAnswerDto { return BotCallbackAnswerDto( FfiConverterBoolean.read(buf), @@ -2462,6 +2633,9 @@ data class ChannelCursorDto( ) { + + + companion object } @@ -2594,7 +2768,7 @@ data class ChatDto ( /** * @suppress */ -object FfiConverterTypeChatDto: FfiConverterRustBuffer { +object FfiConverterTypeChatDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChatDto { return ChatDto( FfiConverterLong.read(buf), @@ -2728,7 +2902,7 @@ data class ContactsSearchDto ( /** * @suppress */ -object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeContactsSearchDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ContactsSearchDto { return ContactsSearchDto( FfiConverterSequenceTypeSearchPeerDto.read(buf), @@ -2766,7 +2940,7 @@ data class DiscussionDto ( /** * @suppress */ -object FfiConverterTypeDiscussionDto: FfiConverterRustBuffer { +object FfiConverterTypeDiscussionDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): DiscussionDto { return DiscussionDto( FfiConverterLong.read(buf), @@ -2831,7 +3005,7 @@ data class FolderDto ( /** * @suppress */ -object FfiConverterTypeFolderDto: FfiConverterRustBuffer { +object FfiConverterTypeFolderDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): FolderDto { return FolderDto( FfiConverterInt.read(buf), @@ -2933,7 +3107,7 @@ data class ForumTopicDto ( /** * @suppress */ -object FfiConverterTypeForumTopicDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicDto { return ForumTopicDto( FfiConverterInt.read(buf), @@ -3013,7 +3187,7 @@ data class ForumTopicsPageDto ( /** * @suppress */ -object FfiConverterTypeForumTopicsPageDto: FfiConverterRustBuffer { +object FfiConverterTypeForumTopicsPageDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ForumTopicsPageDto { return ForumTopicsPageDto( FfiConverterInt.read(buf), @@ -3055,7 +3229,8 @@ data class GlobalMessageSearchDto ( /** * @suppress */ -object FfiConverterTypeGlobalMessageSearchDto: FfiConverterRustBuffer { +object FfiConverterTypeGlobalMessageSearchDto : + FfiConverterRustBuffer { override fun read(buf: ByteBuffer): GlobalMessageSearchDto { return GlobalMessageSearchDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -3111,7 +3286,7 @@ data class InitConnectionInfo ( /** * @suppress */ -object FfiConverterTypeInitConnectionInfo: FfiConverterRustBuffer { +object FfiConverterTypeInitConnectionInfo : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InitConnectionInfo { return InitConnectionInfo( FfiConverterString.read(buf), @@ -3171,7 +3346,7 @@ data class InlineBotResultDto ( /** * @suppress */ -object FfiConverterTypeInlineBotResultDto: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultDto { return InlineBotResultDto( FfiConverterString.read(buf), @@ -3230,7 +3405,7 @@ data class InlineBotResultsDto ( /** * @suppress */ -object FfiConverterTypeInlineBotResultsDto: FfiConverterRustBuffer { +object FfiConverterTypeInlineBotResultsDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InlineBotResultsDto { return InlineBotResultsDto( FfiConverterLong.read(buf), @@ -3299,7 +3474,7 @@ data class InstantViewDto ( /** * @suppress */ -object FfiConverterTypeInstantViewDto: FfiConverterRustBuffer { +object FfiConverterTypeInstantViewDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): InstantViewDto { return InstantViewDto( FfiConverterString.read(buf), @@ -3372,7 +3547,7 @@ data class JoinChatDto ( /** * @suppress */ -object FfiConverterTypeJoinChatDto: FfiConverterRustBuffer { +object FfiConverterTypeJoinChatDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): JoinChatDto { return JoinChatDto( FfiConverterBoolean.read(buf), @@ -3413,7 +3588,7 @@ data class LottieSize ( /** * @suppress */ -object FfiConverterTypeLottieSize: FfiConverterRustBuffer { +object FfiConverterTypeLottieSize : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): LottieSize { return LottieSize( FfiConverterUInt.read(buf), @@ -3519,7 +3694,7 @@ data class MessageDto ( /** * @suppress */ -object FfiConverterTypeMessageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessageDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessageDto { return MessageDto( FfiConverterLong.read(buf), @@ -3650,7 +3825,7 @@ data class MessagesPageDto ( /** * @suppress */ -object FfiConverterTypeMessagesPageDto: FfiConverterRustBuffer { +object FfiConverterTypeMessagesPageDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): MessagesPageDto { return MessagesPageDto( FfiConverterSequenceTypeMessageDto.read(buf), @@ -3702,7 +3877,7 @@ data class NotifyExceptionDto ( /** * @suppress */ -object FfiConverterTypeNotifyExceptionDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifyExceptionDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifyExceptionDto { return NotifyExceptionDto( FfiConverterString.read(buf), @@ -3769,7 +3944,7 @@ data class NotifySettingsDto ( /** * @suppress */ -object FfiConverterTypeNotifySettingsDto: FfiConverterRustBuffer { +object FfiConverterTypeNotifySettingsDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): NotifySettingsDto { return NotifySettingsDto( FfiConverterOptionalBoolean.read(buf), @@ -3819,7 +3994,7 @@ data class OutboxReadDto ( /** * @suppress */ -object FfiConverterTypeOutboxReadDto: FfiConverterRustBuffer { +object FfiConverterTypeOutboxReadDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): OutboxReadDto { return OutboxReadDto( FfiConverterInt.read(buf), @@ -3861,7 +4036,7 @@ data class PollVoterDto ( /** * @suppress */ -object FfiConverterTypePollVoterDto: FfiConverterRustBuffer { +object FfiConverterTypePollVoterDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVoterDto { return PollVoterDto( FfiConverterLong.read(buf), @@ -3905,7 +4080,7 @@ data class PollVotersDto ( /** * @suppress */ -object FfiConverterTypePollVotersDto: FfiConverterRustBuffer { +object FfiConverterTypePollVotersDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): PollVotersDto { return PollVotersDto( FfiConverterSequenceTypePollVoterDto.read(buf), @@ -3962,7 +4137,7 @@ data class ProfileDto ( /** * @suppress */ -object FfiConverterTypeProfileDto: FfiConverterRustBuffer { +object FfiConverterTypeProfileDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ProfileDto { return ProfileDto( FfiConverterLong.read(buf), @@ -4024,7 +4199,7 @@ data class ReactionChoiceDto ( /** * @suppress */ -object FfiConverterTypeReactionChoiceDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionChoiceDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionChoiceDto { return ReactionChoiceDto( FfiConverterString.read(buf), @@ -4068,7 +4243,7 @@ data class ReactionPeerDto ( /** * @suppress */ -object FfiConverterTypeReactionPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeerDto { return ReactionPeerDto( FfiConverterLong.read(buf), @@ -4115,7 +4290,7 @@ data class ReactionPeersDto ( /** * @suppress */ -object FfiConverterTypeReactionPeersDto: FfiConverterRustBuffer { +object FfiConverterTypeReactionPeersDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReactionPeersDto { return ReactionPeersDto( FfiConverterSequenceTypeReactionPeerDto.read(buf), @@ -4153,7 +4328,7 @@ data class ReadParticipantDto ( /** * @suppress */ -object FfiConverterTypeReadParticipantDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantDto { return ReadParticipantDto( FfiConverterLong.read(buf), @@ -4191,7 +4366,7 @@ data class ReadParticipantsDto ( /** * @suppress */ -object FfiConverterTypeReadParticipantsDto: FfiConverterRustBuffer { +object FfiConverterTypeReadParticipantsDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadParticipantsDto { return ReadParticipantsDto( FfiConverterSequenceTypeReadParticipantDto.read(buf), @@ -4233,7 +4408,7 @@ data class ReadReceiptConfigDto ( /** * @suppress */ -object FfiConverterTypeReadReceiptConfigDto: FfiConverterRustBuffer { +object FfiConverterTypeReadReceiptConfigDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ReadReceiptConfigDto { return ReadReceiptConfigDto( FfiConverterInt.read(buf), @@ -4281,7 +4456,7 @@ data class ResolvedPeerDto ( /** * @suppress */ -object FfiConverterTypeResolvedPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeResolvedPeerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ResolvedPeerDto { return ResolvedPeerDto( FfiConverterLong.read(buf), @@ -4331,7 +4506,7 @@ data class SavedGifDto ( /** * @suppress */ -object FfiConverterTypeSavedGifDto: FfiConverterRustBuffer { +object FfiConverterTypeSavedGifDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SavedGifDto { return SavedGifDto( FfiConverterLong.read(buf), @@ -4388,7 +4563,7 @@ data class SearchPeerDto ( /** * @suppress */ -object FfiConverterTypeSearchPeerDto: FfiConverterRustBuffer { +object FfiConverterTypeSearchPeerDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): SearchPeerDto { return SearchPeerDto( FfiConverterLong.read(buf), @@ -4443,7 +4618,7 @@ data class StickerCatalogDto ( /** * @suppress */ -object FfiConverterTypeStickerCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerCatalogDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerCatalogDto { return StickerCatalogDto( FfiConverterLong.read(buf), @@ -4486,7 +4661,7 @@ data class StickerListDto ( /** * @suppress */ -object FfiConverterTypeStickerListDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerListDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerListDto { return StickerListDto( FfiConverterLong.read(buf), @@ -4537,7 +4712,7 @@ data class StickerPackDto ( /** * @suppress */ -object FfiConverterTypeStickerPackDto: FfiConverterRustBuffer { +object FfiConverterTypeStickerPackDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): StickerPackDto { return StickerPackDto( FfiConverterLong.read(buf), @@ -4625,6 +4800,9 @@ data class UpdateDrainDto( ) { + + + companion object } @@ -4676,7 +4854,7 @@ data class UpdatesStateDto ( /** * @suppress */ -object FfiConverterTypeUpdatesStateDto: FfiConverterRustBuffer { +object FfiConverterTypeUpdatesStateDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdatesStateDto { return UpdatesStateDto( FfiConverterInt.read(buf), @@ -4736,7 +4914,7 @@ data class UploadItemDto ( /** * @suppress */ -object FfiConverterTypeUploadItemDto: FfiConverterRustBuffer { +object FfiConverterTypeUploadItemDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UploadItemDto { return UploadItemDto( FfiConverterString.read(buf), @@ -4800,7 +4978,7 @@ data class VpxAlphaFrame ( /** * @suppress */ -object FfiConverterTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterTypeVpxAlphaFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame { return VpxAlphaFrame( FfiConverterUInt.read(buf), @@ -4843,7 +5021,7 @@ data class VpxFrame ( /** * @suppress */ -object FfiConverterTypeVpxFrame: FfiConverterRustBuffer { +object FfiConverterTypeVpxFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame { return VpxFrame( FfiConverterUInt.read(buf), @@ -4886,7 +5064,7 @@ data class WallpaperCatalogDto ( /** * @suppress */ -object FfiConverterTypeWallpaperCatalogDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperCatalogDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperCatalogDto { return WallpaperCatalogDto( FfiConverterLong.read(buf), @@ -4947,7 +5125,7 @@ data class WallpaperDto ( /** * @suppress */ -object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer { +object FfiConverterTypeWallpaperDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): WallpaperDto { return WallpaperDto( FfiConverterLong.read(buf), @@ -4997,10 +5175,7 @@ object FfiConverterTypeWallpaperDto: FfiConverterRustBuffer { } - - - -sealed class MtprotoException: Exception() { +sealed class MtprotoException : Exception() { class UnknownClient : MtprotoException() { override val message @@ -5110,7 +5285,8 @@ sealed class UpdateEventDto { data class NewMessage( - val `message`: MessageDto) : UpdateEventDto() + val `message`: MessageDto + ) : UpdateEventDto() { @@ -5119,7 +5295,8 @@ sealed class UpdateEventDto { } data class MessageEdited( - val `message`: MessageDto) : UpdateEventDto() + val `message`: MessageDto + ) : UpdateEventDto() { @@ -5129,7 +5306,8 @@ sealed class UpdateEventDto { data class MessagesDeleted( val `chatId`: Long?, - val `messageIds`: List) : UpdateEventDto() + val `messageIds`: List + ) : UpdateEventDto() { @@ -5141,7 +5319,8 @@ sealed class UpdateEventDto { val `chatId`: Long, val `userId`: Long, val `typing`: Boolean, - val `action`: String) : UpdateEventDto() + val `action`: String + ) : UpdateEventDto() { @@ -5152,7 +5331,8 @@ sealed class UpdateEventDto { data class PeerStatus( val `userId`: Long, val `status`: String?, - val `statusAt`: Long?) : UpdateEventDto() + val `statusAt`: Long? + ) : UpdateEventDto() { @@ -5162,7 +5342,8 @@ sealed class UpdateEventDto { data class PeerEmojiStatus( val `userId`: Long, - val `documentId`: Long?) : UpdateEventDto() + val `documentId`: Long? + ) : UpdateEventDto() { @@ -5173,7 +5354,8 @@ sealed class UpdateEventDto { data class ReadInbox( val `chatId`: Long, val `maxId`: Int, - val `stillUnread`: Int) : UpdateEventDto() + val `stillUnread`: Int + ) : UpdateEventDto() { @@ -5183,7 +5365,8 @@ sealed class UpdateEventDto { data class ReadOutbox( val `chatId`: Long, - val `maxId`: Int) : UpdateEventDto() + val `maxId`: Int + ) : UpdateEventDto() { @@ -5197,7 +5380,8 @@ sealed class UpdateEventDto { data class MessageReactions( val `chatId`: Long, val `messageId`: Int, - val `reactionsJson`: String) : UpdateEventDto() + val `reactionsJson`: String + ) : UpdateEventDto() { @@ -5208,7 +5392,8 @@ sealed class UpdateEventDto { data class DiscussionInbox( val `channelId`: Long, val `topMessageId`: Int, - val `readMaxId`: Int) : UpdateEventDto() + val `readMaxId`: Int + ) : UpdateEventDto() { @@ -5220,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() { @@ -5232,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`: Boolean) : UpdateEventDto() + val `isSyncing`: Boolean + ) : UpdateEventDto() { @@ -5241,7 +5428,8 @@ sealed class UpdateEventDto { } data class Ignored( - val `kind`: String) : UpdateEventDto() + val `kind`: String + ) : UpdateEventDto() { @@ -5262,7 +5450,7 @@ sealed class UpdateEventDto { /** * @suppress */ -object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer{ +object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): UpdateEventDto { return when(buf.getInt()) { 1 -> UpdateEventDto.ChatsChanged @@ -5536,11 +5724,6 @@ object FfiConverterTypeUpdateEventDto : FfiConverterRustBuffer{ } - - - - - interface DownloadProgressListener { fun `onProgress`(`path`: String, `downloaded`: Long, `total`: Long) @@ -5597,7 +5780,8 @@ internal object uniffiCallbackInterfaceDownloadProgressListener { * * @suppress */ -object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInterface() +object FfiConverterTypeDownloadProgressListener : + FfiConverterCallbackInterface() @@ -5605,7 +5789,7 @@ object FfiConverterTypeDownloadProgressListener: FfiConverterCallbackInterface { +object FfiConverterOptionalInt : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): Int? { if (buf.get().toInt() == 0) { return null @@ -5637,7 +5821,7 @@ object FfiConverterOptionalInt: FfiConverterRustBuffer { /** * @suppress */ -object FfiConverterOptionalLong: FfiConverterRustBuffer { +object FfiConverterOptionalLong : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): Long? { if (buf.get().toInt() == 0) { return null @@ -5669,7 +5853,7 @@ object FfiConverterOptionalLong: FfiConverterRustBuffer { /** * @suppress */ -object FfiConverterOptionalBoolean: FfiConverterRustBuffer { +object FfiConverterOptionalBoolean : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): Boolean? { if (buf.get().toInt() == 0) { return null @@ -5701,7 +5885,7 @@ object FfiConverterOptionalBoolean: FfiConverterRustBuffer { /** * @suppress */ -object FfiConverterOptionalString: FfiConverterRustBuffer { +object FfiConverterOptionalString : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): String? { if (buf.get().toInt() == 0) { return null @@ -5733,7 +5917,7 @@ object FfiConverterOptionalString: FfiConverterRustBuffer { /** * @suppress */ -object FfiConverterOptionalByteArray: FfiConverterRustBuffer { +object FfiConverterOptionalByteArray : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ByteArray? { if (buf.get().toInt() == 0) { return null @@ -5795,7 +5979,7 @@ object FfiConverterOptionalTypeUpdatesStateDto : FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxAlphaFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxAlphaFrame? { if (buf.get().toInt() == 0) { return null @@ -5827,7 +6011,7 @@ object FfiConverterOptionalTypeVpxAlphaFrame: FfiConverterRustBuffer { +object FfiConverterOptionalTypeVpxFrame : FfiConverterRustBuffer { override fun read(buf: ByteBuffer): VpxFrame? { if (buf.get().toInt() == 0) { return null @@ -5859,7 +6043,7 @@ object FfiConverterOptionalTypeVpxFrame: FfiConverterRustBuffer { /** * @suppress */ -object FfiConverterSequenceShort: FfiConverterRustBuffer> { +object FfiConverterSequenceShort : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5887,7 +6071,7 @@ object FfiConverterSequenceShort: FfiConverterRustBuffer> { /** * @suppress */ -object FfiConverterSequenceInt: FfiConverterRustBuffer> { +object FfiConverterSequenceInt : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5915,7 +6099,7 @@ object FfiConverterSequenceInt: FfiConverterRustBuffer> { /** * @suppress */ -object FfiConverterSequenceLong: FfiConverterRustBuffer> { +object FfiConverterSequenceLong : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5943,7 +6127,7 @@ object FfiConverterSequenceLong: FfiConverterRustBuffer> { /** * @suppress */ -object FfiConverterSequenceFloat: FfiConverterRustBuffer> { +object FfiConverterSequenceFloat : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5971,7 +6155,7 @@ object FfiConverterSequenceFloat: FfiConverterRustBuffer> { /** * @suppress */ -object FfiConverterSequenceString: FfiConverterRustBuffer> { +object FfiConverterSequenceString : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -5999,7 +6183,7 @@ object FfiConverterSequenceString: FfiConverterRustBuffer> { /** * @suppress */ -object FfiConverterSequenceByteArray: FfiConverterRustBuffer> { +object FfiConverterSequenceByteArray : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6054,7 +6238,7 @@ object FfiConverterSequenceTypeChannelCursorDto : /** * @suppress */ -object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeChatDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6082,7 +6266,7 @@ object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { /** * @suppress */ -object FfiConverterSequenceTypeFolderDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeFolderDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6110,7 +6294,7 @@ object FfiConverterSequenceTypeFolderDto: FfiConverterRustBuffer /** * @suppress */ -object FfiConverterSequenceTypeForumTopicDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeForumTopicDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6138,7 +6322,8 @@ object FfiConverterSequenceTypeForumTopicDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeInlineBotResultDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6166,7 +6351,7 @@ object FfiConverterSequenceTypeInlineBotResultDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeMessageDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6194,7 +6379,8 @@ object FfiConverterSequenceTypeMessageDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeNotifyExceptionDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6222,7 +6408,7 @@ object FfiConverterSequenceTypeNotifyExceptionDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypePollVoterDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6250,7 +6436,8 @@ object FfiConverterSequenceTypePollVoterDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReactionChoiceDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6278,7 +6465,8 @@ object FfiConverterSequenceTypeReactionChoiceDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReactionPeerDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6306,7 +6494,8 @@ object FfiConverterSequenceTypeReactionPeerDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeReadParticipantDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6334,7 +6523,7 @@ object FfiConverterSequenceTypeReadParticipantDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeSavedGifDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6362,7 +6551,7 @@ object FfiConverterSequenceTypeSavedGifDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeSearchPeerDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6390,7 +6579,8 @@ object FfiConverterSequenceTypeSearchPeerDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeStickerPackDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6445,7 +6635,7 @@ object FfiConverterSequenceTypeUpdateBatchDto : /** * @suppress */ -object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUploadItemDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6473,7 +6663,7 @@ object FfiConverterSequenceTypeUploadItemDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeWallpaperDto : FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6501,7 +6691,8 @@ object FfiConverterSequenceTypeWallpaperDto: FfiConverterRustBuffer> { +object FfiConverterSequenceTypeUpdateEventDto : + FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() return List(len) { @@ -6540,8 +6731,14 @@ fun `acknowledgeUpdateBatch`( } - - @Throws(MtprotoException::class) fun `addWaveformPcm`(`handle`: ULong, `samples`: List, `sampleRate`: UInt, `channels`: UInt, `presentationTimeUs`: ULong) +@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( @@ -6555,8 +6752,8 @@ fun `acknowledgeUpdateBatch`( } - - @Throws(MtprotoException::class) fun `animatedEmojiMax`(`handle`: ULong): Int { +@Throws(MtprotoException::class) +fun `animatedEmojiMax`(`handle`: ULong): Int { return FfiConverterInt.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_animated_emoji_max( @@ -6568,7 +6765,14 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `appendTodoItems`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `firstId`: Int, `titles`: List) +@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( @@ -6582,9 +6786,9 @@ fun `acknowledgeUpdateBatch`( } - fun `bindRequestControl`(`id`: ULong): ULong { +fun `bindRequestControl`(`id`: ULong): ULong { return FfiConverterULong.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_bind_request_control( @@ -6593,7 +6797,7 @@ fun `acknowledgeUpdateBatch`( ) } - fun `cancelRequestControl`(`id`: ULong) +fun `cancelRequestControl`(`id`: ULong) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_cancel_request_control( @@ -6603,8 +6807,8 @@ fun `acknowledgeUpdateBatch`( } - - @Throws(MtprotoException::class) fun `checkPassword`(`handle`: ULong, `password`: String): AuthSignedIn { +@Throws(MtprotoException::class) +fun `checkPassword`(`handle`: ULong, `password`: String): AuthSignedIn { return FfiConverterTypeAuthSignedIn.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_check_password( @@ -6617,7 +6821,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `clearActiveDialog`(`handle`: ULong) +@Throws(MtprotoException::class) +fun `clearActiveDialog`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_clear_active_dialog( @@ -6637,9 +6842,9 @@ fun `acknowledgeUpdateBatch`( } - fun `clientApiId`(`handle`: ULong): Int { +fun `clientApiId`(`handle`: ULong): Int { return FfiConverterInt.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_api_id( @@ -6648,9 +6853,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `clientExists`(`handle`: ULong): Boolean { +fun `clientExists`(`handle`: ULong): Boolean { return FfiConverterBoolean.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_exists( @@ -6659,9 +6864,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `clientUsesTestDc`(`handle`: ULong): Boolean { +fun `clientUsesTestDc`(`handle`: ULong): Boolean { return FfiConverterBoolean.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_client_uses_test_dc( @@ -6671,7 +6876,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `connect`(`handle`: ULong) +@Throws(MtprotoException::class) +fun `connect`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_connect( @@ -6681,8 +6887,12 @@ fun `acknowledgeUpdateBatch`( } - - @Throws(MtprotoException::class) fun `contactsSearch`(`handle`: ULong, `query`: String, `limit`: Int): ContactsSearchDto { +@Throws(MtprotoException::class) +fun `contactsSearch`( + `handle`: ULong, + `query`: String, + `limit`: Int +): ContactsSearchDto { return FfiConverterTypeContactsSearchDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_contacts_search( @@ -6695,9 +6905,13 @@ fun `acknowledgeUpdateBatch`( ) } - fun `createClient`(`apiId`: Int, `apiHash`: String, `sessionPath`: String): ULong { +fun `createClient`( + `apiId`: Int, + `apiHash`: String, + `sessionPath`: String +): ULong { return FfiConverterULong.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_client( @@ -6709,7 +6923,13 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createEncryptedClient`(`apiId`: Int, `apiHash`: String, `sessionPath`: String, `key`: ByteArray): ULong { +@Throws(MtprotoException::class) +fun `createEncryptedClient`( + `apiId`: Int, + `apiHash`: String, + `sessionPath`: String, + `key`: ByteArray +): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_encrypted_client( @@ -6724,7 +6944,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createLottie`(`data`: ByteArray): ULong { +@Throws(MtprotoException::class) +fun `createLottie`(`data`: ByteArray): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_lottie( @@ -6735,9 +6956,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `createRequestControl`(): ULong { +fun `createRequestControl`(): ULong { return FfiConverterULong.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_request_control( _status) @@ -6746,7 +6967,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createVpxDecoder`(): ULong { +@Throws(MtprotoException::class) +fun `createVpxDecoder`(): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_vpx_decoder( @@ -6757,7 +6979,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `createWaveform`(`durationUs`: ULong): ULong { +@Throws(MtprotoException::class) +fun `createWaveform`(`durationUs`: ULong): ULong { return FfiConverterULong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_create_waveform( @@ -6769,7 +6992,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `customEmojiIsFree`(`handle`: ULong, `documentId`: Long): Boolean { +@Throws(MtprotoException::class) +fun `customEmojiIsFree`(`handle`: ULong, `documentId`: Long): Boolean { return FfiConverterBoolean.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_custom_emoji_is_free( @@ -6782,7 +7006,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `decodeVpxAlphaPacket`(`handle`: ULong, `data`: ByteArray): VpxAlphaFrame? { +@Throws(MtprotoException::class) +fun `decodeVpxAlphaPacket`(`handle`: ULong, `data`: ByteArray): VpxAlphaFrame? { return FfiConverterOptionalTypeVpxAlphaFrame.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_alpha_packet( @@ -6795,7 +7020,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `decodeVpxPacket`(`handle`: ULong, `data`: ByteArray): VpxFrame? { +@Throws(MtprotoException::class) +fun `decodeVpxPacket`(`handle`: ULong, `data`: ByteArray): VpxFrame? { return FfiConverterOptionalTypeVpxFrame.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_decode_vpx_packet( @@ -6808,7 +7034,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `decryptPushPayload`(`secret`: ByteArray, `payload`: String): String { +@Throws(MtprotoException::class) +fun `decryptPushPayload`(`secret`: ByteArray, `payload`: String): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_decrypt_push_payload( @@ -6821,7 +7048,8 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `deleteFolder`(`handle`: ULong, `id`: Int) +@Throws(MtprotoException::class) +fun `deleteFolder`(`handle`: ULong, `id`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_delete_folder( @@ -6832,8 +7060,13 @@ fun `acknowledgeUpdateBatch`( } - - @Throws(MtprotoException::class) fun `deleteMessage`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `revoke`: Boolean) +@Throws(MtprotoException::class) +fun `deleteMessage`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `revoke`: Boolean +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_delete_message( @@ -6846,7 +7079,7 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyClient`(`handle`: ULong) +fun `destroyClient`(`handle`: ULong) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_client( @@ -6856,7 +7089,7 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyLottie`(`handle`: ULong) +fun `destroyLottie`(`handle`: ULong) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_lottie( @@ -6866,7 +7099,7 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyVpxDecoder`(`handle`: ULong) +fun `destroyVpxDecoder`(`handle`: ULong) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_vpx_decoder( @@ -6876,7 +7109,7 @@ fun `acknowledgeUpdateBatch`( } - fun `destroyWaveform`(`handle`: ULong) +fun `destroyWaveform`(`handle`: ULong) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_destroy_waveform( @@ -6886,9 +7119,9 @@ fun `acknowledgeUpdateBatch`( } - fun `downloadChunkKib`(): Int { +fun `downloadChunkKib`(): Int { return FfiConverterInt.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_chunk_kib( _status) @@ -6896,9 +7129,9 @@ fun `acknowledgeUpdateBatch`( ) } - fun `downloadConcurrency`(): List { +fun `downloadConcurrency`(): List { return FfiConverterSequenceInt.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_concurrency( _status) @@ -6907,7 +7140,12 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadCustomEmoji`(`handle`: ULong, `documentId`: Long, `destPath`: String): String { +@Throws(MtprotoException::class) +fun `downloadCustomEmoji`( + `handle`: ULong, + `documentId`: Long, + `destPath`: String +): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_custom_emoji( @@ -6921,7 +7159,13 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageDisplay`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String): String { +@Throws(MtprotoException::class) +fun `downloadMessageDisplay`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `destPath`: String +): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_display( @@ -6936,7 +7180,13 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageMedia`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String): String { +@Throws(MtprotoException::class) +fun `downloadMessageMedia`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `destPath`: String +): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media( @@ -6951,7 +7201,14 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageMediaChunk`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String, `offset`: Long): String { +@Throws(MtprotoException::class) +fun `downloadMessageMediaChunk`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `destPath`: String, + `offset`: Long +): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_media_chunk( @@ -6967,7 +7224,13 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadMessageThumb`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `destPath`: String): String { +@Throws(MtprotoException::class) +fun `downloadMessageThumb`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `destPath`: String +): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_message_thumb( @@ -6982,7 +7245,13 @@ fun `acknowledgeUpdateBatch`( } - @Throws(MtprotoException::class) fun `downloadWallpaper`(`handle`: ULong, `id`: Long, `accessHash`: Long, `destPath`: String): String { +@Throws(MtprotoException::class) +fun `downloadWallpaper`( + `handle`: ULong, + `id`: Long, + `accessHash`: Long, + `destPath`: String +): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_download_wallpaper( @@ -7010,7 +7279,13 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `editForumTopicHidden`(`handle`: ULong, `chatId`: Long, `topicId`: Int, `hidden`: Boolean) +@Throws(MtprotoException::class) +fun `editForumTopicHidden`( + `handle`: ULong, + `chatId`: Long, + `topicId`: Int, + `hidden`: Boolean +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_edit_forum_topic_hidden( @@ -7023,8 +7298,14 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - - @Throws(MtprotoException::class) fun `editTextMessage`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `text`: String, `entitiesJson`: String?): MessageDto { +@Throws(MtprotoException::class) +fun `editTextMessage`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `text`: String, + `entitiesJson`: String? +): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_edit_text_message( @@ -7040,7 +7321,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `finishWaveform`(`handle`: ULong): List { +@Throws(MtprotoException::class) +fun `finishWaveform`(`handle`: ULong): List { return FfiConverterSequenceFloat.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_finish_waveform( @@ -7052,7 +7334,14 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `forwardMessages`(`handle`: ULong, `fromChatId`: Long, `messageIds`: List, `toChatId`: Long, `dropAuthor`: 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( @@ -7068,7 +7357,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getAllStickers`(`handle`: ULong, `hash`: Long): StickerCatalogDto { +@Throws(MtprotoException::class) +fun `getAllStickers`(`handle`: ULong, `hash`: Long): StickerCatalogDto { return FfiConverterTypeStickerCatalogDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_all_stickers( @@ -7081,7 +7371,13 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getBotCallbackAnswer`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `dataHex`: String): BotCallbackAnswerDto { +@Throws(MtprotoException::class) +fun `getBotCallbackAnswer`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `dataHex`: String +): BotCallbackAnswerDto { return FfiConverterTypeBotCallbackAnswerDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_bot_callback_answer( @@ -7096,7 +7392,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getChats`(`handle`: ULong): List { +@Throws(MtprotoException::class) +fun `getChats`(`handle`: ULong): List { return FfiConverterSequenceTypeChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_chats( @@ -7111,7 +7408,13 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { /** * Groups and channels shared with a user, as compact JSON. */ - @Throws(MtprotoException::class) fun `getCommonChats`(`handle`: ULong, `userId`: Long, `maxId`: Long, `limit`: Int): String { + @Throws(MtprotoException::class) + fun `getCommonChats`( + `handle`: ULong, + `userId`: Long, + `maxId`: Long, + `limit`: Int + ): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_common_chats( @@ -7126,7 +7429,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getDiscussionMessage`(`handle`: ULong, `chatId`: Long, `messageId`: Int): DiscussionDto { +@Throws(MtprotoException::class) +fun `getDiscussionMessage`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int +): DiscussionDto { return FfiConverterTypeDiscussionDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_discussion_message( @@ -7140,7 +7448,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getEmojiStickers`(`handle`: ULong, `hash`: Long): StickerCatalogDto { +@Throws(MtprotoException::class) +fun `getEmojiStickers`(`handle`: ULong, `hash`: Long): StickerCatalogDto { return FfiConverterTypeStickerCatalogDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_emoji_stickers( @@ -7153,7 +7462,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getFolders`(`handle`: ULong): List { +@Throws(MtprotoException::class) +fun `getFolders`(`handle`: ULong): List { return FfiConverterSequenceTypeFolderDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_folders( @@ -7165,7 +7475,15 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getForumTopics`(`handle`: ULong, `chatId`: Long, `offsetDate`: Int, `offsetId`: Int, `offsetTopic`: Int, `limit`: Int): ForumTopicsPageDto { +@Throws(MtprotoException::class) +fun `getForumTopics`( + `handle`: ULong, + `chatId`: Long, + `offsetDate`: Int, + `offsetId`: Int, + `offsetTopic`: Int, + `limit`: Int +): ForumTopicsPageDto { return FfiConverterTypeForumTopicsPageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_forum_topics( @@ -7182,7 +7500,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getForumTopicsById`(`handle`: ULong, `chatId`: Long, `topicIds`: List): 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( @@ -7196,7 +7519,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getGroupAdminTags`(`handle`: ULong, `chatId`: Long): String { +@Throws(MtprotoException::class) +fun `getGroupAdminTags`(`handle`: ULong, `chatId`: Long): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_group_admin_tags( @@ -7209,7 +7533,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getHistory`(`handle`: ULong, `chatId`: Long, `limit`: Int): List { +@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( @@ -7223,7 +7552,15 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getHistoryPage`(`handle`: ULong, `chatId`: Long, `limit`: Int, `offsetId`: Int, `offsetDate`: Int, `addOffset`: Int): List { +@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( @@ -7240,7 +7577,14 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getInlineBotResults`(`handle`: ULong, `chatId`: Long, `botId`: Long, `query`: String, `offset`: String): InlineBotResultsDto { +@Throws(MtprotoException::class) +fun `getInlineBotResults`( + `handle`: ULong, + `chatId`: Long, + `botId`: Long, + `query`: String, + `offset`: String +): InlineBotResultsDto { return FfiConverterTypeInlineBotResultsDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_inline_bot_results( @@ -7256,7 +7600,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getMessageReactionsList`(`handle`: ULong, `chatId`: Long, `messageId`: Int): ReactionPeersDto { +@Throws(MtprotoException::class) +fun `getMessageReactionsList`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int +): ReactionPeersDto { return FfiConverterTypeReactionPeersDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_reactions_list( @@ -7270,7 +7619,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getMessageReadParticipants`(`handle`: ULong, `chatId`: Long, `msgId`: Int): ReadParticipantsDto { +@Throws(MtprotoException::class) +fun `getMessageReadParticipants`( + `handle`: ULong, + `chatId`: Long, + `msgId`: Int +): ReadParticipantsDto { return FfiConverterTypeReadParticipantsDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_message_read_participants( @@ -7284,7 +7638,11 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getNotifyExceptions`(`handle`: ULong, `compareSound`: Boolean): List { +@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( @@ -7297,7 +7655,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getNotifySettings`(`handle`: ULong, `peerKind`: String, `chatId`: Long): NotifySettingsDto { +@Throws(MtprotoException::class) +fun `getNotifySettings`( + `handle`: ULong, + `peerKind`: String, + `chatId`: Long +): NotifySettingsDto { return FfiConverterTypeNotifySettingsDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_notify_settings( @@ -7311,7 +7674,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getOutboxReadDate`(`handle`: ULong, `chatId`: Long, `msgId`: Int): OutboxReadDto { +@Throws(MtprotoException::class) +fun `getOutboxReadDate`( + `handle`: ULong, + `chatId`: Long, + `msgId`: Int +): OutboxReadDto { return FfiConverterTypeOutboxReadDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_outbox_read_date( @@ -7328,7 +7696,15 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { /** * Participant page with roles for groups and channels, as compact JSON. */ - @Throws(MtprotoException::class) fun `getParticipants`(`handle`: ULong, `chatId`: Long, `filter`: String, `query`: String, `offset`: Int, `limit`: Int): String { + @Throws(MtprotoException::class) + fun `getParticipants`( + `handle`: ULong, + `chatId`: Long, + `filter`: String, + `query`: String, + `offset`: Int, + `limit`: Int + ): String { return FfiConverterString.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_participants( @@ -7345,7 +7721,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getPinnedMessages`(`handle`: ULong, `chatId`: Long, `limit`: Int): List { +@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( @@ -7359,7 +7740,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getPollVotes`(`handle`: ULong, `chatId`: Long, `messageId`: Int): PollVotersDto { +@Throws(MtprotoException::class) +fun `getPollVotes`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int +): PollVotersDto { return FfiConverterTypePollVotersDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_poll_votes( @@ -7373,7 +7759,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getProfile`(`handle`: ULong, `peerId`: Long): ProfileDto { +@Throws(MtprotoException::class) +fun `getProfile`(`handle`: ULong, `peerId`: Long): ProfileDto { return FfiConverterTypeProfileDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_profile( @@ -7386,7 +7773,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getReadReceiptConfig`(`handle`: ULong): ReadReceiptConfigDto { +@Throws(MtprotoException::class) +fun `getReadReceiptConfig`(`handle`: ULong): ReadReceiptConfigDto { return FfiConverterTypeReadReceiptConfigDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_read_receipt_config( @@ -7398,7 +7786,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getRecentReactions`(`handle`: ULong): List { +@Throws(MtprotoException::class) +fun `getRecentReactions`(`handle`: ULong): List { return FfiConverterSequenceTypeReactionChoiceDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_recent_reactions( @@ -7410,7 +7799,15 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getReplies`(`handle`: ULong, `chatId`: Long, `msgId`: Int, `limit`: Int, `offsetId`: Int, `addOffset`: Int): List { +@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( @@ -7427,7 +7824,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getSavedGifs`(`handle`: ULong): List { +@Throws(MtprotoException::class) +fun `getSavedGifs`(`handle`: ULong): List { return FfiConverterSequenceTypeSavedGifDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_saved_gifs( @@ -7442,7 +7840,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { /** * `messages.getSearchCounters` counts per filter (photo_video/document/url/gif/voice/music/...). */ - @Throws(MtprotoException::class) fun `getSearchCounters`(`handle`: ULong, `chatId`: Long, `filters`: List): 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( @@ -7456,7 +7859,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getStickerPack`(`handle`: ULong, `documentId`: Long): StickerPackDto { +@Throws(MtprotoException::class) +fun `getStickerPack`(`handle`: ULong, `documentId`: Long): StickerPackDto { return FfiConverterTypeStickerPackDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_pack( @@ -7469,7 +7873,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getStickerSet`(`handle`: ULong, `setId`: Long, `accessHash`: Long): StickerPackDto { +@Throws(MtprotoException::class) +fun `getStickerSet`( + `handle`: ULong, + `setId`: Long, + `accessHash`: Long +): StickerPackDto { return FfiConverterTypeStickerPackDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_sticker_set( @@ -7483,7 +7892,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getStickers`(`handle`: ULong, `emoticon`: String, `hash`: Long): StickerListDto { +@Throws(MtprotoException::class) +fun `getStickers`( + `handle`: ULong, + `emoticon`: String, + `hash`: Long +): StickerListDto { return FfiConverterTypeStickerListDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_stickers( @@ -7500,7 +7914,15 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { /** * https://core.telegram.org/method/messages.getUnreadMentions */ - @Throws(MtprotoException::class) fun `getUnreadMentions`(`handle`: ULong, `chatId`: Long, `offsetId`: Int, `addOffset`: Int, `limit`: Int, `topMsgId`: Int): List { + @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( @@ -7520,7 +7942,15 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { /** * https://core.telegram.org/method/messages.getUnreadReactions */ - @Throws(MtprotoException::class) fun `getUnreadReactions`(`handle`: ULong, `chatId`: Long, `offsetId`: Int, `addOffset`: Int, `limit`: Int, `topMsgId`: Int): List { + @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( @@ -7537,7 +7967,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getUpdatesState`(`handle`: ULong): UpdatesStateDto { +@Throws(MtprotoException::class) +fun `getUpdatesState`(`handle`: ULong): UpdatesStateDto { return FfiConverterTypeUpdatesStateDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_updates_state( @@ -7549,7 +7980,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getWallpapers`(`handle`: ULong, `hash`: Long): WallpaperCatalogDto { +@Throws(MtprotoException::class) +fun `getWallpapers`(`handle`: ULong, `hash`: Long): WallpaperCatalogDto { return FfiConverterTypeWallpaperCatalogDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_wallpapers( @@ -7562,7 +7994,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getWebPage`(`handle`: ULong, `url`: String, `hash`: Int): InstantViewDto { +@Throws(MtprotoException::class) +fun `getWebPage`(`handle`: ULong, `url`: String, `hash`: Int): InstantViewDto { return FfiConverterTypeInstantViewDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page( @@ -7576,7 +8009,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `getWebPagePreview`(`handle`: ULong, `message`: String): InstantViewDto { +@Throws(MtprotoException::class) +fun `getWebPagePreview`(`handle`: ULong, `message`: String): InstantViewDto { return FfiConverterTypeInstantViewDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_get_web_page_preview( @@ -7589,7 +8023,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `isAuthorized`(`handle`: ULong): Boolean { +@Throws(MtprotoException::class) +fun `isAuthorized`(`handle`: ULong): Boolean { return FfiConverterBoolean.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_is_authorized( @@ -7601,7 +8036,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `joinChat`(`handle`: ULong, `chatId`: Long): JoinChatDto { +@Throws(MtprotoException::class) +fun `joinChat`(`handle`: ULong, `chatId`: Long): JoinChatDto { return FfiConverterTypeJoinChatDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_join_chat( @@ -7613,9 +8049,9 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { ) } - fun `libraryVersion`(): String { +fun `libraryVersion`(): String { return FfiConverterString.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_library_version( _status) @@ -7624,7 +8060,14 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `loadMoreChats`(`handle`: ULong, `offsetDate`: Int, `offsetId`: Int, `offsetPeerId`: Long, `folderId`: Int): List { +@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( @@ -7640,7 +8083,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `logout`(`handle`: ULong) +@Throws(MtprotoException::class) +fun `logout`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_logout( @@ -7650,8 +8094,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - - @Throws(MtprotoException::class) fun `lottieFrameCount`(`handle`: ULong): UInt { +@Throws(MtprotoException::class) +fun `lottieFrameCount`(`handle`: ULong): UInt { return FfiConverterUInt.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_count( @@ -7663,7 +8107,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `lottieFrameRate`(`handle`: ULong): Float { +@Throws(MtprotoException::class) +fun `lottieFrameRate`(`handle`: ULong): Float { return FfiConverterFloat.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_frame_rate( @@ -7675,7 +8120,8 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - @Throws(MtprotoException::class) fun `lottieSize`(`handle`: ULong): LottieSize { +@Throws(MtprotoException::class) +fun `lottieSize`(`handle`: ULong): LottieSize { return FfiConverterTypeLottieSize.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_lottie_size( @@ -7690,7 +8136,12 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { /** * https://core.telegram.org/method/messages.markDialogUnread */ - @Throws(MtprotoException::class) fun `markDialogUnread`(`handle`: ULong, `chatId`: Long, `unread`: Boolean) + @Throws(MtprotoException::class) + fun `markDialogUnread`( + `handle`: ULong, + `chatId`: Long, + `unread`: Boolean + ) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_mark_dialog_unread( @@ -7702,9 +8153,13 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { } - fun `peekMessageInlineThumb`(`handle`: ULong, `chatId`: Long, `messageId`: Int): ByteArray? { +fun `peekMessageInlineThumb`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int +): ByteArray? { return FfiConverterOptionalByteArray.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_message_inline_thumb( @@ -7715,9 +8170,9 @@ fun `drainUpdates`(`handle`: ULong): UpdateDrainDto { ) } - fun `peekPeerInlineThumb`(`handle`: ULong, `peerId`: Long): ByteArray? { +fun `peekPeerInlineThumb`(`handle`: ULong, `peerId`: Long): ByteArray? { return FfiConverterOptionalByteArray.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_peek_peer_inline_thumb( @@ -7744,9 +8199,10 @@ fun `pendingUpdateBatches`(`handle`: ULong): List { /** * Netcode timing spans on/off; off by default. - */ fun `perfSetEnabled`(`enabled`: Boolean) + */ + fun `perfSetEnabled`(`enabled`: Boolean) = - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_perf_set_enabled( @@ -7757,9 +8213,10 @@ fun `pendingUpdateBatches`(`handle`: ULong): List { /** * JSON timing snapshot; `reset` clears the window. - */ fun `perfSnapshot`(`reset`: Boolean): String { + */ + fun `perfSnapshot`(`reset`: Boolean): String { return FfiConverterString.lift( - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_perf_snapshot( @@ -7769,7 +8226,15 @@ fun `pendingUpdateBatches`(`handle`: ULong): List { } - @Throws(MtprotoException::class) fun `pingProxy`(`kind`: String, `host`: String, `port`: UShort, `username`: String?, `password`: String?, `secret`: ByteArray): Long { +@Throws(MtprotoException::class) +fun `pingProxy`( + `kind`: String, + `host`: String, + `port`: UShort, + `username`: String?, + `password`: String?, + `secret`: ByteArray +): Long { return FfiConverterLong.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_ping_proxy( @@ -7796,8 +8261,13 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `readDiscussion`(`handle`: ULong, `chatId`: Long, `msgId`: Int, `readMaxId`: Int) +@Throws(MtprotoException::class) +fun `readDiscussion`( + `handle`: ULong, + `chatId`: Long, + `msgId`: Int, + `readMaxId`: Int +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_discussion( @@ -7810,8 +8280,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `readHistory`(`handle`: ULong, `chatId`: Long, `maxId`: Int) +@Throws(MtprotoException::class) +fun `readHistory`(`handle`: ULong, `chatId`: Long, `maxId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_history( @@ -7827,7 +8297,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = /** * https://core.telegram.org/method/messages.readMentions */ - @Throws(MtprotoException::class) fun `readMentions`(`handle`: ULong, `chatId`: Long, `topMsgId`: Int) + @Throws(MtprotoException::class) + fun `readMentions`(`handle`: ULong, `chatId`: Long, `topMsgId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_mentions( @@ -7844,7 +8315,12 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = * https://core.telegram.org/method/messages.readMessageContents * https://core.telegram.org/method/channels.readMessageContents */ - @Throws(MtprotoException::class) fun `readMessageContents`(`handle`: ULong, `chatId`: Long, `messageIds`: List) + @Throws(MtprotoException::class) + fun `readMessageContents`( + `handle`: ULong, + `chatId`: Long, + `messageIds`: List + ) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_message_contents( @@ -7860,7 +8336,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = /** * https://core.telegram.org/method/messages.readReactions */ - @Throws(MtprotoException::class) fun `readReactions`(`handle`: ULong, `chatId`: Long, `topMsgId`: Int) + @Throws(MtprotoException::class) + fun `readReactions`(`handle`: ULong, `chatId`: Long, `topMsgId`: Int) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_read_reactions( @@ -7872,8 +8349,16 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `registerDevice`(`handle`: ULong, `tokenType`: Int, `token`: String, `secret`: ByteArray, `noMuted`: Boolean, `appSandbox`: Boolean, `otherUids`: List) +@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( @@ -7889,7 +8374,7 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - fun `releaseRequestControl`(`id`: ULong) +fun `releaseRequestControl`(`id`: ULong) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_release_request_control( @@ -7899,8 +8384,13 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `renderLottieFrame`(`handle`: ULong, `frame`: Float, `width`: UInt, `height`: UInt): ByteArray { +@Throws(MtprotoException::class) +fun `renderLottieFrame`( + `handle`: ULong, + `frame`: Float, + `width`: UInt, + `height`: UInt +): ByteArray { return FfiConverterByteArray.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_render_lottie_frame( @@ -7915,7 +8405,12 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `resendAuthCode`(`handle`: ULong, `phone`: String, `phoneCodeHash`: String): AuthCodeSent { +@Throws(MtprotoException::class) +fun `resendAuthCode`( + `handle`: ULong, + `phone`: String, + `phoneCodeHash`: String +): AuthCodeSent { return FfiConverterTypeAuthCodeSent.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_resend_auth_code( @@ -7929,7 +8424,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `resetNotifySettings`(`handle`: ULong) +@Throws(MtprotoException::class) +fun `resetNotifySettings`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_reset_notify_settings( @@ -7939,8 +8435,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `resolveUsername`(`handle`: ULong, `username`: String): ResolvedPeerDto { +@Throws(MtprotoException::class) +fun `resolveUsername`(`handle`: ULong, `username`: String): ResolvedPeerDto { return FfiConverterTypeResolvedPeerDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_resolve_username( @@ -7953,7 +8449,16 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `searchGlobal`(`handle`: ULong, `query`: String, `offsetRate`: Int, `offsetPeerId`: Long, `offsetId`: Int, `limit`: Int, `folderId`: Int): GlobalMessageSearchDto { +@Throws(MtprotoException::class) +fun `searchGlobal`( + `handle`: ULong, + `query`: String, + `offsetRate`: Int, + `offsetPeerId`: Long, + `offsetId`: Int, + `limit`: Int, + `folderId`: Int +): GlobalMessageSearchDto { return FfiConverterTypeGlobalMessageSearchDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_global( @@ -7971,7 +8476,13 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `searchMessages`(`handle`: ULong, `chatId`: Long, `query`: String, `limit`: Int): List { +@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( @@ -7986,7 +8497,16 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `searchMessagesFiltered`(`handle`: ULong, `chatId`: Long, `query`: String, `filter`: String, `offsetId`: Int, `addOffset`: Int, `limit`: Int): List { +@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( @@ -8004,7 +8524,16 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `searchMessagesPage`(`handle`: ULong, `chatId`: Long, `query`: String, `filter`: String, `offsetId`: Int, `addOffset`: Int, `limit`: Int): MessagesPageDto { +@Throws(MtprotoException::class) +fun `searchMessagesPage`( + `handle`: ULong, + `chatId`: Long, + `query`: String, + `filter`: String, + `offsetId`: Int, + `addOffset`: Int, + `limit`: Int +): MessagesPageDto { return FfiConverterTypeMessagesPageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_search_messages_page( @@ -8022,7 +8551,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `sendAuthCode`(`handle`: ULong, `phone`: String): AuthCodeSent { +@Throws(MtprotoException::class) +fun `sendAuthCode`(`handle`: ULong, `phone`: String): AuthCodeSent { return FfiConverterTypeAuthCodeSent.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_auth_code( @@ -8035,7 +8565,15 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `sendInlineBotResult`(`handle`: ULong, `chatId`: Long, `queryId`: Long, `resultId`: String, `replyToMsgId`: Int, `topMsgId`: Int): MessageDto { +@Throws(MtprotoException::class) +fun `sendInlineBotResult`( + `handle`: ULong, + `chatId`: Long, + `queryId`: Long, + `resultId`: String, + `replyToMsgId`: Int, + `topMsgId`: Int +): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_inline_bot_result( @@ -8052,7 +8590,16 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `sendLocation`(`handle`: ULong, `chatId`: Long, `latitude`: Double, `longitude`: Double, `livePeriod`: Int, `heading`: Int, `replyToMsgId`: Int) +@Throws(MtprotoException::class) +fun `sendLocation`( + `handle`: ULong, + `chatId`: Long, + `latitude`: Double, + `longitude`: Double, + `livePeriod`: Int, + `heading`: Int, + `replyToMsgId`: Int +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_location( @@ -8068,8 +8615,16 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `sendPhotoMessage`(`handle`: ULong, `chatId`: Long, `path`: String, `caption`: String, `replyToMsgId`: Int, `topMsgId`: Int, `entitiesJson`: String?): MessageDto { +@Throws(MtprotoException::class) +fun `sendPhotoMessage`( + `handle`: ULong, + `chatId`: Long, + `path`: String, + `caption`: String, + `replyToMsgId`: Int, + `topMsgId`: Int, + `entitiesJson`: String? +): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_photo_message( @@ -8087,7 +8642,13 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `sendPollVote`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `options`: List) +@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( @@ -8100,8 +8661,14 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `sendReaction`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `emoticon`: String, `documentId`: Long) +@Throws(MtprotoException::class) +fun `sendReaction`( + `handle`: ULong, + `chatId`: Long, + `messageId`: Int, + `emoticon`: String, + `documentId`: Long +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_reaction( @@ -8115,8 +8682,14 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `sendSavedGif`(`handle`: ULong, `chatId`: Long, `documentId`: Long, `replyToMsgId`: Int, `topMsgId`: Int): MessageDto { +@Throws(MtprotoException::class) +fun `sendSavedGif`( + `handle`: ULong, + `chatId`: Long, + `documentId`: Long, + `replyToMsgId`: Int, + `topMsgId`: Int +): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_saved_gif( @@ -8132,7 +8705,17 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `sendTextMessage`(`handle`: ULong, `chatId`: Long, `text`: String, `replyToMsgId`: Int, `entitiesJson`: String?, `topMsgId`: Int, `webpageUrl`: String?, `clientRandomId`: Long): MessageDto { +@Throws(MtprotoException::class) +fun `sendTextMessage`( + `handle`: ULong, + `chatId`: Long, + `text`: String, + `replyToMsgId`: Int, + `entitiesJson`: String?, + `topMsgId`: Int, + `webpageUrl`: String?, + `clientRandomId`: Long +): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_text_message( @@ -8151,7 +8734,14 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `sendUploadedAlbum`(`handle`: ULong, `chatId`: Long, `items`: List, `replyToMsgId`: Int, `topMsgId`: 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( @@ -8167,7 +8757,15 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `sendUploadedMedia`(`handle`: ULong, `chatId`: Long, `item`: UploadItemDto, `replyToMsgId`: Int, `topMsgId`: Int, `entitiesJson`: String?): MessageDto { +@Throws(MtprotoException::class) +fun `sendUploadedMedia`( + `handle`: ULong, + `chatId`: Long, + `item`: UploadItemDto, + `replyToMsgId`: Int, + `topMsgId`: Int, + `entitiesJson`: String? +): MessageDto { return FfiConverterTypeMessageDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_send_uploaded_media( @@ -8184,7 +8782,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `setClientTestDc`(`handle`: ULong, `test`: Boolean) +@Throws(MtprotoException::class) +fun `setClientTestDc`(`handle`: ULong, `test`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_client_test_dc( @@ -8195,8 +8794,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `setContactJoinedSilent`(`handle`: ULong, `silent`: Boolean) +@Throws(MtprotoException::class) +fun `setContactJoinedSilent`(`handle`: ULong, `silent`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_contact_joined_silent( @@ -8211,9 +8810,10 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = /** * 0 interactive read, 1 background read, 2 interactive media, 3 background media, * 4 interactive write, 5 ordinary media, 6 visible media, 7 user media. - */ fun `setDispatchClass`(`class`: Int) + */ + fun `setDispatchClass`(`class`: Int) = - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_dispatch_class( @@ -8221,7 +8821,7 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - fun `setDownloadChunkKib`(`kib`: Int) +fun `setDownloadChunkKib`(`kib`: Int) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_chunk_kib( @@ -8234,9 +8834,10 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = /** * "Faster downloads" setting: media lanes (1..=8) and parts in flight (1..=16). - */ fun `setDownloadConcurrency`(`lanes`: Int, `parts`: Int) + */ + fun `setDownloadConcurrency`(`lanes`: Int, `parts`: Int) = - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_concurrency( @@ -8247,7 +8848,7 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = fun `setDownloadProgressListener`(`listener`: DownloadProgressListener) = - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_download_progress_listener( @@ -8255,7 +8856,7 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - fun `setFilePartKib`(`kib`: Int) +fun `setFilePartKib`(`kib`: Int) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_file_part_kib( @@ -8267,7 +8868,7 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = fun `setInitConnectionInfo`(`info`: InitConnectionInfo) = - uniffiRustCall { _status -> + uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_init_connection_info( @@ -8275,8 +8876,15 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `setProxy`(`kind`: String, `host`: String, `port`: UShort, `username`: String?, `password`: String?, `secret`: ByteArray) +@Throws(MtprotoException::class) +fun `setProxy`( + `kind`: String, + `host`: String, + `port`: UShort, + `username`: String?, + `password`: String?, + `secret`: ByteArray +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_proxy( @@ -8291,8 +8899,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `setTransportMode`(`mode`: String) +@Throws(MtprotoException::class) +fun `setTransportMode`(`mode`: String) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_transport_mode( @@ -8302,8 +8910,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `setTyping`(`handle`: ULong, `chatId`: Long, `typing`: Boolean) +@Throws(MtprotoException::class) +fun `setTyping`(`handle`: ULong, `chatId`: Long, `typing`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_set_typing( @@ -8315,8 +8923,13 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `signIn`(`handle`: ULong, `phone`: String, `phoneCodeHash`: String, `phoneCode`: String): AuthSignedIn { +@Throws(MtprotoException::class) +fun `signIn`( + `handle`: ULong, + `phone`: String, + `phoneCodeHash`: String, + `phoneCode`: String +): AuthSignedIn { return FfiConverterTypeAuthSignedIn.lift( uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_sign_in( @@ -8331,7 +8944,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - @Throws(MtprotoException::class) fun `startUpdates`(`handle`: ULong) +@Throws(MtprotoException::class) +fun `startUpdates`(`handle`: ULong) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_start_updates( @@ -8341,8 +8955,14 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `toggleTodoCompleted`(`handle`: ULong, `chatId`: Long, `messageId`: Int, `completed`: List, `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( @@ -8356,8 +8976,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `unblockUser`(`handle`: ULong, `chatId`: Long) +@Throws(MtprotoException::class) +fun `unblockUser`(`handle`: ULong, `chatId`: Long) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_unblock_user( @@ -8368,8 +8988,13 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `unregisterDevice`(`handle`: ULong, `tokenType`: Int, `token`: String, `otherUids`: List) +@Throws(MtprotoException::class) +fun `unregisterDevice`( + `handle`: ULong, + `tokenType`: Int, + `token`: String, + `otherUids`: List +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_unregister_device( @@ -8382,8 +9007,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `updateFolder`(`handle`: ULong, `folder`: FolderDto) +@Throws(MtprotoException::class) +fun `updateFolder`(`handle`: ULong, `folder`: FolderDto) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder( @@ -8394,8 +9019,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `updateFolderOrder`(`handle`: ULong, `order`: List) +@Throws(MtprotoException::class) +fun `updateFolderOrder`(`handle`: ULong, `order`: List) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_folder_order( @@ -8406,7 +9031,11 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - fun `updateLazySyncConfig`(`handle`: ULong, `lazy`: Boolean, `exceptions`: List) +fun `updateLazySyncConfig`( + `handle`: ULong, + `lazy`: Boolean, + `exceptions`: List +) = uniffiRustCall { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_lazy_sync_config( @@ -8418,8 +9047,17 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `updateNotifySettings`(`handle`: ULong, `peerKind`: String, `chatId`: Long, `showPreviews`: Boolean?, `silent`: Boolean, `muteUntil`: Int, `storiesMuted`: Boolean, `sound`: String) +@Throws(MtprotoException::class) +fun `updateNotifySettings`( + `handle`: ULong, + `peerKind`: String, + `chatId`: Long, + `showPreviews`: Boolean?, + `silent`: Boolean, + `muteUntil`: Int, + `storiesMuted`: Boolean, + `sound`: String +) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_notify_settings( @@ -8436,8 +9074,8 @@ fun `promoteRequestControl`(`id`: ULong, `class`: Int) = } - - @Throws(MtprotoException::class) fun `updateStatus`(`handle`: ULong, `offline`: Boolean) +@Throws(MtprotoException::class) +fun `updateStatus`(`handle`: ULong, `offline`: Boolean) = uniffiRustCallWithError(MtprotoException) { _status -> UniffiLib.uniffi_monogram_mtproto_fn_func_update_status( diff --git a/network/http/src/main/java/org/monogram/network/http/FileCache.kt b/network/http/src/main/java/org/monogram/network/http/FileCache.kt index ce6d8a03..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 @@ -55,6 +55,7 @@ class FileCache( 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 @@ -115,6 +116,15 @@ class FileCache( 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() @@ -293,8 +303,10 @@ class FileCache( val file = fileFor(key) if (!file.isFile) return@synchronized null val now = nowMs() - file.setLastModified(now) - lastAccess[diskName(key)] = now + val name = diskName(key) + val previous = lastAccess[name] + lastAccess[name] = now + if (previous == null || now - previous >= ACCESS_TOUCH_MS) file.setLastModified(now) file } @@ -643,12 +655,21 @@ class FileCache( 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 { @@ -656,6 +677,11 @@ class FileCache( 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-" 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 0977ce6b..968d22dc 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 @@ -315,6 +315,8 @@ class MediaRepository( 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) From 3122907861f8bbc32e25c065bae470a13727a359 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:28:49 +0300 Subject: [PATCH 6/7] fix width for settings sheets --- .../java/org/monogram/feature/settings/ui/SettingsData.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 e63df58f..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 @@ -498,7 +498,7 @@ internal fun CacheLimitPickerDialog( onSelect: (MediaCacheLimit) -> Unit, onDismiss: () -> Unit, ) { - AppModalSheet(onDismissRequest = onDismiss, maxWidth = 560.dp) { + AppModalSheet(onDismissRequest = onDismiss) { Text( text = stringResource(R.string.settings_cache_limit), style = MaterialTheme.typography.headlineSmallEmphasized, @@ -543,7 +543,7 @@ internal fun CacheClearDialog( onConfirm: () -> Unit, ) { val selectedBytes = selectedCacheBytes(byKind, selected) - AppModalSheet(onDismissRequest = onDismiss, maxWidth = 560.dp) { + AppModalSheet(onDismissRequest = onDismiss) { Column(Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) { Text( text = stringResource(R.string.settings_clear_cache), @@ -632,7 +632,7 @@ internal fun CacheChatClearSheet( onDismiss: () -> Unit, onConfirm: () -> Unit, ) { - AppModalSheet(onDismissRequest = onDismiss, maxWidth = 560.dp) { + AppModalSheet(onDismissRequest = onDismiss) { Text( text = stringResource(R.string.settings_storage_clear_chat), style = MaterialTheme.typography.headlineSmallEmphasized, From a8ed07144a30932631e726d74599078eea3c0490 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:37:41 +0300 Subject: [PATCH 7/7] fix custom emoji download via sticker sets --- .../mtproto-rs/src/client/media_download.rs | 124 ++++++++++++++---- .../tests/unit/client_mgr_session_tests.rs | 70 ++++++++++ .../monogram/network/bridge/media/MediaApi.kt | 6 +- .../network/bridge/profile/ProfileApi.kt | 4 +- .../monogram/network/http/MediaRepository.kt | 6 +- 5 files changed, 182 insertions(+), 28 deletions(-) diff --git a/native/mtproto-rs/src/client/media_download.rs b/native/mtproto-rs/src/client/media_download.rs index ab2c5602..d1772b3e 100644 --- a/native/mtproto-rs/src/client/media_download.rs +++ b/native/mtproto-rs/src/client/media_download.rs @@ -587,18 +587,20 @@ 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)) })?; + // 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, @@ -641,29 +643,103 @@ 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> { - let sticker = state - .media - .get(&(document_id, 0)) - .and_then(sticker_set_of); - if let Some((set_id, access_hash)) = sticker { - crate::messages::sticker_rpc::get_sticker_set( - &mut state.snapshot, - state.api_id, - &mut state.media, - set_id, - access_hash, - )?; - return Ok(()); + 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(()) + } } - 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( 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 c99ad5bd..623c5f9f 100644 --- a/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs +++ b/native/mtproto-rs/tests/unit/client_mgr_session_tests.rs @@ -60,6 +60,76 @@ fn sticker_document_refreshes_from_its_set_not_saved_gifs() { 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/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 be16cc4b..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 @@ -134,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 { @@ -146,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") } } 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/http/src/main/java/org/monogram/network/http/MediaRepository.kt b/network/http/src/main/java/org/monogram/network/http/MediaRepository.kt index 968d22dc..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 @@ -650,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 @@ -680,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(