From 1a3a7ed9cdc22c131c2ba005bbe73495646928a2 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:25:05 +0300 Subject: [PATCH 1/4] init --- .../monogram/core/perf/ChatOpenPerfBridge.kt | 75 +- .../core/perf/ChatOpenPerfBridgeTest.kt | 32 + data/build.gradle.kts | 11 + .../37.json | 2835 ++++++++++++++++ .../38.json | 2906 +++++++++++++++++ .../cache/RoomMessageCacheIntegrationTest.kt | 211 ++ .../monogram/data/db/MonogramMigrationTest.kt | 82 + .../data/datasource/TdFileDataSource.kt | 3 +- .../datasource/cache/ChatLocalDataSource.kt | 64 +- .../cache/InMemoryChatLocalDataSource.kt | 55 + .../datasource/cache/MessageCacheMutation.kt | 22 +- .../datasource/cache/MessageCacheWriter.kt | 111 +- .../cache/RoomChatLocalDataSource.kt | 216 +- .../data/datasource/remote/MessageFileApi.kt | 9 + .../remote/MessageFileCoordinator.kt | 16 + .../remote/MessageRemoteDataSource.kt | 46 +- .../datasource/remote/RemoteMessageBatch.kt | 75 +- .../remote/TdMessageRemoteDataSource.kt | 425 ++- .../org/monogram/data/db/MonogramDatabase.kt | 8 +- .../monogram/data/db/MonogramMigrations.kt | 22 + .../org/monogram/data/db/dao/MessageDao.kt | 73 +- .../monogram/data/db/dao/MessageWindowDao.kt | 60 + .../data/db/model/MessageWindowEntity.kt | 21 + .../java/org/monogram/data/di/dataModule.kt | 23 +- .../data/gateway/UpdateDispatcherImpl.kt | 28 + .../monogram/data/infra/AtomicSingleFlight.kt | 32 + .../monogram/data/infra/FileDownloadQueue.kt | 235 +- .../mapper/MappedMediaDemandCoordinator.kt | 187 ++ .../org/monogram/data/mapper/MessageMapper.kt | 148 +- .../data/mapper/SponsoredMessageMapper.kt | 2 +- .../org/monogram/data/mapper/WebPageMapper.kt | 87 +- .../mapper/message/MessageContentMapper.kt | 231 +- .../message/MessagePersistenceMapper.kt | 9 - .../repository/ChatOpenOwnershipRegistry.kt | 2 + .../repository/ChatOpenSessionCoordinator.kt | 65 + .../data/repository/ConversationUpdateKey.kt | 24 + .../data/repository/MessageReadBatcher.kt | 60 + .../data/repository/MessageRepositoryImpl.kt | 666 ++-- .../repository/ProfilePhotoRepositoryImpl.kt | 248 +- .../cache/InMemoryChatLocalDataSourceTest.kt | 70 +- .../cache/MessageCacheWriterTest.kt | 138 + .../remote/MessageMapOptionsTest.kt | 25 + .../remote/RemoteHistoryFetchModeTest.kt | 92 + .../data/infra/AtomicSingleFlightTest.kt | 84 + .../data/infra/ConnectionManagerTest.kt | 6 + .../data/infra/FileDownloadQueueTest.kt | 232 +- .../MappedMediaDemandCoordinatorTest.kt | 101 + .../ChatOpenOwnershipRegistryTest.kt | 4 +- .../ChatOpenSessionCoordinatorTest.kt | 68 + .../repository/ConversationUpdateKeyTest.kt | 28 + .../data/repository/MessageReadBatcherTest.kt | 87 + .../domain/models/ConversationUpdate.kt | 44 + .../domain/models/ProfilePhotoMedia.kt | 13 + .../monogram/domain/models/TransferEvents.kt | 4 + .../repository/AppPreferencesProvider.kt | 8 + .../domain/repository/MessageRepository.kt | 134 +- .../repository/ProfilePhotoRepository.kt | 15 +- gradle/libs.versions.toml | 3 + presentation/build.gradle.kts | 7 +- .../presentation/core/util/AppPreferences.kt | 56 + .../chats/conversation/ChatComponent.kt | 2 +- .../features/chats/conversation/ChatStore.kt | 2 +- .../chats/conversation/ChatStoreFactory.kt | 5 +- .../chats/conversation/ConversationSession.kt | 568 ++++ .../ConversationViewportReducer.kt | 73 + .../conversation/DefaultChatComponent.kt | 50 +- .../logic/ChatInitialLoadPolicy.kt | 7 + .../logic/files/FileOperations.kt | 6 +- .../message-actions/MessageOperations.kt | 14 +- .../logic/message-loading/MessageLoading.kt | 1140 +++++-- .../logic/thread/ThreadContext.kt | 17 + .../conversation/ui/CompactMediaMosaic.kt | 56 - .../ui/channel/ChannelGifMessageBubble.kt | 16 - .../ui/channel/ChannelPhotoMessageBubble.kt | 14 - .../ui/channel/ChannelVideoMessageBubble.kt | 14 - .../ui/channel/ChannelVoiceMessageBubble.kt | 23 - .../ui/content/ChatContentBody.kt | 46 +- .../ui/content/ChatContentEffects.kt | 23 +- .../ui/content/ChatContentList.kt | 41 - .../ui/content/ChatContentViewers.kt | 122 +- .../ui/message/AudioMessageBubble.kt | 23 - .../ui/message/DocumentMessageBubble.kt | 26 +- .../ui/message/GifMessageBubble.kt | 16 - .../ui/message/LinkPreviewActions.kt | 18 +- .../ui/message/PhotoMessageBubble.kt | 13 - .../ui/message/RichMessageBubble.kt | 27 +- .../ui/message/VideoMessageBubble.kt | 16 - .../ui/message/VoiceMessageBubble.kt | 23 - .../chats/list/DefaultChatListComponent.kt | 30 +- .../features/instantview/InstantViewer.kt | 27 +- .../profile/DefaultProfileComponent.kt | 121 +- .../features/profile/ProfileComponent.kt | 3 +- .../features/profile/ProfileViewers.kt | 104 +- .../profile/logs/ProfileLogsContent.kt | 5 +- .../features/viewers/FullscreenImageItem.kt | 40 + .../viewers/FullscreenImageStateHolder.kt | 138 + .../features/viewers/ImageViewer.kt | 78 +- .../features/viewers/MediaViewer.kt | 20 +- .../components/ImageViewerComponents.kt | 111 +- .../settings/debug/DebugComponent.kt | 3 + .../settings/debug/DebugContent.kt | 19 +- .../settings/debug/DefaultDebugComponent.kt | 37 + .../settings/DefaultSettingsComponent.kt | 31 +- .../settings/settings/SettingsComponent.kt | 5 +- .../settings/settings/SettingsContent.kt | 5 +- .../src/main/res/values-ru-rRU/string.xml | 2 + presentation/src/main/res/values/string.xml | 2 + .../util/ConversationPipelineDefaultTest.kt | 115 + .../conversation/ConversationSessionTest.kt | 281 ++ .../ConversationViewportReducerTest.kt | 127 + .../logic/ChatInitialLoadPolicyTest.kt | 42 + .../ConversationSessionProjectionTest.kt | 266 ++ .../ui/message/LinkPreviewActionsTest.kt | 8 +- 113 files changed, 12517 insertions(+), 2148 deletions(-) create mode 100644 core/src/test/java/org/monogram/core/perf/ChatOpenPerfBridgeTest.kt create mode 100644 data/schemas/org.monogram.data.db.MonogramDatabase/37.json create mode 100644 data/schemas/org.monogram.data.db.MonogramDatabase/38.json create mode 100644 data/src/androidTest/java/org/monogram/data/datasource/cache/RoomMessageCacheIntegrationTest.kt create mode 100644 data/src/androidTest/java/org/monogram/data/db/MonogramMigrationTest.kt create mode 100644 data/src/main/java/org/monogram/data/db/dao/MessageWindowDao.kt create mode 100644 data/src/main/java/org/monogram/data/db/model/MessageWindowEntity.kt create mode 100644 data/src/main/java/org/monogram/data/infra/AtomicSingleFlight.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/MappedMediaDemandCoordinator.kt create mode 100644 data/src/main/java/org/monogram/data/repository/ChatOpenSessionCoordinator.kt create mode 100644 data/src/main/java/org/monogram/data/repository/ConversationUpdateKey.kt create mode 100644 data/src/main/java/org/monogram/data/repository/MessageReadBatcher.kt create mode 100644 data/src/test/java/org/monogram/data/datasource/remote/MessageMapOptionsTest.kt create mode 100644 data/src/test/java/org/monogram/data/datasource/remote/RemoteHistoryFetchModeTest.kt create mode 100644 data/src/test/java/org/monogram/data/infra/AtomicSingleFlightTest.kt create mode 100644 data/src/test/java/org/monogram/data/mapper/MappedMediaDemandCoordinatorTest.kt create mode 100644 data/src/test/java/org/monogram/data/repository/ChatOpenSessionCoordinatorTest.kt create mode 100644 data/src/test/java/org/monogram/data/repository/ConversationUpdateKeyTest.kt create mode 100644 data/src/test/java/org/monogram/data/repository/MessageReadBatcherTest.kt create mode 100644 domain/src/main/java/org/monogram/domain/models/ConversationUpdate.kt create mode 100644 domain/src/main/java/org/monogram/domain/models/ProfilePhotoMedia.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationSession.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducer.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageItem.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageStateHolder.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/core/util/ConversationPipelineDefaultTest.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationSessionTest.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducerTest.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ConversationSessionProjectionTest.kt diff --git a/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt index 72204996d..65084cdf8 100644 --- a/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt +++ b/core/src/main/java/org/monogram/core/perf/ChatOpenPerfBridge.kt @@ -10,9 +10,19 @@ data class ChatOpenPerfSessionSnapshot( val requestCount: Int, val replyFetchCount: Int, val persistCount: Int, - val persistSkippedCount: Int + val persistSkippedCount: Int, + val firstContentLatencyMs: Long? = null, + val settledLatencyMs: Long? = null, + val shadowMismatchCount: Int = 0 ) +data class ChatOpenPerfReportSnapshot( + val completedSessions: List +) { + val sessionCount: Int get() = completedSessions.size + val shadowMismatchCount: Int get() = completedSessions.sumOf { it.shadowMismatchCount } +} + object ChatOpenPerfBridge { private data class SessionRecord( val sessionId: String, @@ -21,10 +31,15 @@ object ChatOpenPerfBridge { var requestCount: Int = 0, var replyFetchCount: Int = 0, var persistCount: Int = 0, - var persistSkippedCount: Int = 0 + var persistSkippedCount: Int = 0, + val startedAtMs: Long = nowMs(), + var firstContentLatencyMs: Long? = null, + var settledLatencyMs: Long? = null, + var shadowMismatchCount: Int = 0 ) private val sessions = ConcurrentHashMap() + private val completed = ArrayDeque() fun startSession( chatId: Long, @@ -87,14 +102,61 @@ object ChatOpenPerfBridge { } } + fun markFirstContent(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? = + markSession(chatId, threadId) { record -> + if (record.firstContentLatencyMs == null) { + record.firstContentLatencyMs = elapsedSince(record.startedAtMs) + } + } + + fun markSettled(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? = + markSession(chatId, threadId) { record -> + if (record.settledLatencyMs == null) { + record.settledLatencyMs = elapsedSince(record.startedAtMs) + } + } + + fun recordShadowMismatch(chatId: Long, threadId: Long?): ChatOpenPerfSessionSnapshot? = + markSession(chatId, threadId) { it.shadowMismatchCount += 1 } + + /** Returns a bounded process-local report for device/reference-corpus harnesses. */ + fun report(): ChatOpenPerfReportSnapshot = synchronized(completed) { + ChatOpenPerfReportSnapshot(completed.toList()) + } + + fun resetReport() = synchronized(completed) { completed.clear() } + fun clearSession(chatId: Long, threadId: Long?, sessionId: String? = null) { val key = key(chatId, threadId) val record = sessions[key] ?: return if (sessionId == null || record.sessionId == sessionId) { - sessions.remove(key, record) + if (sessions.remove(key, record)) { + synchronized(record) { + synchronized(completed) { + if (completed.size == MAX_COMPLETED_SESSIONS) completed.removeFirst() + completed.addLast(record.snapshot()) + } + } + } + } + } + + private fun markSession( + chatId: Long, + threadId: Long?, + block: (SessionRecord) -> Unit + ): ChatOpenPerfSessionSnapshot? { + val record = findRecord(chatId, threadId) ?: return null + synchronized(record) { + block(record) + return record.snapshot() } } + private fun elapsedSince(startedAtMs: Long): Long = (nowMs() - startedAtMs).coerceAtLeast(0L) + + private fun nowMs(): Long = System.nanoTime() / 1_000_000L + private fun findRecord(chatId: Long, threadId: Long?): SessionRecord? { sessions[key(chatId, threadId)]?.let { return it } return sessions.entries.firstOrNull { entry -> @@ -112,7 +174,12 @@ object ChatOpenPerfBridge { requestCount = requestCount, replyFetchCount = replyFetchCount, persistCount = persistCount, - persistSkippedCount = persistSkippedCount + persistSkippedCount = persistSkippedCount, + firstContentLatencyMs = firstContentLatencyMs, + settledLatencyMs = settledLatencyMs, + shadowMismatchCount = shadowMismatchCount ) } + + private const val MAX_COMPLETED_SESSIONS = 256 } diff --git a/core/src/test/java/org/monogram/core/perf/ChatOpenPerfBridgeTest.kt b/core/src/test/java/org/monogram/core/perf/ChatOpenPerfBridgeTest.kt new file mode 100644 index 000000000..3a8a6c649 --- /dev/null +++ b/core/src/test/java/org/monogram/core/perf/ChatOpenPerfBridgeTest.kt @@ -0,0 +1,32 @@ +package org.monogram.core.perf + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class ChatOpenPerfBridgeTest { + + @Test + fun `completed session keeps latency counters and report is resettable`() { + ChatOpenPerfBridge.resetReport() + ChatOpenPerfBridge.startSession(42L, null, "initial", "resolving") + ChatOpenPerfBridge.recordHistoryRequest(42L, null) + ChatOpenPerfBridge.markFirstContent(42L, null) + ChatOpenPerfBridge.recordShadowMismatch(42L, null) + ChatOpenPerfBridge.markSettled(42L, null) + ChatOpenPerfBridge.clearSession(42L, null) + + val report = ChatOpenPerfBridge.report() + assertEquals(1, report.sessionCount) + val session = report.completedSessions.single() + assertEquals(1, session.requestCount) + assertNotNull(session.firstContentLatencyMs) + assertNotNull(session.settledLatencyMs) + assertEquals(1, session.shadowMismatchCount) + + ChatOpenPerfBridge.resetReport() + assertEquals(0, ChatOpenPerfBridge.report().sessionCount) + assertNull(ChatOpenPerfBridge.currentSession(42L, null)) + } +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 658bd27d3..a27236a8e 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -12,6 +12,7 @@ android { defaultConfig { minSdk = 25 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") ndk { @@ -48,6 +49,9 @@ android { getByName("main") { jniLibs.directories.clear() } + getByName("androidTest") { + assets.srcDir(file("schemas")) + } getByName("official") { jniLibs.directories.add("src/official/jniLibs") } @@ -87,6 +91,10 @@ android { } } +ksp { + arg("room.schemaLocation", file("schemas").path) +} + dependencies { implementation(project(":core")) implementation(project(":domain")) @@ -109,4 +117,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.ktor.client.mock) testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) } diff --git a/data/schemas/org.monogram.data.db.MonogramDatabase/37.json b/data/schemas/org.monogram.data.db.MonogramDatabase/37.json new file mode 100644 index 000000000..e6f21c869 --- /dev/null +++ b/data/schemas/org.monogram.data.db.MonogramDatabase/37.json @@ -0,0 +1,2835 @@ +{ + "formatVersion": 1, + "database": { + "version": 37, + "identityHash": "3649592ebcfbc9f77d8d6753c984fe77", + "entities": [ + { + "tableName": "chats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `unreadCount` INTEGER NOT NULL, `avatarPath` TEXT, `lastMessageText` TEXT NOT NULL, `lastMessageTime` TEXT NOT NULL, `lastMessageDate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isMuted` INTEGER NOT NULL, `isChannel` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `type` TEXT NOT NULL, `privateUserId` INTEGER NOT NULL, `basicGroupId` INTEGER NOT NULL, `supergroupId` INTEGER NOT NULL, `secretChatId` INTEGER NOT NULL, `positionsCache` TEXT, `isArchived` INTEGER NOT NULL, `memberCount` INTEGER NOT NULL, `onlineCount` INTEGER NOT NULL, `unreadMentionCount` INTEGER NOT NULL, `unreadReactionCount` INTEGER NOT NULL, `isMarkedAsUnread` INTEGER NOT NULL, `hasProtectedContent` INTEGER NOT NULL, `isTranslatable` INTEGER NOT NULL, `hasAutomaticTranslation` INTEGER NOT NULL, `messageAutoDeleteTime` INTEGER NOT NULL, `canBeDeletedOnlyForSelf` INTEGER NOT NULL, `canBeDeletedForAllUsers` INTEGER NOT NULL, `canBeReported` INTEGER NOT NULL, `lastReadInboxMessageId` INTEGER NOT NULL, `lastReadOutboxMessageId` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `isLastMessageOutgoing` INTEGER NOT NULL, `replyMarkupMessageId` INTEGER NOT NULL, `messageSenderId` INTEGER, `blockList` INTEGER NOT NULL, `emojiStatusId` INTEGER, `accentColorId` INTEGER NOT NULL, `profileAccentColorId` INTEGER NOT NULL, `backgroundCustomEmojiId` INTEGER NOT NULL, `photoId` INTEGER NOT NULL, `isSupergroup` INTEGER NOT NULL, `isAdmin` INTEGER NOT NULL, `isOnline` INTEGER NOT NULL, `typingAction` TEXT, `draftMessage` TEXT, `isVerified` INTEGER NOT NULL, `isScam` INTEGER NOT NULL, `isFake` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `restrictionReason` TEXT, `hasSensitiveContent` INTEGER NOT NULL, `activeStoryStateType` TEXT, `activeStoryId` INTEGER NOT NULL, `boostLevel` INTEGER NOT NULL, `hasForumTabs` INTEGER NOT NULL, `isAdministeredDirectMessagesGroup` INTEGER NOT NULL, `paidMessageStarCount` INTEGER NOT NULL, `isSponsor` INTEGER NOT NULL, `viewAsTopics` INTEGER NOT NULL, `isForum` INTEGER NOT NULL, `isBot` INTEGER NOT NULL, `isMember` INTEGER NOT NULL, `username` TEXT, `description` TEXT, `inviteLink` TEXT, `permissionCanSendBasicMessages` INTEGER NOT NULL, `permissionCanSendAudios` INTEGER NOT NULL, `permissionCanSendDocuments` INTEGER NOT NULL, `permissionCanSendPhotos` INTEGER NOT NULL, `permissionCanSendVideos` INTEGER NOT NULL, `permissionCanSendVideoNotes` INTEGER NOT NULL, `permissionCanSendVoiceNotes` INTEGER NOT NULL, `permissionCanSendPolls` INTEGER NOT NULL, `permissionCanSendOtherMessages` INTEGER NOT NULL, `permissionCanAddLinkPreviews` INTEGER NOT NULL, `permissionCanReactToMessages` INTEGER NOT NULL, `permissionCanEditTag` INTEGER NOT NULL, `permissionCanChangeInfo` INTEGER NOT NULL, `permissionCanInviteUsers` INTEGER NOT NULL, `permissionCanPinMessages` INTEGER NOT NULL, `permissionCanCreateTopics` INTEGER NOT NULL, `lastMessageContentType` TEXT NOT NULL, `lastMessageSenderName` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "avatarPath", + "columnName": "avatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageText", + "columnName": "lastMessageText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageTime", + "columnName": "lastMessageTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageDate", + "columnName": "lastMessageDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateUserId", + "columnName": "privateUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "basicGroupId", + "columnName": "basicGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supergroupId", + "columnName": "supergroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secretChatId", + "columnName": "secretChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionsCache", + "columnName": "positionsCache", + "affinity": "TEXT" + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memberCount", + "columnName": "memberCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlineCount", + "columnName": "onlineCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionCount", + "columnName": "unreadMentionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadReactionCount", + "columnName": "unreadReactionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkedAsUnread", + "columnName": "isMarkedAsUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasProtectedContent", + "columnName": "hasProtectedContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTranslatable", + "columnName": "isTranslatable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasAutomaticTranslation", + "columnName": "hasAutomaticTranslation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageAutoDeleteTime", + "columnName": "messageAutoDeleteTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedOnlyForSelf", + "columnName": "canBeDeletedOnlyForSelf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedForAllUsers", + "columnName": "canBeDeletedForAllUsers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeReported", + "columnName": "canBeReported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadInboxMessageId", + "columnName": "lastReadInboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadOutboxMessageId", + "columnName": "lastReadOutboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLastMessageOutgoing", + "columnName": "isLastMessageOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyMarkupMessageId", + "columnName": "replyMarkupMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageSenderId", + "columnName": "messageSenderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "blockList", + "columnName": "blockList", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiStatusId", + "columnName": "emojiStatusId", + "affinity": "INTEGER" + }, + { + "fieldPath": "accentColorId", + "columnName": "accentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileAccentColorId", + "columnName": "profileAccentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backgroundCustomEmojiId", + "columnName": "backgroundCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoId", + "columnName": "photoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSupergroup", + "columnName": "isSupergroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAdmin", + "columnName": "isAdmin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOnline", + "columnName": "isOnline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typingAction", + "columnName": "typingAction", + "affinity": "TEXT" + }, + { + "fieldPath": "draftMessage", + "columnName": "draftMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "isVerified", + "columnName": "isVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isScam", + "columnName": "isScam", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFake", + "columnName": "isFake", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictionReason", + "columnName": "restrictionReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hasSensitiveContent", + "columnName": "hasSensitiveContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeStoryStateType", + "columnName": "activeStoryStateType", + "affinity": "TEXT" + }, + { + "fieldPath": "activeStoryId", + "columnName": "activeStoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boostLevel", + "columnName": "boostLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasForumTabs", + "columnName": "hasForumTabs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAdministeredDirectMessagesGroup", + "columnName": "isAdministeredDirectMessagesGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paidMessageStarCount", + "columnName": "paidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSponsor", + "columnName": "isSponsor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewAsTopics", + "columnName": "viewAsTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForum", + "columnName": "isForum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBot", + "columnName": "isBot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMember", + "columnName": "isMember", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "inviteLink", + "columnName": "inviteLink", + "affinity": "TEXT" + }, + { + "fieldPath": "permissionCanSendBasicMessages", + "columnName": "permissionCanSendBasicMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendAudios", + "columnName": "permissionCanSendAudios", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendDocuments", + "columnName": "permissionCanSendDocuments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendPhotos", + "columnName": "permissionCanSendPhotos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendVideos", + "columnName": "permissionCanSendVideos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendVideoNotes", + "columnName": "permissionCanSendVideoNotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendVoiceNotes", + "columnName": "permissionCanSendVoiceNotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendPolls", + "columnName": "permissionCanSendPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendOtherMessages", + "columnName": "permissionCanSendOtherMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanAddLinkPreviews", + "columnName": "permissionCanAddLinkPreviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanReactToMessages", + "columnName": "permissionCanReactToMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanEditTag", + "columnName": "permissionCanEditTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanChangeInfo", + "columnName": "permissionCanChangeInfo", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanInviteUsers", + "columnName": "permissionCanInviteUsers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanPinMessages", + "columnName": "permissionCanPinMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanCreateTopics", + "columnName": "permissionCanCreateTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageContentType", + "columnName": "lastMessageContentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageSenderName", + "columnName": "lastMessageSenderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chats_isPinned_order", + "unique": false, + "columnNames": [ + "isPinned", + "order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_isPinned_order` ON `${TABLE_NAME}` (`isPinned`, `order`)" + }, + { + "name": "index_chats_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `senderId` INTEGER NOT NULL, `senderName` TEXT NOT NULL, `content` TEXT NOT NULL, `contentType` TEXT NOT NULL, `contentMeta` TEXT, `mediaFileId` INTEGER NOT NULL, `mediaPath` TEXT, `mediaThumbnailPath` TEXT, `minithumbnail` BLOB, `date` INTEGER NOT NULL, `isOutgoing` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `replyMarkupData` TEXT, `reactionsData` TEXT, `replyToMessageId` INTEGER NOT NULL, `replyToPreview` TEXT, `replyToPreviewType` TEXT, `replyToPreviewText` TEXT, `replyToPreviewSenderName` TEXT, `replyCount` INTEGER NOT NULL, `threadId` INTEGER, `viaBotUserId` INTEGER NOT NULL, `viaBotName` TEXT, `senderPersonalAvatarPath` TEXT, `senderCustomTitle` TEXT, `isSenderAdmin` INTEGER NOT NULL, `isSenderVerified` INTEGER NOT NULL, `isSenderPremium` INTEGER NOT NULL, `senderStatusEmojiId` INTEGER NOT NULL, `senderStatusEmojiPath` TEXT, `forwardFromName` TEXT, `forwardFromId` INTEGER NOT NULL, `forwardOriginChatId` INTEGER, `forwardOriginMessageId` INTEGER, `forwardDate` INTEGER NOT NULL, `editDate` INTEGER NOT NULL, `canBeEdited` INTEGER NOT NULL, `canBeForwarded` INTEGER NOT NULL, `canBeDeletedOnlyForSelf` INTEGER NOT NULL, `canBeDeletedForAllUsers` INTEGER NOT NULL, `canBeSaved` INTEGER NOT NULL, `canGetMessageThread` INTEGER NOT NULL, `canGetStatistics` INTEGER NOT NULL, `canGetRevenueStatistics` INTEGER NOT NULL, `canGetMediaStatistics` INTEGER NOT NULL, `canGetReadReceipts` INTEGER NOT NULL, `canGetViewers` INTEGER NOT NULL, `readDate` INTEGER NOT NULL, `hasUnreadMention` INTEGER NOT NULL, `hasUnreadReactions` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `containsUnreadPollVotes` INTEGER NOT NULL, `suggestedPostInfoData` TEXT, `factCheckData` TEXT, `mediaAlbumId` INTEGER NOT NULL, `entities` TEXT, `viewCount` INTEGER NOT NULL, `forwardCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "senderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderName", + "columnName": "senderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentMeta", + "columnName": "contentMeta", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPath", + "columnName": "mediaPath", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaThumbnailPath", + "columnName": "mediaThumbnailPath", + "affinity": "TEXT" + }, + { + "fieldPath": "minithumbnail", + "columnName": "minithumbnail", + "affinity": "BLOB" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyMarkupData", + "columnName": "replyMarkupData", + "affinity": "TEXT" + }, + { + "fieldPath": "reactionsData", + "columnName": "reactionsData", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyToPreview", + "columnName": "replyToPreview", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToPreviewType", + "columnName": "replyToPreviewType", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToPreviewText", + "columnName": "replyToPreviewText", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToPreviewSenderName", + "columnName": "replyToPreviewSenderName", + "affinity": "TEXT" + }, + { + "fieldPath": "replyCount", + "columnName": "replyCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "viaBotUserId", + "columnName": "viaBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaBotName", + "columnName": "viaBotName", + "affinity": "TEXT" + }, + { + "fieldPath": "senderPersonalAvatarPath", + "columnName": "senderPersonalAvatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "senderCustomTitle", + "columnName": "senderCustomTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "isSenderAdmin", + "columnName": "isSenderAdmin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSenderVerified", + "columnName": "isSenderVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSenderPremium", + "columnName": "isSenderPremium", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderStatusEmojiId", + "columnName": "senderStatusEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderStatusEmojiPath", + "columnName": "senderStatusEmojiPath", + "affinity": "TEXT" + }, + { + "fieldPath": "forwardFromName", + "columnName": "forwardFromName", + "affinity": "TEXT" + }, + { + "fieldPath": "forwardFromId", + "columnName": "forwardFromId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardOriginChatId", + "columnName": "forwardOriginChatId", + "affinity": "INTEGER" + }, + { + "fieldPath": "forwardOriginMessageId", + "columnName": "forwardOriginMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "forwardDate", + "columnName": "forwardDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editDate", + "columnName": "editDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeEdited", + "columnName": "canBeEdited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeForwarded", + "columnName": "canBeForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedOnlyForSelf", + "columnName": "canBeDeletedOnlyForSelf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedForAllUsers", + "columnName": "canBeDeletedForAllUsers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeSaved", + "columnName": "canBeSaved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetMessageThread", + "columnName": "canGetMessageThread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetStatistics", + "columnName": "canGetStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetRevenueStatistics", + "columnName": "canGetRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetMediaStatistics", + "columnName": "canGetMediaStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetReadReceipts", + "columnName": "canGetReadReceipts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetViewers", + "columnName": "canGetViewers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readDate", + "columnName": "readDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasUnreadMention", + "columnName": "hasUnreadMention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasUnreadReactions", + "columnName": "hasUnreadReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "containsUnreadPollVotes", + "columnName": "containsUnreadPollVotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "suggestedPostInfoData", + "columnName": "suggestedPostInfoData", + "affinity": "TEXT" + }, + { + "fieldPath": "factCheckData", + "columnName": "factCheckData", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaAlbumId", + "columnName": "mediaAlbumId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entities", + "columnName": "entities", + "affinity": "TEXT" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardCount", + "columnName": "forwardCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "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`)" + }, + { + "name": "index_messages_chatId_id", + "unique": false, + "columnNames": [ + "chatId", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_id` ON `${TABLE_NAME}` (`chatId`, `id`)" + }, + { + "name": "index_messages_chatId_threadId_date", + "unique": false, + "columnNames": [ + "chatId", + "threadId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_date` ON `${TABLE_NAME}` (`chatId`, `threadId`, `date`)" + }, + { + "name": "index_messages_chatId_threadId_id", + "unique": false, + "columnNames": [ + "chatId", + "threadId", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_id` ON `${TABLE_NAME}` (`chatId`, `threadId`, `id`)" + }, + { + "name": "index_messages_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `phoneNumber` TEXT, `avatarPath` TEXT, `personalAvatarPath` TEXT, `isPremium` INTEGER NOT NULL, `isVerified` INTEGER NOT NULL, `isScam` INTEGER NOT NULL, `isFake` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `isSupport` INTEGER NOT NULL, `isContact` INTEGER NOT NULL, `isMutualContact` INTEGER NOT NULL, `isCloseFriend` INTEGER NOT NULL, `botTypeCanBeEdited` INTEGER NOT NULL, `botTypeCanJoinGroups` INTEGER NOT NULL, `botTypeCanReadAllGroupMessages` INTEGER NOT NULL, `botTypeHasMainWebApp` INTEGER NOT NULL, `botTypeHasTopics` INTEGER NOT NULL, `botTypeAllowsUsersToCreateTopics` INTEGER NOT NULL, `botTypeCanManageBots` INTEGER NOT NULL, `botTypeIsInline` INTEGER NOT NULL, `botTypeInlineQueryPlaceholder` TEXT, `botTypeSupportsGuestQueries` INTEGER NOT NULL, `botTypeIsGuard` INTEGER NOT NULL, `botTypeNeedLocation` INTEGER NOT NULL, `botTypeCanConnectToBusiness` INTEGER NOT NULL, `botTypeCanBeAddedToAttachmentMenu` INTEGER NOT NULL, `botTypeActiveUserCount` INTEGER NOT NULL, `userType` TEXT NOT NULL, `restrictionReason` TEXT, `hasSensitiveContent` INTEGER NOT NULL, `activeStoryStateType` TEXT, `activeStoryId` INTEGER NOT NULL, `restrictsNewChats` INTEGER NOT NULL, `paidMessageStarCount` INTEGER NOT NULL, `haveAccess` INTEGER NOT NULL, `username` TEXT, `usernamesData` TEXT, `statusType` TEXT NOT NULL, `accentColorId` INTEGER NOT NULL, `backgroundCustomEmojiId` INTEGER NOT NULL, `profileAccentColorId` INTEGER NOT NULL, `profileBackgroundCustomEmojiId` INTEGER NOT NULL, `statusEmojiId` INTEGER NOT NULL, `languageCode` TEXT, `addedToAttachmentMenu` INTEGER NOT NULL, `lastSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneNumber", + "columnName": "phoneNumber", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarPath", + "columnName": "avatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "personalAvatarPath", + "columnName": "personalAvatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isPremium", + "columnName": "isPremium", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVerified", + "columnName": "isVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isScam", + "columnName": "isScam", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFake", + "columnName": "isFake", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSupport", + "columnName": "isSupport", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isContact", + "columnName": "isContact", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMutualContact", + "columnName": "isMutualContact", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCloseFriend", + "columnName": "isCloseFriend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanBeEdited", + "columnName": "botTypeCanBeEdited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanJoinGroups", + "columnName": "botTypeCanJoinGroups", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanReadAllGroupMessages", + "columnName": "botTypeCanReadAllGroupMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeHasMainWebApp", + "columnName": "botTypeHasMainWebApp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeHasTopics", + "columnName": "botTypeHasTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeAllowsUsersToCreateTopics", + "columnName": "botTypeAllowsUsersToCreateTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanManageBots", + "columnName": "botTypeCanManageBots", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeIsInline", + "columnName": "botTypeIsInline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeInlineQueryPlaceholder", + "columnName": "botTypeInlineQueryPlaceholder", + "affinity": "TEXT" + }, + { + "fieldPath": "botTypeSupportsGuestQueries", + "columnName": "botTypeSupportsGuestQueries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeIsGuard", + "columnName": "botTypeIsGuard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeNeedLocation", + "columnName": "botTypeNeedLocation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanConnectToBusiness", + "columnName": "botTypeCanConnectToBusiness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanBeAddedToAttachmentMenu", + "columnName": "botTypeCanBeAddedToAttachmentMenu", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeActiveUserCount", + "columnName": "botTypeActiveUserCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userType", + "columnName": "userType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "restrictionReason", + "columnName": "restrictionReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hasSensitiveContent", + "columnName": "hasSensitiveContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeStoryStateType", + "columnName": "activeStoryStateType", + "affinity": "TEXT" + }, + { + "fieldPath": "activeStoryId", + "columnName": "activeStoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictsNewChats", + "columnName": "restrictsNewChats", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paidMessageStarCount", + "columnName": "paidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "haveAccess", + "columnName": "haveAccess", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "usernamesData", + "columnName": "usernamesData", + "affinity": "TEXT" + }, + { + "fieldPath": "statusType", + "columnName": "statusType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accentColorId", + "columnName": "accentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backgroundCustomEmojiId", + "columnName": "backgroundCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileAccentColorId", + "columnName": "profileAccentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileBackgroundCustomEmojiId", + "columnName": "profileBackgroundCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusEmojiId", + "columnName": "statusEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "languageCode", + "columnName": "languageCode", + "affinity": "TEXT" + }, + { + "fieldPath": "addedToAttachmentMenu", + "columnName": "addedToAttachmentMenu", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_users_username", + "unique": false, + "columnNames": [ + "username" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_username` ON `${TABLE_NAME}` (`username`)" + }, + { + "name": "index_users_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "chat_full_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `description` TEXT, `inviteLink` TEXT, `memberCount` INTEGER NOT NULL, `onlineCount` INTEGER NOT NULL, `administratorCount` INTEGER NOT NULL, `restrictedCount` INTEGER NOT NULL, `bannedCount` INTEGER NOT NULL, `directMessagesChatId` INTEGER NOT NULL, `commonGroupsCount` INTEGER NOT NULL, `giftCount` INTEGER NOT NULL, `isBlocked` INTEGER NOT NULL, `botInfo` TEXT, `botInfoData` TEXT, `blockListType` TEXT, `publicPhotoPath` TEXT, `usesUnofficialApp` INTEGER NOT NULL, `hasSponsoredMessagesEnabled` INTEGER NOT NULL, `needPhoneNumberPrivacyException` INTEGER NOT NULL, `botVerificationBotUserId` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `botVerificationCustomDescription` TEXT, `mainProfileTab` TEXT, `firstProfileAudioData` TEXT, `ratingData` TEXT, `pendingRatingData` TEXT, `pendingRatingDate` INTEGER NOT NULL, `slowModeDelay` INTEGER NOT NULL, `slowModeDelayExpiresIn` REAL NOT NULL, `locationAddress` TEXT, `canEnablePaidMessages` INTEGER NOT NULL, `canEnablePaidReaction` INTEGER NOT NULL, `hasHiddenMembers` INTEGER NOT NULL, `canHideMembers` INTEGER NOT NULL, `canSetStickerSet` INTEGER NOT NULL, `canSetLocation` INTEGER NOT NULL, `canGetMembers` INTEGER NOT NULL, `canGetStatistics` INTEGER NOT NULL, `canGetRevenueStatistics` INTEGER NOT NULL, `canGetStarRevenueStatistics` INTEGER NOT NULL, `canToggleAggressiveAntiSpam` INTEGER NOT NULL, `isAllHistoryAvailable` INTEGER NOT NULL, `canHaveSponsoredMessages` INTEGER NOT NULL, `hasAggressiveAntiSpamEnabled` INTEGER NOT NULL, `hasPaidMediaAllowed` INTEGER NOT NULL, `hasPinnedStories` INTEGER NOT NULL, `myBoostCount` INTEGER NOT NULL, `unrestrictBoostCount` INTEGER NOT NULL, `stickerSetId` INTEGER NOT NULL, `customEmojiStickerSetId` INTEGER NOT NULL, `botCommandsData` TEXT, `upgradedFromBasicGroupId` INTEGER NOT NULL, `upgradedFromMaxMessageId` INTEGER NOT NULL, `linkedChatId` INTEGER NOT NULL, `note` TEXT, `canBeCalled` INTEGER NOT NULL, `supportsVideoCalls` INTEGER NOT NULL, `hasPrivateCalls` INTEGER NOT NULL, `hasPrivateForwards` INTEGER NOT NULL, `hasRestrictedVoiceAndVideoNoteMessages` INTEGER NOT NULL, `hasPostedToProfileStories` INTEGER NOT NULL, `setChatBackground` INTEGER NOT NULL, `incomingPaidMessageStarCount` INTEGER NOT NULL, `outgoingPaidMessageStarCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "inviteLink", + "columnName": "inviteLink", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "memberCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlineCount", + "columnName": "onlineCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "administratorCount", + "columnName": "administratorCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictedCount", + "columnName": "restrictedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bannedCount", + "columnName": "bannedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directMessagesChatId", + "columnName": "directMessagesChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commonGroupsCount", + "columnName": "commonGroupsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftCount", + "columnName": "giftCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfo", + "columnName": "botInfo", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoData", + "columnName": "botInfoData", + "affinity": "TEXT" + }, + { + "fieldPath": "blockListType", + "columnName": "blockListType", + "affinity": "TEXT" + }, + { + "fieldPath": "publicPhotoPath", + "columnName": "publicPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "usesUnofficialApp", + "columnName": "usesUnofficialApp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSponsoredMessagesEnabled", + "columnName": "hasSponsoredMessagesEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needPhoneNumberPrivacyException", + "columnName": "needPhoneNumberPrivacyException", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationBotUserId", + "columnName": "botVerificationBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationCustomDescription", + "columnName": "botVerificationCustomDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "mainProfileTab", + "columnName": "mainProfileTab", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioData", + "columnName": "firstProfileAudioData", + "affinity": "TEXT" + }, + { + "fieldPath": "ratingData", + "columnName": "ratingData", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingRatingData", + "columnName": "pendingRatingData", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingRatingDate", + "columnName": "pendingRatingDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slowModeDelay", + "columnName": "slowModeDelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slowModeDelayExpiresIn", + "columnName": "slowModeDelayExpiresIn", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "locationAddress", + "columnName": "locationAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "canEnablePaidMessages", + "columnName": "canEnablePaidMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canEnablePaidReaction", + "columnName": "canEnablePaidReaction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasHiddenMembers", + "columnName": "hasHiddenMembers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canHideMembers", + "columnName": "canHideMembers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSetStickerSet", + "columnName": "canSetStickerSet", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSetLocation", + "columnName": "canSetLocation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetMembers", + "columnName": "canGetMembers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetStatistics", + "columnName": "canGetStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetRevenueStatistics", + "columnName": "canGetRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetStarRevenueStatistics", + "columnName": "canGetStarRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canToggleAggressiveAntiSpam", + "columnName": "canToggleAggressiveAntiSpam", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAllHistoryAvailable", + "columnName": "isAllHistoryAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canHaveSponsoredMessages", + "columnName": "canHaveSponsoredMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasAggressiveAntiSpamEnabled", + "columnName": "hasAggressiveAntiSpamEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPaidMediaAllowed", + "columnName": "hasPaidMediaAllowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPinnedStories", + "columnName": "hasPinnedStories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myBoostCount", + "columnName": "myBoostCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unrestrictBoostCount", + "columnName": "unrestrictBoostCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stickerSetId", + "columnName": "stickerSetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customEmojiStickerSetId", + "columnName": "customEmojiStickerSetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botCommandsData", + "columnName": "botCommandsData", + "affinity": "TEXT" + }, + { + "fieldPath": "upgradedFromBasicGroupId", + "columnName": "upgradedFromBasicGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "upgradedFromMaxMessageId", + "columnName": "upgradedFromMaxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkedChatId", + "columnName": "linkedChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "canBeCalled", + "columnName": "canBeCalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportsVideoCalls", + "columnName": "supportsVideoCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateCalls", + "columnName": "hasPrivateCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateForwards", + "columnName": "hasPrivateForwards", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasRestrictedVoiceAndVideoNoteMessages", + "columnName": "hasRestrictedVoiceAndVideoNoteMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPostedToProfileStories", + "columnName": "hasPostedToProfileStories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setChatBackground", + "columnName": "setChatBackground", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incomingPaidMessageStarCount", + "columnName": "incomingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outgoingPaidMessageStarCount", + "columnName": "outgoingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `iconCustomEmojiId` INTEGER NOT NULL, `iconColor` INTEGER NOT NULL, `isClosed` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `lastReadInboxMessageId` INTEGER NOT NULL, `lastReadOutboxMessageId` INTEGER NOT NULL, `unreadMentionCount` INTEGER NOT NULL, `unreadReactionCount` INTEGER NOT NULL, `unreadPollVoteCount` INTEGER NOT NULL, `lastMessageText` TEXT NOT NULL, `lastMessageTime` TEXT NOT NULL, `order` INTEGER NOT NULL, `lastMessageSenderName` TEXT, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconCustomEmojiId", + "columnName": "iconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iconColor", + "columnName": "iconColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isClosed", + "columnName": "isClosed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadInboxMessageId", + "columnName": "lastReadInboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadOutboxMessageId", + "columnName": "lastReadOutboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionCount", + "columnName": "unreadMentionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadReactionCount", + "columnName": "unreadReactionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadPollVoteCount", + "columnName": "unreadPollVoteCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageText", + "columnName": "lastMessageText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageTime", + "columnName": "lastMessageTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageSenderName", + "columnName": "lastMessageSenderName", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + } + }, + { + "tableName": "user_full_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `bio` TEXT, `commonGroupsCount` INTEGER NOT NULL, `giftCount` INTEGER NOT NULL, `botInfoDescription` TEXT, `botInfoShortDescription` TEXT, `botInfoPhotoFileId` INTEGER NOT NULL, `botInfoPhotoPath` TEXT, `botInfoAnimationFileId` INTEGER NOT NULL, `botInfoAnimationPath` TEXT, `botInfoManagerBotUserId` INTEGER NOT NULL, `botInfoMenuButtonText` TEXT, `botInfoMenuButtonUrl` TEXT, `botInfoCommandsData` TEXT, `botInfoPrivacyPolicyUrl` TEXT, `botInfoDefaultGroupRightsData` TEXT, `botInfoDefaultChannelRightsData` TEXT, `botInfoAffiliateProgramData` TEXT, `botInfoWebAppBackgroundLightColor` INTEGER NOT NULL, `botInfoWebAppBackgroundDarkColor` INTEGER NOT NULL, `botInfoWebAppHeaderLightColor` INTEGER NOT NULL, `botInfoWebAppHeaderDarkColor` INTEGER NOT NULL, `botInfoVerificationParametersIconCustomEmojiId` INTEGER NOT NULL, `botInfoVerificationParametersOrganizationName` TEXT, `botInfoVerificationParametersDefaultCustomDescription` TEXT, `botInfoVerificationParametersCanSetCustomDescription` INTEGER NOT NULL, `botInfoCanManageEmojiStatus` INTEGER NOT NULL, `botInfoHasMediaPreviews` INTEGER NOT NULL, `botInfoEditCommandsLinkType` TEXT, `botInfoEditDescriptionLinkType` TEXT, `botInfoEditDescriptionMediaLinkType` TEXT, `botInfoEditSettingsLinkType` TEXT, `personalChatId` INTEGER NOT NULL, `birthdateDay` INTEGER NOT NULL, `birthdateMonth` INTEGER NOT NULL, `birthdateYear` INTEGER NOT NULL, `publicPhotoPath` TEXT, `blockListType` TEXT, `businessLocationAddress` TEXT, `businessLocationLatitude` REAL NOT NULL, `businessLocationLongitude` REAL NOT NULL, `businessOpeningHoursTimeZone` TEXT, `businessNextOpenIn` INTEGER NOT NULL, `businessNextCloseIn` INTEGER NOT NULL, `businessStartPageTitle` TEXT, `businessStartPageMessage` TEXT, `note` TEXT, `personalPhotoPath` TEXT, `isBlocked` INTEGER NOT NULL, `hasSponsoredMessagesEnabled` INTEGER NOT NULL, `needPhoneNumberPrivacyException` INTEGER NOT NULL, `usesUnofficialApp` INTEGER NOT NULL, `botVerificationBotUserId` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `botVerificationCustomDescription` TEXT, `mainProfileTab` TEXT, `firstProfileAudioDuration` INTEGER NOT NULL, `firstProfileAudioTitle` TEXT, `firstProfileAudioPerformer` TEXT, `firstProfileAudioFileName` TEXT, `firstProfileAudioMimeType` TEXT, `firstProfileAudioFileId` INTEGER NOT NULL, `firstProfileAudioPath` TEXT, `ratingLevel` INTEGER NOT NULL, `ratingIsMaximumLevelReached` INTEGER NOT NULL, `ratingValue` INTEGER NOT NULL, `ratingCurrentLevelValue` INTEGER NOT NULL, `ratingNextLevelValue` INTEGER NOT NULL, `pendingRatingLevel` INTEGER NOT NULL, `pendingRatingIsMaximumLevelReached` INTEGER NOT NULL, `pendingRatingValue` INTEGER NOT NULL, `pendingRatingCurrentLevelValue` INTEGER NOT NULL, `pendingRatingNextLevelValue` INTEGER NOT NULL, `pendingRatingDate` INTEGER NOT NULL, `canBeCalled` INTEGER NOT NULL, `supportsVideoCalls` INTEGER NOT NULL, `hasPrivateCalls` INTEGER NOT NULL, `hasPrivateForwards` INTEGER NOT NULL, `hasRestrictedVoiceAndVideoNoteMessages` INTEGER NOT NULL, `hasPostedToProfileStories` INTEGER NOT NULL, `setChatBackground` INTEGER NOT NULL, `canGetRevenueStatistics` INTEGER NOT NULL, `incomingPaidMessageStarCount` INTEGER NOT NULL, `outgoingPaidMessageStarCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "commonGroupsCount", + "columnName": "commonGroupsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftCount", + "columnName": "giftCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoDescription", + "columnName": "botInfoDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoShortDescription", + "columnName": "botInfoShortDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoPhotoFileId", + "columnName": "botInfoPhotoFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoPhotoPath", + "columnName": "botInfoPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoAnimationFileId", + "columnName": "botInfoAnimationFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoAnimationPath", + "columnName": "botInfoAnimationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoManagerBotUserId", + "columnName": "botInfoManagerBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoMenuButtonText", + "columnName": "botInfoMenuButtonText", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoMenuButtonUrl", + "columnName": "botInfoMenuButtonUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoCommandsData", + "columnName": "botInfoCommandsData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoPrivacyPolicyUrl", + "columnName": "botInfoPrivacyPolicyUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoDefaultGroupRightsData", + "columnName": "botInfoDefaultGroupRightsData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoDefaultChannelRightsData", + "columnName": "botInfoDefaultChannelRightsData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoAffiliateProgramData", + "columnName": "botInfoAffiliateProgramData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoWebAppBackgroundLightColor", + "columnName": "botInfoWebAppBackgroundLightColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoWebAppBackgroundDarkColor", + "columnName": "botInfoWebAppBackgroundDarkColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoWebAppHeaderLightColor", + "columnName": "botInfoWebAppHeaderLightColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoWebAppHeaderDarkColor", + "columnName": "botInfoWebAppHeaderDarkColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoVerificationParametersIconCustomEmojiId", + "columnName": "botInfoVerificationParametersIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoVerificationParametersOrganizationName", + "columnName": "botInfoVerificationParametersOrganizationName", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoVerificationParametersDefaultCustomDescription", + "columnName": "botInfoVerificationParametersDefaultCustomDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoVerificationParametersCanSetCustomDescription", + "columnName": "botInfoVerificationParametersCanSetCustomDescription", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoCanManageEmojiStatus", + "columnName": "botInfoCanManageEmojiStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoHasMediaPreviews", + "columnName": "botInfoHasMediaPreviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoEditCommandsLinkType", + "columnName": "botInfoEditCommandsLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoEditDescriptionLinkType", + "columnName": "botInfoEditDescriptionLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoEditDescriptionMediaLinkType", + "columnName": "botInfoEditDescriptionMediaLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoEditSettingsLinkType", + "columnName": "botInfoEditSettingsLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "personalChatId", + "columnName": "personalChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "birthdateDay", + "columnName": "birthdateDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "birthdateMonth", + "columnName": "birthdateMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "birthdateYear", + "columnName": "birthdateYear", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicPhotoPath", + "columnName": "publicPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "blockListType", + "columnName": "blockListType", + "affinity": "TEXT" + }, + { + "fieldPath": "businessLocationAddress", + "columnName": "businessLocationAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "businessLocationLatitude", + "columnName": "businessLocationLatitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "businessLocationLongitude", + "columnName": "businessLocationLongitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "businessOpeningHoursTimeZone", + "columnName": "businessOpeningHoursTimeZone", + "affinity": "TEXT" + }, + { + "fieldPath": "businessNextOpenIn", + "columnName": "businessNextOpenIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "businessNextCloseIn", + "columnName": "businessNextCloseIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "businessStartPageTitle", + "columnName": "businessStartPageTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "businessStartPageMessage", + "columnName": "businessStartPageMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "personalPhotoPath", + "columnName": "personalPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSponsoredMessagesEnabled", + "columnName": "hasSponsoredMessagesEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needPhoneNumberPrivacyException", + "columnName": "needPhoneNumberPrivacyException", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usesUnofficialApp", + "columnName": "usesUnofficialApp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationBotUserId", + "columnName": "botVerificationBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationCustomDescription", + "columnName": "botVerificationCustomDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "mainProfileTab", + "columnName": "mainProfileTab", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioDuration", + "columnName": "firstProfileAudioDuration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstProfileAudioTitle", + "columnName": "firstProfileAudioTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioPerformer", + "columnName": "firstProfileAudioPerformer", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioFileName", + "columnName": "firstProfileAudioFileName", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioMimeType", + "columnName": "firstProfileAudioMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioFileId", + "columnName": "firstProfileAudioFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstProfileAudioPath", + "columnName": "firstProfileAudioPath", + "affinity": "TEXT" + }, + { + "fieldPath": "ratingLevel", + "columnName": "ratingLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingIsMaximumLevelReached", + "columnName": "ratingIsMaximumLevelReached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingCurrentLevelValue", + "columnName": "ratingCurrentLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingNextLevelValue", + "columnName": "ratingNextLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingLevel", + "columnName": "pendingRatingLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingIsMaximumLevelReached", + "columnName": "pendingRatingIsMaximumLevelReached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingValue", + "columnName": "pendingRatingValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingCurrentLevelValue", + "columnName": "pendingRatingCurrentLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingNextLevelValue", + "columnName": "pendingRatingNextLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingDate", + "columnName": "pendingRatingDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeCalled", + "columnName": "canBeCalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportsVideoCalls", + "columnName": "supportsVideoCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateCalls", + "columnName": "hasPrivateCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateForwards", + "columnName": "hasPrivateForwards", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasRestrictedVoiceAndVideoNoteMessages", + "columnName": "hasRestrictedVoiceAndVideoNoteMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPostedToProfileStories", + "columnName": "hasPostedToProfileStories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setChatBackground", + "columnName": "setChatBackground", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetRevenueStatistics", + "columnName": "canGetRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incomingPaidMessageStarCount", + "columnName": "incomingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outgoingPaidMessageStarCount", + "columnName": "outgoingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_user_full_info_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_full_info_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "sticker_sets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `isInstalled` INTEGER NOT NULL, `isArchived` INTEGER NOT NULL, `data` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "isInstalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sticker_sets_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sticker_sets_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "recent_emojis", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `emoji` TEXT NOT NULL, `stickerId` INTEGER, `data` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stickerId", + "columnName": "stickerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "chat_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `data` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "attach_bots", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`botUserId` INTEGER NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`botUserId`))", + "fields": [ + { + "fieldPath": "botUserId", + "columnName": "botUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "botUserId" + ] + } + }, + { + "tableName": "key_value", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "notification_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `muteFor` INTEGER NOT NULL, `useDefault` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "muteFor", + "columnName": "muteFor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useDefault", + "columnName": "useDefault", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "notification_exceptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `scope` TEXT NOT NULL, `title` TEXT NOT NULL, `avatarPath` TEXT, `personalAvatarPath` TEXT, `isMuted` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `isChannel` INTEGER NOT NULL, `type` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarPath", + "columnName": "avatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "personalAvatarPath", + "columnName": "personalAvatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + }, + "indices": [ + { + "name": "index_notification_exceptions_scope", + "unique": false, + "columnNames": [ + "scope" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notification_exceptions_scope` ON `${TABLE_NAME}` (`scope`)" + } + ] + }, + { + "tableName": "wallpapers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `slug` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `pattern` INTEGER NOT NULL, `documentId` INTEGER NOT NULL, `thumbnailFileId` INTEGER, `thumbnailWidth` INTEGER, `thumbnailHeight` INTEGER, `thumbnailLocalPath` TEXT, `backgroundColor` INTEGER, `secondBackgroundColor` INTEGER, `thirdBackgroundColor` INTEGER, `fourthBackgroundColor` INTEGER, `intensity` INTEGER, `rotation` INTEGER, `isInverted` INTEGER, `settingsIsMoving` INTEGER, `settingsIsBlurred` INTEGER, `themeName` TEXT, `isDownloaded` INTEGER NOT NULL, `localPath` TEXT, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailFileId", + "columnName": "thumbnailFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "thumbnailWidth", + "columnName": "thumbnailWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "thumbnailHeight", + "columnName": "thumbnailHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "thumbnailLocalPath", + "columnName": "thumbnailLocalPath", + "affinity": "TEXT" + }, + { + "fieldPath": "backgroundColor", + "columnName": "backgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "secondBackgroundColor", + "columnName": "secondBackgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "thirdBackgroundColor", + "columnName": "thirdBackgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "fourthBackgroundColor", + "columnName": "fourthBackgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "intensity", + "columnName": "intensity", + "affinity": "INTEGER" + }, + { + "fieldPath": "rotation", + "columnName": "rotation", + "affinity": "INTEGER" + }, + { + "fieldPath": "isInverted", + "columnName": "isInverted", + "affinity": "INTEGER" + }, + { + "fieldPath": "settingsIsMoving", + "columnName": "settingsIsMoving", + "affinity": "INTEGER" + }, + { + "fieldPath": "settingsIsBlurred", + "columnName": "settingsIsBlurred", + "affinity": "INTEGER" + }, + { + "fieldPath": "themeName", + "columnName": "themeName", + "affinity": "TEXT" + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sticker_paths", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`fileId`))", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + } + }, + { + "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" + ] + } + }, + { + "tableName": "text_composition_styles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `customEmojiId` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "customEmojiId", + "columnName": "customEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + } + ], + "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, '3649592ebcfbc9f77d8d6753c984fe77')" + ] + } +} \ No newline at end of file diff --git a/data/schemas/org.monogram.data.db.MonogramDatabase/38.json b/data/schemas/org.monogram.data.db.MonogramDatabase/38.json new file mode 100644 index 000000000..69bee122a --- /dev/null +++ b/data/schemas/org.monogram.data.db.MonogramDatabase/38.json @@ -0,0 +1,2906 @@ +{ + "formatVersion": 1, + "database": { + "version": 38, + "identityHash": "8fe10416cc3a86945f9f71b68c3028de", + "entities": [ + { + "tableName": "chats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `unreadCount` INTEGER NOT NULL, `avatarPath` TEXT, `lastMessageText` TEXT NOT NULL, `lastMessageTime` TEXT NOT NULL, `lastMessageDate` INTEGER NOT NULL, `order` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isMuted` INTEGER NOT NULL, `isChannel` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `type` TEXT NOT NULL, `privateUserId` INTEGER NOT NULL, `basicGroupId` INTEGER NOT NULL, `supergroupId` INTEGER NOT NULL, `secretChatId` INTEGER NOT NULL, `positionsCache` TEXT, `isArchived` INTEGER NOT NULL, `memberCount` INTEGER NOT NULL, `onlineCount` INTEGER NOT NULL, `unreadMentionCount` INTEGER NOT NULL, `unreadReactionCount` INTEGER NOT NULL, `isMarkedAsUnread` INTEGER NOT NULL, `hasProtectedContent` INTEGER NOT NULL, `isTranslatable` INTEGER NOT NULL, `hasAutomaticTranslation` INTEGER NOT NULL, `messageAutoDeleteTime` INTEGER NOT NULL, `canBeDeletedOnlyForSelf` INTEGER NOT NULL, `canBeDeletedForAllUsers` INTEGER NOT NULL, `canBeReported` INTEGER NOT NULL, `lastReadInboxMessageId` INTEGER NOT NULL, `lastReadOutboxMessageId` INTEGER NOT NULL, `lastMessageId` INTEGER NOT NULL, `isLastMessageOutgoing` INTEGER NOT NULL, `replyMarkupMessageId` INTEGER NOT NULL, `messageSenderId` INTEGER, `blockList` INTEGER NOT NULL, `emojiStatusId` INTEGER, `accentColorId` INTEGER NOT NULL, `profileAccentColorId` INTEGER NOT NULL, `backgroundCustomEmojiId` INTEGER NOT NULL, `photoId` INTEGER NOT NULL, `isSupergroup` INTEGER NOT NULL, `isAdmin` INTEGER NOT NULL, `isOnline` INTEGER NOT NULL, `typingAction` TEXT, `draftMessage` TEXT, `isVerified` INTEGER NOT NULL, `isScam` INTEGER NOT NULL, `isFake` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `restrictionReason` TEXT, `hasSensitiveContent` INTEGER NOT NULL, `activeStoryStateType` TEXT, `activeStoryId` INTEGER NOT NULL, `boostLevel` INTEGER NOT NULL, `hasForumTabs` INTEGER NOT NULL, `isAdministeredDirectMessagesGroup` INTEGER NOT NULL, `paidMessageStarCount` INTEGER NOT NULL, `isSponsor` INTEGER NOT NULL, `viewAsTopics` INTEGER NOT NULL, `isForum` INTEGER NOT NULL, `isBot` INTEGER NOT NULL, `isMember` INTEGER NOT NULL, `username` TEXT, `description` TEXT, `inviteLink` TEXT, `permissionCanSendBasicMessages` INTEGER NOT NULL, `permissionCanSendAudios` INTEGER NOT NULL, `permissionCanSendDocuments` INTEGER NOT NULL, `permissionCanSendPhotos` INTEGER NOT NULL, `permissionCanSendVideos` INTEGER NOT NULL, `permissionCanSendVideoNotes` INTEGER NOT NULL, `permissionCanSendVoiceNotes` INTEGER NOT NULL, `permissionCanSendPolls` INTEGER NOT NULL, `permissionCanSendOtherMessages` INTEGER NOT NULL, `permissionCanAddLinkPreviews` INTEGER NOT NULL, `permissionCanReactToMessages` INTEGER NOT NULL, `permissionCanEditTag` INTEGER NOT NULL, `permissionCanChangeInfo` INTEGER NOT NULL, `permissionCanInviteUsers` INTEGER NOT NULL, `permissionCanPinMessages` INTEGER NOT NULL, `permissionCanCreateTopics` INTEGER NOT NULL, `lastMessageContentType` TEXT NOT NULL, `lastMessageSenderName` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "avatarPath", + "columnName": "avatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "lastMessageText", + "columnName": "lastMessageText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageTime", + "columnName": "lastMessageTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageDate", + "columnName": "lastMessageDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateUserId", + "columnName": "privateUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "basicGroupId", + "columnName": "basicGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supergroupId", + "columnName": "supergroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secretChatId", + "columnName": "secretChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionsCache", + "columnName": "positionsCache", + "affinity": "TEXT" + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memberCount", + "columnName": "memberCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlineCount", + "columnName": "onlineCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionCount", + "columnName": "unreadMentionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadReactionCount", + "columnName": "unreadReactionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMarkedAsUnread", + "columnName": "isMarkedAsUnread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasProtectedContent", + "columnName": "hasProtectedContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTranslatable", + "columnName": "isTranslatable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasAutomaticTranslation", + "columnName": "hasAutomaticTranslation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageAutoDeleteTime", + "columnName": "messageAutoDeleteTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedOnlyForSelf", + "columnName": "canBeDeletedOnlyForSelf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedForAllUsers", + "columnName": "canBeDeletedForAllUsers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeReported", + "columnName": "canBeReported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadInboxMessageId", + "columnName": "lastReadInboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadOutboxMessageId", + "columnName": "lastReadOutboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "lastMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLastMessageOutgoing", + "columnName": "isLastMessageOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyMarkupMessageId", + "columnName": "replyMarkupMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageSenderId", + "columnName": "messageSenderId", + "affinity": "INTEGER" + }, + { + "fieldPath": "blockList", + "columnName": "blockList", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiStatusId", + "columnName": "emojiStatusId", + "affinity": "INTEGER" + }, + { + "fieldPath": "accentColorId", + "columnName": "accentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileAccentColorId", + "columnName": "profileAccentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backgroundCustomEmojiId", + "columnName": "backgroundCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoId", + "columnName": "photoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSupergroup", + "columnName": "isSupergroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAdmin", + "columnName": "isAdmin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOnline", + "columnName": "isOnline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typingAction", + "columnName": "typingAction", + "affinity": "TEXT" + }, + { + "fieldPath": "draftMessage", + "columnName": "draftMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "isVerified", + "columnName": "isVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isScam", + "columnName": "isScam", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFake", + "columnName": "isFake", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictionReason", + "columnName": "restrictionReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hasSensitiveContent", + "columnName": "hasSensitiveContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeStoryStateType", + "columnName": "activeStoryStateType", + "affinity": "TEXT" + }, + { + "fieldPath": "activeStoryId", + "columnName": "activeStoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boostLevel", + "columnName": "boostLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasForumTabs", + "columnName": "hasForumTabs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAdministeredDirectMessagesGroup", + "columnName": "isAdministeredDirectMessagesGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paidMessageStarCount", + "columnName": "paidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSponsor", + "columnName": "isSponsor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewAsTopics", + "columnName": "viewAsTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isForum", + "columnName": "isForum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBot", + "columnName": "isBot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMember", + "columnName": "isMember", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "inviteLink", + "columnName": "inviteLink", + "affinity": "TEXT" + }, + { + "fieldPath": "permissionCanSendBasicMessages", + "columnName": "permissionCanSendBasicMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendAudios", + "columnName": "permissionCanSendAudios", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendDocuments", + "columnName": "permissionCanSendDocuments", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendPhotos", + "columnName": "permissionCanSendPhotos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendVideos", + "columnName": "permissionCanSendVideos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendVideoNotes", + "columnName": "permissionCanSendVideoNotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendVoiceNotes", + "columnName": "permissionCanSendVoiceNotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendPolls", + "columnName": "permissionCanSendPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanSendOtherMessages", + "columnName": "permissionCanSendOtherMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanAddLinkPreviews", + "columnName": "permissionCanAddLinkPreviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanReactToMessages", + "columnName": "permissionCanReactToMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanEditTag", + "columnName": "permissionCanEditTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanChangeInfo", + "columnName": "permissionCanChangeInfo", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanInviteUsers", + "columnName": "permissionCanInviteUsers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanPinMessages", + "columnName": "permissionCanPinMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "permissionCanCreateTopics", + "columnName": "permissionCanCreateTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageContentType", + "columnName": "lastMessageContentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageSenderName", + "columnName": "lastMessageSenderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chats_isPinned_order", + "unique": false, + "columnNames": [ + "isPinned", + "order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_isPinned_order` ON `${TABLE_NAME}` (`isPinned`, `order`)" + }, + { + "name": "index_chats_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `chatId` INTEGER NOT NULL, `senderId` INTEGER NOT NULL, `senderName` TEXT NOT NULL, `content` TEXT NOT NULL, `contentType` TEXT NOT NULL, `contentMeta` TEXT, `mediaFileId` INTEGER NOT NULL, `mediaPath` TEXT, `mediaThumbnailPath` TEXT, `minithumbnail` BLOB, `date` INTEGER NOT NULL, `isOutgoing` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `replyMarkupData` TEXT, `reactionsData` TEXT, `replyToMessageId` INTEGER NOT NULL, `replyToPreview` TEXT, `replyToPreviewType` TEXT, `replyToPreviewText` TEXT, `replyToPreviewSenderName` TEXT, `replyCount` INTEGER NOT NULL, `threadId` INTEGER, `viaBotUserId` INTEGER NOT NULL, `viaBotName` TEXT, `senderPersonalAvatarPath` TEXT, `senderCustomTitle` TEXT, `isSenderAdmin` INTEGER NOT NULL, `isSenderVerified` INTEGER NOT NULL, `isSenderPremium` INTEGER NOT NULL, `senderStatusEmojiId` INTEGER NOT NULL, `senderStatusEmojiPath` TEXT, `forwardFromName` TEXT, `forwardFromId` INTEGER NOT NULL, `forwardOriginChatId` INTEGER, `forwardOriginMessageId` INTEGER, `forwardDate` INTEGER NOT NULL, `editDate` INTEGER NOT NULL, `canBeEdited` INTEGER NOT NULL, `canBeForwarded` INTEGER NOT NULL, `canBeDeletedOnlyForSelf` INTEGER NOT NULL, `canBeDeletedForAllUsers` INTEGER NOT NULL, `canBeSaved` INTEGER NOT NULL, `canGetMessageThread` INTEGER NOT NULL, `canGetStatistics` INTEGER NOT NULL, `canGetRevenueStatistics` INTEGER NOT NULL, `canGetMediaStatistics` INTEGER NOT NULL, `canGetReadReceipts` INTEGER NOT NULL, `canGetViewers` INTEGER NOT NULL, `readDate` INTEGER NOT NULL, `hasUnreadMention` INTEGER NOT NULL, `hasUnreadReactions` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `containsUnreadPollVotes` INTEGER NOT NULL, `suggestedPostInfoData` TEXT, `factCheckData` TEXT, `mediaAlbumId` INTEGER NOT NULL, `entities` TEXT, `viewCount` INTEGER NOT NULL, `forwardCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "senderId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderName", + "columnName": "senderName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentMeta", + "columnName": "contentMeta", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaFileId", + "columnName": "mediaFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPath", + "columnName": "mediaPath", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaThumbnailPath", + "columnName": "mediaThumbnailPath", + "affinity": "TEXT" + }, + { + "fieldPath": "minithumbnail", + "columnName": "minithumbnail", + "affinity": "BLOB" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyMarkupData", + "columnName": "replyMarkupData", + "affinity": "TEXT" + }, + { + "fieldPath": "reactionsData", + "columnName": "reactionsData", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "replyToPreview", + "columnName": "replyToPreview", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToPreviewType", + "columnName": "replyToPreviewType", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToPreviewText", + "columnName": "replyToPreviewText", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToPreviewSenderName", + "columnName": "replyToPreviewSenderName", + "affinity": "TEXT" + }, + { + "fieldPath": "replyCount", + "columnName": "replyCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "INTEGER" + }, + { + "fieldPath": "viaBotUserId", + "columnName": "viaBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaBotName", + "columnName": "viaBotName", + "affinity": "TEXT" + }, + { + "fieldPath": "senderPersonalAvatarPath", + "columnName": "senderPersonalAvatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "senderCustomTitle", + "columnName": "senderCustomTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "isSenderAdmin", + "columnName": "isSenderAdmin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSenderVerified", + "columnName": "isSenderVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSenderPremium", + "columnName": "isSenderPremium", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderStatusEmojiId", + "columnName": "senderStatusEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderStatusEmojiPath", + "columnName": "senderStatusEmojiPath", + "affinity": "TEXT" + }, + { + "fieldPath": "forwardFromName", + "columnName": "forwardFromName", + "affinity": "TEXT" + }, + { + "fieldPath": "forwardFromId", + "columnName": "forwardFromId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardOriginChatId", + "columnName": "forwardOriginChatId", + "affinity": "INTEGER" + }, + { + "fieldPath": "forwardOriginMessageId", + "columnName": "forwardOriginMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "forwardDate", + "columnName": "forwardDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editDate", + "columnName": "editDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeEdited", + "columnName": "canBeEdited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeForwarded", + "columnName": "canBeForwarded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedOnlyForSelf", + "columnName": "canBeDeletedOnlyForSelf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeDeletedForAllUsers", + "columnName": "canBeDeletedForAllUsers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeSaved", + "columnName": "canBeSaved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetMessageThread", + "columnName": "canGetMessageThread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetStatistics", + "columnName": "canGetStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetRevenueStatistics", + "columnName": "canGetRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetMediaStatistics", + "columnName": "canGetMediaStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetReadReceipts", + "columnName": "canGetReadReceipts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetViewers", + "columnName": "canGetViewers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readDate", + "columnName": "readDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasUnreadMention", + "columnName": "hasUnreadMention", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasUnreadReactions", + "columnName": "hasUnreadReactions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "containsUnreadPollVotes", + "columnName": "containsUnreadPollVotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "suggestedPostInfoData", + "columnName": "suggestedPostInfoData", + "affinity": "TEXT" + }, + { + "fieldPath": "factCheckData", + "columnName": "factCheckData", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaAlbumId", + "columnName": "mediaAlbumId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entities", + "columnName": "entities", + "affinity": "TEXT" + }, + { + "fieldPath": "viewCount", + "columnName": "viewCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forwardCount", + "columnName": "forwardCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "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`)" + }, + { + "name": "index_messages_chatId_id", + "unique": false, + "columnNames": [ + "chatId", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_id` ON `${TABLE_NAME}` (`chatId`, `id`)" + }, + { + "name": "index_messages_chatId_threadId_date", + "unique": false, + "columnNames": [ + "chatId", + "threadId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_date` ON `${TABLE_NAME}` (`chatId`, `threadId`, `date`)" + }, + { + "name": "index_messages_chatId_threadId_id", + "unique": false, + "columnNames": [ + "chatId", + "threadId", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_threadId_id` ON `${TABLE_NAME}` (`chatId`, `threadId`, `id`)" + }, + { + "name": "index_messages_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "message_windows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `scopeType` TEXT NOT NULL, `scopeId` INTEGER NOT NULL, `oldestMessageId` INTEGER, `newestMessageId` INTEGER, `olderBoundaryReached` INTEGER NOT NULL, `newerBoundaryReached` INTEGER NOT NULL, `lastTdlibSyncAt` INTEGER NOT NULL, `generation` INTEGER NOT NULL, `protectedMessageId` INTEGER, PRIMARY KEY(`chatId`, `scopeType`, `scopeId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scopeType", + "columnName": "scopeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scopeId", + "columnName": "scopeId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "oldestMessageId", + "columnName": "oldestMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "newestMessageId", + "columnName": "newestMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "olderBoundaryReached", + "columnName": "olderBoundaryReached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "newerBoundaryReached", + "columnName": "newerBoundaryReached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastTdlibSyncAt", + "columnName": "lastTdlibSyncAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "protectedMessageId", + "columnName": "protectedMessageId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "scopeType", + "scopeId" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT, `phoneNumber` TEXT, `avatarPath` TEXT, `personalAvatarPath` TEXT, `isPremium` INTEGER NOT NULL, `isVerified` INTEGER NOT NULL, `isScam` INTEGER NOT NULL, `isFake` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `isSupport` INTEGER NOT NULL, `isContact` INTEGER NOT NULL, `isMutualContact` INTEGER NOT NULL, `isCloseFriend` INTEGER NOT NULL, `botTypeCanBeEdited` INTEGER NOT NULL, `botTypeCanJoinGroups` INTEGER NOT NULL, `botTypeCanReadAllGroupMessages` INTEGER NOT NULL, `botTypeHasMainWebApp` INTEGER NOT NULL, `botTypeHasTopics` INTEGER NOT NULL, `botTypeAllowsUsersToCreateTopics` INTEGER NOT NULL, `botTypeCanManageBots` INTEGER NOT NULL, `botTypeIsInline` INTEGER NOT NULL, `botTypeInlineQueryPlaceholder` TEXT, `botTypeSupportsGuestQueries` INTEGER NOT NULL, `botTypeIsGuard` INTEGER NOT NULL, `botTypeNeedLocation` INTEGER NOT NULL, `botTypeCanConnectToBusiness` INTEGER NOT NULL, `botTypeCanBeAddedToAttachmentMenu` INTEGER NOT NULL, `botTypeActiveUserCount` INTEGER NOT NULL, `userType` TEXT NOT NULL, `restrictionReason` TEXT, `hasSensitiveContent` INTEGER NOT NULL, `activeStoryStateType` TEXT, `activeStoryId` INTEGER NOT NULL, `restrictsNewChats` INTEGER NOT NULL, `paidMessageStarCount` INTEGER NOT NULL, `haveAccess` INTEGER NOT NULL, `username` TEXT, `usernamesData` TEXT, `statusType` TEXT NOT NULL, `accentColorId` INTEGER NOT NULL, `backgroundCustomEmojiId` INTEGER NOT NULL, `profileAccentColorId` INTEGER NOT NULL, `profileBackgroundCustomEmojiId` INTEGER NOT NULL, `statusEmojiId` INTEGER NOT NULL, `languageCode` TEXT, `addedToAttachmentMenu` INTEGER NOT NULL, `lastSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "firstName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "lastName", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneNumber", + "columnName": "phoneNumber", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarPath", + "columnName": "avatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "personalAvatarPath", + "columnName": "personalAvatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isPremium", + "columnName": "isPremium", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVerified", + "columnName": "isVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isScam", + "columnName": "isScam", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFake", + "columnName": "isFake", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSupport", + "columnName": "isSupport", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isContact", + "columnName": "isContact", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isMutualContact", + "columnName": "isMutualContact", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCloseFriend", + "columnName": "isCloseFriend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanBeEdited", + "columnName": "botTypeCanBeEdited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanJoinGroups", + "columnName": "botTypeCanJoinGroups", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanReadAllGroupMessages", + "columnName": "botTypeCanReadAllGroupMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeHasMainWebApp", + "columnName": "botTypeHasMainWebApp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeHasTopics", + "columnName": "botTypeHasTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeAllowsUsersToCreateTopics", + "columnName": "botTypeAllowsUsersToCreateTopics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanManageBots", + "columnName": "botTypeCanManageBots", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeIsInline", + "columnName": "botTypeIsInline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeInlineQueryPlaceholder", + "columnName": "botTypeInlineQueryPlaceholder", + "affinity": "TEXT" + }, + { + "fieldPath": "botTypeSupportsGuestQueries", + "columnName": "botTypeSupportsGuestQueries", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeIsGuard", + "columnName": "botTypeIsGuard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeNeedLocation", + "columnName": "botTypeNeedLocation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanConnectToBusiness", + "columnName": "botTypeCanConnectToBusiness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeCanBeAddedToAttachmentMenu", + "columnName": "botTypeCanBeAddedToAttachmentMenu", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botTypeActiveUserCount", + "columnName": "botTypeActiveUserCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userType", + "columnName": "userType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "restrictionReason", + "columnName": "restrictionReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hasSensitiveContent", + "columnName": "hasSensitiveContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeStoryStateType", + "columnName": "activeStoryStateType", + "affinity": "TEXT" + }, + { + "fieldPath": "activeStoryId", + "columnName": "activeStoryId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictsNewChats", + "columnName": "restrictsNewChats", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paidMessageStarCount", + "columnName": "paidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "haveAccess", + "columnName": "haveAccess", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "usernamesData", + "columnName": "usernamesData", + "affinity": "TEXT" + }, + { + "fieldPath": "statusType", + "columnName": "statusType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accentColorId", + "columnName": "accentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backgroundCustomEmojiId", + "columnName": "backgroundCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileAccentColorId", + "columnName": "profileAccentColorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "profileBackgroundCustomEmojiId", + "columnName": "profileBackgroundCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusEmojiId", + "columnName": "statusEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "languageCode", + "columnName": "languageCode", + "affinity": "TEXT" + }, + { + "fieldPath": "addedToAttachmentMenu", + "columnName": "addedToAttachmentMenu", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeen", + "columnName": "lastSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_users_username", + "unique": false, + "columnNames": [ + "username" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_username` ON `${TABLE_NAME}` (`username`)" + }, + { + "name": "index_users_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "chat_full_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `description` TEXT, `inviteLink` TEXT, `memberCount` INTEGER NOT NULL, `onlineCount` INTEGER NOT NULL, `administratorCount` INTEGER NOT NULL, `restrictedCount` INTEGER NOT NULL, `bannedCount` INTEGER NOT NULL, `directMessagesChatId` INTEGER NOT NULL, `commonGroupsCount` INTEGER NOT NULL, `giftCount` INTEGER NOT NULL, `isBlocked` INTEGER NOT NULL, `botInfo` TEXT, `botInfoData` TEXT, `blockListType` TEXT, `publicPhotoPath` TEXT, `usesUnofficialApp` INTEGER NOT NULL, `hasSponsoredMessagesEnabled` INTEGER NOT NULL, `needPhoneNumberPrivacyException` INTEGER NOT NULL, `botVerificationBotUserId` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `botVerificationCustomDescription` TEXT, `mainProfileTab` TEXT, `firstProfileAudioData` TEXT, `ratingData` TEXT, `pendingRatingData` TEXT, `pendingRatingDate` INTEGER NOT NULL, `slowModeDelay` INTEGER NOT NULL, `slowModeDelayExpiresIn` REAL NOT NULL, `locationAddress` TEXT, `canEnablePaidMessages` INTEGER NOT NULL, `canEnablePaidReaction` INTEGER NOT NULL, `hasHiddenMembers` INTEGER NOT NULL, `canHideMembers` INTEGER NOT NULL, `canSetStickerSet` INTEGER NOT NULL, `canSetLocation` INTEGER NOT NULL, `canGetMembers` INTEGER NOT NULL, `canGetStatistics` INTEGER NOT NULL, `canGetRevenueStatistics` INTEGER NOT NULL, `canGetStarRevenueStatistics` INTEGER NOT NULL, `canToggleAggressiveAntiSpam` INTEGER NOT NULL, `isAllHistoryAvailable` INTEGER NOT NULL, `canHaveSponsoredMessages` INTEGER NOT NULL, `hasAggressiveAntiSpamEnabled` INTEGER NOT NULL, `hasPaidMediaAllowed` INTEGER NOT NULL, `hasPinnedStories` INTEGER NOT NULL, `myBoostCount` INTEGER NOT NULL, `unrestrictBoostCount` INTEGER NOT NULL, `stickerSetId` INTEGER NOT NULL, `customEmojiStickerSetId` INTEGER NOT NULL, `botCommandsData` TEXT, `upgradedFromBasicGroupId` INTEGER NOT NULL, `upgradedFromMaxMessageId` INTEGER NOT NULL, `linkedChatId` INTEGER NOT NULL, `note` TEXT, `canBeCalled` INTEGER NOT NULL, `supportsVideoCalls` INTEGER NOT NULL, `hasPrivateCalls` INTEGER NOT NULL, `hasPrivateForwards` INTEGER NOT NULL, `hasRestrictedVoiceAndVideoNoteMessages` INTEGER NOT NULL, `hasPostedToProfileStories` INTEGER NOT NULL, `setChatBackground` INTEGER NOT NULL, `incomingPaidMessageStarCount` INTEGER NOT NULL, `outgoingPaidMessageStarCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "inviteLink", + "columnName": "inviteLink", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "memberCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onlineCount", + "columnName": "onlineCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "administratorCount", + "columnName": "administratorCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restrictedCount", + "columnName": "restrictedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bannedCount", + "columnName": "bannedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directMessagesChatId", + "columnName": "directMessagesChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commonGroupsCount", + "columnName": "commonGroupsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftCount", + "columnName": "giftCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfo", + "columnName": "botInfo", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoData", + "columnName": "botInfoData", + "affinity": "TEXT" + }, + { + "fieldPath": "blockListType", + "columnName": "blockListType", + "affinity": "TEXT" + }, + { + "fieldPath": "publicPhotoPath", + "columnName": "publicPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "usesUnofficialApp", + "columnName": "usesUnofficialApp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSponsoredMessagesEnabled", + "columnName": "hasSponsoredMessagesEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needPhoneNumberPrivacyException", + "columnName": "needPhoneNumberPrivacyException", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationBotUserId", + "columnName": "botVerificationBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationCustomDescription", + "columnName": "botVerificationCustomDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "mainProfileTab", + "columnName": "mainProfileTab", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioData", + "columnName": "firstProfileAudioData", + "affinity": "TEXT" + }, + { + "fieldPath": "ratingData", + "columnName": "ratingData", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingRatingData", + "columnName": "pendingRatingData", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingRatingDate", + "columnName": "pendingRatingDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slowModeDelay", + "columnName": "slowModeDelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slowModeDelayExpiresIn", + "columnName": "slowModeDelayExpiresIn", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "locationAddress", + "columnName": "locationAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "canEnablePaidMessages", + "columnName": "canEnablePaidMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canEnablePaidReaction", + "columnName": "canEnablePaidReaction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasHiddenMembers", + "columnName": "hasHiddenMembers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canHideMembers", + "columnName": "canHideMembers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSetStickerSet", + "columnName": "canSetStickerSet", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canSetLocation", + "columnName": "canSetLocation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetMembers", + "columnName": "canGetMembers", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetStatistics", + "columnName": "canGetStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetRevenueStatistics", + "columnName": "canGetRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetStarRevenueStatistics", + "columnName": "canGetStarRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canToggleAggressiveAntiSpam", + "columnName": "canToggleAggressiveAntiSpam", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAllHistoryAvailable", + "columnName": "isAllHistoryAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canHaveSponsoredMessages", + "columnName": "canHaveSponsoredMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasAggressiveAntiSpamEnabled", + "columnName": "hasAggressiveAntiSpamEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPaidMediaAllowed", + "columnName": "hasPaidMediaAllowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPinnedStories", + "columnName": "hasPinnedStories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myBoostCount", + "columnName": "myBoostCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unrestrictBoostCount", + "columnName": "unrestrictBoostCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stickerSetId", + "columnName": "stickerSetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customEmojiStickerSetId", + "columnName": "customEmojiStickerSetId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botCommandsData", + "columnName": "botCommandsData", + "affinity": "TEXT" + }, + { + "fieldPath": "upgradedFromBasicGroupId", + "columnName": "upgradedFromBasicGroupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "upgradedFromMaxMessageId", + "columnName": "upgradedFromMaxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "linkedChatId", + "columnName": "linkedChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "canBeCalled", + "columnName": "canBeCalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportsVideoCalls", + "columnName": "supportsVideoCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateCalls", + "columnName": "hasPrivateCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateForwards", + "columnName": "hasPrivateForwards", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasRestrictedVoiceAndVideoNoteMessages", + "columnName": "hasRestrictedVoiceAndVideoNoteMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPostedToProfileStories", + "columnName": "hasPostedToProfileStories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setChatBackground", + "columnName": "setChatBackground", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incomingPaidMessageStarCount", + "columnName": "incomingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outgoingPaidMessageStarCount", + "columnName": "outgoingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `iconCustomEmojiId` INTEGER NOT NULL, `iconColor` INTEGER NOT NULL, `isClosed` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `unreadCount` INTEGER NOT NULL, `lastReadInboxMessageId` INTEGER NOT NULL, `lastReadOutboxMessageId` INTEGER NOT NULL, `unreadMentionCount` INTEGER NOT NULL, `unreadReactionCount` INTEGER NOT NULL, `unreadPollVoteCount` INTEGER NOT NULL, `lastMessageText` TEXT NOT NULL, `lastMessageTime` TEXT NOT NULL, `order` INTEGER NOT NULL, `lastMessageSenderName` TEXT, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`, `id`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconCustomEmojiId", + "columnName": "iconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iconColor", + "columnName": "iconColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isClosed", + "columnName": "isClosed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPinned", + "columnName": "isPinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadCount", + "columnName": "unreadCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadInboxMessageId", + "columnName": "lastReadInboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadOutboxMessageId", + "columnName": "lastReadOutboxMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadMentionCount", + "columnName": "unreadMentionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadReactionCount", + "columnName": "unreadReactionCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadPollVoteCount", + "columnName": "unreadPollVoteCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageText", + "columnName": "lastMessageText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastMessageTime", + "columnName": "lastMessageTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageSenderName", + "columnName": "lastMessageSenderName", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId", + "id" + ] + } + }, + { + "tableName": "user_full_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `bio` TEXT, `commonGroupsCount` INTEGER NOT NULL, `giftCount` INTEGER NOT NULL, `botInfoDescription` TEXT, `botInfoShortDescription` TEXT, `botInfoPhotoFileId` INTEGER NOT NULL, `botInfoPhotoPath` TEXT, `botInfoAnimationFileId` INTEGER NOT NULL, `botInfoAnimationPath` TEXT, `botInfoManagerBotUserId` INTEGER NOT NULL, `botInfoMenuButtonText` TEXT, `botInfoMenuButtonUrl` TEXT, `botInfoCommandsData` TEXT, `botInfoPrivacyPolicyUrl` TEXT, `botInfoDefaultGroupRightsData` TEXT, `botInfoDefaultChannelRightsData` TEXT, `botInfoAffiliateProgramData` TEXT, `botInfoWebAppBackgroundLightColor` INTEGER NOT NULL, `botInfoWebAppBackgroundDarkColor` INTEGER NOT NULL, `botInfoWebAppHeaderLightColor` INTEGER NOT NULL, `botInfoWebAppHeaderDarkColor` INTEGER NOT NULL, `botInfoVerificationParametersIconCustomEmojiId` INTEGER NOT NULL, `botInfoVerificationParametersOrganizationName` TEXT, `botInfoVerificationParametersDefaultCustomDescription` TEXT, `botInfoVerificationParametersCanSetCustomDescription` INTEGER NOT NULL, `botInfoCanManageEmojiStatus` INTEGER NOT NULL, `botInfoHasMediaPreviews` INTEGER NOT NULL, `botInfoEditCommandsLinkType` TEXT, `botInfoEditDescriptionLinkType` TEXT, `botInfoEditDescriptionMediaLinkType` TEXT, `botInfoEditSettingsLinkType` TEXT, `personalChatId` INTEGER NOT NULL, `birthdateDay` INTEGER NOT NULL, `birthdateMonth` INTEGER NOT NULL, `birthdateYear` INTEGER NOT NULL, `publicPhotoPath` TEXT, `blockListType` TEXT, `businessLocationAddress` TEXT, `businessLocationLatitude` REAL NOT NULL, `businessLocationLongitude` REAL NOT NULL, `businessOpeningHoursTimeZone` TEXT, `businessNextOpenIn` INTEGER NOT NULL, `businessNextCloseIn` INTEGER NOT NULL, `businessStartPageTitle` TEXT, `businessStartPageMessage` TEXT, `note` TEXT, `personalPhotoPath` TEXT, `isBlocked` INTEGER NOT NULL, `hasSponsoredMessagesEnabled` INTEGER NOT NULL, `needPhoneNumberPrivacyException` INTEGER NOT NULL, `usesUnofficialApp` INTEGER NOT NULL, `botVerificationBotUserId` INTEGER NOT NULL, `botVerificationIconCustomEmojiId` INTEGER NOT NULL, `botVerificationCustomDescription` TEXT, `mainProfileTab` TEXT, `firstProfileAudioDuration` INTEGER NOT NULL, `firstProfileAudioTitle` TEXT, `firstProfileAudioPerformer` TEXT, `firstProfileAudioFileName` TEXT, `firstProfileAudioMimeType` TEXT, `firstProfileAudioFileId` INTEGER NOT NULL, `firstProfileAudioPath` TEXT, `ratingLevel` INTEGER NOT NULL, `ratingIsMaximumLevelReached` INTEGER NOT NULL, `ratingValue` INTEGER NOT NULL, `ratingCurrentLevelValue` INTEGER NOT NULL, `ratingNextLevelValue` INTEGER NOT NULL, `pendingRatingLevel` INTEGER NOT NULL, `pendingRatingIsMaximumLevelReached` INTEGER NOT NULL, `pendingRatingValue` INTEGER NOT NULL, `pendingRatingCurrentLevelValue` INTEGER NOT NULL, `pendingRatingNextLevelValue` INTEGER NOT NULL, `pendingRatingDate` INTEGER NOT NULL, `canBeCalled` INTEGER NOT NULL, `supportsVideoCalls` INTEGER NOT NULL, `hasPrivateCalls` INTEGER NOT NULL, `hasPrivateForwards` INTEGER NOT NULL, `hasRestrictedVoiceAndVideoNoteMessages` INTEGER NOT NULL, `hasPostedToProfileStories` INTEGER NOT NULL, `setChatBackground` INTEGER NOT NULL, `canGetRevenueStatistics` INTEGER NOT NULL, `incomingPaidMessageStarCount` INTEGER NOT NULL, `outgoingPaidMessageStarCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "commonGroupsCount", + "columnName": "commonGroupsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftCount", + "columnName": "giftCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoDescription", + "columnName": "botInfoDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoShortDescription", + "columnName": "botInfoShortDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoPhotoFileId", + "columnName": "botInfoPhotoFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoPhotoPath", + "columnName": "botInfoPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoAnimationFileId", + "columnName": "botInfoAnimationFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoAnimationPath", + "columnName": "botInfoAnimationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoManagerBotUserId", + "columnName": "botInfoManagerBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoMenuButtonText", + "columnName": "botInfoMenuButtonText", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoMenuButtonUrl", + "columnName": "botInfoMenuButtonUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoCommandsData", + "columnName": "botInfoCommandsData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoPrivacyPolicyUrl", + "columnName": "botInfoPrivacyPolicyUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoDefaultGroupRightsData", + "columnName": "botInfoDefaultGroupRightsData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoDefaultChannelRightsData", + "columnName": "botInfoDefaultChannelRightsData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoAffiliateProgramData", + "columnName": "botInfoAffiliateProgramData", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoWebAppBackgroundLightColor", + "columnName": "botInfoWebAppBackgroundLightColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoWebAppBackgroundDarkColor", + "columnName": "botInfoWebAppBackgroundDarkColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoWebAppHeaderLightColor", + "columnName": "botInfoWebAppHeaderLightColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoWebAppHeaderDarkColor", + "columnName": "botInfoWebAppHeaderDarkColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoVerificationParametersIconCustomEmojiId", + "columnName": "botInfoVerificationParametersIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoVerificationParametersOrganizationName", + "columnName": "botInfoVerificationParametersOrganizationName", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoVerificationParametersDefaultCustomDescription", + "columnName": "botInfoVerificationParametersDefaultCustomDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoVerificationParametersCanSetCustomDescription", + "columnName": "botInfoVerificationParametersCanSetCustomDescription", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoCanManageEmojiStatus", + "columnName": "botInfoCanManageEmojiStatus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoHasMediaPreviews", + "columnName": "botInfoHasMediaPreviews", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botInfoEditCommandsLinkType", + "columnName": "botInfoEditCommandsLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoEditDescriptionLinkType", + "columnName": "botInfoEditDescriptionLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoEditDescriptionMediaLinkType", + "columnName": "botInfoEditDescriptionMediaLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "botInfoEditSettingsLinkType", + "columnName": "botInfoEditSettingsLinkType", + "affinity": "TEXT" + }, + { + "fieldPath": "personalChatId", + "columnName": "personalChatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "birthdateDay", + "columnName": "birthdateDay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "birthdateMonth", + "columnName": "birthdateMonth", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "birthdateYear", + "columnName": "birthdateYear", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicPhotoPath", + "columnName": "publicPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "blockListType", + "columnName": "blockListType", + "affinity": "TEXT" + }, + { + "fieldPath": "businessLocationAddress", + "columnName": "businessLocationAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "businessLocationLatitude", + "columnName": "businessLocationLatitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "businessLocationLongitude", + "columnName": "businessLocationLongitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "businessOpeningHoursTimeZone", + "columnName": "businessOpeningHoursTimeZone", + "affinity": "TEXT" + }, + { + "fieldPath": "businessNextOpenIn", + "columnName": "businessNextOpenIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "businessNextCloseIn", + "columnName": "businessNextCloseIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "businessStartPageTitle", + "columnName": "businessStartPageTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "businessStartPageMessage", + "columnName": "businessStartPageMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "personalPhotoPath", + "columnName": "personalPhotoPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isBlocked", + "columnName": "isBlocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasSponsoredMessagesEnabled", + "columnName": "hasSponsoredMessagesEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needPhoneNumberPrivacyException", + "columnName": "needPhoneNumberPrivacyException", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usesUnofficialApp", + "columnName": "usesUnofficialApp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationBotUserId", + "columnName": "botVerificationBotUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationIconCustomEmojiId", + "columnName": "botVerificationIconCustomEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "botVerificationCustomDescription", + "columnName": "botVerificationCustomDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "mainProfileTab", + "columnName": "mainProfileTab", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioDuration", + "columnName": "firstProfileAudioDuration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstProfileAudioTitle", + "columnName": "firstProfileAudioTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioPerformer", + "columnName": "firstProfileAudioPerformer", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioFileName", + "columnName": "firstProfileAudioFileName", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioMimeType", + "columnName": "firstProfileAudioMimeType", + "affinity": "TEXT" + }, + { + "fieldPath": "firstProfileAudioFileId", + "columnName": "firstProfileAudioFileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstProfileAudioPath", + "columnName": "firstProfileAudioPath", + "affinity": "TEXT" + }, + { + "fieldPath": "ratingLevel", + "columnName": "ratingLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingIsMaximumLevelReached", + "columnName": "ratingIsMaximumLevelReached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingValue", + "columnName": "ratingValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingCurrentLevelValue", + "columnName": "ratingCurrentLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingNextLevelValue", + "columnName": "ratingNextLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingLevel", + "columnName": "pendingRatingLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingIsMaximumLevelReached", + "columnName": "pendingRatingIsMaximumLevelReached", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingValue", + "columnName": "pendingRatingValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingCurrentLevelValue", + "columnName": "pendingRatingCurrentLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingNextLevelValue", + "columnName": "pendingRatingNextLevelValue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRatingDate", + "columnName": "pendingRatingDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canBeCalled", + "columnName": "canBeCalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "supportsVideoCalls", + "columnName": "supportsVideoCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateCalls", + "columnName": "hasPrivateCalls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPrivateForwards", + "columnName": "hasPrivateForwards", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasRestrictedVoiceAndVideoNoteMessages", + "columnName": "hasRestrictedVoiceAndVideoNoteMessages", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasPostedToProfileStories", + "columnName": "hasPostedToProfileStories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setChatBackground", + "columnName": "setChatBackground", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canGetRevenueStatistics", + "columnName": "canGetRevenueStatistics", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incomingPaidMessageStarCount", + "columnName": "incomingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outgoingPaidMessageStarCount", + "columnName": "outgoingPaidMessageStarCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [ + { + "name": "index_user_full_info_createdAt", + "unique": false, + "columnNames": [ + "createdAt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_user_full_info_createdAt` ON `${TABLE_NAME}` (`createdAt`)" + } + ] + }, + { + "tableName": "sticker_sets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `isInstalled` INTEGER NOT NULL, `isArchived` INTEGER NOT NULL, `data` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "isInstalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isArchived", + "columnName": "isArchived", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sticker_sets_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sticker_sets_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "recent_emojis", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `emoji` TEXT NOT NULL, `stickerId` INTEGER, `data` TEXT NOT NULL, `timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stickerId", + "columnName": "stickerId", + "affinity": "INTEGER" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "chat_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `data` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "attach_bots", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`botUserId` INTEGER NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`botUserId`))", + "fields": [ + { + "fieldPath": "botUserId", + "columnName": "botUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "botUserId" + ] + } + }, + { + "tableName": "key_value", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "notification_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `muteFor` INTEGER NOT NULL, `useDefault` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "muteFor", + "columnName": "muteFor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "useDefault", + "columnName": "useDefault", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + } + }, + { + "tableName": "notification_exceptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatId` INTEGER NOT NULL, `scope` TEXT NOT NULL, `title` TEXT NOT NULL, `avatarPath` TEXT, `personalAvatarPath` TEXT, `isMuted` INTEGER NOT NULL, `isGroup` INTEGER NOT NULL, `isChannel` INTEGER NOT NULL, `type` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`chatId`))", + "fields": [ + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatarPath", + "columnName": "avatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "personalAvatarPath", + "columnName": "personalAvatarPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isMuted", + "columnName": "isMuted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isGroup", + "columnName": "isGroup", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isChannel", + "columnName": "isChannel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatId" + ] + }, + "indices": [ + { + "name": "index_notification_exceptions_scope", + "unique": false, + "columnNames": [ + "scope" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_notification_exceptions_scope` ON `${TABLE_NAME}` (`scope`)" + } + ] + }, + { + "tableName": "wallpapers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `slug` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `pattern` INTEGER NOT NULL, `documentId` INTEGER NOT NULL, `thumbnailFileId` INTEGER, `thumbnailWidth` INTEGER, `thumbnailHeight` INTEGER, `thumbnailLocalPath` TEXT, `backgroundColor` INTEGER, `secondBackgroundColor` INTEGER, `thirdBackgroundColor` INTEGER, `fourthBackgroundColor` INTEGER, `intensity` INTEGER, `rotation` INTEGER, `isInverted` INTEGER, `settingsIsMoving` INTEGER, `settingsIsBlurred` INTEGER, `themeName` TEXT, `isDownloaded` INTEGER NOT NULL, `localPath` TEXT, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "slug", + "columnName": "slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailFileId", + "columnName": "thumbnailFileId", + "affinity": "INTEGER" + }, + { + "fieldPath": "thumbnailWidth", + "columnName": "thumbnailWidth", + "affinity": "INTEGER" + }, + { + "fieldPath": "thumbnailHeight", + "columnName": "thumbnailHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "thumbnailLocalPath", + "columnName": "thumbnailLocalPath", + "affinity": "TEXT" + }, + { + "fieldPath": "backgroundColor", + "columnName": "backgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "secondBackgroundColor", + "columnName": "secondBackgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "thirdBackgroundColor", + "columnName": "thirdBackgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "fourthBackgroundColor", + "columnName": "fourthBackgroundColor", + "affinity": "INTEGER" + }, + { + "fieldPath": "intensity", + "columnName": "intensity", + "affinity": "INTEGER" + }, + { + "fieldPath": "rotation", + "columnName": "rotation", + "affinity": "INTEGER" + }, + { + "fieldPath": "isInverted", + "columnName": "isInverted", + "affinity": "INTEGER" + }, + { + "fieldPath": "settingsIsMoving", + "columnName": "settingsIsMoving", + "affinity": "INTEGER" + }, + { + "fieldPath": "settingsIsBlurred", + "columnName": "settingsIsBlurred", + "affinity": "INTEGER" + }, + { + "fieldPath": "themeName", + "columnName": "themeName", + "affinity": "TEXT" + }, + { + "fieldPath": "isDownloaded", + "columnName": "isDownloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "sticker_paths", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`fileId`))", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + } + }, + { + "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" + ] + } + }, + { + "tableName": "text_composition_styles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `customEmojiId` INTEGER NOT NULL, `title` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "customEmojiId", + "columnName": "customEmojiId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + } + ], + "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, '8fe10416cc3a86945f9f71b68c3028de')" + ] + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/org/monogram/data/datasource/cache/RoomMessageCacheIntegrationTest.kt b/data/src/androidTest/java/org/monogram/data/datasource/cache/RoomMessageCacheIntegrationTest.kt new file mode 100644 index 000000000..e8ccfe4ac --- /dev/null +++ b/data/src/androidTest/java/org/monogram/data/datasource/cache/RoomMessageCacheIntegrationTest.kt @@ -0,0 +1,211 @@ +package org.monogram.data.datasource.cache + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.monogram.data.db.MonogramDatabase +import org.monogram.data.db.model.MessageEntity +import org.monogram.data.db.model.MessageWindowEntity +import org.monogram.domain.repository.ConversationKey + +@RunWith(AndroidJUnit4::class) +class RoomMessageCacheIntegrationTest { + private lateinit var database: MonogramDatabase + private lateinit var source: RoomChatLocalDataSource + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + MonogramDatabase::class.java + ).allowMainThreadQueries().build() + source = RoomChatLocalDataSource( + database = database, + chatDao = database.chatDao(), + messageDao = database.messageDao(), + chatFullInfoDao = database.chatFullInfoDao(), + topicDao = database.topicDao(), + messageWindowDao = database.messageWindowDao() + ) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun historyPageAndCoverageAreCommittedTogether() = runBlocking { + val window = window(scopeType = "forum_topic", scopeId = 42L) + source.applyMessageCacheMutations( + listOf( + MessageCacheMutation.PersistHistoryBatch( + writes = listOf( + MessageCacheMutation.HistoryWrite(message(1L, threadId = 42L), null), + MessageCacheMutation.HistoryWrite(message(2L, threadId = 42L), null) + ), + window = window + ) + ) + ) + + assertEquals( + listOf(2L, 1L), + source.getLatestMessages(CHAT_ID, 10, 42L).map(MessageEntity::id) + ) + assertEquals(window, source.getMessageWindow(CHAT_ID, "forum_topic", 42L)) + assertNull(source.getMessageWindow(CHAT_ID, "message_thread", 42L)) + } + + @Test + fun replaceDeleteAndReadMutationsRemainOrdered() = runBlocking { + val temporary = message(-1L, isOutgoing = true) + val inbox = message(10L) + source.applyMessageCacheMutations( + listOf( + MessageCacheMutation.Persist(temporary, ConversationKey(CHAT_ID)), + MessageCacheMutation.Persist(inbox, ConversationKey(CHAT_ID)), + MessageCacheMutation.MarkRead(CHAT_ID, 10L), + MessageCacheMutation.ReplaceId( + CHAT_ID, + -1L, + message(20L, isOutgoing = true), + ConversationKey(CHAT_ID) + ), + MessageCacheMutation.DeleteMessages(CHAT_ID, listOf(10L)) + ) + ) + + val rows = source.getLatestMessages(CHAT_ID, 10) + assertEquals(listOf(20L), rows.map(MessageEntity::id)) + assertTrue(rows.single().isOutgoing) + } + + @Test + fun readAndContentUpdatesDoNotOverwriteEachOther() = runBlocking { + source.insertMessage(message(10L, content = "before")) + + val read = async(Dispatchers.IO) { source.markAsRead(CHAT_ID, 10L) } + val edit = async(Dispatchers.IO) { + source.updateMessageContent( + chatId = CHAT_ID, + messageId = 10L, + content = "after", + contentType = "text", + contentMeta = null, + mediaFileId = 0, + mediaPath = null, + editDate = 2 + ) + } + read.await() + edit.await() + + val row = source.getMessagesByIds(CHAT_ID, listOf(10L)).single() + assertTrue(row.isRead) + assertEquals("after", row.content) + assertEquals(2, row.editDate) + } + + @Test + fun cleanupInvalidatesCoverageAndKeepsProtectedAndPendingRows() = runBlocking { + val protectedId = 1L + val pendingId = -1L + val writes = buildList { + add(MessageCacheMutation.HistoryWrite(message(pendingId, isOutgoing = true), null)) + (1L..502L).forEach { id -> add(MessageCacheMutation.HistoryWrite(message(id), null)) } + } + source.applyMessageCacheMutations( + listOf( + MessageCacheMutation.PersistHistoryBatch( + writes = writes, + window = window(protectedMessageId = protectedId) + ) + ) + ) + + val remaining = source.getLatestMessages(CHAT_ID, 600) + val coverage = source.getMessageWindow(CHAT_ID, "main", 0L) + assertTrue(remaining.any { it.id == protectedId }) + assertTrue(remaining.any { it.id == pendingId }) + assertEquals(502, remaining.size) + assertNull(coverage?.oldestMessageId) + assertNull(coverage?.newestMessageId) + assertFalse(coverage?.olderBoundaryReached ?: true) + assertFalse(coverage?.newerBoundaryReached ?: true) + assertEquals(protectedId, coverage?.protectedMessageId) + } + + @Test + fun historyQueriesUseChatScopeIndices() { + val plans = listOf( + explain("SELECT * FROM messages WHERE chatId = 1 AND threadId IS NULL ORDER BY date DESC, id DESC LIMIT 50"), + explain("SELECT * FROM messages WHERE chatId = 1 AND threadId IS NULL AND id < 100 ORDER BY date DESC, id DESC LIMIT 50"), + explain("SELECT * FROM messages WHERE chatId = 1 AND threadId IS NULL AND id > 100 ORDER BY date ASC, id ASC LIMIT 50"), + explain("SELECT * FROM messages WHERE chatId = 1 AND threadId = 42 ORDER BY date DESC, id DESC LIMIT 50"), + explain("SELECT * FROM messages WHERE chatId = 1 AND threadId = 42 AND id < 100 ORDER BY date DESC, id DESC LIMIT 50"), + explain("SELECT * FROM messages WHERE chatId = 1 AND threadId = 42 AND id > 100 ORDER BY date ASC, id ASC LIMIT 50") + ) + + plans.forEach { plan -> + assertNotNull(plan) + assertTrue(plan, plan.contains("index_messages_chatId_threadId_")) + } + } + + private fun explain(sql: String): String = buildString { + database.openHelper.readableDatabase.query("EXPLAIN QUERY PLAN $sql").use { cursor -> + val detailIndex = cursor.getColumnIndexOrThrow("detail") + while (cursor.moveToNext()) appendLine(cursor.getString(detailIndex)) + } + } + + private fun message( + id: Long, + threadId: Long? = null, + content: String = "message-$id", + isOutgoing: Boolean = false + ) = MessageEntity( + id = id, + chatId = CHAT_ID, + senderId = 1L, + content = content, + date = id.toInt(), + isOutgoing = isOutgoing, + isRead = false, + threadId = threadId + ) + + private fun window( + scopeType: String = "main", + scopeId: Long = 0L, + protectedMessageId: Long? = null + ) = MessageWindowEntity( + chatId = CHAT_ID, + scopeType = scopeType, + scopeId = scopeId, + oldestMessageId = 1L, + newestMessageId = 501L, + olderBoundaryReached = true, + newerBoundaryReached = true, + lastTdlibSyncAt = 1L, + generation = 1L, + protectedMessageId = protectedMessageId + ) + + private companion object { + const val CHAT_ID = 1L + } +} diff --git a/data/src/androidTest/java/org/monogram/data/db/MonogramMigrationTest.kt b/data/src/androidTest/java/org/monogram/data/db/MonogramMigrationTest.kt new file mode 100644 index 000000000..87c642737 --- /dev/null +++ b/data/src/androidTest/java/org/monogram/data/db/MonogramMigrationTest.kt @@ -0,0 +1,82 @@ +package org.monogram.data.db + +import android.content.ContentValues +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MonogramMigrationTest { + @get:Rule + val helper = MigrationTestHelper( + instrumentation = InstrumentationRegistry.getInstrumentation(), + databaseClass = MonogramDatabase::class.java + ) + + @Test + fun migration37To38PreservesMessagesAndStartsWithUnknownCoverage() { + helper.createDatabase(TEST_DATABASE, 37).use { database -> + database.insertMessageRow(chatId = 100L, messageId = 200L, content = "preserved") + } + + helper.runMigrationsAndValidate( + TEST_DATABASE, + 38, + true, + MonogramMigrations.MIGRATION_37_38 + ).use { database -> + database.query( + "SELECT content FROM messages WHERE chatId = 100 AND id = 200" + ).use { cursor -> + assertEquals(true, cursor.moveToFirst()) + assertEquals("preserved", cursor.getString(0)) + assertFalse(cursor.moveToNext()) + } + database.query("SELECT COUNT(*) FROM message_windows").use { cursor -> + cursor.moveToFirst() + assertEquals(0, cursor.getInt(0)) + } + } + } + + private fun SupportSQLiteDatabase.insertMessageRow( + chatId: Long, + messageId: Long, + content: String + ) { + val values = ContentValues() + query("PRAGMA table_info(messages)").use { cursor -> + val nameIndex = cursor.getColumnIndexOrThrow("name") + val typeIndex = cursor.getColumnIndexOrThrow("type") + val notNullIndex = cursor.getColumnIndexOrThrow("notnull") + val defaultIndex = cursor.getColumnIndexOrThrow("dflt_value") + while (cursor.moveToNext()) { + val name = cursor.getString(nameIndex) + when (name) { + "chatId" -> values.put(name, chatId) + "id" -> values.put(name, messageId) + "content" -> values.put(name, content) + else -> if (cursor.getInt(notNullIndex) != 0 && cursor.isNull(defaultIndex)) { + when (cursor.getString(typeIndex).uppercase()) { + "TEXT" -> values.put(name, "") + "BLOB" -> values.put(name, ByteArray(0)) + "REAL" -> values.put(name, 0.0) + else -> values.put(name, 0L) + } + } + } + } + } + insert("messages", 0, values) + } + + private companion object { + const val TEST_DATABASE = "migration-37-38" + } +} diff --git a/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt b/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt index 664e831d5..bf91937c3 100644 --- a/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt @@ -66,8 +66,7 @@ class TdFileDataSource( override suspend fun cancelDownload(fileId: Int): TdApi.Ok? { fileDownloadQueue.cancelDownload(fileId, force = true) - val result = gateway.execute(TdApi.CancelDownloadFile(fileId, false)) - return if (result is TdApi.Ok) result else null + return TdApi.Ok() } override suspend fun getFile(fileId: Int): TdApi.File? { diff --git a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt index c9d69a2cd..e4476a893 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt @@ -5,6 +5,7 @@ import org.monogram.data.db.model.ChatEntity import org.monogram.data.db.model.ChatFullInfoEntity import org.monogram.data.db.model.MessageEntity import org.monogram.data.db.model.TopicEntity +import org.monogram.data.db.model.MessageWindowEntity interface ChatLocalDataSource { fun getAllChats(): Flow> @@ -45,8 +46,56 @@ interface ChatLocalDataSource { threadId: Long? = null ): List suspend fun getMessagesByIds(chatId: Long, messageIds: List): List + suspend fun getMessageWindow( + chatId: Long, + scopeType: String, + scopeId: Long + ): MessageWindowEntity? + + suspend fun upsertMessageWindow(window: MessageWindowEntity) + suspend fun deleteMessageWindow(chatId: Long, scopeType: String, scopeId: Long) + suspend fun deleteMessageWindowsForChat(chatId: Long) + suspend fun invalidateMessageWindowCoverage(chatId: Long, scopeType: String, scopeId: Long) { + val current = getMessageWindow(chatId, scopeType, scopeId) ?: return + upsertMessageWindow(current.withInvalidCoverage()) + } + + suspend fun invalidateMessageWindowCoverageForChat(chatId: Long) = Unit + suspend fun updateProtectedMessageId( + chatId: Long, + scopeType: String, + scopeId: Long, + messageId: Long? + ) { + val current = getMessageWindow(chatId, scopeType, scopeId) + upsertMessageWindow( + current?.copy(protectedMessageId = messageId) ?: MessageWindowEntity( + chatId = chatId, + scopeType = scopeType, + scopeId = scopeId, + oldestMessageId = null, + newestMessageId = null, + olderBoundaryReached = false, + newerBoundaryReached = false, + lastTdlibSyncAt = 0L, + generation = 0L, + protectedMessageId = messageId + ) + ) + } suspend fun insertMessage(message: MessageEntity) suspend fun insertMessages(messages: List) + suspend fun persistHistoryMessages(writes: List) { + if (writes.isEmpty()) return + val chatId = writes.first().message.chatId + val currentById = + getMessagesByIds(chatId, writes.map { it.message.id }).associateBy { it.id } + insertMessages( + writes.mapNotNull { write -> + write.message.takeIf { currentById[write.message.id] == write.expectedExisting } + } + ) + } suspend fun replaceMessage(message: MessageEntity) suspend fun replaceMessageId(chatId: Long, oldMessageId: Long, message: MessageEntity) suspend fun markAsRead(chatId: Long, upToMessageId: Long) @@ -83,6 +132,12 @@ interface ChatLocalDataSource { mutations.forEach { mutation -> when (mutation) { is MessageCacheMutation.Persist -> replaceMessage(mutation.message) + is MessageCacheMutation.PersistHistoryBatch -> { + persistHistoryMessages(mutation.writes) + upsertMessageWindow(mutation.window) + } + + is MessageCacheMutation.UpdateWindow -> upsertMessageWindow(mutation.window) is MessageCacheMutation.ReplaceId -> replaceMessageId( mutation.chatId, mutation.oldMessageId, @@ -140,4 +195,11 @@ interface ChatLocalDataSource { suspend fun clearTopicsForChat(chatId: Long) suspend fun deleteExpired(timestamp: Long) -} \ No newline at end of file +} + +private fun MessageWindowEntity.withInvalidCoverage() = copy( + oldestMessageId = null, + newestMessageId = null, + olderBoundaryReached = false, + newerBoundaryReached = false +) diff --git a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt index 02928bfa6..e0647bbb3 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt @@ -8,6 +8,7 @@ import org.monogram.data.db.model.ChatEntity import org.monogram.data.db.model.ChatFullInfoEntity import org.monogram.data.db.model.MessageEntity import org.monogram.data.db.model.TopicEntity +import org.monogram.data.db.model.MessageWindowEntity import java.util.concurrent.ConcurrentHashMap class InMemoryChatLocalDataSource : ChatLocalDataSource { @@ -15,6 +16,7 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { private val messages = ConcurrentHashMap>>() private val fullInfos = ConcurrentHashMap() private val topics = ConcurrentHashMap>>() + private val windows = ConcurrentHashMap, MessageWindowEntity>() override fun getAllChats(): Flow> = chats.map { @@ -68,6 +70,7 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { messages.clear() fullInfos.clear() topics.clear() + windows.clear() } override fun getMessagesForChat(chatId: Long): Flow> = @@ -135,6 +138,40 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { return messageIds.mapNotNull { chatMessages[it] } } + override suspend fun getMessageWindow( + chatId: Long, + scopeType: String, + scopeId: Long + ): MessageWindowEntity? = + windows[Triple(chatId, scopeType, scopeId)] + + override suspend fun upsertMessageWindow(window: MessageWindowEntity) { + windows[Triple(window.chatId, window.scopeType, window.scopeId)] = window + } + + override suspend fun deleteMessageWindow(chatId: Long, scopeType: String, scopeId: Long) { + windows.remove(Triple(chatId, scopeType, scopeId)) + } + + override suspend fun deleteMessageWindowsForChat(chatId: Long) { + windows.keys.removeAll { it.first == chatId } + } + + override suspend fun invalidateMessageWindowCoverageForChat(chatId: Long) { + windows.replaceAll { key, window -> + if (key.first == chatId) { + window.copy( + oldestMessageId = null, + newestMessageId = null, + olderBoundaryReached = false, + newerBoundaryReached = false + ) + } else { + window + } + } + } + override suspend fun insertMessage(message: MessageEntity) { messages.getOrPut(message.chatId) { MutableStateFlow(emptyMap()) } .update { it + (message.id to message) } @@ -144,6 +181,21 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { messages.forEach { insertMessage(it) } } + override suspend fun persistHistoryMessages(writes: List) { + writes.groupBy { it.message.chatId }.forEach { (chatId, chatWrites) -> + messages.getOrPut(chatId) { MutableStateFlow(emptyMap()) } + .update { current -> + chatWrites.fold(current) { result, write -> + if (result[write.message.id] == write.expectedExisting) { + result + (write.message.id to write.message) + } else { + result + } + } + } + } + } + override suspend fun replaceMessage(message: MessageEntity) { insertMessage(message) } @@ -256,10 +308,12 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { flow.update { it - messageId } } } + windows.keys.removeAll { it.first == chatId } } override suspend fun clearMessagesForChat(chatId: Long) { messages[chatId]?.value = emptyMap() + windows.keys.removeAll { it.first == chatId } } override suspend fun getChatFullInfo(chatId: Long): ChatFullInfoEntity? = fullInfos[chatId] @@ -297,6 +351,7 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { messages.values.forEach { flow -> flow.update { it.filterValues { msg -> msg.createdAt >= timestamp } } } + windows.clear() } private fun scopedMessages(chatId: Long, threadId: Long?): List { diff --git a/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheMutation.kt b/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheMutation.kt index c056eff43..a9908682b 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheMutation.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheMutation.kt @@ -1,14 +1,32 @@ package org.monogram.data.datasource.cache import org.monogram.data.db.model.MessageEntity +import org.monogram.data.db.model.MessageWindowEntity +import org.monogram.domain.repository.ConversationKey sealed interface MessageCacheMutation { - data class Persist(val message: MessageEntity) : MessageCacheMutation + data class Persist( + val message: MessageEntity, + val key: ConversationKey + ) : MessageCacheMutation + + data class HistoryWrite( + val message: MessageEntity, + val expectedExisting: MessageEntity? + ) + + data class PersistHistoryBatch( + val writes: List, + val window: MessageWindowEntity + ) : MessageCacheMutation + + data class UpdateWindow(val window: MessageWindowEntity) : MessageCacheMutation data class ReplaceId( val chatId: Long, val oldMessageId: Long, - val message: MessageEntity + val message: MessageEntity, + val key: ConversationKey ) : MessageCacheMutation data class UpdateContent( diff --git a/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheWriter.kt b/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheWriter.kt index b84ac90ab..993744be8 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheWriter.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheWriter.kt @@ -2,6 +2,7 @@ package org.monogram.data.datasource.cache import android.util.Log import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -14,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger internal class MessageCacheWriter( scope: CoroutineScope, private val applyBatch: suspend (List) -> Unit, + private val onTerminalFailure: suspend (List, Throwable) -> Unit = { _, _ -> }, private val maxBatchSize: Int = DEFAULT_MAX_BATCH_SIZE ) { data class Stats( @@ -28,7 +30,12 @@ internal class MessageCacheWriter( val dropped: Long = 0 ) - private val mutations = Channel(Channel.UNLIMITED) + private data class QueuedMutation( + val mutation: MessageCacheMutation, + val completion: CompletableDeferred? + ) + + private val mutations = Channel(Channel.UNLIMITED) private val pending = AtomicInteger() private val _stats = MutableStateFlow(Stats()) val stats: StateFlow = _stats.asStateFlow() @@ -37,7 +44,7 @@ internal class MessageCacheWriter( scope.launch { for (first in mutations) { pending.decrementAndGet() - val batch = ArrayList(maxBatchSize) + val batch = ArrayList(maxBatchSize) batch += first while (batch.size < maxBatchSize) { mutations.tryReceive().getOrNull()?.let { mutation -> @@ -51,18 +58,37 @@ internal class MessageCacheWriter( var failure: Throwable? = null var attempt = 0 do { - failure = runCatching { applyBatch(batch) }.exceptionOrNull() + failure = + runCatching { applyBatch(batch.map(QueuedMutation::mutation)) }.exceptionOrNull() attempt++ if (failure != null && attempt < MAX_ATTEMPTS) { retries++ - delay(RETRY_DELAY_MS) + delay(RETRY_DELAYS_MS[attempt - 1]) } } while (failure != null && attempt < MAX_ATTEMPTS) - if (failure != null) Log.e( - TAG, - "Message cache batch failed: size=${batch.size}", - failure - ) + if (failure != null) { + Log.e( + TAG, + "Message cache batch failed: size=${batch.size}", + failure + ) + runCatching { + onTerminalFailure(batch.map(QueuedMutation::mutation), failure) + }.onFailure { invalidationFailure -> + Log.e( + TAG, + "Message cache coverage invalidation failed", + invalidationFailure + ) + } + } + batch.forEach { queued -> + if (failure == null) { + queued.completion?.complete(Unit) + } else { + queued.completion?.completeExceptionally(failure) + } + } _stats.update { previous -> previous.copy( batches = previous.batches + 1, @@ -79,8 +105,21 @@ internal class MessageCacheWriter( } fun enqueue(mutation: MessageCacheMutation): Boolean { + return enqueue(mutation, completion = null) + } + + suspend fun enqueueAndAwait(mutation: MessageCacheMutation) { + val completion = CompletableDeferred() + check(enqueue(mutation, completion)) { "Message cache writer is closed" } + completion.await() + } + + private fun enqueue( + mutation: MessageCacheMutation, + completion: CompletableDeferred? + ): Boolean { pending.incrementAndGet() - if (mutations.trySend(mutation).isSuccess) { + if (mutations.trySend(QueuedMutation(mutation, completion)).isSuccess) { _stats.update { stats -> stats.copy(enqueued = stats.enqueued + 1, pending = pending.get()) } @@ -97,7 +136,55 @@ internal class MessageCacheWriter( private companion object { const val TAG = "MessageCacheWriter" const val DEFAULT_MAX_BATCH_SIZE = 64 - const val MAX_ATTEMPTS = 2 - const val RETRY_DELAY_MS = 50L + const val MAX_ATTEMPTS = 3 + val RETRY_DELAYS_MS = longArrayOf(50L, 150L) + } +} + +internal suspend fun invalidateFailedMessageCacheCoverage( + localDataSource: ChatLocalDataSource, + mutations: List +) { + val chatWideInvalidations = linkedSetOf() + val scopeInvalidations = linkedSetOf>() + + mutations.forEach { mutation -> + when (mutation) { + is MessageCacheMutation.Persist -> mutation.message.run { + scopeInvalidations += mutation.key.toInvalidationScope() + } + + is MessageCacheMutation.PersistHistoryBatch -> { + mutation.window.let { window -> + scopeInvalidations += Triple(window.chatId, window.scopeType, window.scopeId) + } + } + + is MessageCacheMutation.UpdateWindow -> mutation.window.let { window -> + scopeInvalidations += Triple(window.chatId, window.scopeType, window.scopeId) + } + + is MessageCacheMutation.ReplaceId -> mutation.message.run { + scopeInvalidations += mutation.key.toInvalidationScope() + } + + is MessageCacheMutation.UpdateContent -> chatWideInvalidations += mutation.chatId + is MessageCacheMutation.UpdateInteraction -> chatWideInvalidations += mutation.chatId + is MessageCacheMutation.MarkRead -> chatWideInvalidations += mutation.chatId + is MessageCacheMutation.DeleteMessages -> chatWideInvalidations += mutation.chatId + is MessageCacheMutation.UpdateMediaPath -> chatWideInvalidations += mutation.chatId + } } + + chatWideInvalidations.forEach { chatId -> + localDataSource.invalidateMessageWindowCoverageForChat(chatId) + } + scopeInvalidations + .filterNot { (chatId, _, _) -> chatId in chatWideInvalidations } + .forEach { (chatId, scopeType, scopeId) -> + localDataSource.invalidateMessageWindowCoverage(chatId, scopeType, scopeId) + } } + +private fun org.monogram.domain.repository.ConversationKey.toInvalidationScope() = + Triple(chatId, scopeType, scopeId) diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt index 7d912ee5f..0171982c2 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt @@ -11,13 +11,16 @@ import org.monogram.data.db.model.ChatEntity import org.monogram.data.db.model.ChatFullInfoEntity import org.monogram.data.db.model.MessageEntity import org.monogram.data.db.model.TopicEntity +import org.monogram.data.db.model.MessageWindowEntity +import org.monogram.data.db.dao.MessageWindowDao class RoomChatLocalDataSource( private val database: MonogramDatabase, private val chatDao: ChatDao, private val messageDao: MessageDao, private val chatFullInfoDao: ChatFullInfoDao, - private val topicDao: TopicDao + private val topicDao: TopicDao, + private val messageWindowDao: MessageWindowDao ) : ChatLocalDataSource { override fun getAllChats(): Flow> = chatDao.getAllChats() @@ -40,6 +43,7 @@ class RoomChatLocalDataSource( database.withTransaction { chatDao.clearAll() messageDao.clearAll() + messageWindowDao.clearAll() chatFullInfoDao.clearAll() topicDao.clearAll() } @@ -104,10 +108,46 @@ class RoomChatLocalDataSource( override suspend fun getMessagesByIds(chatId: Long, messageIds: List) = if (messageIds.isEmpty()) emptyList() else messageDao.getMessagesByIds(chatId, messageIds) + override suspend fun getMessageWindow( + chatId: Long, + scopeType: String, + scopeId: Long + ): MessageWindowEntity? = + messageWindowDao.get(chatId, scopeType, scopeId) + + override suspend fun upsertMessageWindow(window: MessageWindowEntity) = + messageWindowDao.upsert(window) + + override suspend fun deleteMessageWindow(chatId: Long, scopeType: String, scopeId: Long) = + messageWindowDao.delete(chatId, scopeType, scopeId) + + override suspend fun deleteMessageWindowsForChat(chatId: Long) = + messageWindowDao.deleteForChat(chatId) + + override suspend fun updateProtectedMessageId( + chatId: Long, + scopeType: String, + scopeId: Long, + messageId: Long? + ) { + database.withTransaction { + super.updateProtectedMessageId(chatId, scopeType, scopeId, messageId) + } + } + + override suspend fun invalidateMessageWindowCoverage( + chatId: Long, + scopeType: String, + scopeId: Long + ) = messageWindowDao.invalidateCoverage(chatId, scopeType, scopeId) + + override suspend fun invalidateMessageWindowCoverageForChat(chatId: Long) = + messageWindowDao.invalidateCoverageForChat(chatId) + override suspend fun insertMessage(message: MessageEntity) { database.withTransaction { messageDao.insertMessage(message) - cleanupMessageCache(message.chatId) + cleanupMessageCache(message.chatId, message.threadId) } } @@ -115,8 +155,27 @@ class RoomChatLocalDataSource( if (messages.isEmpty()) return database.withTransaction { messageDao.insertMessages(messages) - for (chatId in messages.asSequence().map(MessageEntity::chatId).distinct()) { - cleanupMessageCache(chatId) + for ((chatId, threadId) in messages.asSequence().map { it.chatId to it.threadId } + .distinct()) { + cleanupMessageCache(chatId, threadId) + } + } + } + + override suspend fun persistHistoryMessages(writes: List) { + if (writes.isEmpty()) return + database.withTransaction { + writes.groupBy { it.message.chatId }.forEach { (chatId, chatWrites) -> + val currentById = messageDao + .getMessagesByIds(chatId, chatWrites.map { it.message.id }) + .associateBy { it.id } + val applicable = chatWrites.mapNotNull { write -> + write.message.takeIf { currentById[write.message.id] == write.expectedExisting } + } + if (applicable.isNotEmpty()) messageDao.insertMessages(applicable) + chatWrites.asSequence().map { it.message.threadId }.distinct().forEach { threadId -> + cleanupMessageCache(chatId, threadId) + } } } } @@ -173,12 +232,19 @@ class RoomChatLocalDataSource( ) = messageDao.updateInteractionInfo(chatId, messageId, viewCount, forwardCount, replyCount) - override suspend fun deleteMessage(chatId: Long, messageId: Long) = - messageDao.deleteMessage(chatId, messageId) + override suspend fun deleteMessage(chatId: Long, messageId: Long) { + database.withTransaction { + messageDao.deleteMessage(chatId, messageId) + messageWindowDao.invalidateCoverageForChat(chatId) + } + } override suspend fun deleteMessages(chatId: Long, messageIds: List) { if (messageIds.isNotEmpty()) { - messageDao.deleteMessages(chatId, messageIds) + database.withTransaction { + messageDao.deleteMessages(chatId, messageIds) + messageWindowDao.invalidateCoverageForChat(chatId) + } } } @@ -189,6 +255,19 @@ class RoomChatLocalDataSource( mutations.forEach { mutation -> when (mutation) { is MessageCacheMutation.Persist -> messageDao.insertMessage(mutation.message) + is MessageCacheMutation.PersistHistoryBatch -> { + mutation.writes.groupBy { it.message.chatId }.forEach { (chatId, writes) -> + val currentById = messageDao + .getMessagesByIds(chatId, writes.map { it.message.id }) + .associateBy { it.id } + val applicable = writes.mapNotNull { write -> + write.message.takeIf { currentById[write.message.id] == write.expectedExisting } + } + if (applicable.isNotEmpty()) messageDao.insertMessages(applicable) + } + } + + is MessageCacheMutation.UpdateWindow -> Unit is MessageCacheMutation.ReplaceId -> { messageDao.deleteMessage(mutation.chatId, mutation.oldMessageId) messageDao.insertMessage(mutation.message) @@ -221,37 +300,123 @@ class RoomChatLocalDataSource( is MessageCacheMutation.DeleteMessages -> { if (mutation.messageIds.isNotEmpty()) { messageDao.deleteMessages(mutation.chatId, mutation.messageIds) + messageWindowDao.invalidateCoverageForChat(mutation.chatId) } } is MessageCacheMutation.UpdateMediaPath -> Unit } } - for (chatId in mutations.asSequence().map(::mutationChatId).distinct()) { - cleanupMessageCache(chatId) + mutations.forEach { mutation -> + when (mutation) { + is MessageCacheMutation.PersistHistoryBatch -> + messageWindowDao.upsert(mutation.window) + + is MessageCacheMutation.UpdateWindow -> messageWindowDao.upsert(mutation.window) + else -> Unit + } + } + for (cacheScope in mutations.asSequence().mapNotNull(::cacheGrowthScope).distinct()) { + cleanupMessageCache(cacheScope) } } } - private suspend fun cleanupMessageCache(chatId: Long) { - messageDao.cleanupChat( + private suspend fun cleanupMessageCache(chatId: Long, threadId: Long?) = cleanupMessageCache( + CacheCleanupScope( chatId = chatId, - keepCount = MESSAGE_CACHE_ROWS_PER_CHAT, - olderThan = System.currentTimeMillis() - MESSAGE_CACHE_TTL_MS + threadId = threadId, + scopeType = if (threadId == null) "main" else null, + scopeId = if (threadId == null) 0L else null ) + ) + + private suspend fun cleanupMessageCache(scope: CacheCleanupScope) { + val olderThan = System.currentTimeMillis() - MESSAGE_CACHE_TTL_MS + val protectedMessageId = messageWindowDao + .let { dao -> + val scopeType = scope.scopeType ?: return@let null + dao.get(scope.chatId, scopeType, scope.scopeId ?: 0L) + } + ?.protectedMessageId + val removedFromScope = if (scope.threadId == null) { + messageDao.cleanupMainScope( + scope.chatId, + MAIN_SCOPE_ROWS, + olderThan, + protectedMessageId + ) + } else { + messageDao.cleanupThreadScope( + scope.chatId, + scope.threadId, + THREAD_SCOPE_ROWS, + olderThan, + protectedMessageId + ) + } + if (removedFromScope > 0) { + val scopeType = scope.scopeType + if (scopeType == null) { + messageWindowDao.invalidateCoverageForChat(scope.chatId) + } else { + messageWindowDao.invalidateCoverage(scope.chatId, scopeType, scope.scopeId ?: 0L) + } + } + if (messageDao.enforceChatCap(scope.chatId, MESSAGE_CACHE_ROWS_PER_CHAT) > 0) { + messageWindowDao.invalidateCoverageForChat(scope.chatId) + } } - private fun mutationChatId(mutation: MessageCacheMutation): Long = when (mutation) { - is MessageCacheMutation.Persist -> mutation.message.chatId - is MessageCacheMutation.ReplaceId -> mutation.chatId - is MessageCacheMutation.UpdateContent -> mutation.chatId - is MessageCacheMutation.UpdateInteraction -> mutation.chatId - is MessageCacheMutation.MarkRead -> mutation.chatId - is MessageCacheMutation.DeleteMessages -> mutation.chatId - is MessageCacheMutation.UpdateMediaPath -> mutation.chatId + private fun cacheGrowthScope(mutation: MessageCacheMutation): CacheCleanupScope? = + when (mutation) { + is MessageCacheMutation.Persist -> mutation.toCleanupScope() + is MessageCacheMutation.PersistHistoryBatch -> mutation.window.let { window -> + CacheCleanupScope( + chatId = window.chatId, + threadId = mutation.writes.firstOrNull()?.message?.threadId + ?: window.scopeId.takeIf { window.scopeType != "main" }, + scopeType = window.scopeType, + scopeId = window.scopeId + ) + } + + is MessageCacheMutation.UpdateWindow, + is MessageCacheMutation.ReplaceId, + is MessageCacheMutation.UpdateContent, + is MessageCacheMutation.UpdateInteraction, + is MessageCacheMutation.MarkRead, + is MessageCacheMutation.DeleteMessages, + is MessageCacheMutation.UpdateMediaPath -> null } - override suspend fun clearMessagesForChat(chatId: Long) = messageDao.clearMessagesForChat(chatId) + private fun MessageEntity.toCleanupScope() = CacheCleanupScope( + chatId = chatId, + threadId = threadId, + scopeType = if (threadId == null) "main" else null, + scopeId = if (threadId == null) 0L else null + ) + + private fun MessageCacheMutation.Persist.toCleanupScope() = CacheCleanupScope( + chatId = message.chatId, + threadId = message.threadId, + scopeType = key.scopeType, + scopeId = key.scopeId + ) + + private data class CacheCleanupScope( + val chatId: Long, + val threadId: Long?, + val scopeType: String?, + val scopeId: Long? + ) + + override suspend fun clearMessagesForChat(chatId: Long) { + database.withTransaction { + messageDao.clearMessagesForChat(chatId) + messageWindowDao.deleteForChat(chatId) + } + } override suspend fun getChatFullInfo(chatId: Long): ChatFullInfoEntity? = chatFullInfoDao.getChatFullInfo(chatId) @@ -272,11 +437,14 @@ class RoomChatLocalDataSource( override suspend fun deleteExpired(timestamp: Long) { database.withTransaction { messageDao.deleteExpired(timestamp) + messageWindowDao.invalidateAllCoverage() } } private companion object { - const val MESSAGE_CACHE_ROWS_PER_CHAT = 1_000 - const val MESSAGE_CACHE_TTL_MS = 90L * 24 * 60 * 60 * 1_000 + const val MAIN_SCOPE_ROWS = 500 + const val THREAD_SCOPE_ROWS = 250 + const val MESSAGE_CACHE_ROWS_PER_CHAT = 1_500 + const val MESSAGE_CACHE_TTL_MS = 30L * 24 * 60 * 60 * 1_000 } } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt index f72862439..6f42e0ead 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileApi.kt @@ -1,7 +1,16 @@ package org.monogram.data.datasource.remote +import org.monogram.data.infra.FileDownloadQueue + interface MessageFileApi { fun registerFileForMessage(fileId: Int, chatId: Long, messageId: Long) + fun registerFileForMessage( + fileId: Int, + chatId: Long, + messageId: Long, + type: TdMessageRemoteDataSource.DownloadType, + descriptor: FileDownloadQueue.MediaDescriptor? = null + ) = registerFileForMessage(fileId, chatId, messageId) fun registerSponsoredFileForMessage(fileId: Int, chatId: Long, messageId: Long) fun enqueueDownload( fileId: Int, diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt index 5188aeac0..e4765ac3d 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageFileCoordinator.kt @@ -11,6 +11,22 @@ class MessageFileCoordinator( fileDownloadQueue.registry.register(fileId, chatId, messageId) } + override fun registerFileForMessage( + fileId: Int, + chatId: Long, + messageId: Long, + type: TdMessageRemoteDataSource.DownloadType, + descriptor: FileDownloadQueue.MediaDescriptor? + ) { + fileDownloadQueue.registerFileForMessage( + fileId, + chatId, + messageId, + type.toDomain(), + descriptor + ) + } + override fun registerSponsoredFileForMessage(fileId: Int, chatId: Long, messageId: Long) { fileDownloadQueue.registry.registerSponsored(fileId, chatId, messageId) } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt index 723eeb07b..6919ea5cf 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt @@ -18,9 +18,10 @@ import org.monogram.domain.models.MessageViewerModel import org.monogram.domain.models.PollDraft import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.ThemeParams +import org.monogram.domain.repository.MediaAutoDownloadPolicy import org.monogram.domain.models.webapp.WebAppInfoModel import org.monogram.domain.repository.ChecklistDraft -import org.monogram.domain.repository.OlderMessagesPage +import org.monogram.domain.repository.ConversationScope import org.monogram.domain.repository.ReadUpdate import org.monogram.domain.repository.RichTextParseMode import org.monogram.domain.repository.SearchChatMessagesResult @@ -53,6 +54,11 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { sendCopy: Boolean = false ) suspend fun getMessage(chatId: Long, messageId: Long): TdApi.Message? + suspend fun getMessagesLocally( + chatId: Long, + messageIds: List, + scope: ConversationScope + ): RemoteMessageBatch suspend fun getChatSponsoredMessages(chatId: Long): TdApi.SponsoredMessages? suspend fun clickChatSponsoredMessage( chatId: Long, @@ -238,14 +244,19 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { suspend fun closeWebApp(webAppLaunchId: Long): TdApi.Ok? suspend fun getPaymentForm(inputInvoice: TdApi.InputInvoice): TdApi.PaymentForm? suspend fun sendWebAppData(botUserId: Long, buttonText: String, data: String): TdApi.Ok? - suspend fun markAsRead(chatId: Long, messageId: Long) + suspend fun markMessagesAsRead(chatId: Long, messageIds: LongArray, threadId: Long? = null) suspend fun markAllReactionsAsRead(chatId: Long) suspend fun markAllMentionsAsRead(chatId: Long) suspend fun pinMessage(chatId: Long, messageId: Long, disableNotification: Boolean) suspend fun unpinMessage(chatId: Long, messageId: Long) suspend fun saveChatDraft(chatId: Long, draft: TdApi.DraftMessage?, replyToMsgId: Long?, threadId: Long? = null) suspend fun getChatDraft(chatId: Long, threadId: Long? = null): String? - fun updateVisibleRange(chatId: Long, visibleIds: List, nearbyIds: List) + fun updateVisibleRange( + chatId: Long, + visibleIds: List, + nearbyIds: List, + policy: MediaAutoDownloadPolicy + ) suspend fun onCallbackQueryBuy(chatId: Long, messageId: Long) suspend fun sendWebAppResult(botUserId: Long, data: String, buttonText: String = "") suspend fun onCallbackQuery(chatId: Long, messageId: Long, data: ByteArray) @@ -267,47 +278,30 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { ): List suspend fun getMessageViewersModels(chatId: Long, messageId: Long): List - suspend fun getMessagesOlder( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? = null - ): OlderMessagesPage - suspend fun getRemoteMessagesOlder( chatId: Long, fromMessageId: Long, limit: Int, - threadId: Long? = null + scope: ConversationScope = ConversationScope.Main, + fetchMode: RemoteHistoryFetchMode = RemoteHistoryFetchMode.LocalThenNetwork ): RemoteOlderMessagesPage - suspend fun getMessagesNewer( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? = null - ): List - suspend fun getRemoteMessagesNewer( chatId: Long, fromMessageId: Long, limit: Int, - threadId: Long? = null + scope: ConversationScope = ConversationScope.Main, + fetchMode: RemoteHistoryFetchMode = RemoteHistoryFetchMode.LocalThenNetwork ): RemoteMessageBatch - suspend fun getMessagesAround( - chatId: Long, - messageId: Long, - limit: Int, - threadId: Long? = null - ): List suspend fun getChatMessageByDate(chatId: Long, dateEpochSeconds: Int): MessageModel? suspend fun getRemoteMessagesAround( chatId: Long, messageId: Long, limit: Int, - threadId: Long? = null + scope: ConversationScope = ConversationScope.Main, + fetchMode: RemoteHistoryFetchMode = RemoteHistoryFetchMode.LocalThenNetwork ): RemoteMessageBatch suspend fun getPinnedMessageModel(chatId: Long, threadId: Long? = null): MessageModel? diff --git a/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt b/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt index 836fefb0e..65859df7b 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/RemoteMessageBatch.kt @@ -2,10 +2,83 @@ package org.monogram.data.datasource.remote import org.drinkless.tdlib.TdApi import org.monogram.domain.models.MessageModel +import org.monogram.domain.repository.ConversationScope + +enum class RemoteHistoryFetchMode { + LocalOnly, + NetworkOnly, + LocalThenNetwork +} + +internal fun historyFetchAttempts( + mode: RemoteHistoryFetchMode, + scope: ConversationScope +): List = when (mode) { + RemoteHistoryFetchMode.LocalOnly -> if (scope == ConversationScope.Main) listOf(true) else emptyList() + RemoteHistoryFetchMode.NetworkOnly -> listOf(false) + RemoteHistoryFetchMode.LocalThenNetwork -> + if (scope == ConversationScope.Main) listOf(true, false) else listOf(false) +} + +internal fun messageMatchesScope(topic: TdApi.MessageTopic?, scope: ConversationScope): Boolean = + when (scope) { + ConversationScope.Main -> true + is ConversationScope.ForumTopic -> + topic is TdApi.MessageTopicForum && topic.forumTopicId.toLong() == scope.topicId + + is ConversationScope.MessageThread -> + topic is TdApi.MessageTopicThread && topic.messageThreadId == scope.threadId + } + +internal fun buildLocalMessageRequests( + chatId: Long, + messageIds: List +): List = messageIds.map { messageId -> + TdApi.GetMessageLocally(chatId, messageId) +} + +internal fun buildHistoryRequest( + chatId: Long, + fromMessageId: Long, + offset: Int, + limit: Int, + scope: ConversationScope, + onlyLocal: Boolean +): TdApi.Function { + require(scope == ConversationScope.Main || !onlyLocal) { + "Scoped history must use the dedicated local reconciliation path" + } + return when (scope) { + ConversationScope.Main -> TdApi.GetChatHistory( + chatId, + fromMessageId, + offset, + limit, + onlyLocal + ) + + is ConversationScope.ForumTopic -> TdApi.GetForumTopicHistory( + chatId, + scope.topicId.toInt(), + fromMessageId, + offset, + limit + ) + + is ConversationScope.MessageThread -> TdApi.GetMessageThreadHistory( + chatId, + scope.threadId, + fromMessageId, + offset, + limit + ) + } +} data class MessageMapOptions( val resolveReplyPreviewFromNetwork: Boolean = true, - val allowAutoDownload: Boolean = true + val allowAutoDownload: Boolean = true, + val resolveEnrichmentFromNetwork: Boolean = true ) data class RemoteMessageBatch( diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index 6cd8573e2..a00479598 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -5,13 +5,14 @@ import android.util.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -38,6 +39,7 @@ import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.infra.AtomicSingleFlight import org.monogram.data.infra.OrderedEventFlow import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.WebPageMapper @@ -64,7 +66,8 @@ import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.ChecklistDraft -import org.monogram.domain.repository.OlderMessagesPage +import org.monogram.domain.repository.ConversationScope +import org.monogram.domain.repository.MediaAutoDownloadPolicy import org.monogram.domain.repository.PollRepository import org.monogram.domain.repository.ReadUpdate import org.monogram.domain.repository.RichTextParseMode @@ -90,13 +93,15 @@ class TdMessageRemoteDataSource( private val tdLibLimitsRepository: TdLibLimitsRepository ) : MessageRemoteDataSource { - private val chatRequests = ConcurrentHashMap>() - private val messageRequests = ConcurrentHashMap, Deferred>() + private val chatRequests = AtomicSingleFlight(scope) + private val messageRequests = AtomicSingleFlight, TdApi.Message?>(scope) private val refreshJobs = ConcurrentHashMap, Job>() + private val openChatIds = ConcurrentHashMap.newKeySet() private val missingMessageCooldownUntil = ConcurrentHashMap, Long>() private val sendQueue = Channel Unit>(Channel.BUFFERED) - // These are fed from `scope.launch { ... }` inside update handling. With the default - // arguments (replay 0, no buffer, SUSPEND) a MutableSharedFlow is a rendezvous channel: + + // With the default arguments (replay 0, no buffer, SUSPEND), MutableSharedFlow is a + // rendezvous channel: // every emit parks until *all* subscribers have taken the value, which piled emitters up // without bound during bursts. OrderedEventFlow.enqueue is non-suspending and lossless, // so the event streams go through it; progress ticks are conflatable and get an explicit @@ -156,6 +161,9 @@ class TdMessageRemoteDataSource( } } } + scope.launch(dispatcherProvider.io) { + fileUpdateHandler.fileUpdates.collect(::projectFileUpdate) + } } private suspend fun safeExecute(function: TdApi.Function): T? { @@ -196,9 +204,10 @@ class TdMessageRemoteDataSource( } missingMessageCooldownUntil.remove(key, cooldownUntil) } - val deferred = messageRequests.getOrPut(key) { - scope.async { - val result = safeExecute(TdApi.GetMessage(chatId, messageId)) + return runCatching { + messageRequests.execute(key) { + val local = safeExecute(TdApi.GetMessageLocally(chatId, messageId)) + val result = local ?: safeExecute(TdApi.GetMessage(chatId, messageId)) if (result != null) { cache.putMessage(result) missingMessageCooldownUntil.remove(key) @@ -208,14 +217,28 @@ class TdMessageRemoteDataSource( } result } + }.getOrNull() + } + + override suspend fun getMessagesLocally( + chatId: Long, + messageIds: List, + scope: ConversationScope + ): RemoteMessageBatch = withContext(dispatcherProvider.io) { + val rawMessages = buildLocalMessageRequests(chatId, messageIds).mapNotNull { request -> + val message = cache.getMessage(request.chatId, request.messageId) + ?: safeExecute(request) + message?.takeIf { messageMatchesScope(it.topicId, scope) } } - return try { - deferred.await() - } catch (e: Exception) { - null - } finally { - messageRequests.remove(key) - } + RemoteMessageBatch( + rawMessages = rawMessages, + models = mapHistoryMessages( + chatId = chatId, + messages = rawMessages, + options = LOCAL_HISTORY_MAP_OPTIONS, + allowChatFetch = false + ) + ) } override suspend fun getChatSponsoredMessages(chatId: Long): TdApi.SponsoredMessages? { @@ -240,60 +263,21 @@ class TdMessageRemoteDataSource( suspend fun getChat(chatId: Long): TdApi.Chat? { cache.getChat(chatId)?.let { return it } - val deferred = chatRequests.getOrPut(chatId) { - scope.async { + return runCatching { + chatRequests.execute(chatId) { val result = safeExecute(TdApi.GetChat(chatId)) if (result != null) cache.putChat(result) result } - } - return try { - deferred.await() - } catch (e: Exception) { - null - } finally { - chatRequests.remove(chatId) - } - } - - override suspend fun getMessagesOlder(chatId: Long, fromMessageId: Long, limit: Int, threadId: Long?): OlderMessagesPage { - if (fromMessageId == 0L) { - val batch = loadMessageBatch( - chatId = chatId, - fromMessageId = fromMessageId, - offset = 0, - limit = limit, - threadId = threadId - ) - val page = OlderMessagesPage( - messages = batch.models, - reachedOldest = batch.models.isEmpty(), - isRemote = true - ) - return page - } - - val batch = loadMessageBatch( - chatId = chatId, - fromMessageId = fromMessageId, - offset = 0, - limit = limit + 1, - threadId = threadId - ) - val filtered = batch.models.filter { it.id != fromMessageId }.take(limit) - val page = OlderMessagesPage( - messages = filtered, - reachedOldest = filtered.isEmpty(), - isRemote = true - ) - return page + }.getOrNull() } override suspend fun getRemoteMessagesOlder( chatId: Long, fromMessageId: Long, limit: Int, - threadId: Long? + scope: ConversationScope, + fetchMode: RemoteHistoryFetchMode ): RemoteOlderMessagesPage { if (fromMessageId == 0L) { val batch = loadMessageBatch( @@ -301,7 +285,8 @@ class TdMessageRemoteDataSource( fromMessageId = fromMessageId, offset = 0, limit = limit, - threadId = threadId, + conversationScope = scope, + fetchMode = fetchMode, options = MessageMapOptions( resolveReplyPreviewFromNetwork = false, allowAutoDownload = false @@ -319,7 +304,8 @@ class TdMessageRemoteDataSource( fromMessageId = fromMessageId, offset = 0, limit = limit + 1, - threadId = threadId, + conversationScope = scope, + fetchMode = fetchMode, options = MessageMapOptions( resolveReplyPreviewFromNetwork = false, allowAutoDownload = false @@ -335,26 +321,20 @@ class TdMessageRemoteDataSource( ) } - override suspend fun getMessagesNewer(chatId: Long, fromMessageId: Long, limit: Int, threadId: Long?): List { - return getRemoteMessagesNewer(chatId, fromMessageId, limit, threadId).models - } - - override suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int, threadId: Long?): List { - return getRemoteMessagesAround(chatId, messageId, limit, threadId).models - } - override suspend fun getRemoteMessagesNewer( chatId: Long, fromMessageId: Long, limit: Int, - threadId: Long? + scope: ConversationScope, + fetchMode: RemoteHistoryFetchMode ): RemoteMessageBatch { return loadMessageBatch( chatId = chatId, fromMessageId = fromMessageId, offset = -limit, limit = limit, - threadId = threadId, + conversationScope = scope, + fetchMode = fetchMode, options = MessageMapOptions( resolveReplyPreviewFromNetwork = false, allowAutoDownload = false @@ -366,14 +346,16 @@ class TdMessageRemoteDataSource( chatId: Long, messageId: Long, limit: Int, - threadId: Long? + scope: ConversationScope, + fetchMode: RemoteHistoryFetchMode ): RemoteMessageBatch { return loadMessageBatch( chatId = chatId, fromMessageId = messageId, offset = -limit / 2, limit = limit, - threadId = threadId, + conversationScope = scope, + fetchMode = fetchMode, options = MessageMapOptions( resolveReplyPreviewFromNetwork = false, allowAutoDownload = false @@ -684,9 +666,15 @@ class TdMessageRemoteDataSource( fromMessageId: Long, offset: Int, limit: Int, - threadId: Long? = null, + conversationScope: ConversationScope = ConversationScope.Main, + fetchMode: RemoteHistoryFetchMode = RemoteHistoryFetchMode.LocalThenNetwork, options: MessageMapOptions = MessageMapOptions() ): RemoteMessageBatch = withContext(dispatcherProvider.io) { + val threadId = when (conversationScope) { + ConversationScope.Main -> null + is ConversationScope.ForumTopic -> conversationScope.topicId + is ConversationScope.MessageThread -> conversationScope.threadId + } ChatOpenPerfBridge.recordHistoryRequest(chatId, threadId) if (BuildConfig.DEBUG) { Log.d( @@ -699,39 +687,41 @@ class TdMessageRemoteDataSource( ) ) } - val historyResult = getChatHistoryInternal(chatId, fromMessageId, offset, limit, threadId) - ?: throw IllegalStateException( - "Failed to load history for chatId=$chatId fromMessageId=$fromMessageId offset=$offset limit=$limit threadId=$threadId" + var historyResult: TdApi.Object? = null + for (onlyLocal in historyFetchAttempts(fetchMode, conversationScope)) { + val attempt = getChatHistoryInternal( + chatId = chatId, + fromMessageId = fromMessageId, + offset = offset, + limit = limit, + conversationScope = conversationScope, + onlyLocal = onlyLocal ) - val chat = getChat(chatId) - val lastReadInbox = chat?.lastReadInboxMessageId ?: 0L - val lastReadOutbox = chat?.lastReadOutboxMessageId ?: 0L + historyResult = attempt + if (!isEmptyHistoryResult(attempt)) { + break + } + } + if (historyResult == null && fetchMode == RemoteHistoryFetchMode.LocalOnly) { + historyResult = TdApi.Messages(0, emptyArray()) + } + historyResult ?: throw IllegalStateException( + "Failed to load history for chatId=$chatId fromMessageId=$fromMessageId offset=$offset limit=$limit threadId=$threadId" + ) + val models = mapHistoryMessages( + chatId = chatId, + messages = when (historyResult) { + is TdApi.Messages -> historyResult.messages.toList() + is TdApi.MessageThreadInfo -> historyResult.messages.toList() + else -> emptyList() + }, + options = options + ) val messages = when (historyResult) { is TdApi.Messages -> historyResult.messages is TdApi.MessageThreadInfo -> historyResult.messages else -> emptyArray() } - val models = messages.map { msg -> - cache.putMessage(msg) - async { - try { - withTimeout(5000) { - messageMapper.mapMessageToModelSync( - msg = msg, - inboxLimit = lastReadInbox, - outboxLimit = lastReadOutbox, - isChatOpen = true, - options = options - ) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e("TdMessageRemote", "Error mapping message ${msg.id}", e) - createFallbackMessage(msg) - } - } - }.awaitAll() RemoteMessageBatch( rawMessages = messages.toList(), models = models @@ -751,42 +741,56 @@ class TdMessageRemoteDataSource( } } - private suspend fun getChatHistoryInternal(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long? = null): TdApi.Object? { - return if (threadId != null) { - val chat = getChat(chatId) - if (chat != null) { - if (chat.viewAsTopics) { - val req = TdApi.GetForumTopicHistory().apply { - this.chatId = chatId - this.forumTopicId = threadId.toInt() - this.fromMessageId = fromMessageId - this.offset = offset - this.limit = limit - } - safeExecute(req) - } else { - val req = TdApi.GetMessageThreadHistory().apply { - this.chatId = chatId - this.messageId = threadId - this.fromMessageId = fromMessageId - this.offset = offset - this.limit = limit + private suspend fun mapHistoryMessages( + chatId: Long, + messages: List, + options: MessageMapOptions, + allowChatFetch: Boolean = true + ): List = coroutineScope { + val chat = if (allowChatFetch) getChat(chatId) else cache.getChat(chatId) + val lastReadInbox = chat?.lastReadInboxMessageId ?: 0L + val lastReadOutbox = chat?.lastReadOutboxMessageId ?: 0L + messages.map { msg -> + cache.putMessage(msg) + async { + try { + withTimeout(5000) { + messageMapper.mapMessageToModelSync( + msg = msg, + inboxLimit = lastReadInbox, + outboxLimit = lastReadOutbox, + isChatOpen = true, + options = options + ) } - safeExecute(req) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e("TdMessageRemote", "Error mapping message ${msg.id}", e) + createFallbackMessage(msg) } - } else null - } else { - val req = TdApi.GetChatHistory().apply { - this.chatId = chatId - this.fromMessageId = fromMessageId - this.offset = offset - this.limit = limit - this.onlyLocal = false } - safeExecute(req) - } + }.awaitAll() } + private suspend fun getChatHistoryInternal( + chatId: Long, + fromMessageId: Long, + offset: Int, + limit: Int, + conversationScope: ConversationScope = ConversationScope.Main, + onlyLocal: Boolean = false + ): TdApi.Object? = safeExecute( + buildHistoryRequest( + chatId = chatId, + fromMessageId = fromMessageId, + offset = offset, + limit = limit, + scope = conversationScope, + onlyLocal = onlyLocal + ) + ) + override suspend fun getChatHistory(chatId: Long, fromMessageId: Long, offset: Int, limit: Int): TdApi.Messages? { val req = TdApi.GetChatHistory().apply { this.chatId = chatId @@ -803,7 +807,9 @@ class TdMessageRemoteDataSource( } override suspend fun getMessages(chatId: Long, fromMessageId: Long, offset: Int, limit: Int, threadId: Long?): TdApi.Messages? { - return when (val result = getChatHistoryInternal(chatId, fromMessageId, offset, limit, threadId)) { + val scope = threadId?.let(ConversationScope::MessageThread) ?: ConversationScope.Main + return when (val result = + getChatHistoryInternal(chatId, fromMessageId, offset, limit, scope)) { is TdApi.Messages -> result is TdApi.MessageThreadInfo -> TdApi.Messages(result.messages.size, result.messages) else -> null @@ -1937,8 +1943,28 @@ class TdMessageRemoteDataSource( } } - override suspend fun markAsRead(chatId: Long, messageId: Long) { - safeExecute(TdApi.ViewMessages(chatId, longArrayOf(messageId), null, true)) + override suspend fun markMessagesAsRead(chatId: Long, messageIds: LongArray, threadId: Long?) { + val distinctMessageIds = messageIds.distinct().toLongArray() + if (distinctMessageIds.isEmpty()) return + val source = when { + threadId == null -> TdApi.MessageSourceChatHistory() + else -> TdApi.MessageSourceMessageThreadHistory() + } + safeExecute( + TdApi.ViewMessages( + chatId, + distinctMessageIds, + source, + false + ) + ) + } + + private fun isEmptyHistoryResult(result: TdApi.Object?): Boolean = when (result) { + null -> true + is TdApi.Messages -> result.messages.isEmpty() + is TdApi.MessageThreadInfo -> result.messages.isEmpty() + else -> false } override suspend fun markAllMentionsAsRead(chatId: Long) { @@ -2023,11 +2049,12 @@ class TdMessageRemoteDataSource( val poll = (message.content as TdApi.MessagePoll).poll pollRepository.mapPollIdToMessage(poll.id, message.chatId, message.id) } - scope.launch(dispatcherProvider.io) { + if (isChatOpen(message.chatId)) { try { - val model = mapMessageToModel(message) - newMessages.enqueue(model) - } catch (e: Exception) { Log.e("TdMessageRemote", "Error mapping NewMessage", e) } + newMessages.enqueue(mapLiveMessageToModel(message)) + } catch (e: Exception) { + Log.e("TdMessageRemote", "Error mapping NewMessage", e) + } } } is TdApi.UpdateMessageSendSucceeded -> { @@ -2039,9 +2066,9 @@ class TdMessageRemoteDataSource( val poll = (message.content as TdApi.MessagePoll).poll pollRepository.mapPollIdToMessage(poll.id, message.chatId, message.id) } - scope.launch(dispatcherProvider.io) { + if (isChatOpen(message.chatId)) { try { - val model = mapMessageToModel(message) + val model = mapLiveMessageToModel(message) messageIdUpdates.enqueue( MessageIdUpdatedEvent( chatId = message.chatId, @@ -2049,7 +2076,9 @@ class TdMessageRemoteDataSource( message = model ) ) - } catch (e: Exception) { Log.e("TdMessageRemote", "Error handling SendSucceeded", e) } + } catch (e: Exception) { + Log.e("TdMessageRemote", "Error handling SendSucceeded", e) + } } } @@ -2063,8 +2092,8 @@ class TdMessageRemoteDataSource( } is TdApi.UpdateMessageSendFailed -> { cache.putMessage(update.message) - scope.launch(dispatcherProvider.io) { - val model = mapMessageToModel(update.message) + if (isChatOpen(update.message.chatId)) { + val model = mapLiveMessageToModel(update.message) messageSendFailures.enqueue( MessageSendFailedEvent( chatId = update.message.chatId, @@ -2162,7 +2191,7 @@ class TdMessageRemoteDataSource( cache.updateMessage(update.chatId, update.messageId) { message -> message.isPinned = update.isPinned } - scope.launch(dispatcherProvider.io) { pinnedMessageFlow.emit(update.chatId) } + pinnedMessageFlow.emit(update.chatId) } is TdApi.UpdateMessageContainsUnreadPollVotes -> { @@ -2171,20 +2200,22 @@ class TdMessageRemoteDataSource( } refreshMessageDebounced(update.chatId, update.messageId) } - is TdApi.UpdateFile -> { - scope.launch(dispatcherProvider.io) { handleFileUpdate(update.file) } - } + // FileUpdateHandler owns the single UpdateFile consumer and queue completion path. + is TdApi.UpdateFile -> Unit is TdApi.UpdatePollAnswer -> { - scope.launch(dispatcherProvider.io) { - pollRepository.getMessageIdByPollId(update.pollId)?.let { (chatId, messageId) -> + pollRepository.getMessageIdByPollId(update.pollId)?.let { (chatId, messageId) -> + if (isChatOpen(chatId)) { cache.removeMessage(chatId, messageId) - refreshAndEmitMessage(chatId, messageId) + safeExecute(TdApi.GetMessageLocally(chatId, messageId))?.let { message -> + cache.putMessage(message) + messageEdits.enqueue(mapLiveMessageToModel(message)) + } } } } is TdApi.UpdateChatReadOutbox -> { cache.updateChat(update.chatId) { it.lastReadOutboxMessageId = update.lastReadOutboxMessageId } - scope.launch(dispatcherProvider.io) { + if (isChatOpen(update.chatId)) { messageReads.enqueue( ReadUpdate.Outbox( update.chatId, @@ -2196,7 +2227,7 @@ class TdMessageRemoteDataSource( } is TdApi.UpdateChatReadInbox -> { cache.updateChat(update.chatId) { it.lastReadInboxMessageId = update.lastReadInboxMessageId } - scope.launch(dispatcherProvider.io) { + if (isChatOpen(update.chatId)) { messageReads.enqueue( ReadUpdate.Inbox( update.chatId, @@ -2206,18 +2237,16 @@ class TdMessageRemoteDataSource( } } is TdApi.UpdateDeleteMessages -> { - if (!update.fromCache) { - val messageIds = update.messageIds.toList() - scope.launch(dispatcherProvider.io) { - messageIds.forEach { cache.removeMessage(update.chatId, it) } - removeMessagesFromCache(update.chatId, messageIds) + val messageIds = update.messageIds.toList() + cache.removeMessages(update.chatId, messageIds) + removeMessagesFromCache(update.chatId, messageIds) + if (!update.fromCache && isChatOpen(update.chatId)) { messageDeletes.enqueue( MessageDeletedEvent( chatId = update.chatId, messageIds = messageIds ) ) - } } } is TdApi.UpdateChatUnreadMentionCount -> { @@ -2232,7 +2261,7 @@ class TdMessageRemoteDataSource( } private fun refreshMessageDebounced(chatId: Long, messageId: Long) { - if (messageId == 0L) return + if (messageId == 0L || !isChatOpen(chatId)) return val key = chatId to messageId refreshJobs[key]?.cancel() val job = scope.launch(dispatcherProvider.io) { @@ -2247,32 +2276,59 @@ class TdMessageRemoteDataSource( } private suspend fun refreshAndEmitMessage(chatId: Long, messageId: Long) { - if (messageId == 0L) return + if (messageId == 0L || !isChatOpen(chatId)) return val msg = cache.getMessage(chatId, messageId) ?: return - val model = mapMessageToModel(msg) + val model = mapLiveMessageToModel(msg) messageEdits.enqueue(model) } - private suspend fun mapMessageToModel(message: TdApi.Message): MessageModel { - val chat = getChat(message.chatId) + private suspend fun mapLiveMessageToModel(message: TdApi.Message): MessageModel { + val chat = cache.getChat(message.chatId) + val options = LIVE_MESSAGE_MAP_OPTIONS val model = if (chat != null) { - messageMapper.mapMessageToModelSync(message, chat.lastReadInboxMessageId, chat.lastReadOutboxMessageId, isChatOpen = true) + messageMapper.mapMessageToModelSync( + message, + chat.lastReadInboxMessageId, + chat.lastReadOutboxMessageId, + isChatOpen = true, + options = options + ) } else { - messageMapper.mapMessageToModel(message, isChatOpen = true) + messageMapper.mapMessageToModel( + message, + isChatOpen = true, + options = options + ) } - val readDate = if (message.isOutgoing && model.isRead) messageMapper.getMessageReadDate(message.chatId, message.id, message.date) else 0 - return model.copy(readDate = readDate) + return model } - override fun updateVisibleRange(chatId: Long, visibleIds: List, nearbyIds: List) { - fileDownloadQueue.updateVisibleRange(chatId, visibleIds, nearbyIds) + override fun updateVisibleRange( + chatId: Long, + visibleIds: List, + nearbyIds: List, + policy: MediaAutoDownloadPolicy + ) { + fileDownloadQueue.updateVisibleRange(chatId, visibleIds, nearbyIds, policy) } - override fun setChatOpened(chatId: Long) { fileDownloadQueue.setChatOpened(chatId) } + override fun setChatOpened(chatId: Long) { + openChatIds.add(chatId) + fileDownloadQueue.setChatOpened(chatId) + } override fun setChatClosed(chatId: Long) { + openChatIds.remove(chatId) + refreshJobs.keys.filter { it.first == chatId }.forEach { key -> + refreshJobs.remove(key)?.cancel() + } + messageUpdateJobs.keys.filter { it.first == chatId }.forEach { key -> + messageUpdateJobs.remove(key)?.cancel() + } fileDownloadQueue.setChatClosed(chatId) } + private fun isChatOpen(chatId: Long): Boolean = openChatIds.contains(chatId) + override fun enqueueDownload(fileId: Int, priority: Int, type: DownloadType, offset: Long, limit: Long, synchronous: Boolean) { fileDownloadQueue.enqueue( fileId, @@ -2308,8 +2364,7 @@ class TdMessageRemoteDataSource( fun waitForUpload(fileId: Int): CompletableDeferred = fileDownloadQueue.waitForUpload(fileId) - fun handleFileUpdate(file: TdApi.File) { - fileDownloadQueue.updateFileCache(file) + private fun projectFileUpdate(file: TdApi.File) { val isDC = file.local?.isDownloadingCompleted == true val isD = file.local?.isDownloadingActive == true val wasDownloading = lastDownloadActiveMap[file.id] == true @@ -2330,7 +2385,6 @@ class TdMessageRemoteDataSource( } if (isDC) { - fileDownloadQueue.notifyDownloadComplete(file.id) lastProgressMap.remove(file.id) scope.launch { fileDownloads.enqueue( @@ -2413,7 +2467,6 @@ class TdMessageRemoteDataSource( } if (isUC) { - fileDownloadQueue.notifyUploadComplete(file.id) lastProgressMap.remove(file.id xor 0x55555555) val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { @@ -2473,13 +2526,14 @@ class TdMessageRemoteDataSource( private fun updateMessageWithFile(fileId: Int) { val entries = fileIdToMessageMap[fileId] ?: return entries.toList().forEach { (chatId, messageId) -> + if (!isChatOpen(chatId)) return@forEach val key = chatId to messageId messageUpdateJobs[key]?.cancel() val job = scope.launch { delay(150) val msg = cache.getMessage(chatId, messageId) ?: return@launch try { - messageEdits.enqueue(mapMessageToModel(msg)) + messageEdits.enqueue(mapLiveMessageToModel(msg)) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -2496,6 +2550,7 @@ class TdMessageRemoteDataSource( messageUpdateJobs.values.forEach { it.cancel() }; messageUpdateJobs.clear() lastProgressMap.clear() missingMessageCooldownUntil.clear() + openChatIds.clear() fileDownloadQueue.setObserver(null) } @@ -2507,6 +2562,10 @@ class TdMessageRemoteDataSource( if (previous != null && now - previous < 250L) return lastCancelledEmissionAt[fileId] = now + scope.launch { + fileDownloads.enqueue(FileDownloadEvent.Cancelled(fileId)) + } + val entries = fileIdToMessageMap[fileId] if (!entries.isNullOrEmpty()) { scope.launch { @@ -2523,7 +2582,17 @@ class TdMessageRemoteDataSource( } } - private companion object { + companion object { + internal val LIVE_MESSAGE_MAP_OPTIONS = MessageMapOptions( + resolveReplyPreviewFromNetwork = false, + allowAutoDownload = false, + resolveEnrichmentFromNetwork = false + ) + private val LOCAL_HISTORY_MAP_OPTIONS = MessageMapOptions( + resolveReplyPreviewFromNetwork = false, + allowAutoDownload = false, + resolveEnrichmentFromNetwork = false + ) private val MISSING_MESSAGE_COOLDOWN_MS = TimeUnit.MINUTES.toMillis(2) private const val DRAFT_LINK_PREVIEW_TAG = "DraftLinkPreview" } diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index 024c1cbc7..b288229f6 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -8,6 +8,7 @@ import org.monogram.data.db.dao.ChatFolderDao import org.monogram.data.db.dao.ChatFullInfoDao import org.monogram.data.db.dao.KeyValueDao import org.monogram.data.db.dao.MessageDao +import org.monogram.data.db.dao.MessageWindowDao import org.monogram.data.db.dao.NotificationExceptionDao import org.monogram.data.db.dao.NotificationSettingDao import org.monogram.data.db.dao.RecentEmojiDao @@ -26,6 +27,7 @@ import org.monogram.data.db.model.ChatFolderEntity import org.monogram.data.db.model.ChatFullInfoEntity import org.monogram.data.db.model.KeyValueEntity import org.monogram.data.db.model.MessageEntity +import org.monogram.data.db.model.MessageWindowEntity import org.monogram.data.db.model.NotificationExceptionEntity import org.monogram.data.db.model.NotificationSettingEntity import org.monogram.data.db.model.RecentEmojiEntity @@ -43,6 +45,7 @@ import org.monogram.data.db.model.WallpaperEntity entities = [ ChatEntity::class, MessageEntity::class, + MessageWindowEntity::class, UserEntity::class, ChatFullInfoEntity::class, TopicEntity::class, @@ -60,12 +63,13 @@ import org.monogram.data.db.model.WallpaperEntity SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 37, - exportSchema = false + version = 38, + exportSchema = true ) abstract class MonogramDatabase : RoomDatabase() { abstract fun chatDao(): ChatDao abstract fun messageDao(): MessageDao + abstract fun messageWindowDao(): MessageWindowDao abstract fun userDao(): UserDao abstract fun chatFullInfoDao(): ChatFullInfoDao abstract fun topicDao(): TopicDao diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index 58cc06325..39c35eeb2 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -395,6 +395,28 @@ object MonogramMigrations { } } + val MIGRATION_37_38 = object : Migration(37, 38) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `message_windows` ( + `chatId` INTEGER NOT NULL, + `scopeType` TEXT NOT NULL, + `scopeId` INTEGER NOT NULL, + `oldestMessageId` INTEGER, + `newestMessageId` INTEGER, + `olderBoundaryReached` INTEGER NOT NULL, + `newerBoundaryReached` INTEGER NOT NULL, + `lastTdlibSyncAt` INTEGER NOT NULL, + `generation` INTEGER NOT NULL, + `protectedMessageId` INTEGER, + PRIMARY KEY(`chatId`, `scopeType`, `scopeId`) + ) + """.trimIndent() + ) + } + } + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") } diff --git a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt index aef9cff88..d8196f8f9 100644 --- a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt @@ -158,19 +158,65 @@ interface MessageDao { replyCount: Int ) + @Query( + """ + DELETE FROM messages + WHERE chatId = :chatId AND threadId IS NULL + AND NOT (isOutgoing = 1 AND id < 0) + AND (:protectedMessageId IS NULL OR id != :protectedMessageId) + AND (createdAt < :olderThan OR id NOT IN ( + SELECT id FROM messages + WHERE chatId = :chatId AND threadId IS NULL + ORDER BY date DESC, id DESC LIMIT :keepCount + )) + """ + ) + suspend fun cleanupMainScope( + chatId: Long, + keepCount: Int, + olderThan: Long, + protectedMessageId: Long? + ): Int + + @Query( + """ + DELETE FROM messages + WHERE chatId = :chatId AND threadId = :threadId + AND NOT (isOutgoing = 1 AND id < 0) + AND (:protectedMessageId IS NULL OR id != :protectedMessageId) + AND (createdAt < :olderThan OR id NOT IN ( + SELECT id FROM messages + WHERE chatId = :chatId AND threadId = :threadId + ORDER BY date DESC, id DESC LIMIT :keepCount + )) + """ + ) + suspend fun cleanupThreadScope( + chatId: Long, + threadId: Long, + keepCount: Int, + olderThan: Long, + protectedMessageId: Long? + ): Int + @Query( """ DELETE FROM messages WHERE chatId = :chatId - AND ( - createdAt < :olderThan - OR id NOT IN ( - SELECT id FROM messages WHERE chatId = :chatId ORDER BY date DESC LIMIT :keepCount - ) + AND NOT (isOutgoing = 1 AND id < 0) + AND NOT EXISTS ( + SELECT 1 FROM message_windows + WHERE message_windows.chatId = messages.chatId + AND message_windows.protectedMessageId = messages.id ) + AND id NOT IN ( + SELECT id FROM messages + WHERE chatId = :chatId + ORDER BY date DESC, id DESC LIMIT :keepCount + ) """ ) - suspend fun cleanupChat(chatId: Long, keepCount: Int, olderThan: Long) + suspend fun enforceChatCap(chatId: Long, keepCount: Int): Int @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMessage(message: MessageEntity) @@ -190,6 +236,17 @@ interface MessageDao { @Query("DELETE FROM messages") suspend fun clearAll() - @Query("DELETE FROM messages WHERE createdAt < :timestamp") + @Query( + """ + DELETE FROM messages + WHERE createdAt < :timestamp + AND NOT (isOutgoing = 1 AND id < 0) + AND NOT EXISTS ( + SELECT 1 FROM message_windows + WHERE message_windows.chatId = messages.chatId + AND message_windows.protectedMessageId = messages.id + ) + """ + ) suspend fun deleteExpired(timestamp: Long) -} \ No newline at end of file +} diff --git a/data/src/main/java/org/monogram/data/db/dao/MessageWindowDao.kt b/data/src/main/java/org/monogram/data/db/dao/MessageWindowDao.kt new file mode 100644 index 000000000..c00ccf06e --- /dev/null +++ b/data/src/main/java/org/monogram/data/db/dao/MessageWindowDao.kt @@ -0,0 +1,60 @@ +package org.monogram.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.monogram.data.db.model.MessageWindowEntity + +@Dao +interface MessageWindowDao { + @Query("SELECT * FROM message_windows WHERE chatId = :chatId AND scopeType = :scopeType AND scopeId = :scopeId") + suspend fun get(chatId: Long, scopeType: String, scopeId: Long): MessageWindowEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(window: MessageWindowEntity) + + @Query( + """ + UPDATE message_windows + SET oldestMessageId = NULL, + newestMessageId = NULL, + olderBoundaryReached = 0, + newerBoundaryReached = 0 + WHERE chatId = :chatId AND scopeType = :scopeType AND scopeId = :scopeId + """ + ) + suspend fun invalidateCoverage(chatId: Long, scopeType: String, scopeId: Long) + + @Query( + """ + UPDATE message_windows + SET oldestMessageId = NULL, + newestMessageId = NULL, + olderBoundaryReached = 0, + newerBoundaryReached = 0 + WHERE chatId = :chatId + """ + ) + suspend fun invalidateCoverageForChat(chatId: Long) + + @Query( + """ + UPDATE message_windows + SET oldestMessageId = NULL, + newestMessageId = NULL, + olderBoundaryReached = 0, + newerBoundaryReached = 0 + """ + ) + suspend fun invalidateAllCoverage() + + @Query("DELETE FROM message_windows WHERE chatId = :chatId AND scopeType = :scopeType AND scopeId = :scopeId") + suspend fun delete(chatId: Long, scopeType: String, scopeId: Long) + + @Query("DELETE FROM message_windows WHERE chatId = :chatId") + suspend fun deleteForChat(chatId: Long) + + @Query("DELETE FROM message_windows") + suspend fun clearAll() +} diff --git a/data/src/main/java/org/monogram/data/db/model/MessageWindowEntity.kt b/data/src/main/java/org/monogram/data/db/model/MessageWindowEntity.kt new file mode 100644 index 000000000..fa872eaf6 --- /dev/null +++ b/data/src/main/java/org/monogram/data/db/model/MessageWindowEntity.kt @@ -0,0 +1,21 @@ +package org.monogram.data.db.model + +import androidx.room.Entity + +/** Bounded cache coverage for one chat history scope. Missing rows mean unknown coverage. */ +@Entity( + tableName = "message_windows", + primaryKeys = ["chatId", "scopeType", "scopeId"] +) +data class MessageWindowEntity( + val chatId: Long, + val scopeType: String, + val scopeId: Long, + val oldestMessageId: Long?, + val newestMessageId: Long?, + val olderBoundaryReached: Boolean, + val newerBoundaryReached: Boolean, + val lastTdlibSyncAt: Long, + val generation: Long, + val protectedMessageId: Long? = null +) diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 0028214b6..ffb279ff3 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -19,6 +19,7 @@ import org.monogram.data.datasource.cache.ChatLocalDataSource import org.monogram.data.datasource.cache.ChatsCacheDataSource import org.monogram.data.datasource.cache.InMemorySettingsCacheDataSource import org.monogram.data.datasource.cache.MessageCacheWriter +import org.monogram.data.datasource.cache.invalidateFailedMessageCacheCoverage import org.monogram.data.datasource.cache.RoomChatLocalDataSource import org.monogram.data.datasource.cache.RoomStickerLocalDataSource import org.monogram.data.datasource.cache.RoomUserLocalDataSource @@ -81,6 +82,7 @@ import org.monogram.data.infra.SponsorSyncManager import org.monogram.data.infra.TdLibParametersProvider import org.monogram.data.mapper.ChatMapper import org.monogram.data.mapper.CustomEmojiLoader +import org.monogram.data.mapper.MappedMediaDemandCoordinator import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.NetworkMapper import org.monogram.data.mapper.SponsoredMessageMapper @@ -306,12 +308,14 @@ val dataModule = module { MonogramMigrations.MIGRATION_33_34, MonogramMigrations.MIGRATION_34_35, MonogramMigrations.MIGRATION_35_36, - MonogramMigrations.MIGRATION_36_37 + MonogramMigrations.MIGRATION_36_37, + MonogramMigrations.MIGRATION_37_38 ) .build() } single { get().chatDao() } single { get().messageDao() } + single { get().messageWindowDao() } single { get().userDao() } single { get().chatFullInfoDao() } single { get().topicDao() } @@ -342,7 +346,8 @@ val dataModule = module { chatDao = get(), messageDao = get(), chatFullInfoDao = get(), - topicDao = get() + topicDao = get(), + messageWindowDao = get() ) } @@ -498,23 +503,22 @@ val dataModule = module { single { WebPageMapper( - fileHelper = get(), - appPreferences = get() + fileHelper = get() ) } single { MessageContentMapper( fileHelper = get(), - appPreferences = get(), customEmojiLoader = get(), webPageMapper = get(), cache = get(), - scope = get(), stringProvider = get() ) } + single { MappedMediaDemandCoordinator(fileApi = get()) } + single { MessageSenderResolver( gateway = get(), @@ -542,6 +546,7 @@ val dataModule = module { fileHelper = get(), senderResolver = get(), contentMapper = get(), + mediaDemandCoordinator = get(), persistenceMapper = get(), customEmojiLoader = get(), stringProvider = get() @@ -671,7 +676,10 @@ val dataModule = module { val localDataSource = get() MessageCacheWriter( scope = get(), - applyBatch = localDataSource::applyMessageCacheMutations + applyBatch = localDataSource::applyMessageCacheMutations, + onTerminalFailure = { mutations, _ -> + invalidateFailedMessageCacheCoverage(localDataSource, mutations) + } ) } @@ -722,6 +730,7 @@ val dataModule = module { context = androidContext(), gateway = get(), updates = get(), + pollRepository = get(), messageMapper = get(), messageRemoteDataSource = get(), cache = get(), diff --git a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt index b82343456..9784124b2 100644 --- a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt +++ b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt @@ -109,3 +109,31 @@ val CHATS_LIST_LANE_FILTER: (TdApi.Update) -> Boolean = { it is TdApi.UpdateAuthorizationState || it is TdApi.UpdateConnectionState } + +/** Updates owned by the normalized message/cache pipeline. File and chat-only state stay separate. */ +val MESSAGE_STATE_LANE_FILTER: (TdApi.Update) -> Boolean = { update -> + update is TdApi.UpdateNewMessage || + update is TdApi.UpdateMessageSendAcknowledged || + update is TdApi.UpdateMessageSendSucceeded || + update is TdApi.UpdateMessageSendFailed || + update is TdApi.UpdateMessageContent || + update is TdApi.UpdateMessageEdited || + update is TdApi.UpdateMessageInteractionInfo || + update is TdApi.UpdateMessageReaction || + update is TdApi.UpdateMessageReactions || + update is TdApi.UpdateMessageMentionRead || + update is TdApi.UpdateMessageUnreadReactions || + update is TdApi.UpdateMessageFactCheck || + update is TdApi.UpdateMessageSuggestedPostInfo || + update is TdApi.UpdateMessageIsPinned || + update is TdApi.UpdateMessageContainsUnreadPollVotes || + update is TdApi.UpdateMessageLiveLocationViewed || + update is TdApi.UpdateMessageContentOpened || + update is TdApi.UpdatePollAnswer || + update is TdApi.UpdateChatReadOutbox || + update is TdApi.UpdateChatReadInbox || + update is TdApi.UpdateDeleteMessages || + update is TdApi.UpdateChatUnreadMentionCount || + update is TdApi.UpdateChatUnreadReactionCount || + update is TdApi.UpdateTextCompositionStyles +} diff --git a/data/src/main/java/org/monogram/data/infra/AtomicSingleFlight.kt b/data/src/main/java/org/monogram/data/infra/AtomicSingleFlight.kt new file mode 100644 index 000000000..4104a0e7e --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/AtomicSingleFlight.kt @@ -0,0 +1,32 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import java.util.concurrent.ConcurrentHashMap + +internal class AtomicSingleFlight( + private val scope: CoroutineScope +) { + private val requests = ConcurrentHashMap>() + private val requestScope = CoroutineScope( + scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job]) + ) + + suspend fun execute(key: K, block: suspend () -> V): V { + val candidate = requestScope.async(start = CoroutineStart.LAZY) { block() } + val selected = requests.putIfAbsent(key, candidate) ?: candidate + if (selected === candidate) { + candidate.invokeOnCompletion { + requests.remove(key, candidate) + } + candidate.start() + } else { + candidate.cancel() + } + return selected.await() + } +} diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index c38f40aa6..3a7474f22 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -18,9 +18,9 @@ import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway +import org.monogram.domain.repository.MediaAutoDownloadPolicy import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit -import kotlin.random.Random class FileDownloadQueue( private val gateway: TelegramGateway, @@ -35,6 +35,15 @@ class FileDownloadQueue( enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } + enum class MediaKind { PHOTO, VIDEO, GIF, VOICE, VIDEO_NOTE, DOCUMENT, AUDIO, STICKER, OTHER } + enum class DemandRole { PRIMARY, PREVIEW, MANUAL_ONLY } + data class MediaDescriptor( + val kind: MediaKind, + val role: DemandRole, + val size: Long, + val supportsStreaming: Boolean = false + ) + private data class DownloadRequest( val fileId: Int, val priority: Int, @@ -66,6 +75,7 @@ class FileDownloadQueue( private val notFoundCooldownUntil = ConcurrentHashMap() private val fileDownloadTypes = ConcurrentHashMap() + private val mediaDescriptors = ConcurrentHashMap() private val manualDownloadIds = ConcurrentHashMap.newKeySet() private val suppressedAutoDownloadIds = ConcurrentHashMap.newKeySet() private val downloadWaiters = ConcurrentHashMap>() @@ -76,22 +86,15 @@ class FileDownloadQueue( private val openChatIds = ConcurrentHashMap.newKeySet() private val visibleMessageIds = ConcurrentHashMap>() private val nearbyMessageIds = ConcurrentHashMap>() + private val autoDownloadPolicies = ConcurrentHashMap() @Volatile private var activeChatId: Long = 0L - // TDLib has no concurrent-download cap of its own: it self-regulates with a 2 MiB in-flight - // byte budget per (DC, size-class) and orders equal priorities LIFO. Keeping these caps tight - // only withholds requests from that scheduler, so they are sized to stay out of its way. private val notFoundCooldownMs = TimeUnit.MINUTES.toMillis(2) - private val maxTotalParallelDownloads = 48 - // Video stays low deliberately: a large file monopolises TDLib's byte window anyway, so - // extra video slots add queueing without throughput. - private val maxVideoParallelDownloads = 4 - private val maxGifParallelDownloads = 4 - private val maxDefaultParallelDownloads = 32 + private val maxAutoParallelDownloads = 8 + private val maxLargeAutoParallelDownloads = 2 private val maxPendingDefaultAutoDownloads = 64 - private val maxStickerParallelDownloads = 16 private val stickerStallMs = 20_000L private val defaultStallMs = 35_000L private val stalledRecoveryCooldownMs = 12_000L @@ -110,14 +113,6 @@ class FileDownloadQueue( } } - scope.launch(dispatcherProvider.default) { - while (isActive) { - delay(TimeUnit.MINUTES.toMillis(1)) - coRunCatching { retryFailedDownloads() } - .onFailure { Log.e("FileDownloadQueue", "retryFailedDownloads failed", it) } - } - } - scope.launch(dispatcherProvider.default) { while (isActive) { delay(15_000) @@ -140,40 +135,22 @@ class FileDownloadQueue( val now = System.currentTimeMillis() stateMutex.withLock { - var vCount = - activeRequests.values.count { it.type == DownloadType.VIDEO || it.type == DownloadType.VIDEO_NOTE } - var gCount = activeRequests.values.count { it.type == DownloadType.GIF } - var dCount = activeRequests.values.count { it.type == DownloadType.DEFAULT } - var sCount = activeRequests.values.count { it.type == DownloadType.STICKER } - var totalCount = activeRequests.size + var autoCount = activeRequests.values.count { !it.isManual } + var largeAutoCount = + activeRequests.values.count { !it.isManual && it.type.isLargeMedia() } val candidates = pendingRequests.values .filter { it.availableAt <= now } .sorted() for (req in candidates) { - if (totalCount >= maxTotalParallelDownloads) break - var canStart = false - when (req.type) { - DownloadType.VIDEO, DownloadType.VIDEO_NOTE -> if (vCount < maxVideoParallelDownloads) { - vCount++; canStart = true - } - - DownloadType.GIF -> if (gCount < maxGifParallelDownloads) { - gCount++; canStart = true - } - - DownloadType.DEFAULT -> if (dCount < maxDefaultParallelDownloads) { - dCount++; canStart = true - } - - DownloadType.STICKER -> if (sCount < maxStickerParallelDownloads) { - sCount++; canStart = true - } - } + if (autoCount >= maxAutoParallelDownloads) break + val isLargeAuto = req.type.isLargeMedia() + val canStart = !isLargeAuto || largeAutoCount < maxLargeAutoParallelDownloads if (canStart) { - totalCount++ + autoCount++ + if (isLargeAuto) largeAutoCount++ pendingRequests.remove(req.fileId) activeRequests[req.fileId] = req tasksToStart.add(req) @@ -203,7 +180,7 @@ class FileDownloadQueue( val fileId = req.fileId if (!isStillRelevant(fileId)) { - finishTask(fileId) + finishTask(req) return } @@ -256,7 +233,7 @@ class FileDownloadQueue( } } } finally { - finishTask(fileId) + finishTask(req) try { val finalFile = withTimeoutOrNull(10000) { gateway.execute(TdApi.GetFile(fileId)) } @@ -286,14 +263,7 @@ class FileDownloadQueue( private suspend fun handleDownloadFailure(req: DownloadRequest, errorCode: Int? = null) { val nextRetry = req.retryCount + 1 - val maxRetries = when (req.type) { - DownloadType.VIDEO -> 4 - DownloadType.GIF -> 5 - DownloadType.STICKER, DownloadType.VIDEO_NOTE -> 6 - DownloadType.DEFAULT -> 5 - } - - if (nextRetry <= maxRetries) { + if (nextRetry <= MAX_RECOVERY_RETRIES) { val backoffMs = calculateBackoffMs(req, nextRetry, errorCode) val nextReq = req.copy( retryCount = nextRetry, @@ -309,20 +279,18 @@ class FileDownloadQueue( trigger.trySend(Unit) } } else { - val cooldownMs = TimeUnit.MINUTES.toMillis(5) - failedRequests[req.fileId] = req.copy(availableAt = System.currentTimeMillis() + cooldownMs) + failedRequests[req.fileId] = req downloadWaiters.remove(req.fileId)?.cancel() } } private suspend fun handleNotFoundDownloadFailure(req: DownloadRequest) { val nextRetry = req.retryCount + 1 - val maxRetries = if (req.type == DownloadType.STICKER) 10 else 4 - val cooldownMs = (notFoundCooldownMs * nextRetry.coerceAtMost(6)).coerceAtMost(TimeUnit.MINUTES.toMillis(15)) + val cooldownMs = notFoundCooldownMs val availableAt = System.currentTimeMillis() + cooldownMs notFoundCooldownUntil[req.fileId] = availableAt - if (nextRetry <= maxRetries) { + if (nextRetry <= MAX_RECOVERY_RETRIES) { val nextReq = req.copy(retryCount = nextRetry, availableAt = availableAt) stateMutex.withLock { pendingRequests[req.fileId] = nextReq @@ -351,18 +319,7 @@ class FileDownloadQueue( DownloadType.STICKER, DownloadType.VIDEO_NOTE -> TimeUnit.MINUTES.toMillis(2) else -> TimeUnit.MINUTES.toMillis(5) } - val scaled = (baseMs * attempt.coerceAtMost(8)).coerceAtMost(capMs) - val jitter = Random.nextLong(250L, 1_250L) - return scaled + jitter - } - - private fun retryFailedDownloads() { - val now = System.currentTimeMillis() - val toRetry = failedRequests.filter { it.value.availableAt <= now } - toRetry.forEach { (id, req) -> - failedRequests.remove(id) - enqueue(id, req.priority, req.type, req.offset, req.limit, req.synchronous) - } + return (baseMs * attempt).coerceAtMost(capMs) } private fun cleanupDeadState() { @@ -396,6 +353,12 @@ class FileDownloadQueue( } val lastProgress = lastProgressAt[req.fileId] ?: req.createdAt if (now - lastProgress >= timeoutMs) { + if (req.retryCount >= MAX_RECOVERY_RETRIES) { + activeRequests.remove(req.fileId, req) + failedRequests[req.fileId] = req + downloadWaiters.remove(req.fileId)?.cancel() + return@forEach + } val recoveredAt = stalledRecoveryAt[req.fileId] ?: 0L if (now - recoveredAt < stalledRecoveryCooldownMs) return@forEach stalledRecoveryAt[req.fileId] = now @@ -411,7 +374,8 @@ class FileDownloadQueue( req.copy( priority = if (req.type == DownloadType.STICKER) maxOf(req.priority, 32) else maxOf(req.priority, 16), availableAt = System.currentTimeMillis() + 250L, - createdAt = System.currentTimeMillis() + createdAt = System.currentTimeMillis(), + retryCount = req.retryCount + 1 ) ) true @@ -420,7 +384,7 @@ class FileDownloadQueue( if (recovered) { coRunCatching { withContext(dispatcherProvider.io) { - gateway.execute(TdApi.CancelDownloadFile(req.fileId, false)) + gateway.execute(TdApi.CancelDownloadFile(req.fileId, true)) } } lastProgressAt[req.fileId] = System.currentTimeMillis() @@ -431,11 +395,11 @@ class FileDownloadQueue( } } - private suspend fun finishTask(fileId: Int) { + private suspend fun finishTask(request: DownloadRequest) { stateMutex.withLock { - activeRequests.remove(fileId) + activeRequests.remove(request.fileId, request) } - stalledRecoveryAt.remove(fileId) + stalledRecoveryAt.remove(request.fileId) trigger.trySend(Unit) } @@ -478,18 +442,7 @@ class FileDownloadQueue( } notifyDownloadComplete(file.id) } else if (oldFile?.local?.isDownloadingActive == true && !file.local.isDownloadingActive) { - val type = fileDownloadTypes[file.id] - if (type == DownloadType.STICKER || manualDownloadIds.contains(file.id)) { - scope.launch(dispatcherProvider.default) { - enqueue( - fileId = file.id, - priority = if (type == DownloadType.STICKER) 32 else calculatePriority(file.id), - type = type ?: DownloadType.DEFAULT - ) - } - } else { - manualDownloadIds.remove(file.id) - } + manualDownloadIds.remove(file.id) } if (file.remote.isUploadingCompleted) { @@ -514,21 +467,29 @@ class FileDownloadQueue( openChatIds.remove(chatId) visibleMessageIds.remove(chatId) nearbyMessageIds.remove(chatId) + autoDownloadPolicies.remove(chatId) if (activeChatId == chatId) activeChatId = 0L registry.unregisterChat(chatId) cancelIrrelevantDownloads() } - fun updateVisibleRange(chatId: Long, visible: List, nearby: List) { + fun updateVisibleRange( + chatId: Long, + visible: List, + nearby: List, + policy: MediaAutoDownloadPolicy + ) { visibleMessageIds[chatId] = visible.toSet() nearbyMessageIds[chatId] = nearby.toSet() + autoDownloadPolicies[chatId] = policy activeChatId = chatId scope.launch(dispatcherProvider.default) { cancelIrrelevantDownloads() (visible + nearby).forEach { messageId -> registry.getFileIdsForMessage(chatId, messageId).forEach { fileId -> - fileDownloadTypes[fileId]?.let { type -> + fileDownloadTypes[fileId]?.takeIf { isAutoDownloadAllowed(fileId) } + ?.let { type -> enqueue(fileId, calculatePriority(fileId), type) } } @@ -536,6 +497,21 @@ class FileDownloadQueue( } } + fun registerFileForMessage( + fileId: Int, + chatId: Long, + messageId: Long, + type: DownloadType, + descriptor: MediaDescriptor? = null + ) { + if (fileId == 0) return + registry.register(fileId, chatId, messageId) + if (type != DownloadType.DEFAULT || !fileDownloadTypes.containsKey(fileId)) { + fileDownloadTypes[fileId] = type + } + descriptor?.let { mediaDescriptors[fileId] = it } + } + fun enqueue( fileId: Int, priority: Int = 1, @@ -557,6 +533,16 @@ class FileDownloadQueue( val isManualRequest = userInitiated if (isManualRequest) manualDownloadIds.add(fileId) + val registeredMessages = registry.getMessages(fileId) + if ( + !isManualRequest && + !synchronous && + registeredMessages.isNotEmpty() && + !hasViewportDemand(fileId) + ) { + return@launch + } + val cooldownUntil = notFoundCooldownUntil[fileId] if (!isManualRequest && cooldownUntil != null && cooldownUntil > System.currentTimeMillis()) { return@launch @@ -565,7 +551,7 @@ class FileDownloadQueue( notFoundCooldownUntil.remove(fileId) } - if (registry.getMessages(fileId).isEmpty()) registry.standaloneFileIds.add(fileId) + if (registeredMessages.isEmpty()) registry.standaloneFileIds.add(fileId) if (type != DownloadType.DEFAULT || !fileDownloadTypes.containsKey(fileId)) { fileDownloadTypes[fileId] = type } @@ -604,13 +590,7 @@ class FileDownloadQueue( val active = activeRequests[fileId] if (active != null) { val merged = mergeRequests(active, req) - // Always re-send on user action. FileManager::download_impl runs with - // force_update_priority=true and ResourceManager::add_node inserts before - // equals, so re-issuing DownloadFile -- even at an unchanged priority -- - // moves the file to the head of TDLib's queue. It is a free bump-to-front. - val shouldKick = userInitiated || - merged != active || - cache.fileCache[fileId]?.local?.isDownloadingActive != true + val shouldKick = merged != active if (shouldKick) { activeRequests[fileId] = merged scope.launch(dispatcherProvider.io) { @@ -631,12 +611,7 @@ class FileDownloadQueue( return@launch } - // A genuinely user-initiated request must reach TDLib immediately rather than - // queueing behind background work. TDLib imposes no concurrency cap and orders - // equal priorities LIFO, so handing it straight over makes the newest action win. - // This intentionally lets activeRequests exceed maxTotalParallelDownloads; - // dispatchTasks() recomputes its counters per pass and simply admits nothing new - // until the set drains. + // Explicit user actions bypass the automatic-download caps. if (userInitiated) { pendingRequests.remove(fileId) activeRequests[fileId] = req @@ -677,7 +652,7 @@ class FileDownloadQueue( scope.launch(dispatcherProvider.io) { try { - gateway.execute(TdApi.CancelDownloadFile(fileId, false)) + gateway.execute(TdApi.CancelDownloadFile(fileId, !force)) } catch (_: Exception) { } @@ -733,14 +708,39 @@ class FileDownloadQueue( if (manualDownloadIds.contains(fileId)) return true if (registry.sponsoredFileIds.contains(fileId)) return true if (registry.standaloneFileIds.contains(fileId)) return true - val type = fileDownloadTypes[fileId] - if (type == DownloadType.STICKER || type == DownloadType.VIDEO_NOTE) return true + return hasViewportDemand(fileId) + } - return registry.getMessages(fileId).any { (chatId, msgId) -> + private fun hasViewportDemand(fileId: Int): Boolean = + registry.getMessages(fileId).any { (chatId, msgId) -> openChatIds.contains(chatId) && + isAutoDownloadAllowed(fileId, chatId, msgId) && (visibleMessageIds[chatId]?.contains(msgId) == true || nearbyMessageIds[chatId]?.contains(msgId) == true) } + + private fun isAutoDownloadAllowed(fileId: Int): Boolean = + registry.getMessages(fileId).any { (chatId, messageId) -> + isAutoDownloadAllowed(fileId, chatId, messageId) + } + + private fun isAutoDownloadAllowed(fileId: Int, chatId: Long, messageId: Long): Boolean { + val policy = autoDownloadPolicies[chatId] ?: return false + val descriptor = mediaDescriptors[fileId] ?: return false + if (!policy.enabled || descriptor.role == DemandRole.MANUAL_ONLY || descriptor.supportsStreaming) return false + val nearbyOnly = nearbyMessageIds[chatId]?.contains(messageId) == true && + visibleMessageIds[chatId]?.contains(messageId) != true + if (nearbyOnly && descriptor.role != DemandRole.PREVIEW && descriptor.kind != MediaKind.STICKER) return false + if ((descriptor.kind == MediaKind.DOCUMENT || descriptor.kind == MediaKind.AUDIO) && !policy.allowFiles) return false + val cap = when (descriptor.kind) { + MediaKind.PHOTO, MediaKind.STICKER -> 10L * MIB + MediaKind.GIF -> 15L * MIB + MediaKind.VOICE, MediaKind.VIDEO_NOTE -> 20L * MIB + MediaKind.VIDEO -> 50L * MIB + MediaKind.DOCUMENT, MediaKind.AUDIO -> 20L * MIB + MediaKind.OTHER -> 10L * MIB + } + return descriptor.size in 1..cap || descriptor.role == DemandRole.PREVIEW } private fun calculatePriority(fileId: Int): Int { @@ -763,9 +763,9 @@ class FileDownloadQueue( var p = 1 if (isVisible) { - p = if (chatId == activeChatId) 16 else 8 + p = if (mediaDescriptors[fileId]?.role == DemandRole.PREVIEW) 16 else 24 } else if (isNearby) { - p = if (chatId == activeChatId) 4 else 2 + p = 8 } max = maxOf(max, p) @@ -792,6 +792,9 @@ class FileDownloadQueue( ) } + private fun DownloadType.isLargeMedia(): Boolean = + this == DownloadType.VIDEO || this == DownloadType.VIDEO_NOTE || this == DownloadType.GIF + private fun cancelIrrelevantDownloads() { scope.launch(dispatcherProvider.default) { // Scan active downloads too, not just pending ones. An in-flight download that the @@ -822,9 +825,6 @@ class FileDownloadQueue( candidateIds.forEach { fileId -> if (manualDownloadIds.contains(fileId)) return@forEach - val type = fileDownloadTypes[fileId] - if (type == DownloadType.STICKER || type == DownloadType.VIDEO_NOTE) return@forEach - val belongsToOpenChat = registry.getMessages(fileId).any { (chatId, _) -> openChatIds.contains(chatId) } @@ -838,4 +838,9 @@ class FileDownloadQueue( toCancel.forEach { fileId -> cancelDownload(fileId, force = false, suppress = false) } } } -} \ No newline at end of file + + private companion object { + const val MIB = 1024L * 1024L + const val MAX_RECOVERY_RETRIES = 1 + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/MappedMediaDemandCoordinator.kt b/data/src/main/java/org/monogram/data/mapper/MappedMediaDemandCoordinator.kt new file mode 100644 index 000000000..5075bfa24 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/MappedMediaDemandCoordinator.kt @@ -0,0 +1,187 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.MessageFileApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType +import org.monogram.data.infra.FileDownloadQueue.DemandRole +import org.monogram.data.infra.FileDownloadQueue.MediaDescriptor +import org.monogram.data.infra.FileDownloadQueue.MediaKind + +internal class MappedMediaDemandCoordinator( + private val fileApi: MessageFileApi +) { + fun register(message: TdApi.Message) { + descriptors(message.content).distinct().forEach { descriptor -> + fileApi.registerFileForMessage( + fileId = descriptor.fileId, + chatId = message.chatId, + messageId = message.id, + type = descriptor.type, + descriptor = descriptor.metadata + ) + } + } + + private fun descriptors(content: TdApi.MessageContent): Sequence = sequence { + when (content) { + is TdApi.MessagePhoto -> yieldPhoto(content.photo) + is TdApi.MessageVideo -> { + yieldFile( + content.video.video, + DownloadType.VIDEO, + MediaKind.VIDEO, + if (content.video.supportsStreaming) DemandRole.MANUAL_ONLY else DemandRole.PRIMARY, + content.video.supportsStreaming + ) + yieldFile( + content.video.thumbnail?.file, + kind = MediaKind.PHOTO, + role = DemandRole.PREVIEW + ) + } + + is TdApi.MessageVoiceNote -> yieldFile(content.voiceNote.voice, kind = MediaKind.VOICE) + is TdApi.MessageVideoNote -> { + yieldFile(content.videoNote.video, DownloadType.VIDEO_NOTE, MediaKind.VIDEO_NOTE) + yieldFile( + content.videoNote.thumbnail?.file, + kind = MediaKind.PHOTO, + role = DemandRole.PREVIEW + ) + } + + is TdApi.MessageSticker -> yieldFile( + content.sticker.sticker, + DownloadType.STICKER, + MediaKind.STICKER + ) + + is TdApi.MessageAnimation -> { + yieldFile(content.animation.animation, DownloadType.GIF, MediaKind.GIF) + yieldFile( + content.animation.thumbnail?.file, + kind = MediaKind.PHOTO, + role = DemandRole.PREVIEW + ) + } + + is TdApi.MessageDocument -> { + yieldFile(content.document.document, kind = MediaKind.DOCUMENT) + yieldFile( + content.document.thumbnail?.file, + kind = MediaKind.PHOTO, + role = DemandRole.PREVIEW + ) + } + + is TdApi.MessageAudio -> yieldFile(content.audio.audio, kind = MediaKind.AUDIO) + is TdApi.MessageText -> content.linkPreview?.let { yieldLinkPreview(it) } + is TdApi.MessagePaidMedia -> content.media.forEach { media -> + when (media) { + is TdApi.PaidMediaPhoto -> yieldPhoto(media.photo) + is TdApi.PaidMediaVideo -> { + yieldFile(media.video.video, DownloadType.VIDEO, MediaKind.VIDEO) + yieldFile( + media.video.thumbnail?.file, + kind = MediaKind.PHOTO, + role = DemandRole.PREVIEW + ) + media.cover?.let { yieldPhoto(it) } + } + } + } + } + } + + private suspend fun SequenceScope.yieldLinkPreview(preview: TdApi.LinkPreview) { + when (val type = preview.type) { + is TdApi.LinkPreviewTypePhoto -> yieldPhoto(type.photo) + is TdApi.LinkPreviewTypeArticle -> type.photo?.let { yieldPhoto(it) } + is TdApi.LinkPreviewTypeVideo -> { + yieldFile( + type.video.video, + DownloadType.VIDEO, + MediaKind.VIDEO, + DemandRole.MANUAL_ONLY, + type.video.supportsStreaming + ) + yieldFile( + type.video.thumbnail?.file, + kind = MediaKind.PHOTO, + role = DemandRole.PREVIEW + ) + } + + is TdApi.LinkPreviewTypeAnimation -> { + yieldFile(type.animation.animation, DownloadType.GIF, MediaKind.GIF) + yieldFile( + type.animation.thumbnail?.file, + kind = MediaKind.PHOTO, + role = DemandRole.PREVIEW + ) + } + + is TdApi.LinkPreviewTypeAudio -> yieldFile( + type.audio.audio, + kind = MediaKind.AUDIO, + role = DemandRole.MANUAL_ONLY + ) + + is TdApi.LinkPreviewTypeDocument -> yieldFile( + type.document.document, + kind = MediaKind.DOCUMENT, + role = DemandRole.MANUAL_ONLY + ) + + is TdApi.LinkPreviewTypeSticker -> yieldFile( + type.sticker.sticker, + DownloadType.STICKER, + MediaKind.STICKER + ) + } + } + + private suspend fun SequenceScope.yieldPhoto(photo: TdApi.Photo) { + val sizes = photo.sizes + val original = + sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: sizes.lastOrNull() + val primary = sizes.find { it.type == "x" } + ?: sizes.find { it.type == "m" } + ?: sizes.getOrNull(sizes.size / 2) + ?: original + val preview = sizes.find { it.type == "m" } + ?: sizes.find { it.type == "s" } + ?: sizes.firstOrNull() + preview?.photo?.let { yieldFile(it, kind = MediaKind.PHOTO, role = DemandRole.PREVIEW) } + primary?.photo?.let { yieldFile(it, kind = MediaKind.PHOTO, role = DemandRole.PRIMARY) } + original?.photo?.takeIf { it.id != primary?.photo?.id } + ?.let { yieldFile(it, kind = MediaKind.PHOTO, role = DemandRole.MANUAL_ONLY) } + } + + private suspend fun SequenceScope.yieldFile( + file: TdApi.File?, + type: DownloadType = DownloadType.DEFAULT, + kind: MediaKind = MediaKind.OTHER, + role: DemandRole = DemandRole.PRIMARY, + supportsStreaming: Boolean = false + ) { + if (file != null && file.id != 0) yield( + FileDescriptor( + fileId = file.id, + type = type, + metadata = MediaDescriptor( + kind = kind, + role = role, + size = maxOf(file.size, file.expectedSize), + supportsStreaming = supportsStreaming + ) + ) + ) + } + + private data class FileDescriptor( + val fileId: Int, + val type: DownloadType, + val metadata: MediaDescriptor + ) +} diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index 6ee32f883..155e3c268 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -19,6 +19,7 @@ import org.monogram.data.mapper.message.MessageContentMapper import org.monogram.data.mapper.message.MessagePersistenceMapper import org.monogram.data.mapper.message.MessageSenderResolver import org.monogram.data.mapper.message.toDomain +import org.monogram.data.mapper.user.toDomain import org.monogram.domain.models.FactCheckModel import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.ForwardOriginType @@ -38,6 +39,7 @@ class MessageMapper internal constructor( private val fileHelper: TdFileHelper, private val senderResolver: MessageSenderResolver, private val contentMapper: MessageContentMapper, + private val mediaDemandCoordinator: MappedMediaDemandCoordinator, private val persistenceMapper: MessagePersistenceMapper, private val customEmojiLoader: CustomEmojiLoader, private val stringProvider: StringProvider @@ -59,7 +61,11 @@ class MessageMapper internal constructor( options: MessageMapOptions = MessageMapOptions() ): MessageModel = coroutineScope { withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) { - val sender = senderResolver.resolveSender(msg) + val sender = if (options.resolveEnrichmentFromNetwork) { + senderResolver.resolveSender(msg) + } else { + senderResolver.resolveFallbackSender(msg) + } val (replyToMsgId, replyToMsg) = resolveReplyInfo( msg = msg, @@ -68,13 +74,13 @@ class MessageMapper internal constructor( options = options ) - val forwardInfo = resolveForwardInfo(msg) + val forwardInfo = resolveForwardInfo(msg, options) val views = msg.interactionInfo?.viewCount val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0 val sendingState = resolveSendingState(msg) val reactions = resolveReactions(msg, isReply, isChatOpen, options) val threadId = resolveThreadId(msg) - val viaBotName = resolveViaBotName(msg) + val viaBotName = resolveViaBotName(msg, options) val factCheck = msg.factCheck?.toDomain() val suggestedPostInfo = msg.suggestedPostInfo?.toDomain() @@ -183,9 +189,10 @@ class MessageMapper internal constructor( factCheck: FactCheckModel? = null, suggestedPostInfo: SuggestedPostInfoModel? = null ): MessageModel { - val networkAutoDownload = isChatOpen && - mapOptions.allowAutoDownload && - fileHelper.isNetworkAutoDownloadEnabled() + // Mapping must not create network work. Viewport and explicit user demands own downloads. + // Keep the context field for the compatibility mapper contract until MediaDemandCoordinator + // supplies typed descriptors and the remaining registry writes move out of mapping. + val networkAutoDownload = false val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending val content = contentMapper.mapContent( @@ -253,7 +260,7 @@ class MessageMapper internal constructor( containsUnreadPollVotes = msg.containsUnreadPollVotes, factCheck = factCheck, suggestedPostInfo = suggestedPostInfo - ) + ).also { mediaDemandCoordinator.register(msg) } } fun mapToEntity( @@ -327,7 +334,10 @@ class MessageMapper internal constructor( return replyToMsgId to replyToMsg } - private suspend fun resolveForwardInfo(msg: TdApi.Message): ForwardInfo? { + private suspend fun resolveForwardInfo( + msg: TdApi.Message, + options: MessageMapOptions + ): ForwardInfo? { val fwd = msg.forwardInfo ?: return null val origin = fwd.origin var originName = unknownUserName @@ -342,11 +352,16 @@ class MessageMapper internal constructor( is TdApi.MessageOriginUser -> { originType = ForwardOriginType.USER originPeerId = origin.senderUserId - val user = try { - withTimeout(500) { userRepository.getUser(originPeerId) } - } catch (_: Exception) { - null - } + val user = cache.getUser(originPeerId)?.toDomain() + ?: if (options.resolveEnrichmentFromNetwork) { + try { + withTimeout(500) { userRepository.getUser(originPeerId) } + } catch (_: Exception) { + null + } + } else { + null + } if (user != null) { avatarPath = user.avatarPath @@ -364,14 +379,19 @@ class MessageMapper internal constructor( is TdApi.MessageOriginChat -> { originType = ForwardOriginType.CHAT originPeerId = origin.senderChatId - val chat = try { - withTimeout(500) { - cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) - .also { cache.putChat(it) } + val chat = cache.getChat(originPeerId) + ?: if (options.resolveEnrichmentFromNetwork) { + try { + withTimeout(500) { + gateway.execute(TdApi.GetChat(originPeerId)) + .also { cache.putChat(it) } + } + } catch (_: Exception) { + null + } + } else { + null } - } catch (_: Exception) { - null - } if (chat != null) { avatarPath = chat.photo?.small?.local?.path.takeIf(fileHelper::isValidPath) originName = chat.title @@ -383,14 +403,19 @@ class MessageMapper internal constructor( originPeerId = origin.chatId originChatId = origin.chatId originMessageId = origin.messageId - val chat = try { - withTimeout(500) { - cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) - .also { cache.putChat(it) } + val chat = cache.getChat(originPeerId) + ?: if (options.resolveEnrichmentFromNetwork) { + try { + withTimeout(500) { + gateway.execute(TdApi.GetChat(originPeerId)) + .also { cache.putChat(it) } + } + } catch (_: Exception) { + null + } + } else { + null } - } catch (_: Exception) { - null - } if (chat != null) { avatarPath = chat.photo?.small?.local?.path.takeIf(fileHelper::isValidPath) originName = chat.title @@ -435,11 +460,20 @@ class MessageMapper internal constructor( async { when (senderId) { is TdApi.MessageSenderUser -> { - val user = try { - withTimeout(500) { userRepository.getUser(senderId.userId) } - } catch (_: Exception) { - null - } + val user = cache.getUser(senderId.userId)?.toDomain() + ?: if (options.resolveEnrichmentFromNetwork) { + try { + withTimeout(500) { + userRepository.getUser( + senderId.userId + ) + } + } catch (_: Exception) { + null + } + } else { + null + } ReactionSender( id = senderId.userId, name = SenderNameResolver.fromPartsOrBlank( @@ -451,15 +485,19 @@ class MessageMapper internal constructor( } is TdApi.MessageSenderChat -> { - val chat = try { - withTimeout(500) { - cache.getChat(senderId.chatId) - ?: gateway.execute(TdApi.GetChat(senderId.chatId)) - .also { cache.putChat(it) } + val chat = cache.getChat(senderId.chatId) + ?: if (options.resolveEnrichmentFromNetwork) { + try { + withTimeout(500) { + gateway.execute(TdApi.GetChat(senderId.chatId)) + .also { cache.putChat(it) } + } + } catch (_: Exception) { + null + } + } else { + null } - } catch (_: Exception) { - null - } ReactionSender( id = senderId.chatId, name = chat?.title ?: "", @@ -493,14 +531,16 @@ class MessageMapper internal constructor( is TdApi.ReactionTypeCustomEmoji -> { val emojiId = type.customEmojiId var path = customEmojiLoader.getPathIfValid(emojiId) - if (path == null) { + val mayLoadCustomEmoji = options.resolveEnrichmentFromNetwork && + isChatOpen && + options.allowAutoDownload && + fileHelper.isNetworkAutoDownloadEnabled() + if (path == null && mayLoadCustomEmoji) { customEmojiLoader.loadIfNeeded( emojiId = emojiId, chatId = msg.chatId, messageId = msg.id, - autoDownload = isChatOpen && - options.allowAutoDownload && - fileHelper.isNetworkAutoDownloadEnabled() + autoDownload = true ) path = customEmojiLoader.getPathIfValid(emojiId) } @@ -529,13 +569,21 @@ class MessageMapper internal constructor( } } - private suspend fun resolveViaBotName(msg: TdApi.Message): String? { + private suspend fun resolveViaBotName( + msg: TdApi.Message, + options: MessageMapOptions + ): String? { if (msg.viaBotUserId == 0L) return null - val bot = try { - withTimeout(500) { userRepository.getUser(msg.viaBotUserId) } - } catch (_: Exception) { - null - } + val bot = cache.getUser(msg.viaBotUserId)?.toDomain() + ?: if (options.resolveEnrichmentFromNetwork) { + try { + withTimeout(500) { userRepository.getUser(msg.viaBotUserId) } + } catch (_: Exception) { + null + } + } else { + null + } return bot?.username ?: bot?.firstName } diff --git a/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt index 95976eac5..b375aa9e0 100644 --- a/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/SponsoredMessageMapper.kt @@ -53,7 +53,7 @@ internal class SponsoredMessageMapper( messageId = message.messageId, senderId = 0L, senderName = message.title.orEmpty(), - networkAutoDownload = true, + networkAutoDownload = false, isActuallyUploading = false ) ) diff --git a/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt index 2fd3130ae..8906eeba4 100644 --- a/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt @@ -1,13 +1,10 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi -import org.monogram.data.datasource.remote.TdMessageRemoteDataSource import org.monogram.domain.models.WebPage -import org.monogram.domain.repository.AppPreferencesProvider class WebPageMapper( - private val fileHelper: TdFileHelper, - private val appPreferences: AppPreferencesProvider + private val fileHelper: TdFileHelper ) { internal data class PhotoSelection( val preferredSize: TdApi.PhotoSize?, @@ -139,37 +136,7 @@ class WebPageMapper( else -> WebPage.LinkPreviewType.Unknown } - fun processTdFile( - file: TdApi.File, - downloadType: TdMessageRemoteDataSource.DownloadType, - supportsStreaming: Boolean = false - ): TdApi.File { - val updatedFile = fileHelper.getUpdatedFile(file) - fileHelper.registerCachedFile(updatedFile.id, chatId, messageId) - - val autoDownload = when (downloadType) { - TdMessageRemoteDataSource.DownloadType.VIDEO -> supportsStreaming && networkAutoDownload - TdMessageRemoteDataSource.DownloadType.DEFAULT -> { - if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload - } - - TdMessageRemoteDataSource.DownloadType.STICKER -> { - networkAutoDownload && appPreferences.autoDownloadStickers.value - } - - TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> { - networkAutoDownload && appPreferences.autoDownloadVideoNotes.value - } - - else -> networkAutoDownload - } - - if (!fileHelper.isValidPath(updatedFile.local.path) && autoDownload) { - fileHelper.enqueueDownload(updatedFile.id, 1, downloadType, 0, 0, false) - } - - return updatedFile - } + fun processTdFile(file: TdApi.File): TdApi.File = fileHelper.getUpdatedFile(file) val photo = photoObj?.let { photoObject -> val selection = selectPhotoSizes(photoObject.sizes) @@ -177,42 +144,6 @@ class WebPageMapper( val thumbnailFile = selection.thumbnailSize?.photo?.let(fileHelper::getUpdatedFile) val originalFile = selection.originalSize?.photo?.let(fileHelper::getUpdatedFile) - if (preferredFile != null) { - fileHelper.registerCachedFile(preferredFile.id, chatId, messageId) - if (fileHelper.findBestAvailablePath( - preferredFile, - photoObject.sizes - ) == null && networkAutoDownload - ) { - fileHelper.enqueueDownload( - preferredFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - - if (thumbnailFile != null) { - fileHelper.registerCachedFile(thumbnailFile.id, chatId, messageId) - if (fileHelper.resolveLocalFilePath(thumbnailFile) == null && networkAutoDownload) { - fileHelper.enqueueDownload( - thumbnailFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - - if (originalFile != null && originalFile.id != preferredFile?.id && originalFile.id != thumbnailFile?.id) { - fileHelper.registerCachedFile(originalFile.id, chatId, messageId) - } - selection.preferredSize?.let { preferredSize -> WebPage.Photo( path = fileHelper.findBestAvailablePath(preferredFile, photoObject.sizes), @@ -228,11 +159,7 @@ class WebPageMapper( } val video = videoObj?.let { videoObject -> - val file = processTdFile( - videoObject.video, - TdMessageRemoteDataSource.DownloadType.VIDEO, - videoObject.supportsStreaming - ) + val file = processTdFile(videoObject.video) WebPage.Video( path = fileHelper.resolveLocalFilePath(file), width = videoObject.width, @@ -251,7 +178,7 @@ class WebPageMapper( } val audio = audioObj?.let { audioObject -> - val file = processTdFile(audioObject.audio, TdMessageRemoteDataSource.DownloadType.DEFAULT) + val file = processTdFile(audioObject.audio) WebPage.Audio( path = fileHelper.resolveLocalFilePath(file), duration = audioObject.duration, @@ -262,7 +189,7 @@ class WebPageMapper( } val document = documentObj?.let { documentObject -> - val file = processTdFile(documentObject.document, TdMessageRemoteDataSource.DownloadType.DEFAULT) + val file = processTdFile(documentObject.document) WebPage.Document( path = fileHelper.resolveLocalFilePath(file), fileName = documentObject.fileName, @@ -273,7 +200,7 @@ class WebPageMapper( } val sticker = stickerObj?.let { stickerObject -> - val file = processTdFile(stickerObject.sticker, TdMessageRemoteDataSource.DownloadType.STICKER) + val file = processTdFile(stickerObject.sticker) WebPage.Sticker( path = fileHelper.resolveLocalFilePath(file), width = stickerObject.width, @@ -284,7 +211,7 @@ class WebPageMapper( } val animation = animationObj?.let { animationObject -> - val file = processTdFile(animationObject.animation, TdMessageRemoteDataSource.DownloadType.GIF) + val file = processTdFile(animationObject.animation) WebPage.Animation( path = fileHelper.resolveLocalFilePath(file), width = animationObject.width, diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt index 5da2b4e6f..0115e29ed 100644 --- a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt @@ -1,12 +1,9 @@ package org.monogram.data.mapper.message -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.data.chats.ChatCache import org.monogram.data.compat.legacyPrizeTonAmount import org.monogram.data.compat.legacyStakeTonAmount -import org.monogram.data.datasource.remote.TdMessageRemoteDataSource import org.monogram.data.mapper.CustomEmojiLoader import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.WebPageMapper @@ -20,7 +17,6 @@ import org.monogram.domain.models.PaidMediaItem import org.monogram.domain.models.PollOption import org.monogram.domain.models.PollType import org.monogram.domain.models.StickerFormat -import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.StringProvider internal data class ContentMappingContext( @@ -34,11 +30,9 @@ internal data class ContentMappingContext( internal class MessageContentMapper( private val fileHelper: TdFileHelper, - private val appPreferences: AppPreferencesProvider, private val customEmojiLoader: CustomEmojiLoader, private val webPageMapper: WebPageMapper, private val cache: ChatCache, - private val scope: CoroutineScope, private val stringProvider: StringProvider ) { private val serviceMessageFormatter = ServiceMessageFormatter( @@ -97,41 +91,6 @@ internal class MessageContentMapper( val path = fileHelper.findBestAvailablePath(photoFile, sizes) val thumbnailPath = fileHelper.resolveLocalFilePath(thumbnailFile) - if (photoFile != null) { - fileHelper.registerCachedFile(photoFile.id, context.chatId, context.messageId) - if (path == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - photoFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - if (originalFile != null && originalFile.id != photoFile?.id) { - fileHelper.registerCachedFile( - originalFile.id, - context.chatId, - context.messageId - ) - } - - if (thumbnailFile != null) { - fileHelper.registerCachedFile(thumbnailFile.id, context.chatId, context.messageId) - if (thumbnailPath == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - thumbnailFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - val isDownloading = photoFile?.local?.isDownloadingActive ?: false val isQueued = photoFile?.let { fileHelper.isFileQueued(it.id) } ?: false val downloadProgress = photoFile?.let(fileHelper::computeDownloadProgress) ?: 0f @@ -163,36 +122,9 @@ internal class MessageContentMapper( val video = content.video val videoFile = fileHelper.getUpdatedFile(video.video) val path = fileHelper.resolveLocalFilePath(videoFile) - fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId) - val thumbFile = video.thumbnail?.file?.let(fileHelper::getUpdatedFile) val thumbnailPath = fileHelper.resolveLocalFilePath(thumbFile) - if (thumbFile != null) { - fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) - if (thumbnailPath == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - thumbFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - - if (path == null && context.networkAutoDownload && !video.supportsStreaming) { - fileHelper.enqueueDownload( - videoFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.VIDEO, - 0, - 0, - false - ) - } - val isDownloading = videoFile.local.isDownloadingActive val isQueued = fileHelper.isFileQueued(videoFile.id) val downloadProgress = fileHelper.computeDownloadProgress(videoFile) @@ -225,18 +157,6 @@ internal class MessageContentMapper( val voice = content.voiceNote val voiceFile = fileHelper.getUpdatedFile(voice.voice) val path = fileHelper.resolveLocalFilePath(voiceFile) - fileHelper.registerCachedFile(voiceFile.id, context.chatId, context.messageId) - - if (path == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - voiceFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } val isDownloading = voiceFile.local.isDownloadingActive val isQueued = fileHelper.isFileQueued(voiceFile.id) @@ -258,34 +178,8 @@ internal class MessageContentMapper( val note = content.videoNote val videoFile = fileHelper.getUpdatedFile(note.video) val videoPath = fileHelper.resolveLocalFilePath(videoFile) - fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId) - - if (videoPath == null && context.networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) { - fileHelper.enqueueDownload( - videoFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE, - 0, - 0, - false - ) - } - val thumbFile = note.thumbnail?.file?.let(fileHelper::getUpdatedFile) val thumbPath = fileHelper.resolveLocalFilePath(thumbFile) - if (thumbFile != null) { - fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) - if (thumbPath == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - thumbFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } val isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive val uploadProgress = fileHelper.computeUploadProgress(videoFile) @@ -311,17 +205,6 @@ internal class MessageContentMapper( val stickerFile = fileHelper.getUpdatedFile(sticker.sticker) val path = fileHelper.resolveLocalFilePath(stickerFile) - fileHelper.registerCachedFile(stickerFile.id, context.chatId, context.messageId) - if (path == null && context.networkAutoDownload && appPreferences.autoDownloadStickers.value) { - fileHelper.enqueueDownload( - stickerFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.STICKER, - 0, - 0, - false - ) - } val format = when (sticker.format) { is TdApi.StickerFormatWebp -> StickerFormat.STATIC @@ -352,33 +235,7 @@ internal class MessageContentMapper( val animation = content.animation val animationFile = fileHelper.getUpdatedFile(animation.animation) val path = fileHelper.resolveLocalFilePath(animationFile) - fileHelper.registerCachedFile(animationFile.id, context.chatId, context.messageId) - - if (path == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - animationFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.GIF, - 0, - 0, - false - ) - } - val thumbFile = animation.thumbnail?.file?.let(fileHelper::getUpdatedFile) - if (thumbFile != null) { - fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) - if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) { - fileHelper.enqueueDownload( - thumbFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } val isDownloading = animationFile.local.isDownloadingActive val isQueued = fileHelper.isFileQueued(animationFile.id) @@ -410,16 +267,6 @@ internal class MessageContentMapper( (content.animatedEmoji.sticker?.fullType as? TdApi.StickerFullTypeCustomEmoji)?.customEmojiId val entities = if (customEmojiId != null && customEmojiId != 0L) { val resolvedPath = customEmojiLoader.getPathIfValid(customEmojiId) - if (resolvedPath == null) { - scope.launch { - customEmojiLoader.loadIfNeeded( - emojiId = customEmojiId, - chatId = context.chatId, - messageId = context.messageId, - autoDownload = context.networkAutoDownload - ) - } - } listOf( MessageEntity( offset = 0, @@ -441,22 +288,7 @@ internal class MessageContentMapper( val document = content.document val documentFile = fileHelper.getUpdatedFile(document.document) val path = fileHelper.resolveLocalFilePath(documentFile) - fileHelper.registerCachedFile(documentFile.id, context.chatId, context.messageId) - val thumbFile = document.thumbnail?.file?.let(fileHelper::getUpdatedFile) - if (thumbFile != null) { - fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) - if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) { - fileHelper.enqueueDownload( - thumbFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } val isDownloading = documentFile.local.isDownloadingActive val isQueued = fileHelper.isFileQueued(documentFile.id) @@ -486,18 +318,6 @@ internal class MessageContentMapper( val audio = content.audio val audioFile = fileHelper.getUpdatedFile(audio.audio) val path = fileHelper.resolveLocalFilePath(audioFile) - fileHelper.registerCachedFile(audioFile.id, context.chatId, context.messageId) - - if (path == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - audioFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } val isDownloading = audioFile.local.isDownloadingActive val isQueued = fileHelper.isFileQueued(audioFile.id) @@ -740,36 +560,6 @@ internal class MessageContentMapper( val path = fileHelper.findBestAvailablePath(photoFile, sizes) val thumbnailPath = fileHelper.resolveLocalFilePath(thumbnailFile) - photoFile?.let { - fileHelper.registerCachedFile(it.id, context.chatId, context.messageId) - if (path == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - it.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - originalFile?.takeIf { it.id != photoFile?.id }?.let { - fileHelper.registerCachedFile(it.id, context.chatId, context.messageId) - } - thumbnailFile?.let { - fileHelper.registerCachedFile(it.id, context.chatId, context.messageId) - if (thumbnailPath == null && context.networkAutoDownload) { - fileHelper.enqueueDownload( - it.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - return PaidMediaItem.Photo( path = path, thumbnailPath = thumbnailPath, @@ -790,23 +580,10 @@ internal class MessageContentMapper( val video = media.video val videoFile = fileHelper.getUpdatedFile(video.video) val path = fileHelper.resolveLocalFilePath(videoFile) - fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId) - if (path == null && context.networkAutoDownload && !video.supportsStreaming) { - fileHelper.enqueueDownload( - videoFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.VIDEO, - 0, - 0, - false - ) - } - val coverPath = media.cover?.sizes ?.maxByOrNull { it.width.toLong() * it.height.toLong() } ?.photo ?.let(fileHelper::getUpdatedFile) - ?.also { fileHelper.registerCachedFile(it.id, context.chatId, context.messageId) } ?.let(fileHelper::resolveLocalFilePath) return PaidMediaItem.Video( @@ -848,11 +625,7 @@ internal class MessageContentMapper( customEmojiPathResolver = { emojiId -> customEmojiLoader.getPathIfValid(emojiId) }, - onMissingCustomEmoji = { emojiId -> - scope.launch { - customEmojiLoader.loadIfNeeded(emojiId, chatId, messageId, networkAutoDownload) - } - } + onMissingCustomEmoji = {} ) } } @@ -895,4 +668,4 @@ internal class MessageContentMapper( private const val TELEGRAM_SERVICE_ID = 777000L private const val TELEGRAM_VERIFY_ID = 42777L } -} \ No newline at end of file +} diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt index 6485f5371..e208a293a 100644 --- a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt @@ -522,8 +522,6 @@ internal class MessagePersistenceMapper( "photo" -> { val fileId = mediaFileId val originalFileId = fileId - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) - fileHelper.registerCachedFile(originalFileId, entity.chatId, entity.id) MessageContent.Photo( path = null, thumbnailPath = null, @@ -541,7 +539,6 @@ internal class MessagePersistenceMapper( "video" -> { val fileId = mediaFileId val supportsStreaming = (meta.getOrNull(3)?.toIntOrNull() ?: 0) == 1 - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) MessageContent.Video( path = null, thumbnailPath = null, @@ -559,7 +556,6 @@ internal class MessagePersistenceMapper( "voice" -> { val fileId = mediaFileId - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) MessageContent.Voice( path = null, duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, @@ -569,7 +565,6 @@ internal class MessagePersistenceMapper( "video_note" -> { val fileId = mediaFileId - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) MessageContent.VideoNote( path = null, thumbnail = null, @@ -581,7 +576,6 @@ internal class MessagePersistenceMapper( "sticker" -> { val fileId = mediaFileId - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) MessageContent.Sticker( id = 0L, setId = meta.getOrNull(0)?.toLongOrNull() ?: 0L, @@ -595,7 +589,6 @@ internal class MessagePersistenceMapper( "document" -> { val fileId = mediaFileId - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) MessageContent.Document( path = null, fileName = meta.getOrNull(0).orEmpty(), @@ -609,7 +602,6 @@ internal class MessagePersistenceMapper( "audio" -> { val fileId = mediaFileId - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) MessageContent.Audio( path = null, duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, @@ -626,7 +618,6 @@ internal class MessagePersistenceMapper( "gif" -> { val fileId = mediaFileId - fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) MessageContent.Gif( path = null, width = meta.getOrNull(0)?.toIntOrNull() ?: 0, diff --git a/data/src/main/java/org/monogram/data/repository/ChatOpenOwnershipRegistry.kt b/data/src/main/java/org/monogram/data/repository/ChatOpenOwnershipRegistry.kt index c2eff2060..fff76ec8c 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatOpenOwnershipRegistry.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatOpenOwnershipRegistry.kt @@ -71,4 +71,6 @@ internal class ChatOpenOwnershipRegistry { owners = owners.toSet() ) } + + fun hasOwners(chatId: Long): Boolean = ownersByChatId[chatId]?.isNotEmpty() == true } diff --git a/data/src/main/java/org/monogram/data/repository/ChatOpenSessionCoordinator.kt b/data/src/main/java/org/monogram/data/repository/ChatOpenSessionCoordinator.kt new file mode 100644 index 000000000..bc362fa39 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ChatOpenSessionCoordinator.kt @@ -0,0 +1,65 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext + +internal class ChatOpenSessionCoordinator( + private val scope: CoroutineScope, + private val context: CoroutineContext, + private val closeGraceMs: Long = DEFAULT_CLOSE_GRACE_MS, + private val openRemote: suspend (chatId: Long, ownerTag: String, owners: Set) -> Boolean, + private val onOpened: (chatId: Long) -> Unit, + private val closeRemote: suspend (chatId: Long) -> Unit, + private val onClosed: (chatId: Long) -> Unit +) { + private val mutex = Mutex() + private val registry = ChatOpenOwnershipRegistry() + private val pendingCloseJobs = mutableMapOf() + + suspend fun open(chatId: Long, ownerTag: String): ChatOpenOwnershipChange = mutex.withLock { + val retainedOpenChat = pendingCloseJobs.remove(chatId)?.let { closeJob -> + closeJob.cancel() + true + } ?: false + val change = registry.acquire(chatId, ownerTag) + if (change.shouldOpen && !retainedOpenChat) { + if (openRemote(chatId, ownerTag, change.owners)) { + onOpened(chatId) + } else { + registry.release(chatId, ownerTag) + } + } + change + } + + suspend fun close(chatId: Long, ownerTag: String): ChatOpenOwnershipChange = mutex.withLock { + val change = registry.release(chatId, ownerTag) + if (change.shouldClose) { + pendingCloseJobs.remove(chatId)?.cancel() + pendingCloseJobs[chatId] = scope.launch(context) { + delay(closeGraceMs) + mutex.withLock { + pendingCloseJobs.remove(chatId) + if (!registry.hasOwners(chatId)) { + runCatching { closeRemote(chatId) } + .onSuccess { onClosed(chatId) } + } + } + } + } + change + } + + internal suspend fun hasOwners(chatId: Long): Boolean = mutex.withLock { + registry.hasOwners(chatId) + } + + private companion object { + const val DEFAULT_CLOSE_GRACE_MS = 750L + } +} diff --git a/data/src/main/java/org/monogram/data/repository/ConversationUpdateKey.kt b/data/src/main/java/org/monogram/data/repository/ConversationUpdateKey.kt new file mode 100644 index 000000000..853d4ea4a --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ConversationUpdateKey.kt @@ -0,0 +1,24 @@ +package org.monogram.data.repository + +import org.drinkless.tdlib.TdApi + +internal fun conversationUpdatedMessageKey( + update: TdApi.Update, + pollMessageKey: Pair? = null +): Pair? = when (update) { + is TdApi.UpdateMessageContent -> update.chatId to update.messageId + is TdApi.UpdateMessageEdited -> update.chatId to update.messageId + is TdApi.UpdateMessageInteractionInfo -> update.chatId to update.messageId + is TdApi.UpdateMessageReaction -> update.chatId to update.messageId + is TdApi.UpdateMessageReactions -> update.chatId to update.messageId + is TdApi.UpdateMessageMentionRead -> update.chatId to update.messageId + is TdApi.UpdateMessageUnreadReactions -> update.chatId to update.messageId + is TdApi.UpdateMessageFactCheck -> update.chatId to update.messageId + is TdApi.UpdateMessageSuggestedPostInfo -> update.chatId to update.messageId + is TdApi.UpdateMessageIsPinned -> update.chatId to update.messageId + is TdApi.UpdateMessageContainsUnreadPollVotes -> update.chatId to update.messageId + is TdApi.UpdateMessageLiveLocationViewed -> update.chatId to update.messageId + is TdApi.UpdateMessageContentOpened -> update.chatId to update.messageId + is TdApi.UpdatePollAnswer -> pollMessageKey + else -> null +} diff --git a/data/src/main/java/org/monogram/data/repository/MessageReadBatcher.kt b/data/src/main/java/org/monogram/data/repository/MessageReadBatcher.kt new file mode 100644 index 000000000..6add32981 --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/MessageReadBatcher.kt @@ -0,0 +1,60 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.CoroutineContext + +internal class MessageReadBatcher( + private val scope: CoroutineScope, + private val context: CoroutineContext, + private val flushDelayMs: Long = DEFAULT_FLUSH_DELAY_MS, + private val flush: suspend (chatId: Long, threadId: Long?, messageIds: LongArray) -> Unit +) { + private data class ReadRequest( + val chatId: Long, + val threadId: Long?, + val messageIds: List + ) + + private val requests = Channel(Channel.UNLIMITED) + + init { + scope.launch(context) { + val pendingIdsByScope = mutableMapOf, LinkedHashSet>() + for (first in requests) { + val scopeKey = first.chatId to first.threadId + pendingIdsByScope.getOrPut(scopeKey) { linkedSetOf() }.addAll(first.messageIds) + while (true) { + val request = withTimeoutOrNull(flushDelayMs) { + requests.receive() + } ?: break + pendingIdsByScope.getOrPut(request.chatId to request.threadId) { linkedSetOf() } + .addAll(request.messageIds) + } + val batches = pendingIdsByScope.toMap() + pendingIdsByScope.clear() + batches.forEach { (scopeKey, ids) -> + ids.chunked(MAX_BATCH_SIZE).forEach { batch -> + flush(scopeKey.first, scopeKey.second, batch.toLongArray()) + } + } + } + } + } + + suspend fun enqueue(chatId: Long, messageIds: Collection, threadId: Long? = null) { + val validIds = messageIds.asSequence().filter { it != 0L }.toList() + if (validIds.isEmpty()) return + + check(requests.trySend(ReadRequest(chatId, threadId, validIds)).isSuccess) { + "Message read batcher is closed" + } + } + + private companion object { + const val DEFAULT_FLUSH_DELAY_MS = 150L + const val MAX_BATCH_SIZE = 100 + } +} diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt index a684d40ec..e00184bcf 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -3,12 +3,11 @@ package org.monogram.data.repository import android.content.Context import android.os.Trace import android.util.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.drinkless.tdlib.TdApi import org.json.JSONArray @@ -31,6 +30,9 @@ import org.monogram.data.datasource.cache.MessageCacheWriter import org.monogram.data.datasource.cache.UserLocalDataSource import org.monogram.data.datasource.remote.FxEmbedRemoteDataSource import org.monogram.data.datasource.remote.MessageRemoteDataSource +import org.monogram.data.datasource.remote.MessageMapOptions +import org.monogram.data.datasource.remote.OrderedEventFlow +import org.monogram.data.datasource.remote.RemoteHistoryFetchMode import org.monogram.data.db.dao.KeyValueDao import org.monogram.data.db.dao.StickerPathDao import org.monogram.data.db.dao.TextCompositionStyleDao @@ -38,7 +40,9 @@ import org.monogram.data.db.model.KeyValueEntity import org.monogram.data.db.model.TextCompositionStyleEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.gateway.MESSAGE_STATE_LANE_FILTER import org.monogram.data.infra.OutboxLifecycleMetrics +import org.monogram.data.infra.AtomicSingleFlight import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.SponsoredMessageMapper import org.monogram.data.mapper.TdFileHelper @@ -49,6 +53,7 @@ import org.monogram.domain.models.ChatEventActionModel import org.monogram.domain.models.ChatEventLogFiltersModel import org.monogram.domain.models.ChatEventModel import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ConversationUpdate import org.monogram.domain.models.DraftLinkPreview import org.monogram.domain.models.DraftLinkPreviewRequest import org.monogram.domain.models.FileModel @@ -69,14 +74,23 @@ import org.monogram.domain.models.webapp.InvoiceModel import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel import org.monogram.domain.repository.ChecklistDraft +import org.monogram.domain.repository.BoundaryState +import org.monogram.domain.repository.ConversationKey +import org.monogram.domain.repository.ConversationScope import org.monogram.domain.repository.FixedTextResult import org.monogram.domain.repository.FormattedTextResult +import org.monogram.domain.repository.HistoryAnchor +import org.monogram.domain.repository.HistoryDirection +import org.monogram.domain.repository.HistoryPage +import org.monogram.domain.repository.HistoryRequest +import org.monogram.domain.repository.HistorySource import org.monogram.domain.repository.ForwardRequest import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.MediaAutoDownloadPolicy import org.monogram.domain.repository.MessageThreadContext -import org.monogram.domain.repository.OlderMessagesPage import org.monogram.domain.repository.ProfileMediaFilter +import org.monogram.domain.repository.PollRepository import org.monogram.domain.repository.RichTextParseMode import org.monogram.domain.repository.RichTextParsingRepository import org.monogram.domain.repository.SearchChatMessagesResult @@ -89,6 +103,7 @@ internal class MessageRepositoryImpl( private val context: Context, private val gateway: TelegramGateway, private val updates: UpdateDispatcher, + private val pollRepository: PollRepository, private val messageMapper: MessageMapper, private val messageRemoteDataSource: MessageRemoteDataSource, private val cache: ChatCache, @@ -116,14 +131,30 @@ internal class MessageRepositoryImpl( ) private val fullRichMessageCache = ConcurrentHashMap() - private val chatOpenOwnershipMutex = Mutex() - private val chatOpenOwnershipRegistry = ChatOpenOwnershipRegistry() + private val chatOpenSessionCoordinator = ChatOpenSessionCoordinator( + scope = scope, + context = dispatcherProvider.io, + openRemote = ::tryOpenChat, + onOpened = messageRemoteDataSource::setChatOpened, + closeRemote = { chatId -> gateway.execute(TdApi.CloseChat(chatId)) }, + onClosed = messageRemoteDataSource::setChatClosed + ) + private val messageReadBatcher = MessageReadBatcher( + scope = scope, + context = dispatcherProvider.io, + flush = { chatId, threadId, messageIds -> + messageRemoteDataSource.markMessagesAsRead(chatId, messageIds, threadId) + } + ) + private val historyRequests = AtomicSingleFlight(scope) internal val outboxLifecycleMetrics = OutboxLifecycleMetrics() + private val conversationUpdateEvents = OrderedEventFlow(scope) private val _textCompositionStyles = MutableStateFlow>(emptyList()) private val hardResetFlagKey = "cache_hard_reset_v2" override val newMessageFlow = messageRemoteDataSource.newMessageFlow + override val conversationUpdates = conversationUpdateEvents.events override val senderUpdateFlow = messageMapper.senderUpdateFlow override val messageEditedFlow = messageRemoteDataSource.messageEditedFlow override val messageUploadProgressFlow = messageRemoteDataSource.messageUploadProgressFlow @@ -158,21 +189,23 @@ internal class MessageRepositoryImpl( // handler exceptions per update — the previous `.catch { }` ended the subscription // for the rest of the process on the first failure. updates.lane( - name = "messages", + name = "message-state", scope = scope, context = dispatcherProvider.io, + filter = MESSAGE_STATE_LANE_FILTER, ) { update -> messageRemoteDataSource.handleUpdate(update) processCachedUpdate(update) + normalizeConversationUpdate(update)?.let(conversationUpdateEvents::enqueue) } scope.launch(dispatcherProvider.io) { - val ninetyDaysAgo = System.currentTimeMillis() - (90L * 24 * 60 * 60 * 1000) + val messageCacheCutoff = System.currentTimeMillis() - MESSAGE_CACHE_TTL_MS Log.i( "MessageRepository", "Expired cache cleanup started: messages only, chats preserved" ) - chatLocalDataSource.deleteExpired(ninetyDaysAgo) + chatLocalDataSource.deleteExpired(messageCacheCutoff) Log.i("MessageRepository", "Expired cache cleanup completed") } @@ -252,7 +285,8 @@ internal class MessageRepositoryImpl( MessageCacheMutation.ReplaceId( chatId = update.message.chatId, oldMessageId = update.oldMessageId, - message = entity + message = entity, + key = update.message.conversationKey() ) ) } @@ -264,7 +298,8 @@ internal class MessageRepositoryImpl( MessageCacheMutation.ReplaceId( chatId = update.message.chatId, oldMessageId = update.oldMessageId, - message = entity + message = entity, + key = update.message.conversationKey() ) ) } @@ -371,10 +406,22 @@ internal class MessageRepositoryImpl( private suspend fun persistMappedMessage(message: TdApi.Message) { messageCacheWriter.enqueue( - MessageCacheMutation.Persist(messageMapper.mapToEntity(message, ::resolveSenderName)) + MessageCacheMutation.Persist( + message = messageMapper.mapToEntity(message, ::resolveSenderName), + key = message.conversationKey() + ) ) } + private fun TdApi.Message.conversationKey() = ConversationKey( + chatId = chatId, + scope = when (val topic = topicId) { + is TdApi.MessageTopicForum -> ConversationScope.ForumTopic(topic.forumTopicId.toLong()) + is TdApi.MessageTopicThread -> ConversationScope.MessageThread(topic.messageThreadId) + else -> ConversationScope.Main + } + ) + private suspend fun persistCachedMessage(chatId: Long, messageId: Long): Boolean { val cachedMessage = cache.getMessage(chatId, messageId) ?: return false persistMappedMessage(cachedMessage) @@ -383,36 +430,10 @@ internal class MessageRepositoryImpl( override suspend fun openChat(chatId: Long, ownerTag: String) { val normalizedOwnerTag = ownerTag.ifBlank { "unknown" } - val ownershipChange = chatOpenOwnershipMutex.withLock { - chatOpenOwnershipRegistry.acquire(chatId, normalizedOwnerTag) - } + val ownershipChange = chatOpenSessionCoordinator.open(chatId, normalizedOwnerTag) when (ownershipChange.transition) { - ChatOpenOwnershipTransition.AcquiredFirstOwner -> { - messageRemoteDataSource.setChatOpened(chatId) - try { - gateway.execute(TdApi.OpenChat(chatId)) - } catch (e: Exception) { - Log.e( - "MessageRepository", - "Error opening chat $chatId owner=$normalizedOwnerTag owners=${ownershipChange.owners}", - e - ) - if (chatId > 0) { - try { - gateway.execute(TdApi.CreatePrivateChat(chatId, false)) - gateway.execute(TdApi.OpenChat(chatId)) - } catch (e2: Exception) { - Log.e( - "MessageRepository", - "Failed to create and open private chat $chatId owner=$normalizedOwnerTag owners=${ownershipChange.owners}", - e2 - ) - } - } - } - } - + ChatOpenOwnershipTransition.AcquiredFirstOwner, ChatOpenOwnershipTransition.AddedOwner, ChatOpenOwnershipTransition.DuplicateOwner -> { Log.d( @@ -427,25 +448,120 @@ internal class MessageRepositoryImpl( } } - override suspend fun closeChat(chatId: Long, ownerTag: String) { - val normalizedOwnerTag = ownerTag.ifBlank { "unknown" } - val ownershipChange = chatOpenOwnershipMutex.withLock { - chatOpenOwnershipRegistry.release(chatId, normalizedOwnerTag) + private suspend fun normalizeConversationUpdate(update: TdApi.Update): ConversationUpdate? = + when (update) { + is TdApi.UpdateNewMessage -> ConversationUpdate.Upsert( + chatId = update.message.chatId, + message = mapConversationMessage(update.message), + isNew = true + ) + + is TdApi.UpdateMessageSendSucceeded -> ConversationUpdate.ReplaceTemporaryId( + chatId = update.message.chatId, + temporaryMessageId = update.oldMessageId, + message = mapConversationMessage(update.message) + ) + + is TdApi.UpdateMessageSendAcknowledged -> ConversationUpdate.SendAcknowledged( + chatId = update.chatId, + temporaryMessageId = update.messageId + ) + + is TdApi.UpdateMessageSendFailed -> ConversationUpdate.SendFailed( + chatId = update.message.chatId, + temporaryMessageId = update.oldMessageId, + message = mapConversationMessage(update.message), + errorCode = update.error?.code ?: 0 + ) + + is TdApi.UpdateDeleteMessages -> ConversationUpdate.Delete( + chatId = update.chatId, + messageIds = update.messageIds.toSet() + ) + + is TdApi.UpdateChatReadInbox -> ConversationUpdate.InboxRead( + chatId = update.chatId, + lastReadMessageId = update.lastReadInboxMessageId + ) + + is TdApi.UpdateChatReadOutbox -> ConversationUpdate.OutboxRead( + chatId = update.chatId, + lastReadMessageId = update.lastReadOutboxMessageId + ) + + else -> conversationUpdatedMessageKey( + update = update, + pollMessageKey = if (update is TdApi.UpdatePollAnswer) { + pollRepository.getMessageIdByPollId(update.pollId) + } else { + null + } + )?.let { (chatId, messageId) -> + val message = if (conversationUpdateRequiresMessageRefresh(update)) { + messageRemoteDataSource.getMessage(chatId, messageId) + } else { + cache.getMessage(chatId, messageId) + } + message?.let { + ConversationUpdate.Upsert( + chatId = chatId, + message = mapConversationMessage(it), + isNew = false + ) + } + } } - when (ownershipChange.transition) { - ChatOpenOwnershipTransition.ReleasedLastOwner -> { - messageRemoteDataSource.setChatClosed(chatId) + private suspend fun mapConversationMessage(message: TdApi.Message): MessageModel = + messageMapper.mapMessageToModel( + msg = message, + isChatOpen = true, + options = MessageMapOptions( + resolveReplyPreviewFromNetwork = false, + allowAutoDownload = false, + resolveEnrichmentFromNetwork = false + ) + ) + + private suspend fun tryOpenChat( + chatId: Long, + ownerTag: String, + owners: Set + ): Boolean { + return try { + gateway.execute(TdApi.OpenChat(chatId)) + true + } catch (e: Exception) { + Log.e( + "MessageRepository", + "Error opening chat $chatId owner=$ownerTag owners=$owners", + e + ) + if (chatId > 0) { try { - gateway.execute(TdApi.CloseChat(chatId)) - } catch (e: Exception) { + gateway.execute(TdApi.CreatePrivateChat(chatId, false)) + gateway.execute(TdApi.OpenChat(chatId)) + true + } catch (e2: Exception) { Log.e( "MessageRepository", - "Error closing chat $chatId owner=$normalizedOwnerTag", - e + "Failed to create and open private chat $chatId owner=$ownerTag owners=$owners", + e2 ) + false } + } else { + false } + } + } + + override suspend fun closeChat(chatId: Long, ownerTag: String) { + val normalizedOwnerTag = ownerTag.ifBlank { "unknown" } + val ownershipChange = chatOpenSessionCoordinator.close(chatId, normalizedOwnerTag) + + when (ownershipChange.transition) { + ChatOpenOwnershipTransition.ReleasedLastOwner -> Unit ChatOpenOwnershipTransition.ReleasedOwner, ChatOpenOwnershipTransition.DuplicateOwner, @@ -767,8 +883,8 @@ internal class MessageRepositoryImpl( ) } - override suspend fun markAsRead(chatId: Long, messageId: Long) { - messageRemoteDataSource.markAsRead(chatId, messageId) + override suspend fun markMessagesAsRead(chatId: Long, messageIds: List, threadId: Long?) { + messageReadBatcher.enqueue(chatId, messageIds, threadId) } override suspend fun markAllMentionsAsRead(chatId: Long) { @@ -780,141 +896,209 @@ internal class MessageRepositoryImpl( } - override suspend fun getMessagesOlder( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? - ): OlderMessagesPage = + override suspend fun getHistoryPage(request: HistoryRequest): HistoryPage = + historyRequests.execute(request) { + loadHistoryPage(request) + } + + private suspend fun loadHistoryPage(request: HistoryRequest): HistoryPage = withContext(dispatcherProvider.io) { - val cached = if (fromMessageId == 0L) { - val cachedEntities = chatLocalDataSource.getLatestMessages(chatId, limit, threadId) - mapLocalMessages(cachedEntities) - } else { - emptyList() + require(request.limit > 0) { "History page limit must be positive" } + val key = request.key + val anchor = (request.anchor as? HistoryAnchor.Message)?.id + if (request.direction != HistoryDirection.Initial) { + requireNotNull(anchor) { "${request.direction} history requires a message anchor" } } - try { - traceSection("chat_history_tdlib") { - val remotePage = messageRemoteDataSource.getRemoteMessagesOlder( - chatId, - fromMessageId, - limit, - threadId - ) - persistRemoteBatch(chatId, remotePage.rawMessages, remotePage.models, threadId) - OlderMessagesPage( - messages = remotePage.models, - reachedOldest = remotePage.reachedOldest, - isRemote = remotePage.isRemote - ) - } - } catch (e: Exception) { - val fallbackMessages = if (cached.isNotEmpty()) { - cached - } else { - val local = - chatLocalDataSource.getMessagesOlder(chatId, fromMessageId, limit, threadId) - mapLocalMessages(local) + if (request.source == HistorySource.RoomSnapshot) { + return@withContext loadRoomHistoryPage(request, anchor) + } + if (request.source == HistorySource.TdlibLocal && key.scope != ConversationScope.Main) { + // Reconcile the known scoped Room window without changing coverage metadata. + return@withContext try { + loadScopedTdlibLocalPage(request, anchor) + } catch (error: Exception) { + if (error is CancellationException) throw error + loadRoomHistoryPage(request, anchor).copy(source = HistorySource.CacheFallback) } - OlderMessagesPage( - messages = fallbackMessages, - reachedOldest = false, - isRemote = false - ) } - } - override suspend fun getCachedMessages( - chatId: Long, - limit: Int, - threadId: Long? - ): List = - withContext(dispatcherProvider.io) { - val local = chatLocalDataSource.getLatestMessages(chatId, limit, threadId) - mapLocalMessages(local) - } - - override suspend fun getMessagesNewer( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? - ): List = - withContext(dispatcherProvider.io) { try { + val fetchMode = request.source.toRemoteHistoryFetchMode() traceSection("chat_history_tdlib") { - val remoteBatch = messageRemoteDataSource.getRemoteMessagesNewer( - chatId, - fromMessageId, - limit, - threadId - ) - persistRemoteBatch( - chatId, - remoteBatch.rawMessages, - remoteBatch.models, - threadId - ) - remoteBatch.models + when (request.direction) { + HistoryDirection.Initial, HistoryDirection.Older -> { + val page = messageRemoteDataSource.getRemoteMessagesOlder( + chatId = key.chatId, + fromMessageId = anchor ?: 0L, + limit = request.limit, + scope = key.scope, + fetchMode = fetchMode + ) + persistRemoteBatch( + chatId = key.chatId, + rawMessages = page.rawMessages, + remoteMessages = page.models, + threadId = key.threadId, + conversationKey = key, + olderBoundaryReached = page.reachedOldest, + mergeDirection = CacheWindowMergeDirection.Older + ) + HistoryPage( + messages = page.models, + olderBoundary = if ( + request.source == HistorySource.TdlibNetwork && page.reachedOldest + ) BoundaryState.Reached else BoundaryState.Open, + newerBoundary = if (anchor == null) BoundaryState.Open else BoundaryState.Gap( + anchor + ), + source = request.source + ) + } + + HistoryDirection.Newer -> { + val requiredAnchor = requireNotNull(anchor) + val batch = messageRemoteDataSource.getRemoteMessagesNewer( + chatId = key.chatId, + fromMessageId = requiredAnchor, + limit = request.limit + 1, + scope = key.scope, + fetchMode = fetchMode + ) + val pairs = batch.rawMessages.zip(batch.models) + .filter { (_, model) -> model.id > requiredAnchor } + .take(request.limit) + val rawMessages = pairs.map { it.first } + val messages = pairs.map { it.second } + val reachedNewest = + request.source == HistorySource.TdlibNetwork && messages.isEmpty() + persistRemoteBatch( + chatId = key.chatId, + rawMessages = rawMessages, + remoteMessages = messages, + threadId = key.threadId, + conversationKey = key, + newerBoundaryReached = reachedNewest, + mergeDirection = CacheWindowMergeDirection.Newer + ) + HistoryPage( + messages = messages, + olderBoundary = BoundaryState.Gap(requiredAnchor), + newerBoundary = if (reachedNewest) BoundaryState.Reached else BoundaryState.Open, + source = request.source + ) + } + + HistoryDirection.Around -> { + val requiredAnchor = requireNotNull(anchor) + val batch = messageRemoteDataSource.getRemoteMessagesAround( + chatId = key.chatId, + messageId = requiredAnchor, + limit = request.limit, + scope = key.scope, + fetchMode = fetchMode + ) + persistRemoteBatch( + chatId = key.chatId, + rawMessages = batch.rawMessages, + remoteMessages = batch.models, + threadId = key.threadId, + conversationKey = key + ) + HistoryPage( + messages = batch.models, + olderBoundary = BoundaryState.Open, + newerBoundary = BoundaryState.Open, + source = request.source + ) + } + } } - } catch (e: Exception) { - chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId) - .let { mapLocalMessages(it) } + } catch (error: Exception) { + if (error is CancellationException) throw error + loadRoomHistoryPage(request, anchor).copy(source = HistorySource.CacheFallback) } } - override suspend fun getCachedMessagesNewer( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? - ): List = - withContext(dispatcherProvider.io) { - chatLocalDataSource.getMessagesNewer(chatId, fromMessageId, limit, threadId) - .let { mapLocalMessages(it) } - } + private fun HistorySource.toRemoteHistoryFetchMode(): RemoteHistoryFetchMode = when (this) { + HistorySource.TdlibLocal -> RemoteHistoryFetchMode.LocalOnly + HistorySource.TdlibNetwork -> RemoteHistoryFetchMode.NetworkOnly + HistorySource.RoomSnapshot, + HistorySource.CacheFallback -> error("$this is not a TDLib history source") + } + + private suspend fun loadRoomHistoryPage(request: HistoryRequest, anchor: Long?): HistoryPage { + val key = request.key + val entities = loadRoomHistoryEntities(request, anchor) + val window = chatLocalDataSource.getMessageWindow(key.chatId, key.scopeType, key.scopeId) + return HistoryPage( + messages = mapLocalMessages(entities), + olderBoundary = when { + window?.olderBoundaryReached == true -> BoundaryState.Reached + anchor != null -> BoundaryState.Gap(anchor) + else -> BoundaryState.Open + }, + newerBoundary = when { + window?.newerBoundaryReached == true -> BoundaryState.Reached + anchor != null -> BoundaryState.Gap(anchor) + else -> BoundaryState.Open + }, + source = HistorySource.RoomSnapshot + ) + } - override suspend fun getCachedMessagesAround( - chatId: Long, - messageId: Long, - limit: Int, - threadId: Long? - ): List = - withContext(dispatcherProvider.io) { - chatLocalDataSource.getMessagesAround(chatId, messageId, limit, threadId) - .let { mapLocalMessages(it) } - } + private suspend fun loadScopedTdlibLocalPage( + request: HistoryRequest, + anchor: Long? + ): HistoryPage { + val entities = loadRoomHistoryEntities(request, anchor) + val batch = messageRemoteDataSource.getMessagesLocally( + chatId = request.key.chatId, + messageIds = entities.map { it.id }, + scope = request.key.scope + ) + return HistoryPage( + messages = batch.models, + olderBoundary = anchor?.let(BoundaryState::Gap) ?: BoundaryState.Open, + newerBoundary = anchor?.let(BoundaryState::Gap) ?: BoundaryState.Open, + source = HistorySource.TdlibLocal + ) + } - override suspend fun getMessagesAround( - chatId: Long, - messageId: Long, - limit: Int, - threadId: Long? - ): List = - withContext(dispatcherProvider.io) { - try { - traceSection("chat_history_tdlib") { - val remoteBatch = messageRemoteDataSource.getRemoteMessagesAround( - chatId, - messageId, - limit, - threadId - ) - persistRemoteBatch( - chatId, - remoteBatch.rawMessages, - remoteBatch.models, - threadId - ) - remoteBatch.models - } - } catch (e: Exception) { - val local = - chatLocalDataSource.getMessagesAround(chatId, messageId, limit, threadId) - mapLocalMessages(local) - } + private suspend fun loadRoomHistoryEntities( + request: HistoryRequest, + anchor: Long? + ): List { + val key = request.key + return when (request.direction) { + HistoryDirection.Initial -> chatLocalDataSource.getLatestMessages( + key.chatId, + request.limit, + key.threadId + ) + + HistoryDirection.Older -> chatLocalDataSource.getMessagesOlder( + key.chatId, + requireNotNull(anchor), + request.limit, + key.threadId + ) + + HistoryDirection.Newer -> chatLocalDataSource.getMessagesNewer( + key.chatId, + requireNotNull(anchor), + request.limit, + key.threadId + ) + + HistoryDirection.Around -> chatLocalDataSource.getMessagesAround( + key.chatId, + requireNotNull(anchor), + request.limit, + key.threadId + ) } + } override suspend fun getMessageThreadContext( chatId: Long, @@ -929,12 +1113,6 @@ internal class MessageRepositoryImpl( } } - @Deprecated("Use getMessagesOlder instead") - override suspend fun getMessages(chatId: Long, fromMessageId: Long, limit: Int): List = - withContext(dispatcherProvider.io) { - getMessagesOlder(chatId, fromMessageId, limit, null).messages - } - override suspend fun getChatDraft(chatId: Long, threadId: Long?): String? = withContext(dispatcherProvider.io) { messageRemoteDataSource.getChatDraft(chatId, threadId) @@ -1346,8 +1524,29 @@ internal class MessageRepositoryImpl( } } - override fun updateVisibleRange(chatId: Long, visibleMessageIds: List, nearbyMessageIds: List) { - messageRemoteDataSource.updateVisibleRange(chatId, visibleMessageIds, nearbyMessageIds) + override fun updateVisibleRange( + chatId: Long, + visibleMessageIds: List, + nearbyMessageIds: List, + policy: MediaAutoDownloadPolicy + ) { + messageRemoteDataSource.updateVisibleRange( + chatId, + visibleMessageIds, + nearbyMessageIds, + policy + ) + } + + override fun updateCachedViewportAnchor(key: ConversationKey, messageId: Long?) { + scope.launch(dispatcherProvider.io) { + chatLocalDataSource.updateProtectedMessageId( + chatId = key.chatId, + scopeType = key.scopeType, + scopeId = key.scopeId, + messageId = messageId + ) + } } override suspend fun onCallbackQuery(chatId: Long, messageId: Long, data: ByteArray) { @@ -2031,43 +2230,90 @@ internal class MessageRepositoryImpl( } } - private fun persistRemoteBatch( + private suspend fun persistRemoteBatch( chatId: Long, rawMessages: List, remoteMessages: List, - threadId: Long? + threadId: Long?, + conversationKey: ConversationKey, + olderBoundaryReached: Boolean = false, + newerBoundaryReached: Boolean = false, + mergeDirection: CacheWindowMergeDirection = CacheWindowMergeDirection.Around ) { - scope.launch(dispatcherProvider.io) { - if (remoteMessages.isEmpty() || rawMessages.isEmpty()) return@launch - val existingById = chatLocalDataSource - .getMessagesByIds(chatId, remoteMessages.map { it.id }) - .associateBy { it.id } - var skippedCount = 0 - val entities = rawMessages.zip(remoteMessages).mapNotNull { (rawMessage, model) -> - val existing = existingById[rawMessage.id] - if (existing != null && existing.isSamePersistedMessage(model)) { - skippedCount += 1 - return@mapNotNull null - } - messageMapper.mapToEntity(rawMessage, ::resolveSenderName) + require(rawMessages.size == remoteMessages.size) { + "Raw and mapped history batches must have equal sizes" + } + val existingById = chatLocalDataSource + .getMessagesByIds(chatId, remoteMessages.map { it.id }) + .associateBy { it.id } + var skippedCount = 0 + val writes = rawMessages.zip(remoteMessages).mapNotNull { (rawMessage, model) -> + val existing = existingById[rawMessage.id] + if (existing != null && existing.isSamePersistedMessage(model)) { + skippedCount += 1 + return@mapNotNull null } - if (entities.isNotEmpty()) { - traceSection("chat_history_persist") { - chatLocalDataSource.insertMessages(entities) - } - } - ChatOpenPerfBridge.recordPersist(chatId, threadId, entities.size, skippedCount) - if (BuildConfig.DEBUG) { - Log.d( - ChatOpenPerfDebug.TAG, - ChatOpenPerfDebug.buildLogMessage( - chatId = chatId, - threadId = threadId, - event = "chat_history_persist_end" + MessageCacheMutation.HistoryWrite( + message = messageMapper.mapToEntity(rawMessage, ::resolveSenderName), + expectedExisting = existing + ) + } + val existingWindow = chatLocalDataSource.getMessageWindow( + chatId, + conversationKey.scopeType, + conversationKey.scopeId + ) + val ids = remoteMessages.map(MessageModel::id) + if (rawMessages.isNotEmpty() || olderBoundaryReached || newerBoundaryReached) { + traceSection("chat_history_persist") { + messageCacheWriter.enqueueAndAwait( + MessageCacheMutation.PersistHistoryBatch( + writes = writes, + window = org.monogram.data.db.model.MessageWindowEntity( + chatId = chatId, + scopeType = conversationKey.scopeType, + scopeId = conversationKey.scopeId, + oldestMessageId = when (mergeDirection) { + CacheWindowMergeDirection.Newer -> existingWindow?.oldestMessageId + ?: ids.minOrNull() + + else -> ids.minOrNull() ?: existingWindow?.oldestMessageId + }, + newestMessageId = when (mergeDirection) { + CacheWindowMergeDirection.Older -> existingWindow?.newestMessageId + ?: ids.maxOrNull() + + else -> ids.maxOrNull() ?: existingWindow?.newestMessageId + }, + olderBoundaryReached = olderBoundaryReached || + existingWindow?.olderBoundaryReached == true, + newerBoundaryReached = newerBoundaryReached || + existingWindow?.newerBoundaryReached == true, + lastTdlibSyncAt = System.currentTimeMillis(), + generation = (existingWindow?.generation ?: 0L) + 1L, + protectedMessageId = existingWindow?.protectedMessageId + ) ) ) } } + ChatOpenPerfBridge.recordPersist(chatId, threadId, writes.size, skippedCount) + if (BuildConfig.DEBUG) { + Log.d( + ChatOpenPerfDebug.TAG, + ChatOpenPerfDebug.buildLogMessage( + chatId = chatId, + threadId = threadId, + event = "chat_history_persist_end" + ) + ) + } + } + + private enum class CacheWindowMergeDirection { + Older, + Newer, + Around } override suspend fun generateTextWithAi( @@ -2248,5 +2494,9 @@ internal class MessageRepositoryImpl( private companion object { private const val DRAFT_LINK_PREVIEW_TAG = "DraftLinkPreview" + private const val MESSAGE_CACHE_TTL_MS = 30L * 24 * 60 * 60 * 1_000 } } + +internal fun conversationUpdateRequiresMessageRefresh(update: TdApi.Update): Boolean = + update is TdApi.UpdatePollAnswer diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt index d6cfd465c..0af291f25 100644 --- a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.ChatLocalDataSource @@ -18,6 +17,7 @@ import org.monogram.data.mapper.isValidFilePath import org.monogram.data.mapper.toEntity import org.monogram.data.mapper.user.toTdApiChat import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.models.ProfilePhotoMedia class ProfilePhotoRepositoryImpl( private val remote: UserRemoteDataSource, @@ -27,19 +27,17 @@ class ProfilePhotoRepositoryImpl( private val fileObserverHub: FileObserverHub ) : ProfilePhotoRepository { private val avatarDownloadPriority = AVATAR_DOWNLOAD_PRIORITY - private val avatarHdPrefetchPriority = AVATAR_HD_PREFETCH_PRIORITY override suspend fun getUserProfilePhotos( userId: Long, offset: Int, - limit: Int, - ensureFullRes: Boolean - ): List { + limit: Int + ): List { if (userId <= 0) return emptyList() val result = remote.getUserProfilePhotos(userId, offset, limit) ?: return emptyList() return coroutineScope { result.photos - .map { photo -> async { resolveUserProfilePhotoPath(photo, ensureFullRes) } } + .map { photo -> async { resolveUserProfilePhotoMedia(photo) } } .awaitAll() .filterNotNull() } @@ -48,18 +46,18 @@ class ProfilePhotoRepositoryImpl( override suspend fun getChatProfilePhotos( chatId: Long, offset: Int, - limit: Int, - ensureFullRes: Boolean - ): List { + limit: Int + ): List { if (chatId == 0L) return emptyList() - val paths = loadChatPhotoHistoryPaths(chatId, offset, limit, ensureFullRes) + val paths = loadChatPhotoHistory(chatId, offset, limit) if (paths.isNotEmpty()) return paths - val currentPath = resolveCurrentChatPhotoPath(chatId, ensureFullRes) + val currentPath = resolveCurrentChatPhoto(chatId) return listOfNotNull(currentPath) } - override fun getUserProfilePhotosFlow(userId: Long): Flow> = channelFlow { + override fun getUserProfilePhotosFlow(userId: Long): Flow> = + channelFlow { if (userId <= 0) { send(emptyList()) return@channelFlow @@ -82,7 +80,8 @@ class ProfilePhotoRepositoryImpl( } } - override fun getChatProfilePhotosFlow(chatId: Long): Flow> = channelFlow { + override fun getChatProfilePhotosFlow(chatId: Long): Flow> = + channelFlow { if (chatId == 0L) { send(emptyList()) return@channelFlow @@ -108,20 +107,18 @@ class ProfilePhotoRepositoryImpl( private suspend fun getUserProfilePhotosWithTracking( userId: Long, offset: Int = 0, - limit: Int = 10, - ensureFullRes: Boolean = false - ): Pair, Set> { - if (userId <= 0) return emptyList() to emptySet() + limit: Int = 10 + ): Pair, Set> { + if (userId <= 0) return emptyList() to emptySet() val trackedFileIds = linkedSetOf() val result = remote.getUserProfilePhotos(userId, offset, limit) - ?: return emptyList() to emptySet() + ?: return emptyList() to emptySet() val paths = coroutineScope { result.photos .map { photo -> async { - resolveUserProfilePhotoPath( + resolveUserProfilePhotoMedia( photo, - ensureFullRes, trackedFileIds ) } @@ -135,25 +132,23 @@ class ProfilePhotoRepositoryImpl( private suspend fun getChatProfilePhotosWithTracking( chatId: Long, offset: Int = 0, - limit: Int = 10, - ensureFullRes: Boolean = false - ): Pair, Set> { - if (chatId == 0L) return emptyList() to emptySet() + limit: Int = 10 + ): Pair, Set> { + if (chatId == 0L) return emptyList() to emptySet() val trackedFileIds = linkedSetOf() - val paths = loadChatPhotoHistoryPaths(chatId, offset, limit, ensureFullRes, trackedFileIds) + val paths = loadChatPhotoHistory(chatId, offset, limit, trackedFileIds) if (paths.isNotEmpty()) return paths to trackedFileIds - val currentPath = resolveCurrentChatPhotoPath(chatId, ensureFullRes, trackedFileIds) + val currentPath = resolveCurrentChatPhoto(chatId, trackedFileIds) return listOfNotNull(currentPath) to trackedFileIds } - private suspend fun loadChatPhotoHistoryPaths( + private suspend fun loadChatPhotoHistory( chatId: Long, offset: Int, limit: Int, - ensureFullRes: Boolean, trackedFileIds: MutableSet? = null - ): List { + ): List { if (limit <= 0) return emptyList() val request = TdApi.SearchChatMessages().apply { @@ -183,9 +178,8 @@ class ProfilePhotoRepositoryImpl( chatPhotos .map { photo -> async { - resolveUserProfilePhotoPath( + resolveUserProfilePhotoMedia( photo, - ensureFullRes, trackedFileIds ) } @@ -196,133 +190,65 @@ class ProfilePhotoRepositoryImpl( } } - private suspend fun resolveCurrentChatPhotoPath( + private suspend fun resolveCurrentChatPhoto( chatId: Long, - ensureFullRes: Boolean, trackedFileIds: MutableSet? = null - ): String? { + ): ProfilePhotoMedia? { val chat = remote.getChat(chatId)?.also { chatLocal.insertChat(it.toEntity()) } ?: chatLocal.getChat(chatId)?.toTdApiChat() ?: return null - return resolveChatPhotoInfoPath(chat.photo, ensureFullRes, trackedFileIds) + return resolveChatPhotoInfo(chat.photo, trackedFileIds) } - private suspend fun resolveChatPhotoInfoPath( + private suspend fun resolveChatPhotoInfo( photoInfo: TdApi.ChatPhotoInfo?, - ensureFullRes: Boolean, trackedFileIds: MutableSet? = null - ): String? { + ): ProfilePhotoMedia? { val smallId = photoInfo?.small?.id?.takeIf { it != 0 } val bigId = photoInfo?.big?.id?.takeIf { it != 0 } smallId?.let { trackedFileIds?.add(it) } bigId?.let { trackedFileIds?.add(it) } - val preferredFile = if (ensureFullRes) { - photoInfo?.big ?: photoInfo?.small - } else { - photoInfo?.small ?: photoInfo?.big - } ?: return null - - val directPath = preferredFile.local.path.takeIf { isValidFilePath(it) } - if (directPath != null) { - if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { - fileQueue.enqueue( - bigId, - avatarHdPrefetchPriority, - FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false - ) - } - return directPath - } - - val downloadedPath = resolveDownloadedFilePath(preferredFile.id) - if (downloadedPath != null) { - if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { - fileQueue.enqueue( - bigId, - avatarHdPrefetchPriority, - FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false - ) - } - return downloadedPath + val previewFile = photoInfo?.small ?: photoInfo?.big ?: return null + val previewPath = previewFile.local.path.takeIf { isValidFilePath(it) } + ?: resolveDownloadedFilePath(previewFile.id) + if (previewPath == null && previewFile.id != 0) { + fileQueue.enqueue( + fileId = previewFile.id, + priority = avatarDownloadPriority, + type = FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) } - - if (!ensureFullRes) { - if (smallId != null) { - fileQueue.enqueue( - smallId, - avatarDownloadPriority, - FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false - ) - if (bigId != null && bigId != smallId) { - fileQueue.enqueue( - bigId, - avatarHdPrefetchPriority, - FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false - ) - } - } else if (bigId != null) { - fileQueue.enqueue( - bigId, - avatarDownloadPriority, - FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false + val originalFile = photoInfo?.big ?: previewFile + return ProfilePhotoMedia( + id = originalFile.id.toLong(), + previewPath = previewPath, + originalFileId = originalFile.id, + originalPath = originalFile.local.path.takeIf { + originalFile.local.isDownloadingCompleted && isValidFilePath( + it ) } - return null - } - - val fileId = preferredFile.id.takeIf { it != 0 } ?: return null - fileQueue.enqueue( - fileId = fileId, - priority = FULL_RES_DOWNLOAD_PRIORITY, - type = FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false, - ignoreSuppression = true + ?: resolveDownloadedFilePath(originalFile.id) ) - withTimeoutOrNull(FILE_DOWNLOAD_TIMEOUT_MS) { - coRunCatching { fileQueue.waitForDownload(fileId).await() } - } - return resolveDownloadedFilePath(fileId) } - private suspend fun resolveUserProfilePhotoPath( + private suspend fun resolveUserProfilePhotoMedia( photo: TdApi.ChatPhoto, - ensureFullRes: Boolean, trackedFileIds: MutableSet? = null - ): String? { + ): ProfilePhotoMedia? { val animationFile = photo.animation?.file val animationFileId = animationFile?.id?.takeIf { it != 0 } animationFileId?.let { trackedFileIds?.add(it) } val animationPath = animationFile?.local?.path?.takeIf { isValidFilePath(it) } - if (animationPath != null) return animationPath val downloadedAnimationPath = resolveDownloadedFilePath(animationFileId) - if (downloadedAnimationPath != null) return downloadedAnimationPath - - if (animationFileId != null) { - if (!ensureFullRes) { - fileQueue.enqueue( - fileId = animationFileId, - priority = avatarDownloadPriority, - type = FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false - ) - } else { - fileQueue.enqueue( - fileId = animationFileId, - priority = FULL_RES_DOWNLOAD_PRIORITY, - type = FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false, - ignoreSuppression = true - ) - withTimeoutOrNull(FILE_DOWNLOAD_TIMEOUT_MS) { - coRunCatching { fileQueue.waitForDownload(animationFileId).await() } - } - resolveDownloadedFilePath(animationFileId)?.let { return it } - } + if (animationFileId != null && animationPath == null && downloadedAnimationPath == null) { + fileQueue.enqueue( + fileId = animationFileId, + priority = avatarDownloadPriority, + type = FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) } photo.sizes.forEach { size -> @@ -335,38 +261,33 @@ class ProfilePhotoRepositoryImpl( ?: photo.sizes.lastOrNull()?.photo ?: return null - val directPath = bestPhotoFile.local.path.takeIf { isValidFilePath(it) } - if (directPath != null) return directPath - - if (!ensureFullRes) { - val fallbackFile = photo.sizes.find { it.type == "m" }?.photo - ?: photo.sizes.find { it.type == "s" }?.photo - ?: photo.sizes.find { it.type == "c" }?.photo - ?: photo.sizes.find { it.type == "b" }?.photo - ?: photo.sizes.find { it.type == "a" }?.photo - ?: photo.sizes.firstOrNull()?.photo - - val fallbackDirectPath = fallbackFile?.local?.path?.takeIf { isValidFilePath(it) } - if (fallbackDirectPath != null) return fallbackDirectPath - - val fallbackDownloadedPath = resolveDownloadedFilePath(fallbackFile?.id) - if (fallbackDownloadedPath != null) return fallbackDownloadedPath - - return null + val fallbackFile = photo.sizes.find { it.type == "m" }?.photo + ?: photo.sizes.find { it.type == "s" }?.photo + ?: photo.sizes.firstOrNull()?.photo + ?: bestPhotoFile + val previewPath = fallbackFile.local.path.takeIf { isValidFilePath(it) } + ?: resolveDownloadedFilePath(fallbackFile.id) + if (previewPath == null && fallbackFile.id != 0) { + fileQueue.enqueue( + fileId = fallbackFile.id, + priority = avatarDownloadPriority, + type = FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) } - - val fileId = bestPhotoFile.id.takeIf { it != 0 } ?: return null - fileQueue.enqueue( - fileId = fileId, - priority = FULL_RES_DOWNLOAD_PRIORITY, - type = FileDownloadQueue.DownloadType.DEFAULT, - synchronous = false, - ignoreSuppression = true + return ProfilePhotoMedia( + id = photo.id, + previewPath = previewPath, + originalFileId = bestPhotoFile.id, + originalPath = bestPhotoFile.local.path.takeIf { + bestPhotoFile.local.isDownloadingCompleted && isValidFilePath( + it + ) + } + ?: resolveDownloadedFilePath(bestPhotoFile.id), + animationFileId = animationFileId ?: 0, + animationPath = animationPath ?: downloadedAnimationPath ) - withTimeoutOrNull(FILE_DOWNLOAD_TIMEOUT_MS) { - coRunCatching { fileQueue.waitForDownload(fileId).await() } - } - return resolveDownloadedFilePath(fileId) } private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { @@ -383,8 +304,5 @@ class ProfilePhotoRepositoryImpl( companion object { private const val AVATAR_DOWNLOAD_PRIORITY = 24 - private const val AVATAR_HD_PREFETCH_PRIORITY = 8 - private const val FULL_RES_DOWNLOAD_PRIORITY = 32 - private const val FILE_DOWNLOAD_TIMEOUT_MS = 15_000L } } diff --git a/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt b/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt index bf83bd704..a83511498 100644 --- a/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt +++ b/data/src/test/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSourceTest.kt @@ -1,14 +1,68 @@ package org.monogram.data.datasource.cache import kotlinx.coroutines.runBlocking +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.data.db.model.ChatEntity import org.monogram.data.db.model.MessageEntity +import org.monogram.data.db.model.MessageWindowEntity class InMemoryChatLocalDataSourceTest { + + @Test + fun `history snapshot does not overwrite newer live mutation`() = runTest { + val source = InMemoryChatLocalDataSource() + val live = message(id = 10L, chatId = 1L, date = 20, content = "edited", editDate = 20) + val staleHistory = + message(id = 10L, chatId = 1L, date = 20, content = "original", editDate = 0) + + source.insertMessage(live) + source.applyMessageCacheMutations( + listOf( + MessageCacheMutation.PersistHistoryBatch( + writes = listOf( + MessageCacheMutation.HistoryWrite( + staleHistory, + expectedExisting = null + ) + ), + window = messageWindow(chatId = 1L) + ) + ) + ) + + assertEquals( + live, + source.getMessagesByIds(chatId = 1L, messageIds = listOf(10L)).single() + ) + } + + @Test + fun `history snapshot refreshes unchanged stale cache row`() = runTest { + val source = InMemoryChatLocalDataSource() + val stale = message(id = 10L, chatId = 1L, content = "old", editDate = 0) + val refreshed = message(id = 10L, chatId = 1L, content = "new", editDate = 10) + source.insertMessage(stale) + + source.applyMessageCacheMutations( + listOf( + MessageCacheMutation.PersistHistoryBatch( + writes = listOf( + MessageCacheMutation.HistoryWrite( + refreshed, + expectedExisting = stale + ) + ), + window = messageWindow(chatId = 1L) + ) + ) + ) + + assertEquals(refreshed, source.getMessagesByIds(1L, listOf(10L)).single()) + } @Test fun `getStartupChats filters zero order sorts and limits`() = runBlocking { val source = InMemoryChatLocalDataSource() @@ -148,7 +202,8 @@ class InMemoryChatLocalDataSourceTest { replyMarkupData: String? = null, reactionsData: String? = null, isPinned: Boolean = false, - hasUnreadReactions: Boolean = false + hasUnreadReactions: Boolean = false, + editDate: Int = 0 ): MessageEntity = MessageEntity( id = id, @@ -161,7 +216,20 @@ class InMemoryChatLocalDataSourceTest { isRead = false, replyMarkupData = replyMarkupData, reactionsData = reactionsData, + editDate = editDate, isPinned = isPinned, hasUnreadReactions = hasUnreadReactions ) + + private fun messageWindow(chatId: Long) = MessageWindowEntity( + chatId = chatId, + scopeType = "main", + scopeId = 0L, + oldestMessageId = null, + newestMessageId = null, + olderBoundaryReached = false, + newerBoundaryReached = false, + lastTdlibSyncAt = 0L, + generation = 1L + ) } diff --git a/data/src/test/java/org/monogram/data/datasource/cache/MessageCacheWriterTest.kt b/data/src/test/java/org/monogram/data/datasource/cache/MessageCacheWriterTest.kt index cc0c7b445..ac286719d 100644 --- a/data/src/test/java/org/monogram/data/datasource/cache/MessageCacheWriterTest.kt +++ b/data/src/test/java/org/monogram/data/datasource/cache/MessageCacheWriterTest.kt @@ -4,12 +4,19 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.monogram.data.db.model.MessageWindowEntity +import org.monogram.data.db.model.MessageEntity +import org.monogram.domain.repository.ConversationKey +import org.monogram.domain.repository.ConversationScope @OptIn(ExperimentalCoroutinesApi::class) class MessageCacheWriterTest { @@ -66,4 +73,135 @@ class MessageCacheWriterTest { assertTrue(writer.stats.value.batches < mutations.size) scope.cancel() } + + @Test + fun `enqueue and await returns only after durable batch completes`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher + SupervisorJob()) + var applied = false + val writer = MessageCacheWriter( + scope = scope, + applyBatch = { applied = true } + ) + + val caller = scope.launch { + writer.enqueueAndAwait(MessageCacheMutation.MarkRead(10L, 20L)) + assertTrue(applied) + } + + assertTrue(caller.isActive) + advanceUntilIdle() + assertTrue(caller.isCompleted) + scope.cancel() + } + + @Test + fun `enqueue and await propagates terminal writer failure`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + var attempts = 0 + var invalidated = false + val writer = MessageCacheWriter( + scope = scope, + applyBatch = { + attempts++ + error("database unavailable") + }, + onTerminalFailure = { mutations, _ -> + assertEquals(1, mutations.size) + invalidated = true + } + ) + + val failure = runCatching { + writer.enqueueAndAwait(MessageCacheMutation.MarkRead(10L, 20L)) + }.exceptionOrNull() + advanceUntilIdle() + + assertTrue(failure is IllegalStateException) + assertEquals(3, attempts) + assertEquals(2L, writer.stats.value.retries) + assertEquals(1L, writer.stats.value.failures) + assertTrue(invalidated) + scope.cancel() + } + + @Test + fun `terminal history failure invalidates coverage but preserves viewport anchor`() = runTest { + val source = InMemoryChatLocalDataSource() + val window = MessageWindowEntity( + chatId = 10L, + scopeType = "thread", + scopeId = 20L, + oldestMessageId = 1L, + newestMessageId = 2L, + olderBoundaryReached = false, + newerBoundaryReached = true, + lastTdlibSyncAt = 30L, + generation = 1L, + protectedMessageId = 99L + ) + source.upsertMessageWindow(window) + + invalidateFailedMessageCacheCoverage( + localDataSource = source, + mutations = listOf(MessageCacheMutation.UpdateWindow(window)) + ) + + val invalidated = source.getMessageWindow(10L, "thread", 20L) + assertEquals(null, invalidated?.oldestMessageId) + assertEquals(null, invalidated?.newestMessageId) + assertEquals(false, invalidated?.olderBoundaryReached) + assertEquals(false, invalidated?.newerBoundaryReached) + assertEquals(99L, invalidated?.protectedMessageId) + } + + @Test + fun `live mutations keep forum topic and message thread coverage separate`() = runTest { + val source = InMemoryChatLocalDataSource() + listOf("forum_topic", "message_thread", "thread").forEach { scopeType -> + source.upsertMessageWindow( + MessageWindowEntity( + chatId = 10L, + scopeType = scopeType, + scopeId = 20L, + oldestMessageId = 1L, + newestMessageId = 2L, + olderBoundaryReached = false, + newerBoundaryReached = true, + lastTdlibSyncAt = 30L, + generation = 1L + ) + ) + } + val message = MessageEntity( + id = 1L, + chatId = 10L, + senderId = 1L, + date = 1, + content = "message", + contentType = "text", + isOutgoing = false, + isRead = false, + threadId = 20L + ) + + invalidateFailedMessageCacheCoverage( + localDataSource = source, + mutations = listOf( + MessageCacheMutation.Persist( + message, + ConversationKey(10L, ConversationScope.ForumTopic(20L)) + ), + MessageCacheMutation.Persist( + message.copy(id = 2L), + ConversationKey(10L, ConversationScope.MessageThread(20L)) + ) + ) + ) + + assertEquals(null, source.getMessageWindow(10L, "forum_topic", 20L)?.oldestMessageId) + assertEquals(null, source.getMessageWindow(10L, "message_thread", 20L)?.oldestMessageId) + assertEquals(1L, source.getMessageWindow(10L, "thread", 20L)?.oldestMessageId) + } + } diff --git a/data/src/test/java/org/monogram/data/datasource/remote/MessageMapOptionsTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/MessageMapOptionsTest.kt new file mode 100644 index 000000000..143e6b90e --- /dev/null +++ b/data/src/test/java/org/monogram/data/datasource/remote/MessageMapOptionsTest.kt @@ -0,0 +1,25 @@ +package org.monogram.data.datasource.remote + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MessageMapOptionsTest { + @Test + fun `default mapping keeps interactive enrichment enabled`() { + val options = MessageMapOptions() + + assertTrue(options.resolveReplyPreviewFromNetwork) + assertTrue(options.allowAutoDownload) + assertTrue(options.resolveEnrichmentFromNetwork) + } + + @Test + fun `live mapping disables network work`() { + val options = TdMessageRemoteDataSource.LIVE_MESSAGE_MAP_OPTIONS + + assertFalse(options.resolveReplyPreviewFromNetwork) + assertFalse(options.allowAutoDownload) + assertFalse(options.resolveEnrichmentFromNetwork) + } +} diff --git a/data/src/test/java/org/monogram/data/datasource/remote/RemoteHistoryFetchModeTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/RemoteHistoryFetchModeTest.kt new file mode 100644 index 000000000..1a98a5a98 --- /dev/null +++ b/data/src/test/java/org/monogram/data/datasource/remote/RemoteHistoryFetchModeTest.kt @@ -0,0 +1,92 @@ +package org.monogram.data.datasource.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows +import org.junit.Test +import org.drinkless.tdlib.TdApi +import org.monogram.domain.repository.ConversationScope + +class RemoteHistoryFetchModeTest { + @Test + fun `typed sources do not hide fallback requests`() { + assertEquals( + listOf(true), + historyFetchAttempts(RemoteHistoryFetchMode.LocalOnly, ConversationScope.Main) + ) + assertEquals( + listOf(false), + historyFetchAttempts(RemoteHistoryFetchMode.NetworkOnly, ConversationScope.Main) + ) + assertEquals( + listOf(true, false), + historyFetchAttempts(RemoteHistoryFetchMode.LocalThenNetwork, ConversationScope.Main) + ) + } + + @Test + fun `scoped history keeps local reconciliation separate from network fetch`() { + val thread = ConversationScope.MessageThread(42L) + assertEquals( + emptyList(), + historyFetchAttempts(RemoteHistoryFetchMode.LocalOnly, thread) + ) + assertEquals( + listOf(false), + historyFetchAttempts(RemoteHistoryFetchMode.NetworkOnly, thread) + ) + assertEquals( + listOf(false), + historyFetchAttempts(RemoteHistoryFetchMode.LocalThenNetwork, thread) + ) + } + + @Test + fun `local history scope distinguishes forum topics from message threads`() { + val forumTopic = TdApi.MessageTopicForum(42) + val messageThread = TdApi.MessageTopicThread(42L) + + assertTrue(messageMatchesScope(forumTopic, ConversationScope.ForumTopic(42L))) + assertFalse(messageMatchesScope(messageThread, ConversationScope.ForumTopic(42L))) + assertTrue(messageMatchesScope(messageThread, ConversationScope.MessageThread(42L))) + assertFalse(messageMatchesScope(forumTopic, ConversationScope.MessageThread(42L))) + } + + @Test + fun `main history accepts messages regardless of topic metadata`() { + assertTrue(messageMatchesScope(null, ConversationScope.Main)) + assertTrue(messageMatchesScope(TdApi.MessageTopicForum(1), ConversationScope.Main)) + assertTrue(messageMatchesScope(TdApi.MessageTopicThread(1L), ConversationScope.Main)) + } + + @Test + fun `typed history scope selects the matching TDLib API`() { + val main = buildHistoryRequest(1L, 2L, -3, 4, ConversationScope.Main, onlyLocal = true) + val forum = + buildHistoryRequest(1L, 2L, -3, 4, ConversationScope.ForumTopic(5L), onlyLocal = false) + val thread = buildHistoryRequest( + 1L, + 2L, + -3, + 4, + ConversationScope.MessageThread(5L), + onlyLocal = false + ) + + assertTrue(main is TdApi.GetChatHistory && main.onlyLocal) + assertTrue(forum is TdApi.GetForumTopicHistory && forum.forumTopicId == 5) + assertTrue(thread is TdApi.GetMessageThreadHistory && thread.messageId == 5L) + assertThrows(IllegalArgumentException::class.java) { + buildHistoryRequest(1L, 0L, 0, 4, ConversationScope.ForumTopic(5L), onlyLocal = true) + } + } + + @Test + fun `scoped local reconciliation uses only offline message requests`() { + val requests = buildLocalMessageRequests(chatId = 7L, messageIds = listOf(11L, 12L)) + + assertEquals(listOf(7L, 7L), requests.map { it.chatId }) + assertEquals(listOf(11L, 12L), requests.map { it.messageId }) + } +} diff --git a/data/src/test/java/org/monogram/data/infra/AtomicSingleFlightTest.kt b/data/src/test/java/org/monogram/data/infra/AtomicSingleFlightTest.kt new file mode 100644 index 000000000..7bb06ca16 --- /dev/null +++ b/data/src/test/java/org/monogram/data/infra/AtomicSingleFlightTest.kt @@ -0,0 +1,84 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AtomicSingleFlightTest { + @Test + fun `concurrent callers share one request`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = CoroutineScope(dispatcher + SupervisorJob()) + val singleFlight = AtomicSingleFlight(scope) + var calls = 0 + + val callers = List(100) { + async(dispatcher) { singleFlight.execute("message") { ++calls } } + } + advanceUntilIdle() + + assertEquals(1, calls) + assertEquals(List(100) { 1 }, callers.map { it.await() }) + } + + @Test + fun `cancelled waiter does not evict active request`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = CoroutineScope(dispatcher + SupervisorJob()) + val singleFlight = AtomicSingleFlight(scope) + val result = CompletableDeferred() + var calls = 0 + + val owner = async(dispatcher) { + singleFlight.execute("message") { + calls++ + result.await() + } + } + val cancelledWaiter = async(dispatcher) { + singleFlight.execute("message") { error("must share owner request") } + } + testScheduler.runCurrent() + cancelledWaiter.cancelAndJoin() + val lateWaiter = async(dispatcher) { + singleFlight.execute("message") { error("cancelled waiter evicted active request") } + } + testScheduler.runCurrent() + + assertEquals(1, calls) + result.complete(42) + advanceUntilIdle() + assertEquals(42, owner.await()) + assertEquals(42, lateWaiter.await()) + } + + @Test + fun `failed request is removed before retry`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = CoroutineScope(dispatcher + SupervisorJob()) + val singleFlight = AtomicSingleFlight(scope) + var calls = 0 + + val failure = runCatching { + singleFlight.execute("message") { + calls++ + error("failed") + } + }.exceptionOrNull() + val retry = singleFlight.execute("message") { ++calls } + + assertTrue(failure is IllegalStateException) + assertEquals(2, retry) + assertEquals(2, calls) + } +} diff --git a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt index 8be7a5ed7..c540d6667 100644 --- a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt @@ -25,6 +25,7 @@ import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ConnectionStatus +import org.monogram.domain.repository.ConversationPipelineMode import org.monogram.domain.repository.DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule @@ -590,6 +591,7 @@ class ConnectionManagerTest { } private class FakeAppPreferencesProvider : AppPreferencesProvider { + override val conversationPipelineMode = MutableStateFlow(ConversationPipelineMode.Legacy) override val autoDownloadMobile = MutableStateFlow(false) override val autoDownloadWifi = MutableStateFlow(false) override val autoDownloadRoaming = MutableStateFlow(false) @@ -648,6 +650,10 @@ class ConnectionManagerTest { override val isSupportViewed = MutableStateFlow(false) override val inAppBrowserEnabled = MutableStateFlow(true) + override fun setConversationPipelineMode(mode: ConversationPipelineMode) { + conversationPipelineMode.value = mode + } + override fun setEnabledProxyId(proxyId: Int?) { enabledProxyId.value = proxyId } diff --git a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt index 128551920..2ddc33083 100644 --- a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt +++ b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt @@ -15,6 +15,7 @@ import org.junit.Test import org.monogram.core.DispatcherProvider import org.monogram.data.chats.ChatCache import org.monogram.data.gateway.TelegramGateway +import org.monogram.domain.repository.MediaAutoDownloadPolicy @OptIn(ExperimentalCoroutinesApi::class) class FileDownloadQueueTest { @@ -60,12 +61,200 @@ class FileDownloadQueueTest { assertTrue(queue.isFileQueued(73)) } + @Test + fun `automatic sticker without open viewport demand is ignored`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val queue = createQueue(dispatcher, this) + queue.registry.register(fileId = 91, chatId = 1L, messageId = 2L) + + queue.enqueue(fileId = 91, type = FileDownloadQueue.DownloadType.STICKER) + runCurrent() + + assertTrue(!queue.isFileQueued(91)) + } + + @Test + fun `automatic sticker starts when message becomes visible in open chat`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val queue = createQueue(dispatcher, this) + queue.registerFileForMessage( + fileId = 92, + chatId = 1L, + messageId = 2L, + type = FileDownloadQueue.DownloadType.STICKER, + descriptor = FileDownloadQueue.MediaDescriptor( + kind = FileDownloadQueue.MediaKind.STICKER, + role = FileDownloadQueue.DemandRole.PRIMARY, + size = 1024L + ) + ) + queue.setChatOpened(1L) + queue.updateVisibleRange( + chatId = 1L, + visible = listOf(2L), + nearby = emptyList(), + policy = ENABLED_POLICY + ) + + queue.enqueue(fileId = 92, type = FileDownloadQueue.DownloadType.STICKER) + runCurrent() + + assertTrue(queue.isFileQueued(92)) + } + + @Test + fun `automatic downloads are capped at eight with two large media`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gateway = FakeTelegramGateway() + val queue = createQueue(dispatcher, this, gateway) + queue.setChatOpened(1L) + + (1..12).forEach { fileId -> + val kind = if (fileId <= 4) { + FileDownloadQueue.MediaKind.VIDEO + } else { + FileDownloadQueue.MediaKind.STICKER + } + queue.registerFileForMessage( + fileId = fileId, + chatId = 1L, + messageId = fileId.toLong(), + type = if (fileId <= 4) { + FileDownloadQueue.DownloadType.VIDEO + } else { + FileDownloadQueue.DownloadType.STICKER + }, + descriptor = FileDownloadQueue.MediaDescriptor( + kind = kind, + role = FileDownloadQueue.DemandRole.PRIMARY, + size = 1024L + ) + ) + } + queue.updateVisibleRange( + chatId = 1L, + visible = (1L..12L).toList(), + nearby = emptyList(), + policy = ENABLED_POLICY + ) + (1..4).forEach { fileId -> + queue.enqueue(fileId, type = FileDownloadQueue.DownloadType.VIDEO) + } + (5..12).forEach { fileId -> + queue.enqueue(fileId, type = FileDownloadQueue.DownloadType.STICKER) + } + runCurrent() + + assertEquals(8, gateway.downloadCalls.size) + assertTrue(gateway.downloadCalls.count { it.fileId in 1..4 } <= 2) + } + + @Test + fun `disabled policy does not start automatic visible download`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val queue = createQueue(dispatcher, this) + queue.registerFileForMessage( + fileId = 93, + chatId = 1L, + messageId = 2L, + type = FileDownloadQueue.DownloadType.STICKER, + descriptor = FileDownloadQueue.MediaDescriptor( + kind = FileDownloadQueue.MediaKind.STICKER, + role = FileDownloadQueue.DemandRole.PRIMARY, + size = 1024L + ) + ) + queue.setChatOpened(1L) + queue.updateVisibleRange( + chatId = 1L, + visible = listOf(2L), + nearby = emptyList(), + policy = MediaAutoDownloadPolicy.Disabled + ) + runCurrent() + + assertTrue(!queue.isFileQueued(93)) + } + + @Test + fun `nearby demand starts preview but not primary media`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gateway = FakeTelegramGateway() + val queue = createQueue(dispatcher, this, gateway) + queue.registerFileForMessage( + 94, 1L, 2L, FileDownloadQueue.DownloadType.VIDEO, + descriptor(FileDownloadQueue.MediaKind.VIDEO) + ) + queue.registerFileForMessage( + 95, 1L, 2L, FileDownloadQueue.DownloadType.DEFAULT, + descriptor(FileDownloadQueue.MediaKind.PHOTO, FileDownloadQueue.DemandRole.PREVIEW) + ) + queue.setChatOpened(1L) + + queue.updateVisibleRange(1L, emptyList(), listOf(2L), ENABLED_POLICY) + runCurrent() + + assertEquals(listOf(95), gateway.downloadCalls.map { it.fileId }) + } + + @Test + fun `streaming and unknown size primary media require manual demand`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gateway = FakeTelegramGateway() + val queue = createQueue(dispatcher, this, gateway) + queue.registerFileForMessage( + 96, 1L, 2L, FileDownloadQueue.DownloadType.VIDEO, + descriptor(FileDownloadQueue.MediaKind.VIDEO, supportsStreaming = true) + ) + queue.registerFileForMessage( + 97, 1L, 3L, FileDownloadQueue.DownloadType.DEFAULT, + descriptor(FileDownloadQueue.MediaKind.DOCUMENT, size = 0L) + ) + queue.setChatOpened(1L) + queue.updateVisibleRange(1L, listOf(2L, 3L), emptyList(), ENABLED_POLICY) + runCurrent() + assertTrue(gateway.downloadCalls.isEmpty()) + + queue.enqueue(96, priority = 32, userInitiated = true, ignoreSuppression = true) + runCurrent() + assertEquals(listOf(96), gateway.downloadCalls.map { it.fileId }) + } + + @Test + fun `identical active demand is not resent`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gateway = FakeTelegramGateway() + val queue = createQueue(dispatcher, this, gateway) + + queue.enqueue(fileId = 100, priority = 32, userInitiated = true) + runCurrent() + queue.enqueue(fileId = 100, priority = 32, userInitiated = true) + runCurrent() + + assertEquals(1, gateway.downloadCalls.count { it.fileId == 100 }) + } + + @Test + fun `manual and speculative cancellation use different pending policy`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val gateway = FakeTelegramGateway() + val queue = createQueue(dispatcher, this, gateway) + + queue.cancelDownload(fileId = 201, force = false, suppress = false) + queue.cancelDownload(fileId = 202, force = true, suppress = false) + runCurrent() + + assertEquals(true, gateway.cancelCalls.single { it.fileId == 201 }.onlyIfPending) + assertEquals(false, gateway.cancelCalls.single { it.fileId == 202 }.onlyIfPending) + } + private fun createQueue( dispatcher: CoroutineDispatcher, - scope: TestScope + scope: TestScope, + gateway: FakeTelegramGateway = FakeTelegramGateway() ): FileDownloadQueue { return FileDownloadQueue( - gateway = FakeTelegramGateway(), + gateway = gateway, registry = FileMessageRegistry(), cache = ChatCache(), scope = scope.backgroundScope, @@ -83,6 +272,8 @@ class FileDownloadQueueTest { } private class FakeTelegramGateway : TelegramGateway { + val downloadCalls = mutableListOf() + val cancelCalls = mutableListOf() override fun lane( name: String, scope: kotlinx.coroutines.CoroutineScope, @@ -97,7 +288,10 @@ class FileDownloadQueueTest { @Suppress("UNCHECKED_CAST") override suspend fun execute(function: TdApi.Function): T { return when (function) { - is TdApi.CancelDownloadFile -> TdApi.Ok() as T + is TdApi.CancelDownloadFile -> { + cancelCalls += function + TdApi.Ok() as T + } is TdApi.GetFile -> TdApi.File().apply { id = function.fileId local = TdApi.LocalFile().apply { @@ -108,18 +302,32 @@ class FileDownloadQueueTest { remote = TdApi.RemoteFile() } as T - is TdApi.DownloadFile -> TdApi.File().apply { - id = function.fileId - local = TdApi.LocalFile().apply { - path = "" - isDownloadingActive = false - isDownloadingCompleted = false - } - remote = TdApi.RemoteFile() - } as T + is TdApi.DownloadFile -> { + downloadCalls += function + TdApi.File().apply { + id = function.fileId + local = TdApi.LocalFile().apply { + path = "" + isDownloadingActive = true + isDownloadingCompleted = false + } + remote = TdApi.RemoteFile() + } as T + } else -> error("Unexpected function ${function.javaClass.simpleName}") } } } + + private fun descriptor( + kind: FileDownloadQueue.MediaKind, + role: FileDownloadQueue.DemandRole = FileDownloadQueue.DemandRole.PRIMARY, + size: Long = 1024L, + supportsStreaming: Boolean = false + ) = FileDownloadQueue.MediaDescriptor(kind, role, size, supportsStreaming) + + private companion object { + val ENABLED_POLICY = MediaAutoDownloadPolicy(enabled = true, allowFiles = false) + } } diff --git a/data/src/test/java/org/monogram/data/mapper/MappedMediaDemandCoordinatorTest.kt b/data/src/test/java/org/monogram/data/mapper/MappedMediaDemandCoordinatorTest.kt new file mode 100644 index 000000000..3815778da --- /dev/null +++ b/data/src/test/java/org/monogram/data/mapper/MappedMediaDemandCoordinatorTest.kt @@ -0,0 +1,101 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.data.datasource.remote.MessageFileApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType +import org.monogram.data.infra.FileDownloadQueue + +class MappedMediaDemandCoordinatorTest { + @Test + fun `registers mapped video descriptors without enqueueing downloads`() { + val fileApi = RecordingMessageFileApi() + val coordinator = MappedMediaDemandCoordinator(fileApi) + val message = TdApi.Message().apply { + id = 20L + chatId = 10L + content = TdApi.MessageVideo().apply { + video = TdApi.Video().apply { + video = file(7) + thumbnail = TdApi.Thumbnail().apply { file = file(8) } + } + } + } + + coordinator.register(message) + + assertEquals( + listOf( + Registration( + 7, + 10L, + 20L, + DownloadType.VIDEO, + FileDownloadQueue.MediaDescriptor( + kind = FileDownloadQueue.MediaKind.VIDEO, + role = FileDownloadQueue.DemandRole.PRIMARY, + size = 0L + ) + ), + Registration( + 8, + 10L, + 20L, + DownloadType.DEFAULT, + FileDownloadQueue.MediaDescriptor( + kind = FileDownloadQueue.MediaKind.PHOTO, + role = FileDownloadQueue.DemandRole.PREVIEW, + size = 0L + ) + ) + ), + fileApi.registrations + ) + assertTrue(fileApi.enqueued.isEmpty()) + } + + private fun file(id: Int) = TdApi.File().apply { this.id = id } + + private data class Registration( + val fileId: Int, + val chatId: Long, + val messageId: Long, + val type: DownloadType, + val descriptor: FileDownloadQueue.MediaDescriptor? + ) + + private class RecordingMessageFileApi : MessageFileApi { + val registrations = mutableListOf() + val enqueued = mutableListOf() + + override fun registerFileForMessage(fileId: Int, chatId: Long, messageId: Long) = Unit + + override fun registerFileForMessage( + fileId: Int, + chatId: Long, + messageId: Long, + type: DownloadType, + descriptor: FileDownloadQueue.MediaDescriptor? + ) { + registrations += Registration(fileId, chatId, messageId, type, descriptor) + } + + override fun registerSponsoredFileForMessage(fileId: Int, chatId: Long, messageId: Long) = + Unit + + override fun enqueueDownload( + fileId: Int, + priority: Int, + type: DownloadType, + offset: Long, + limit: Long, + synchronous: Boolean + ) { + enqueued += fileId + } + + override fun isFileQueued(fileId: Int): Boolean = false + } +} diff --git a/data/src/test/java/org/monogram/data/repository/ChatOpenOwnershipRegistryTest.kt b/data/src/test/java/org/monogram/data/repository/ChatOpenOwnershipRegistryTest.kt index a1d14247e..d219daa56 100644 --- a/data/src/test/java/org/monogram/data/repository/ChatOpenOwnershipRegistryTest.kt +++ b/data/src/test/java/org/monogram/data/repository/ChatOpenOwnershipRegistryTest.kt @@ -11,15 +11,17 @@ class ChatOpenOwnershipRegistryTest { @Test fun `first owner opens and last owner closes`() { val acquired = registry.acquire(chatId = 10L, ownerTag = "cmp1") - val released = registry.release(chatId = 10L, ownerTag = "cmp1") assertEquals(ChatOpenOwnershipTransition.AcquiredFirstOwner, acquired.transition) + assertTrue(registry.hasOwners(10L)) assertTrue(acquired.shouldOpen) assertFalse(acquired.shouldClose) + val released = registry.release(chatId = 10L, ownerTag = "cmp1") assertEquals(ChatOpenOwnershipTransition.ReleasedLastOwner, released.transition) assertFalse(released.shouldOpen) assertTrue(released.shouldClose) + assertFalse(registry.hasOwners(10L)) } @Test diff --git a/data/src/test/java/org/monogram/data/repository/ChatOpenSessionCoordinatorTest.kt b/data/src/test/java/org/monogram/data/repository/ChatOpenSessionCoordinatorTest.kt new file mode 100644 index 000000000..30789eff3 --- /dev/null +++ b/data/src/test/java/org/monogram/data/repository/ChatOpenSessionCoordinatorTest.kt @@ -0,0 +1,68 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatOpenSessionCoordinatorTest { + @Test + fun `failed first open rolls back ownership`() = runTest { + val coordinator = coordinator(openRemote = { _, _, _ -> false }) + + coordinator.open(10L, "cmp") + + assertFalse(coordinator.hasOwners(10L)) + } + + @Test + fun `reopen during grace keeps tdlib chat open`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher + SupervisorJob()) + var opens = 0 + var closes = 0 + val coordinator = ChatOpenSessionCoordinator( + scope = scope, + context = dispatcher, + closeGraceMs = 100L, + openRemote = { _, _, _ -> opens++; true }, + onOpened = {}, + closeRemote = { closes++ }, + onClosed = {} + ) + + coordinator.open(10L, "first") + coordinator.close(10L, "first") + advanceTimeBy(50L) + coordinator.open(10L, "second") + advanceTimeBy(100L) + runCurrent() + + assertEquals(1, opens) + assertEquals(0, closes) + assertTrue(coordinator.hasOwners(10L)) + } + + private fun TestScope.coordinator( + openRemote: suspend (Long, String, Set) -> Boolean + ): ChatOpenSessionCoordinator { + val dispatcher = StandardTestDispatcher(testScheduler) + return ChatOpenSessionCoordinator( + scope = this, + context = dispatcher, + closeGraceMs = 100L, + openRemote = openRemote, + onOpened = {}, + closeRemote = {}, + onClosed = {} + ) + } +} diff --git a/data/src/test/java/org/monogram/data/repository/ConversationUpdateKeyTest.kt b/data/src/test/java/org/monogram/data/repository/ConversationUpdateKeyTest.kt new file mode 100644 index 000000000..023b070e0 --- /dev/null +++ b/data/src/test/java/org/monogram/data/repository/ConversationUpdateKeyTest.kt @@ -0,0 +1,28 @@ +package org.monogram.data.repository + +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConversationUpdateKeyTest { + @Test + fun `poll answer uses mapped message key`() { + val update = + TdApi.UpdatePollAnswer(77L, TdApi.MessageSenderUser(5L), emptyArray(), intArrayOf()) + + assertEquals(10L to 20L, conversationUpdatedMessageKey(update, 10L to 20L)) + assertNull(conversationUpdatedMessageKey(update)) + assertTrue(conversationUpdateRequiresMessageRefresh(update)) + } + + @Test + fun `message update uses its own key`() { + val update = TdApi.UpdateMessageEdited(10L, 20L, 30, null) + + assertEquals(10L to 20L, conversationUpdatedMessageKey(update, 1L to 2L)) + assertFalse(conversationUpdateRequiresMessageRefresh(update)) + } +} diff --git a/data/src/test/java/org/monogram/data/repository/MessageReadBatcherTest.kt b/data/src/test/java/org/monogram/data/repository/MessageReadBatcherTest.kt new file mode 100644 index 000000000..9ec7803eb --- /dev/null +++ b/data/src/test/java/org/monogram/data/repository/MessageReadBatcherTest.kt @@ -0,0 +1,87 @@ +package org.monogram.data.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MessageReadBatcherTest { + @Test + fun `coalesces and deduplicates visible messages per chat`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher + SupervisorJob()) + + data class Batch(val chatId: Long, val threadId: Long?, val ids: LongArray) + + val batches = mutableListOf() + val batcher = MessageReadBatcher( + scope = scope, + context = dispatcher, + flushDelayMs = 30L, + flush = { chatId, threadId, ids -> batches += Batch(chatId, threadId, ids) } + ) + + batcher.enqueue(10L, listOf(1L, 2L)) + batcher.enqueue(10L, listOf(2L, 3L)) + batcher.enqueue(20L, listOf(7L)) + runCurrent() + advanceTimeBy(30L) + runCurrent() + + assertEquals(2, batches.size) + assertArrayEquals(longArrayOf(1L, 2L, 3L), batches.first { it.chatId == 10L }.ids) + assertArrayEquals(longArrayOf(7L), batches.first { it.chatId == 20L }.ids) + } + + @Test + fun `splits one scope into view message batches of at most one hundred ids`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher + SupervisorJob()) + val batches = mutableListOf() + val batcher = MessageReadBatcher( + scope = scope, + context = dispatcher, + flushDelayMs = 30L, + flush = { _, _, ids -> batches += ids } + ) + + batcher.enqueue(10L, (1L..205L).toList(), threadId = 42L) + runCurrent() + advanceTimeBy(30L) + runCurrent() + + assertEquals(listOf(100, 100, 5), batches.map(LongArray::size)) + assertEquals((1L..205L).toList(), batches.flatMap { it.toList() }) + assertTrue(batches.all { it.size <= 100 }) + } + + @Test + fun `default debounce waits one hundred fifty milliseconds`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher + SupervisorJob()) + val batches = mutableListOf() + val batcher = MessageReadBatcher( + scope = scope, + context = dispatcher, + flush = { _, _, ids -> batches += ids } + ) + + batcher.enqueue(10L, listOf(1L, 2L)) + runCurrent() + advanceTimeBy(149L) + runCurrent() + assertTrue(batches.isEmpty()) + + advanceTimeBy(1L) + runCurrent() + assertEquals(1, batches.size) + } +} diff --git a/domain/src/main/java/org/monogram/domain/models/ConversationUpdate.kt b/domain/src/main/java/org/monogram/domain/models/ConversationUpdate.kt new file mode 100644 index 000000000..41f3b61c2 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ConversationUpdate.kt @@ -0,0 +1,44 @@ +package org.monogram.domain.models + +sealed interface ConversationUpdate { + val chatId: Long + + data class Upsert( + override val chatId: Long, + val message: MessageModel, + val isNew: Boolean + ) : ConversationUpdate + + data class ReplaceTemporaryId( + override val chatId: Long, + val temporaryMessageId: Long, + val message: MessageModel + ) : ConversationUpdate + + data class Delete( + override val chatId: Long, + val messageIds: Set + ) : ConversationUpdate + + data class InboxRead( + override val chatId: Long, + val lastReadMessageId: Long + ) : ConversationUpdate + + data class OutboxRead( + override val chatId: Long, + val lastReadMessageId: Long + ) : ConversationUpdate + + data class SendAcknowledged( + override val chatId: Long, + val temporaryMessageId: Long + ) : ConversationUpdate + + data class SendFailed( + override val chatId: Long, + val temporaryMessageId: Long, + val message: MessageModel, + val errorCode: Int + ) : ConversationUpdate +} diff --git a/domain/src/main/java/org/monogram/domain/models/ProfilePhotoMedia.kt b/domain/src/main/java/org/monogram/domain/models/ProfilePhotoMedia.kt new file mode 100644 index 000000000..399ae3e4f --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ProfilePhotoMedia.kt @@ -0,0 +1,13 @@ +package org.monogram.domain.models + +data class ProfilePhotoMedia( + val id: Long, + val previewPath: String?, + val originalFileId: Int, + val originalPath: String? = null, + val animationFileId: Int = 0, + val animationPath: String? = null +) { + val displayPath: String? + get() = animationPath ?: previewPath ?: originalPath +} diff --git a/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt b/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt index 92e0a29e6..13e114fbd 100644 --- a/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt +++ b/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt @@ -12,6 +12,10 @@ sealed interface FileDownloadEvent { override val fileId: Int, val path: String ) : FileDownloadEvent + + data class Cancelled( + override val fileId: Int + ) : FileDownloadEvent } sealed interface MessageDownloadEvent { diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt index 1fb06b6ea..7882bc917 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -6,6 +6,12 @@ enum class PushProvider { FCM, UNIFIED_PUSH, GMS_LESS } +enum class ConversationPipelineMode { + Legacy, + Shadow, + New +} + enum class ProxyNetworkType { WIFI, MOBILE, @@ -58,6 +64,7 @@ const val MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES = 1 const val MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES = 60 interface AppPreferencesProvider { + val conversationPipelineMode: StateFlow val autoDownloadMobile: StateFlow val autoDownloadWifi: StateFlow val autoDownloadRoaming: StateFlow @@ -116,6 +123,7 @@ interface AppPreferencesProvider { val isSupportViewed: StateFlow val inAppBrowserEnabled: StateFlow + fun setConversationPipelineMode(mode: ConversationPipelineMode) fun setInAppBrowserEnabled(enabled: Boolean) fun setAutoDownloadMobile(enabled: Boolean) diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt index 2d4cd06f5..6204fe1ca 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -3,6 +3,7 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ConversationUpdate import org.monogram.domain.models.DraftLinkPreview import org.monogram.domain.models.DraftLinkPreviewRequest import org.monogram.domain.models.MessageContent @@ -40,10 +41,75 @@ data class SearchChatMessagesResult( val nextFromMessageId: Long ) -data class OlderMessagesPage( +data class MediaAutoDownloadPolicy( + val enabled: Boolean, + val allowFiles: Boolean +) { + companion object { + val Disabled = MediaAutoDownloadPolicy(enabled = false, allowFiles = false) + } +} + +sealed interface ConversationScope { + data object Main : ConversationScope + data class ForumTopic(val topicId: Long) : ConversationScope + data class MessageThread(val threadId: Long) : ConversationScope +} + +data class ConversationKey( + val chatId: Long, + val scope: ConversationScope = ConversationScope.Main +) { + /** TDLib still addresses topic/thread history through a shared numeric argument. */ + val threadId: Long? + get() = when (val value = scope) { + ConversationScope.Main -> null + is ConversationScope.ForumTopic -> value.topicId + is ConversationScope.MessageThread -> value.threadId + } + + val scopeType: String + get() = when (scope) { + ConversationScope.Main -> "main" + is ConversationScope.ForumTopic -> "forum_topic" + is ConversationScope.MessageThread -> "message_thread" + } + + val scopeId: Long + get() = when (val value = scope) { + ConversationScope.Main -> 0L + is ConversationScope.ForumTopic -> value.topicId + is ConversationScope.MessageThread -> value.threadId + } +} + +sealed interface HistoryAnchor { + data object Latest : HistoryAnchor + data class Message(val id: Long) : HistoryAnchor +} + +enum class HistoryDirection { Initial, Older, Newer, Around } +enum class HistorySource { RoomSnapshot, TdlibLocal, TdlibNetwork, CacheFallback } + +sealed interface BoundaryState { + data object Reached : BoundaryState + data object Open : BoundaryState + data class Gap(val anchorId: Long) : BoundaryState +} + +data class HistoryRequest( + val key: ConversationKey, + val anchor: HistoryAnchor, + val direction: HistoryDirection, + val limit: Int, + val source: HistorySource = HistorySource.TdlibNetwork +) + +data class HistoryPage( val messages: List, - val reachedOldest: Boolean, - val isRemote: Boolean + val olderBoundary: BoundaryState, + val newerBoundary: BoundaryState, + val source: HistorySource ) data class MessageThreadContext( @@ -91,7 +157,10 @@ interface MessageRepository : MessageAiRepository, PaymentRepository, WebAppRepository { + suspend fun getHistoryPage(request: HistoryRequest): HistoryPage val newMessageFlow: Flow + val conversationUpdates: Flow + get() = emptyFlow() val senderUpdateFlow: Flow val messageReadFlow: Flow val messageUploadProgressFlow: Flow @@ -132,48 +201,10 @@ interface MessageRepository : threadId: Long? = null ) - suspend fun getMessagesOlder( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? = null - ): OlderMessagesPage - - suspend fun getCachedMessages( - chatId: Long, - limit: Int, - threadId: Long? = null - ): List - - suspend fun getMessagesNewer( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? = null - ): List - - suspend fun getCachedMessagesNewer( - chatId: Long, - fromMessageId: Long, - limit: Int, - threadId: Long? = null - ): List - - suspend fun getCachedMessagesAround( - chatId: Long, - messageId: Long, - limit: Int, - threadId: Long? = null - ): List - - suspend fun getMessagesAround(chatId: Long, messageId: Long, limit: Int, threadId: Long? = null): List suspend fun getChatMessageByDate(chatId: Long, dateEpochSeconds: Int): MessageModel? suspend fun getMessageThreadContext(chatId: Long, messageId: Long): MessageThreadContext? - @Deprecated("Use getMessagesOlder instead") - suspend fun getMessages(chatId: Long, fromMessageId: Long, limit: Int): List - suspend fun sendMessage( chatId: Long, text: String, @@ -301,7 +332,15 @@ interface MessageRepository : senderId: Long? = null ): SearchChatMessagesResult - fun updateVisibleRange(chatId: Long, visibleMessageIds: List, nearbyMessageIds: List) + fun updateVisibleRange( + chatId: Long, + visibleMessageIds: List, + nearbyMessageIds: List, + policy: MediaAutoDownloadPolicy = MediaAutoDownloadPolicy.Disabled + ) + + /** Prevents bounded Room cleanup from removing the persisted viewport anchor. */ + fun updateCachedViewportAnchor(key: ConversationKey, messageId: Long?) sealed interface ChatAction { data object Typing : ChatAction @@ -353,7 +392,14 @@ interface MessageRepository : doneIds: List, undoneIds: List ) - suspend fun markAsRead(chatId: Long, messageId: Long) + suspend fun markAsRead(chatId: Long, messageId: Long) = + markMessagesAsRead(chatId, listOf(messageId)) + + suspend fun markMessagesAsRead( + chatId: Long, + messageIds: List, + threadId: Long? = null + ) suspend fun markAllMentionsAsRead(chatId: Long) suspend fun markAllReactionsAsRead(chatId: Long) suspend fun getChatDraft(chatId: Long, threadId: Long? = null): String? @@ -369,4 +415,4 @@ interface MessageRepository : fun clearMessages(chatId: Long) fun clearAllCache() -} \ No newline at end of file +} diff --git a/domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt index bd0209f29..1edefa83f 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ProfilePhotoRepository.kt @@ -1,22 +1,21 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.ProfilePhotoMedia interface ProfilePhotoRepository { suspend fun getUserProfilePhotos( userId: Long, offset: Int = 0, - limit: Int = 10, - ensureFullRes: Boolean = false - ): List + limit: Int = 10 + ): List suspend fun getChatProfilePhotos( chatId: Long, offset: Int = 0, - limit: Int = 10, - ensureFullRes: Boolean = false - ): List + limit: Int = 10 + ): List - fun getUserProfilePhotosFlow(userId: Long): Flow> - fun getChatProfilePhotosFlow(chatId: Long): Flow> + fun getUserProfilePhotosFlow(userId: Long): Flow> + fun getChatProfilePhotosFlow(chatId: Long): Flow> } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73e561fe4..a0d144abb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ androidx-room = "2.8.4" androidx-uiautomator = "2.4.0" androidx-benchmark-macro-junit4 = "1.4.1" androidx-test-ext-junit = "1.3.0" +androidx-test-runner = "1.7.0" androidx-window = "1.5.1" androidx-core-splashscreen = "1.2.0" androidx-lifecycle = "2.11.0" @@ -104,11 +105,13 @@ androidx-security-crypto = { module = "androidx.security:security-crypto-ktx", v androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-room" } # AndroidX Benchmark & UIAutomator androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark-macro-junit4" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } # KotlinX kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 363b56aea..6f20029a7 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -73,18 +73,22 @@ android { create("official") { dimension = "tdlib" buildConfigField("boolean", "ENABLE_TELEMT_DNS", "false") + buildConfigField("boolean", "IS_OFFICIAL_TDLIB", "true") } create("telemt") { dimension = "tdlib" buildConfigField("boolean", "ENABLE_TELEMT_DNS", "true") + buildConfigField("boolean", "IS_OFFICIAL_TDLIB", "false") } create("firebase") { dimension = "runtime" buildConfigField("boolean", "HAS_OSS_LICENSES", "true") + buildConfigField("boolean", "IS_LIBRE_RUNTIME", "false") } create("libre") { dimension = "runtime" buildConfigField("boolean", "HAS_OSS_LICENSES", "false") + buildConfigField("boolean", "IS_LIBRE_RUNTIME", "true") } } @@ -150,4 +154,5 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) -} \ No newline at end of file + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index 8394ee901..0bf6cc8a4 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.ConversationPipelineMode import org.monogram.domain.repository.DEFAULT_SMART_SWITCH_CHECK_INTERVAL_MINUTES import org.monogram.domain.repository.MAX_SMART_SWITCH_CHECK_INTERVAL_MINUTES import org.monogram.domain.repository.MIN_SMART_SWITCH_CHECK_INTERVAL_MINUTES @@ -22,6 +23,7 @@ import org.monogram.domain.repository.ProxySortMode import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.PushProvider import org.monogram.domain.repository.defaultProxyNetworkMode +import org.monogram.presentation.BuildConfig enum class NightMode { SYSTEM, LIGHT, DARK, SCHEDULED, BRIGHTNESS @@ -31,12 +33,64 @@ enum class EmojiStyle { SYSTEM, APPLE, TWITTER, WINDOWS, CATMOJI, NOTO } +internal fun defaultConversationPipelineMode( + isOfficialTdlib: Boolean, + isLibreRuntime: Boolean, + isDebug: Boolean +): ConversationPipelineMode = when { + isOfficialTdlib && isLibreRuntime -> ConversationPipelineMode.New + isDebug -> ConversationPipelineMode.Shadow + else -> ConversationPipelineMode.Legacy +} + +internal fun storedConversationPipelineMode( + storedValue: Any?, + defaultMode: ConversationPipelineMode +): ConversationPipelineMode = when (storedValue) { + is String -> ConversationPipelineMode.entries.firstOrNull { it.name == storedValue } + is Number -> ConversationPipelineMode.entries.getOrNull(storedValue.toInt()) + else -> null +} ?: defaultMode + +internal fun conversationPipelineModeWithLegacyKillSwitch( + forceLegacy: Boolean, + defaultMode: ConversationPipelineMode +): ConversationPipelineMode = if (forceLegacy) { + ConversationPipelineMode.Legacy +} else { + defaultMode +} + +internal fun isConversationPipelineKillSwitchAvailable( + isOfficialTdlib: Boolean, + isLibreRuntime: Boolean +): Boolean = isOfficialTdlib && isLibreRuntime + class AppPreferences( private val context: Context, private val externalScope: CoroutineScope ) : AppPreferencesProvider { private val prefs: SharedPreferences = context.getSharedPreferences("monogram_prefs", Context.MODE_PRIVATE) + private val defaultConversationPipelineMode = defaultConversationPipelineMode( + isOfficialTdlib = BuildConfig.IS_OFFICIAL_TDLIB, + isLibreRuntime = BuildConfig.IS_LIBRE_RUNTIME, + isDebug = BuildConfig.DEBUG + ) + private val _conversationPipelineMode = MutableStateFlow( + storedConversationPipelineMode( + storedValue = prefs.all[KEY_CONVERSATION_PIPELINE_MODE], + defaultMode = defaultConversationPipelineMode + ) + ) + override val conversationPipelineMode: StateFlow = + _conversationPipelineMode + + override fun setConversationPipelineMode(mode: ConversationPipelineMode) { + _conversationPipelineMode.value = mode + prefs.edit().putString(KEY_CONVERSATION_PIPELINE_MODE, mode.name).apply() + } + private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() @@ -1155,6 +1209,7 @@ class AppPreferences( override fun clearPreferences() { prefs.edit().clear().apply() + _conversationPipelineMode.value = defaultConversationPipelineMode _fontSize.value = 16f _bubbleRadius.value = 18f _stickerSize.value = 200f @@ -1354,6 +1409,7 @@ class AppPreferences( private const val KEY_NOTO_EMOJI_DOWNLOADED = "noto_emoji_downloaded" private const val KEY_AUTO_DOWNLOAD_MOBILE = "auto_download_mobile" + private const val KEY_CONVERSATION_PIPELINE_MODE = "conversation_pipeline_mode" private const val KEY_AUTO_DOWNLOAD_WIFI = "auto_download_wifi" private const val KEY_AUTO_DOWNLOAD_ROAMING = "auto_download_roaming" private const val KEY_AUTO_DOWNLOAD_FILES = "auto_download_files" diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt index 5054da0e9..cc2b13045 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatComponent.kt @@ -146,7 +146,7 @@ interface ChatComponent { fun onClearUnreadMentions() fun onScrollToNextUnreadReaction() fun onClearUnreadReactions() - fun onDownloadFile(fileId: Int, userInitiated: Boolean = false) + fun onDownloadFile(fileId: Int, userInitiated: Boolean = true) fun onDownloadHighRes(messageId: Long) fun onCancelDownloadFile(fileId: Int) fun updateScrollPosition(messageId: Long) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt index 082361876..5ccf9de18 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ChatStore.kt @@ -116,7 +116,7 @@ interface ChatStore : Store component._state.update { - if (it.pendingScrollCommand == null && it.scrollToMessageId == null) it - else it.copy(scrollToMessageId = null, pendingScrollCommand = null) + ConversationViewportReducer.consumeScrollCommand(it) } is Intent.ScrollToBottom -> component.scrollToBottomInternal() is Intent.JumpToLatest -> component.jumpToLatestInternal() @@ -318,7 +317,7 @@ class ChatStoreFactory( if (nextState.isAtBottom == intent.isAtBottom) nextState else nextState.copy(isAtBottom = intent.isAtBottom) } - is Intent.HighlightConsumed -> component._state.update { it.copy(highlightRequest = null) } + is Intent.HighlightConsumed -> component._state.update(ConversationViewportReducer::consumeHighlight) is Intent.Typing -> { /* Handle typing */ } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationSession.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationSession.kt new file mode 100644 index 000000000..b5d4f0a8c --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationSession.kt @@ -0,0 +1,568 @@ +package org.monogram.presentation.features.chats.conversation + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.monogram.domain.models.ConversationUpdate +import org.monogram.domain.models.MessageModel +import org.monogram.domain.repository.HistoryDirection +import org.monogram.domain.repository.HistoryAnchor +import org.monogram.domain.repository.HistoryPage +import org.monogram.domain.repository.HistoryRequest +import org.monogram.domain.repository.ConversationPipelineMode +import org.monogram.domain.repository.BoundaryState +import org.monogram.domain.repository.HistorySource +import java.util.concurrent.atomic.AtomicBoolean + +internal object ConversationPipelineFallbackGate { + private val fallbackRequested = AtomicBoolean(false) + + fun modeFor(configured: ConversationPipelineMode): ConversationPipelineMode = + if (fallbackRequested.get()) ConversationPipelineMode.Legacy else configured + + fun requestFallback() { + fallbackRequested.set(true) + } + + internal fun resetForTest() { + fallbackRequested.set(false) + } +} + +internal data class ConversationSessionState( + val generation: Long = 0L, + val messages: List = emptyList(), + val outgoingMessageStates: Map = emptyMap(), + val ascending: Boolean = false, + val closed: Boolean = false, + val rejectedLateResults: Long = 0L, + val isLoadingInitial: Boolean = false, + val isLoadingHistoryRequest: Boolean = false, + val isLoadingOlder: Boolean = false, + val isLoadingNewer: Boolean = false, + val isOldestLoaded: Boolean = false, + val isLatestLoaded: Boolean = true +) + +internal sealed interface ConversationSessionCommand { + data class ApplySnapshot( + val generation: Long, + val messages: List, + val ascending: Boolean, + val completion: CompletableDeferred = CompletableDeferred() + ) : ConversationSessionCommand + + data class LoadHistory( + val generation: Long, + val request: HistoryRequest, + val completion: CompletableDeferred = CompletableDeferred() + ) : ConversationSessionCommand + + data class ApplyUpdate( + val update: ConversationUpdate, + val canInsertNewMessage: Boolean, + val completion: CompletableDeferred = CompletableDeferred() + ) : ConversationSessionCommand + + data class TransformMessage( + val chatId: Long, + val messageId: Long, + val transform: (MessageModel) -> MessageModel, + val completion: CompletableDeferred = CompletableDeferred() + ) : ConversationSessionCommand + + data class HistoryLoaded( + val request: HistoryRequest, + val generation: Long, + val token: Any, + val result: Result + ) : ConversationSessionCommand + + data class AdvanceGeneration(val generation: Long) : ConversationSessionCommand + + data class MarkBoundaryReached( + val generation: Long, + val direction: HistoryDirection + ) : ConversationSessionCommand + + data class SetOperationLoading( + val generation: Long, + val loading: Boolean, + val resetBoundaries: Boolean, + val completion: CompletableDeferred = CompletableDeferred() + ) : ConversationSessionCommand + + data class SetBoundaries( + val generation: Long, + val oldestLoaded: Boolean, + val latestLoaded: Boolean, + val completion: CompletableDeferred = CompletableDeferred() + ) : ConversationSessionCommand + + data class Close( + val generation: Long, + val completion: CompletableDeferred = CompletableDeferred() + ) : ConversationSessionCommand +} + +internal object ConversationWindowReducer { + fun applySnapshot( + state: ConversationSessionState, + generation: Long, + messages: List, + ascending: Boolean + ): ConversationSessionState { + if (state.closed || generation < state.generation) { + return state.copy(rejectedLateResults = state.rejectedLateResults + 1L) + } + val normalized = messages + .associateBy(MessageModel::id) + .values + .let { unique -> + if (ascending) { + unique.sortedWith(compareBy { it.date }.thenBy { it.id }) + } else { + unique.sortedWith(compareByDescending { it.date }.thenByDescending { it.id }) + } + } + return state.copy( + generation = generation, + messages = normalized, + outgoingMessageStates = state.outgoingMessageStates + OutgoingMessageReducer.recover( + normalized + ), + ascending = ascending, + closed = false + ) + } + + fun applyUpdate( + state: ConversationSessionState, + update: ConversationUpdate, + canInsertNewMessage: Boolean + ): ConversationSessionState { + if (state.closed) return state + val messages = when (update) { + is ConversationUpdate.Upsert -> { + val hasMessage = + state.messages.any { it.chatId == update.chatId && it.id == update.message.id } + when { + hasMessage -> state.messages.map { current -> + if (current.chatId == update.chatId && current.id == update.message.id) update.message else current + } + + update.isNew && canInsertNewMessage -> state.messages + update.message + else -> state.messages + } + } + + is ConversationUpdate.ReplaceTemporaryId -> { + val hadTemporary = state.messages.any { + it.chatId == update.chatId && it.id == update.temporaryMessageId + } + val hadFinal = state.messages.any { + it.chatId == update.chatId && it.id == update.message.id + } + val withoutReplacedIds = state.messages.filterNot { + it.chatId == update.chatId && + (it.id == update.temporaryMessageId || it.id == update.message.id) + } + if (hadTemporary || hadFinal || canInsertNewMessage) withoutReplacedIds + update.message + else withoutReplacedIds + } + + is ConversationUpdate.SendFailed -> { + val hadTemporary = state.messages.any { + it.chatId == update.chatId && it.id == update.temporaryMessageId + } + if (hadTemporary) { + state.messages.map { current -> + if (current.chatId == update.chatId && current.id == update.temporaryMessageId) { + update.message + } else { + current + } + } + } else { + state.messages + } + } + + is ConversationUpdate.Delete -> state.messages.filterNot { + it.chatId == update.chatId && it.id in update.messageIds + } + + is ConversationUpdate.InboxRead -> state.messages.map { message -> + if (message.chatId == update.chatId && !message.isOutgoing && + !message.isRead && message.id <= update.lastReadMessageId + ) message.copy(isRead = true) else message + } + + is ConversationUpdate.OutboxRead -> state.messages.map { message -> + if (message.chatId == update.chatId && message.isOutgoing && + !message.isRead && message.id <= update.lastReadMessageId + ) message.copy(isRead = true) else message + } + + is ConversationUpdate.SendAcknowledged -> state.messages + } + + val outgoingStates = when (update) { + is ConversationUpdate.Upsert -> OutgoingMessageReducer.pending( + state.outgoingMessageStates, + update.message + ) + + is ConversationUpdate.ReplaceTemporaryId -> OutgoingMessageReducer.succeeded( + current = state.outgoingMessageStates, + key = OutgoingMessageReducer.Key(update.chatId, update.temporaryMessageId), + finalMessageId = update.message.id + ) + + is ConversationUpdate.SendAcknowledged -> OutgoingMessageReducer.acknowledged( + current = state.outgoingMessageStates, + key = OutgoingMessageReducer.Key(update.chatId, update.temporaryMessageId) + ) + + is ConversationUpdate.SendFailed -> OutgoingMessageReducer.failed( + current = state.outgoingMessageStates, + key = OutgoingMessageReducer.Key(update.chatId, update.temporaryMessageId), + errorCode = update.errorCode + ) + + is ConversationUpdate.Delete -> state.outgoingMessageStates.filterNot { (key, value) -> + key.chatId == update.chatId && + (key.temporaryMessageId in update.messageIds || + (value as? OutgoingMessageReducer.State.Succeeded)?.finalMessageId in update.messageIds) + } + + is ConversationUpdate.InboxRead, + is ConversationUpdate.OutboxRead -> state.outgoingMessageStates + } + + return state.copy( + messages = normalize(messages, state.ascending), + outgoingMessageStates = outgoingStates + ) + } + + private fun normalize(messages: List, ascending: Boolean): List = + messages + .associateBy { it.chatId to it.id } + .values + .let { unique -> + if (ascending) { + unique.sortedWith(compareBy { it.date }.thenBy { it.id }) + } else { + unique.sortedWith(compareByDescending { it.date }.thenByDescending { it.id }) + } + } +} + +internal class ConversationSession( + private val scope: CoroutineScope, + private val historyLoader: suspend (HistoryRequest) -> HistoryPage +) { + private val commands = Channel(Channel.UNLIMITED) + private val _state = MutableStateFlow(ConversationSessionState()) + val state: StateFlow = _state.asStateFlow() + + private data class ActiveLoad( + val generation: Long, + val request: HistoryRequest, + val token: Any, + val job: Job, + val waiters: MutableList> + ) + + private val activeLoads = LinkedHashMap() + + init { + scope.launch { + for (command in commands) { + when (command) { + is ConversationSessionCommand.ApplySnapshot -> { + _state.value = ConversationWindowReducer.applySnapshot( + state = _state.value, + generation = command.generation, + messages = command.messages, + ascending = command.ascending + ) + command.completion.complete(Unit) + } + + is ConversationSessionCommand.LoadHistory -> { + handleLoadHistory(command) + } + + is ConversationSessionCommand.ApplyUpdate -> { + _state.value = ConversationWindowReducer.applyUpdate( + state = _state.value, + update = command.update, + canInsertNewMessage = command.canInsertNewMessage + ) + command.completion.complete(Unit) + } + + is ConversationSessionCommand.TransformMessage -> { + val current = _state.value + val transformed = current.messages.map { message -> + if (message.chatId == command.chatId && message.id == command.messageId) { + command.transform(message) + } else { + message + } + } + _state.value = current.copy(messages = transformed) + command.completion.complete(Unit) + } + + is ConversationSessionCommand.HistoryLoaded -> { + handleHistoryLoaded(command) + } + + is ConversationSessionCommand.AdvanceGeneration -> { + applyGeneration(command.generation) + } + + is ConversationSessionCommand.MarkBoundaryReached -> { + if (command.generation == _state.value.generation) { + _state.value = when (command.direction) { + HistoryDirection.Older -> _state.value.copy(isOldestLoaded = true) + HistoryDirection.Newer, HistoryDirection.Initial -> + _state.value.copy(isLatestLoaded = true) + + HistoryDirection.Around -> _state.value + } + } + } + + is ConversationSessionCommand.SetOperationLoading -> { + if (command.generation >= _state.value.generation) { + applyGeneration(command.generation) + if (command.generation == _state.value.generation) { + _state.value = _state.value.copy( + isLoadingInitial = command.loading, + isOldestLoaded = if (command.resetBoundaries) false else _state.value.isOldestLoaded, + isLatestLoaded = if (command.resetBoundaries) false else _state.value.isLatestLoaded + ) + } + } + command.completion.complete(Unit) + } + + is ConversationSessionCommand.SetBoundaries -> { + if (command.generation == _state.value.generation) { + _state.value = _state.value.copy( + isOldestLoaded = command.oldestLoaded, + isLatestLoaded = command.latestLoaded + ) + } + command.completion.complete(Unit) + } + + is ConversationSessionCommand.Close -> { + applyGeneration(command.generation) + cancelLoads { true } + _state.value = _state.value.copy(closed = true) + command.completion.complete(Unit) + commands.close() + } + } + } + } + } + + private fun handleLoadHistory(command: ConversationSessionCommand.LoadHistory) { + if (_state.value.closed || command.generation < _state.value.generation) { + command.completion.cancel(CancellationException("Stale conversation generation")) + return + } + applyGeneration(command.generation) + + activeLoads[command.request]?.let { active -> + active.waiters += command.completion + return + } + + val direction = command.request.direction + if (direction == HistoryDirection.Initial || direction == HistoryDirection.Around) { + cancelLoads { true } + } else { + cancelLoads { it.request.direction == direction } + } + + val token = Any() + val job = scope.launch { + val result = runCatching { historyLoader(command.request) } + commands.trySend( + ConversationSessionCommand.HistoryLoaded( + request = command.request, + generation = command.generation, + token = token, + result = result + ) + ) + } + activeLoads[command.request] = ActiveLoad( + generation = command.generation, + request = command.request, + token = token, + job = job, + waiters = mutableListOf(command.completion) + ) + syncLoadingState() + } + + private fun handleHistoryLoaded(command: ConversationSessionCommand.HistoryLoaded) { + val active = activeLoads[command.request] + ?.takeIf { it.token === command.token } + ?: return + activeLoads.remove(command.request) + syncLoadingState() + if (_state.value.closed || command.generation != _state.value.generation) { + active.waiters.forEach { it.cancel(CancellationException("Stale conversation generation")) } + return + } + command.result.fold( + onSuccess = { page -> + applyHistoryBoundaries(command.request, page) + active.waiters.forEach { it.complete(page) } + }, + onFailure = { error -> active.waiters.forEach { it.completeExceptionally(error) } } + ) + } + + private fun applyGeneration(generation: Long) { + if (generation <= _state.value.generation) return + _state.value = _state.value.copy(generation = generation) + cancelLoads { it.generation < generation } + } + + private fun cancelLoads(predicate: (ActiveLoad) -> Boolean) { + activeLoads.values.filter(predicate).forEach { active -> + activeLoads.remove(active.request) + active.job.cancel() + active.waiters.forEach { it.cancel(CancellationException("Conversation load superseded")) } + } + syncLoadingState() + } + + private fun syncLoadingState() { + val directions = activeLoads.values.mapTo(mutableSetOf()) { it.request.direction } + _state.value = _state.value.copy( + isLoadingHistoryRequest = HistoryDirection.Initial in directions || HistoryDirection.Around in directions, + isLoadingOlder = HistoryDirection.Older in directions, + isLoadingNewer = HistoryDirection.Newer in directions + ) + } + + private fun applyHistoryBoundaries(request: HistoryRequest, page: HistoryPage) { + val current = _state.value + _state.value = when (request.direction) { + HistoryDirection.Initial -> current.copy( + isOldestLoaded = page.olderBoundary is BoundaryState.Reached + ) + + HistoryDirection.Around -> current.copy( + isOldestLoaded = page.olderBoundary is BoundaryState.Reached, + isLatestLoaded = page.newerBoundary is BoundaryState.Reached + ) + + HistoryDirection.Older -> current.copy( + isOldestLoaded = current.isOldestLoaded || page.olderBoundary is BoundaryState.Reached + ) + + HistoryDirection.Newer -> current.copy( + isLatestLoaded = current.isLatestLoaded || + page.newerBoundary is BoundaryState.Reached || + (page.source == HistorySource.TdlibNetwork && page.messages.size < request.limit) + ) + } + } + + suspend fun applySnapshot( + generation: Long, + messages: List, + ascending: Boolean + ): ConversationSessionState { + val command = ConversationSessionCommand.ApplySnapshot(generation, messages, ascending) + check(commands.trySend(command).isSuccess) { "Conversation session is closed" } + command.completion.await() + return state.value + } + + suspend fun loadHistory(generation: Long, request: HistoryRequest): HistoryPage { + val command = ConversationSessionCommand.LoadHistory(generation, request) + check(commands.trySend(command).isSuccess) { "Conversation session is closed" } + return command.completion.await() + } + + suspend fun applyUpdate( + update: ConversationUpdate, + canInsertNewMessage: Boolean + ): ConversationSessionState { + val command = ConversationSessionCommand.ApplyUpdate(update, canInsertNewMessage) + check(commands.trySend(command).isSuccess) { "Conversation session is closed" } + command.completion.await() + return state.value + } + + suspend fun transformMessage( + chatId: Long, + messageId: Long, + transform: (MessageModel) -> MessageModel + ): ConversationSessionState { + val command = ConversationSessionCommand.TransformMessage(chatId, messageId, transform) + check(commands.trySend(command).isSuccess) { "Conversation session is closed" } + command.completion.await() + return state.value + } + + fun advanceGeneration(generation: Long) { + commands.trySend(ConversationSessionCommand.AdvanceGeneration(generation)) + } + + fun markBoundaryReached(generation: Long, direction: HistoryDirection) { + commands.trySend(ConversationSessionCommand.MarkBoundaryReached(generation, direction)) + } + + suspend fun setOperationLoading( + generation: Long, + loading: Boolean, + resetBoundaries: Boolean = false + ) { + val command = ConversationSessionCommand.SetOperationLoading( + generation = generation, + loading = loading, + resetBoundaries = resetBoundaries + ) + check(commands.trySend(command).isSuccess) { "Conversation session is closed" } + command.completion.await() + } + + suspend fun setBoundaries( + generation: Long, + oldestLoaded: Boolean, + latestLoaded: Boolean + ) { + val command = ConversationSessionCommand.SetBoundaries( + generation = generation, + oldestLoaded = oldestLoaded, + latestLoaded = latestLoaded + ) + check(commands.trySend(command).isSuccess) { "Conversation session is closed" } + command.completion.await() + } + + fun close(generation: Long) { + val command = ConversationSessionCommand.Close(generation) + commands.trySend(command) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducer.kt new file mode 100644 index 000000000..fc92b1d4f --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducer.kt @@ -0,0 +1,73 @@ +package org.monogram.presentation.features.chats.conversation + +internal object ConversationViewportReducer { + fun beginLoad( + state: ChatComponent.State, + legacyOwnsLoadingState: Boolean + ): ChatComponent.State = initialize( + state.copy( + isLoading = if (legacyOwnsLoadingState) true else state.isLoading, + isOldestLoaded = if (legacyOwnsLoadingState) false else state.isOldestLoaded, + isLatestLoaded = if (legacyOwnsLoadingState) false else state.isLatestLoaded + ) + ) + + fun initialize(state: ChatComponent.State): ChatComponent.State = state.copy( + pendingScrollCommand = null, + viewportPhase = ChatViewportPhase.Initializing, + highlightRequest = null + ) + + fun contentReady( + state: ChatComponent.State, + isAtBottom: Boolean, + legacyOldestLoaded: Boolean? = null, + legacyLatestLoaded: Boolean? = null + ): ChatComponent.State = state.copy( + isAtBottom = isAtBottom, + isOldestLoaded = legacyOldestLoaded ?: state.isOldestLoaded, + isLatestLoaded = legacyLatestLoaded ?: state.isLatestLoaded, + scrollToMessageId = null, + highlightRequest = null + ) + + fun restore( + state: ChatComponent.State, + command: ChatScrollCommand + ): ChatComponent.State = state.copy( + pendingScrollCommand = command, + viewportPhase = ChatViewportPhase.Restoring, + highlightRequest = null + ) + + fun enqueue( + state: ChatComponent.State, + command: ChatScrollCommand + ): ChatComponent.State = state.copy( + pendingScrollCommand = command, + highlightRequest = null + ) + + fun consumeScrollCommand(state: ChatComponent.State): ChatComponent.State = state.copy( + scrollToMessageId = null, + pendingScrollCommand = null + ) + + fun settle(state: ChatComponent.State): ChatComponent.State = + if (state.viewportPhase == ChatViewportPhase.Settled) state + else state.copy(viewportPhase = ChatViewportPhase.Settled) + + fun requestHighlight( + state: ChatComponent.State, + messageId: Long + ): ChatComponent.State { + val token = state.highlightRequestToken + 1L + return state.copy( + highlightRequest = MessageHighlightRequest(messageId, token), + highlightRequestToken = token + ) + } + + fun consumeHighlight(state: ChatComponent.State): ChatComponent.State = + if (state.highlightRequest == null) state else state.copy(highlightRequest = null) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt index baf69ba0e..8853cab79 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/DefaultChatComponent.kt @@ -5,6 +5,8 @@ import androidx.compose.ui.platform.Clipboard import com.arkivanov.essenty.lifecycle.doOnResume import com.arkivanov.essenty.lifecycle.doOnStart import com.arkivanov.essenty.lifecycle.doOnStop +import org.monogram.domain.repository.ConversationPipelineMode +import org.monogram.domain.repository.MediaAutoDownloadPolicy import com.arkivanov.mvikotlin.extensions.coroutines.labels import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.Dispatchers @@ -68,8 +70,10 @@ import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.features.chats.conversation.logic.PendingAttachmentSendRegistry +import org.monogram.presentation.features.chats.conversation.logic.activeThreadChatId import org.monogram.presentation.features.chats.conversation.logic.buildChatInitialLoadKey import org.monogram.presentation.features.chats.conversation.logic.effectiveThreadId +import org.monogram.presentation.features.chats.conversation.logic.historyConversationKey import org.monogram.presentation.features.chats.conversation.logic.handleSendPendingAttachments import org.monogram.presentation.features.chats.conversation.logic.loadChatInfo import org.monogram.presentation.features.chats.conversation.logic.loadDraft @@ -314,6 +318,8 @@ class DefaultChatComponent( internal val paymentRepository: PaymentRepository = container.repositories.paymentRepository internal val tdLibLimitsRepository = container.repositories.tdLibLimitsRepository override val appPreferences: AppPreferences = container.preferences.appPreferences + internal val conversationPipelineMode: ConversationPipelineMode = + ConversationPipelineFallbackGate.modeFor(appPreferences.conversationPipelineMode.value) internal val cacheProvider: CacheProvider = container.cacheProvider internal val cacheController: CacheController = container.utils.cacheController internal val distrManager: DistrManager = container.utils.distrManager() @@ -324,6 +330,9 @@ class DefaultChatComponent( var messageLoadingJob: Job? = null var loadMoreJob: Job? = null var loadNewerJob: Job? = null + + /** Monotonic guard for async history jobs; cancellation alone does not guard late commits. */ + internal var loadingGeneration: Long = 0L var inlineBotJob: Job? = null var draftSaveJob: Job? = null var draftLinkPreviewJob: Job? = null @@ -337,7 +346,6 @@ class DefaultChatComponent( internal var searchJob: Job? = null internal val reactionUpdateSuppressedUntil = ConcurrentHashMap() internal val remappedMessageIds = ConcurrentHashMap() - internal val mediaDownloadRetryCount = ConcurrentHashMap() internal val pendingSenderRefreshes = ConcurrentHashMap.newKeySet() internal val senderRefreshRequestedAtMs = ConcurrentHashMap() internal val pendingAttachmentSendRegistry = PendingAttachmentSendRegistry() @@ -347,6 +355,13 @@ class DefaultChatComponent( internal var lastStartedLoadKey: ChatInitialLoadKey? = null internal var hasStartedInitialLoadForContext: Boolean = false internal var activeLoadSession: ConversationLoadSession? = null + internal val conversationSession = ConversationSession( + scope = scope, + historyLoader = repositoryMessage::getHistoryPage + ) + + internal fun isLoadingGenerationCurrent(generation: Long): Boolean = + loadingGeneration == generation internal val richMessageCoordinator = RichMessageCoordinator(this) internal var lastLoadedOlderId: Long = 0L @@ -455,6 +470,7 @@ class DefaultChatComponent( state = _state.value, componentInstanceId = componentInstanceId ) + conversationSession.close(loadingGeneration + 1L) repositoryMessage.closeChat(chatId, ownerTag = componentInstanceId) } } @@ -861,13 +877,8 @@ class DefaultChatComponent( state = _state.value, componentInstanceId = componentInstanceId ) - _state.update { - if (it.viewportPhase == ChatViewportPhase.Settled) { - it - } else { - it.copy(viewportPhase = ChatViewportPhase.Settled) - } - } + _state.update(ConversationViewportReducer::settle) + ChatOpenPerfBridge.markSettled(chatId, currentThreadId) ChatConversationLog.logViewportState( event = "on_viewport_settled_after", state = _state.value, @@ -889,6 +900,21 @@ class DefaultChatComponent( visibleMessageIds = visibleMessageIds, nearbyMessageIds = nearbyMessageIds ) + val state = _state.value + val networkEnabled = when { + downloadUtils.isRoaming() -> state.autoDownloadRoaming + downloadUtils.isWifiConnected() -> state.autoDownloadWifi + else -> state.autoDownloadMobile + } + repositoryMessage.updateVisibleRange( + chatId = chatId, + visibleMessageIds = visibleMessageIds.toList(), + nearbyMessageIds = nearbyMessageIds.filterNot(visibleMessageIds::contains), + policy = MediaAutoDownloadPolicy( + enabled = networkEnabled, + allowFiles = networkEnabled && state.autoDownloadFiles + ) + ) } override fun onScrollToBottom() = store.accept(ChatStore.Intent.ScrollToBottom) @@ -1240,6 +1266,10 @@ class DefaultChatComponent( } cacheProvider.saveChatViewport(chatId, threadId, viewport) + repositoryMessage.updateCachedViewportAnchor( + historyConversationKey(activeThreadChatId(), threadId), + null + ) if (threadId == null) { cacheProvider.saveChatScrollPosition(chatId, 0L) } @@ -1278,6 +1308,10 @@ class DefaultChatComponent( } cacheProvider.saveChatViewport(chatId, threadId, viewport) + repositoryMessage.updateCachedViewportAnchor( + key = historyConversationKey(activeThreadChatId(), threadId), + messageId = viewport.anchorMessageId?.takeUnless { viewport.atBottom } + ) if (threadId == null) { cacheProvider.saveChatScrollPosition(chatId, viewport.anchorMessageId ?: 0L) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt index c19eabf42..155468c01 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicy.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.conversation.logic import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.domain.repository.HistoryPage import org.monogram.presentation.features.chats.conversation.ChatInitialLoadKey internal fun buildChatInitialLoadKey( @@ -40,3 +41,9 @@ internal fun shouldStartInitialLoad( if (!hasStartedForCurrentContext) return true return currentKey != nextKey } + +internal fun shouldRequestInitialNetwork( + localPage: HistoryPage, + requestedLimit: Int, + threadId: Long? +): Boolean = threadId != null || localPage.messages.size < requestedLimit diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt index f4e2abefb..ab71afb59 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/FileOperations.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.launch import org.monogram.domain.models.MessageContent import org.monogram.presentation.features.chats.conversation.DefaultChatComponent -internal fun DefaultChatComponent.handleDownloadFile(fileId: Int, userInitiated: Boolean = false) { +internal fun DefaultChatComponent.handleDownloadFile(fileId: Int, userInitiated: Boolean = true) { // Only an explicit gesture gets priority 32 and the user-initiated fast path. Viewport // prefetch used to pass 32 as well, which marked every scrolled-past thumbnail as "manual" // and left it un-evictable, silting up the download slots. @@ -32,10 +32,6 @@ internal fun DefaultChatComponent.handleDownloadHighRes(messageId: Long) { val fileId = repositoryMessage.getHighResFileId(chatId, messageId) if (fileId != null) { updatePhotoOriginalFileId(messageId, fileId) - // Opening a photo full screen is an unambiguous user action: this file is usually - // cold (only the "x" size was ever prefetched), so it needs the bypass to reach - // TDLib immediately rather than queueing behind background thumbnails. - repositoryMessage.downloadFile(fileId, priority = 32, userInitiated = true) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt index 603ef5e40..b1093e5b4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageOperations.kt @@ -21,9 +21,11 @@ private const val REACTION_UPDATE_SUPPRESSION_MS = 1500L internal fun DefaultChatComponent.handleMessageVisible(messageId: Long) { scope.launch { - val hadUnreadCount = _state.value.unreadCount > 0 - val visibleMessage = _state.value.messages.firstOrNull { it.id == messageId } + val currentState = _state.value + val visibleMessage = currentState.messages.firstOrNull { it.id == messageId } val targetChatId = visibleMessage?.chatId ?: activeThreadChatId() + val hadUnreadCount = currentState.unreadCount > 0 + val hadUnreadTarget = hadUnreadCount && visibleMessage?.isRead == false val visibleMessageIds = if (visibleMessage != null && visibleMessage.mediaAlbumId != 0L) { _state.value.messages .asSequence() @@ -42,8 +44,12 @@ internal fun DefaultChatComponent.handleMessageVisible(messageId: Long) { visibleMessageIds = visibleMessageIds ) } - visibleMessageIds.forEach { repositoryMessage.markAsRead(targetChatId, it) } - if (hadUnreadCount) { + repositoryMessage.markMessagesAsRead( + chatId = targetChatId, + messageIds = visibleMessageIds, + threadId = currentState.effectiveThreadId() + ) + if (hadUnreadTarget) { repositoryMessage.markAllMentionsAsRead(targetChatId) repositoryMessage.markAllReactionsAsRead(targetChatId) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt index 336678fb5..efd8707c7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt @@ -11,8 +11,15 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.monogram.domain.repository.ConversationPipelineMode +import org.monogram.domain.repository.BoundaryState +import org.monogram.domain.repository.HistoryAnchor +import org.monogram.domain.repository.HistoryDirection +import org.monogram.domain.repository.HistoryRequest +import org.monogram.domain.repository.HistorySource import org.monogram.core.perf.ChatOpenPerfBridge import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.domain.models.ConversationUpdate import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageDownloadEvent import org.monogram.domain.models.MessageModel @@ -26,16 +33,19 @@ import org.monogram.presentation.features.chats.conversation.ChatConversationLog import org.monogram.presentation.features.chats.conversation.ChatScrollCommand import org.monogram.presentation.features.chats.conversation.ChatViewportPhase import org.monogram.presentation.features.chats.conversation.ConversationLoadSession +import org.monogram.presentation.features.chats.conversation.ConversationPipelineFallbackGate +import org.monogram.presentation.features.chats.conversation.ConversationSessionState +import org.monogram.presentation.features.chats.conversation.ConversationViewportReducer import org.monogram.presentation.features.chats.conversation.DefaultChatComponent import org.monogram.presentation.features.chats.conversation.OutgoingMessageReducer import org.monogram.presentation.features.chats.conversation.ScrollAlign +import org.monogram.presentation.features.chats.conversation.logic.historyConversationKey import java.io.File import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds private const val PAGE_SIZE = 50 -private const val MAX_DOWNLOAD_RETRIES = 3 private const val SENDER_REFRESH_TTL_MS = 60_000L private fun isUsableAvatarPath(path: String?): Boolean { if (path.isNullOrBlank()) return false @@ -137,10 +147,9 @@ private fun DefaultChatComponent.resolveRemappedMessageId(messageId: Long): Long return current } -private suspend fun DefaultChatComponent.updateMessagesUnsafe( +private suspend fun DefaultChatComponent.filterHistoryMessages( newMessages: List, - replace: Boolean = false -) { +): List { val currentState = _state.value val adBlockEnabled = appPreferences.isAdBlockEnabled.value val keywords = appPreferences.adBlockKeywords.value @@ -148,7 +157,7 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( val isChannel = currentState.isChannel val isWhitelisted = whitelistedChannels.contains(chatId) - val filteredNewMessages = if (adBlockEnabled && isChannel && !isWhitelisted) { + return if (adBlockEnabled && isChannel && !isWhitelisted) { withContext(Dispatchers.Default) { newMessages.filterNot { message -> val text = when (val content = message.content) { @@ -165,89 +174,336 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( } else { newMessages } +} - _state.update { state -> - if (filteredNewMessages.isEmpty()) { - return@update if (replace && state.messages.any { it.sendingState is MessageSendingState.Pending }) { - state.copy(messages = state.messages.filter { it.sendingState is MessageSendingState.Pending }) - } else { - state - } - } - - val currentList = if (replace) { - state.messages.filter { it.sendingState is MessageSendingState.Pending } - } else { - state.messages - } - val existingReactionsById = if (replace) { - state.messages - .filter { it.reactions.isNotEmpty() } - .associate { it.id to it.reactions } - } else { - emptyMap() - } - val previousMessagesById = if (replace) { - state.messages.associateBy { it.id } +internal fun ChatComponent.State.mergeHistoryMessages( + filteredNewMessages: List, + replace: Boolean +): List { + if (filteredNewMessages.isEmpty()) { + return if (replace && messages.any { it.sendingState is MessageSendingState.Pending }) { + messages.filter { it.sendingState is MessageSendingState.Pending } } else { - emptyMap() + messages } + } - val isComments = state.rootMessage != null + val currentList = if (replace) { + messages.filter { it.sendingState is MessageSendingState.Pending } + } else { + messages + } + val existingReactionsById = if (replace) { + messages.filter { it.reactions.isNotEmpty() }.associate { it.id to it.reactions } + } else { + emptyMap() + } + val previousMessagesById = if (replace) messages.associateBy { it.id } else emptyMap() - val messageMap = LinkedHashMap(currentList.size + filteredNewMessages.size) - currentList.forEach { messageMap[it.id] = it } + val isComments = rootMessage != null - var hasChanges = replace - filteredNewMessages.forEach { msg -> - val previous = messageMap[msg.id] ?: previousMessagesById[msg.id] - val mergedMessage = if (previous != null) mergeSenderVisuals(previous, msg) else msg - val restoredMessage = if (mergedMessage.reactions.isEmpty()) { - val previousReactions = existingReactionsById[msg.id] - if (!previousReactions.isNullOrEmpty()) { - mergedMessage.copy(reactions = previousReactions) - } else { - mergedMessage - } + val messageMap = LinkedHashMap(currentList.size + filteredNewMessages.size) + currentList.forEach { messageMap[it.id] = it } + + filteredNewMessages.forEach { msg -> + val previous = messageMap[msg.id] ?: previousMessagesById[msg.id] + val mergedMessage = if (previous != null) mergeSenderVisuals(previous, msg) else msg + val restoredMessage = if (mergedMessage.reactions.isEmpty()) { + val previousReactions = existingReactionsById[msg.id] + if (!previousReactions.isNullOrEmpty()) { + mergedMessage.copy(reactions = previousReactions) } else { mergedMessage } - val contentSafeMessage = state.preservePendingEditedContent( - incomingMessage = restoredMessage, - previousMessage = previous + } else { + mergedMessage + } + val contentSafeMessage = preservePendingEditedContent( + incomingMessage = restoredMessage, + previousMessage = previous + ) + messageMap[msg.id] = contentSafeMessage + } + + return messageMap.values.let { + val sortedMessages = if (isComments) { + it.sortedWith(compareBy { it.date }.thenBy { it.id }) + } else { + it.sortedWith(compareByDescending { it.date }.thenByDescending { it.id }) + } + pruneDeliveredPendingDuplicates(sortedMessages) + } +} + +internal suspend fun DefaultChatComponent.updateMessages(newMessages: List, replace: Boolean = false) { + val filteredNewMessages = filterHistoryMessages(newMessages) + messageMutex.withLock { + val current = _state.value + val mergedMessages = current.mergeHistoryMessages(filteredNewMessages, replace) + if (conversationPipelineMode == ConversationPipelineMode.New) { + val sessionState = conversationSession.applySnapshot( + generation = loadingGeneration, + messages = mergedMessages, + ascending = current.rootMessage != null ) - val old = messageMap.put(msg.id, contentSafeMessage) - if (old != contentSafeMessage) { - hasChanges = true + if (!sessionState.closed) { + _state.update { current -> + if (current.messages == sessionState.messages) current + else current.copy( + messages = sessionState.messages, + outgoingMessageStates = current.outgoingMessageStates + + OutgoingMessageReducer.recover(sessionState.messages) + ) + } + if (sessionState.messages.isNotEmpty()) { + ChatOpenPerfBridge.markFirstContent(chatId, current.effectiveThreadId()) + } } + return@withLock } - if (!hasChanges) { - return@update state + if (mergedMessages != current.messages) { + _state.update { state -> + state.copy( + messages = mergedMessages, + outgoingMessageStates = state.outgoingMessageStates + + OutgoingMessageReducer.recover(mergedMessages) + ) + } + if (mergedMessages.isNotEmpty()) { + ChatOpenPerfBridge.markFirstContent(chatId, current.effectiveThreadId()) + } } + if (conversationPipelineMode == ConversationPipelineMode.Shadow) { + val legacyMessages = _state.value.messages + val sessionState = conversationSession.applySnapshot( + generation = loadingGeneration, + messages = legacyMessages, + ascending = _state.value.rootMessage != null + ) + val shadowIds = sessionState.messages.map(MessageModel::id) + val legacyIds = legacyMessages.map(MessageModel::id) + if (shadowIds != legacyIds) { + ChatOpenPerfBridge.recordShadowMismatch(chatId, current.effectiveThreadId()) + ConversationPipelineFallbackGate.requestFallback() + Log.w( + "ConversationSession", + "shadow_mismatch type=ids_or_order legacyCount=${legacyIds.size} shadowCount=${shadowIds.size}" + ) + } + } + } +} - val mergedMessages = messageMap.values.let { - val sortedMessages = if (isComments) { - it.sortedWith(compareBy { it.date }.thenBy { it.id }) +internal fun ChatComponent.State.withConversationSessionUpdate( + sessionState: ConversationSessionState, + update: ConversationUpdate, + rootChatId: Long, + suppressReactionUpdate: Boolean = false +): ChatComponent.State { + val stateWithSideEffects = when (update) { + is ConversationUpdate.Upsert -> { + val withUnread = if (update.isNew) { + withIncomingUnreadMessage(rootChatId, update.message) } else { - it.sortedWith(compareByDescending { it.date }.thenByDescending { it.id }) + this } - pruneDeliveredPendingDuplicates(sortedMessages) + if (update.isNew) withUnread else withUnread.clearPendingEditedMessage(update.message.id) } - if (mergedMessages == state.messages) state else state.copy( - messages = mergedMessages, - outgoingMessageStates = state.outgoingMessageStates + OutgoingMessageReducer.recover( - mergedMessages - ) + is ConversationUpdate.InboxRead -> withInboxReadUpdate( + readChatId = update.chatId, + readMessageId = update.lastReadMessageId, + updateUnreadSession = update.chatId == rootChatId + ) + + is ConversationUpdate.Delete -> copy( + pendingEditedMessageIds = pendingEditedMessageIds - update.messageIds ) + + is ConversationUpdate.ReplaceTemporaryId, + is ConversationUpdate.OutboxRead, + is ConversationUpdate.SendAcknowledged, + is ConversationUpdate.SendFailed -> this + } + + val previousByKey = messages.associateBy { it.chatId to it.id } + val projectedMessages = sessionState.messages.map { incoming -> + val previous = previousByKey[incoming.chatId to incoming.id] + ?: (update as? ConversationUpdate.ReplaceTemporaryId) + ?.takeIf { incoming.chatId == it.chatId && incoming.id == it.message.id } + ?.let { previousByKey[it.chatId to it.temporaryMessageId] } + ?: return@map incoming + val preserveSendingState = update !is ConversationUpdate.ReplaceTemporaryId && + update !is ConversationUpdate.SendFailed + previous.projectRuntimeFields( + incoming = incoming, + preserveSendingState = preserveSendingState, + preserveReactions = suppressReactionUpdate && + update is ConversationUpdate.Upsert && incoming.id == update.message.id, + pendingEditedMessageIds = stateWithSideEffects.pendingEditedMessageIds + ) + } + + val marksLatest = when (update) { + is ConversationUpdate.Upsert -> update.isNew && + (update.message.isOutgoing || stateWithSideEffects.isAtBottom) + + is ConversationUpdate.ReplaceTemporaryId -> + update.message.isOutgoing || stateWithSideEffects.isAtBottom + + else -> false } + return stateWithSideEffects.copy( + messages = projectedMessages, + outgoingMessageStates = sessionState.outgoingMessageStates, + isLatestLoaded = if (marksLatest) true else stateWithSideEffects.isLatestLoaded + ) } -internal suspend fun DefaultChatComponent.updateMessages(newMessages: List, replace: Boolean = false) { - messageMutex.withLock { - updateMessagesUnsafe(newMessages, replace) +internal fun applyConversationUpdateBookkeeping( + update: ConversationUpdate, + remappedMessageIds: MutableMap, + reactionUpdateSuppressedUntil: MutableMap +) { + when (update) { + is ConversationUpdate.ReplaceTemporaryId -> { + if (update.temporaryMessageId != update.message.id) { + remappedMessageIds[update.temporaryMessageId] = update.message.id + } else { + remappedMessageIds.remove(update.temporaryMessageId) + } + } + + is ConversationUpdate.Delete -> { + update.messageIds.forEach(reactionUpdateSuppressedUntil::remove) + update.messageIds.forEach(remappedMessageIds::remove) + remappedMessageIds.entries.removeIf { (_, mappedId) -> mappedId in update.messageIds } + } + + is ConversationUpdate.Upsert, + is ConversationUpdate.InboxRead, + is ConversationUpdate.OutboxRead, + is ConversationUpdate.SendAcknowledged, + is ConversationUpdate.SendFailed -> Unit + } +} + +private fun MessageModel.projectRuntimeFields( + incoming: MessageModel, + preserveSendingState: Boolean, + preserveReactions: Boolean, + pendingEditedMessageIds: Set +): MessageModel { + val senderSafe = mergeSenderVisuals(this, incoming).copy(reactions = incoming.reactions) + val mediaSafe = senderSafe.copy( + content = content.projectMediaRuntime(senderSafe.content), + sendingState = if (preserveSendingState) sendingState else senderSafe.sendingState + ) + val editSafe = if (id in pendingEditedMessageIds) { + mediaSafe.copy(content = content) + } else { + mediaSafe + } + return when { + preserveReactions -> editSafe.copy(reactions = reactions) + reactionsSemanticEqual( + reactions, + editSafe.reactions + ) -> editSafe.copy(reactions = reactions) + + else -> editSafe + } +} + +private fun MessageContent.projectMediaRuntime(incoming: MessageContent): MessageContent = when { + this is MessageContent.Photo && incoming is MessageContent.Photo && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + thumbnailPath = incoming.thumbnailPath ?: thumbnailPath, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + this is MessageContent.Video && incoming is MessageContent.Video && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + thumbnailPath = incoming.thumbnailPath ?: thumbnailPath, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + this is MessageContent.VideoNote && incoming is MessageContent.VideoNote && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + thumbnail = incoming.thumbnail ?: thumbnail, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + this is MessageContent.Document && incoming is MessageContent.Document && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + this is MessageContent.Audio && incoming is MessageContent.Audio && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + this is MessageContent.Voice && incoming is MessageContent.Voice && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + this is MessageContent.Sticker && incoming is MessageContent.Sticker && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + this is MessageContent.Gif && incoming is MessageContent.Gif && fileId == incoming.fileId -> incoming.copy( + path = incoming.path ?: path, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + downloadError = downloadError + ) + + else -> incoming +} + +private suspend fun DefaultChatComponent.loadHistoryPage(request: HistoryRequest) = try { + if (conversationPipelineMode == ConversationPipelineMode.Legacy) { + repositoryMessage.getHistoryPage(request) + } else { + conversationSession.loadHistory(loadingGeneration, request) + } +} catch (error: Exception) { + if (error !is CancellationException && conversationPipelineMode != ConversationPipelineMode.Legacy) { + ConversationPipelineFallbackGate.requestFallback() } + throw error } private fun pruneDeliveredPendingDuplicates(messages: List): List { @@ -313,6 +569,7 @@ internal fun DefaultChatComponent.loadMessages( if (!force && state.messages.size >= PAGE_SIZE && state.currentTopicId == null) return cancelAllLoadingJobs() + val generation = loadingGeneration messageLoadingJob = scope.launch { val openStartedAt = System.currentTimeMillis() ChatConversationLog.logViewportState( @@ -321,13 +578,17 @@ internal fun DefaultChatComponent.loadMessages( componentInstanceId = componentInstanceId, extra = "source=$loadSource force=$force" ) + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading( + generation, + loading = true, + resetBoundaries = true + ) + } _state.update { - it.copy( - isLoading = true, - isOldestLoaded = false, - isLatestLoaded = false, - pendingScrollCommand = null, - viewportPhase = ChatViewportPhase.Initializing + ConversationViewportReducer.beginLoad( + state = it, + legacyOwnsLoadingState = conversationPipelineMode != ConversationPipelineMode.New ) } ChatConversationLog.logViewportState( @@ -338,6 +599,7 @@ internal fun DefaultChatComponent.loadMessages( ) try { + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } val currentState = _state.value val threadId = currentState.effectiveThreadId() val targetChatId = currentState.effectiveThreadChatId(chatId) @@ -362,7 +624,17 @@ internal fun DefaultChatComponent.loadMessages( val firstUnreadId = unreadSeparatorLastReadInboxMessageId .takeIf { unreadSeparatorCount > 0 } ?.let { lastRead -> - repositoryMessage.getMessagesNewer(targetChatId, lastRead, 1, threadId) + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } + loadHistoryPage( + HistoryRequest( + key = historyConversationKey(targetChatId, threadId), + anchor = HistoryAnchor.Message(lastRead), + direction = HistoryDirection.Newer, + limit = 1, + source = HistorySource.TdlibNetwork + ) + ) + .messages .firstOrNull() ?.id ?: lastRead.takeIf { it > 0L } @@ -375,8 +647,9 @@ internal fun DefaultChatComponent.loadMessages( firstUnreadMessageId = firstUnreadId, unreadCount = unreadSeparatorCount, backfillUnreadThreshold = PAGE_SIZE, - isComments = isComments - ) + isComments = isComments + ) + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } ChatConversationLog.logViewportState( event = "load_messages_target_resolved", state = _state.value, @@ -426,6 +699,7 @@ internal fun DefaultChatComponent.loadMessages( } is InitialChatScrollTarget.Bottom -> { + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } loadBottomMessages( targetChatId = targetChatId, threadId = threadId, @@ -452,8 +726,12 @@ internal fun DefaultChatComponent.loadMessages( // Only clear the flag if this job finished on its own. cancelAllLoadingJobs() cancels // without joining, so a pre-empted job's finally runs *after* the replacement job has // already set isLoading = true -- clearing it here would clobber the live load. - if (isActive) { - _state.update { it.copy(isLoading = false) } + if (isActive && isLoadingGenerationCurrent(generation)) { + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading(generation, loading = false) + } else { + _state.update { it.copy(isLoading = false) } + } } ChatConversationLog.logViewportState( event = "load_messages_finished", @@ -472,32 +750,46 @@ internal suspend fun DefaultChatComponent.loadComments( ) { lastLoadedOlderId = 0L lastLoadedNewerId = 0L - val olderPage = repositoryMessage.getMessagesOlder(targetChatId, 0L, PAGE_SIZE, threadId) + val olderPage = loadHistoryPage( + HistoryRequest( + key = historyConversationKey(targetChatId, threadId), + anchor = HistoryAnchor.Latest, + direction = HistoryDirection.Initial, + limit = PAGE_SIZE, + source = HistorySource.TdlibNetwork + ) + ) val messages = olderPage.messages - val reachedOldest = olderPage.reachedOldest + val reachedOldest = olderPage.olderBoundary is BoundaryState.Reached + + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setBoundaries( + generation = loadingGeneration, + oldestLoaded = reachedOldest, + latestLoaded = true + ) + } _state.update { - it.copy( + ConversationViewportReducer.contentReady( + state = it, isAtBottom = when (scrollCommand) { is ChatScrollCommand.ScrollToBottom -> true is ChatScrollCommand.RestoreViewport -> scrollCommand.atBottom else -> false }, - isLatestLoaded = true, - isOldestLoaded = reachedOldest, - scrollToMessageId = null, - highlightRequest = null + legacyOldestLoaded = reachedOldest.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + }, + legacyLatestLoaded = true.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + } ) } updateMessages(messages, replace = true) refreshCachedSenderProfiles(messages) if (scrollCommand != null) { - _state.update { - it.copy( - pendingScrollCommand = scrollCommand, - viewportPhase = ChatViewportPhase.Restoring - ) - } + restoreViewport(scrollCommand) ChatConversationLog.logViewportState( event = "load_comments_restore", state = _state.value, @@ -516,16 +808,29 @@ private suspend fun DefaultChatComponent.loadBottomMessages( lastLoadedNewerId = 0L var hasCachedPreview = false - val cachedMessages = repositoryMessage.getCachedMessages(targetChatId, PAGE_SIZE, threadId) + var previewMessages = emptyList() + val cachedMessages = loadHistoryPage( + HistoryRequest( + key = historyConversationKey(targetChatId, threadId), + anchor = HistoryAnchor.Latest, + direction = HistoryDirection.Initial, + limit = PAGE_SIZE, + source = HistorySource.RoomSnapshot + ) + ).messages if (cachedMessages.isNotEmpty()) { hasCachedPreview = true + previewMessages = cachedMessages _state.update { - it.copy( + ConversationViewportReducer.contentReady( + state = it, isAtBottom = true, - isLatestLoaded = false, - isOldestLoaded = false, - scrollToMessageId = null, - highlightRequest = null + legacyOldestLoaded = false.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + }, + legacyLatestLoaded = false.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + } ) } updateMessages(cachedMessages, replace = true) @@ -537,12 +842,7 @@ private suspend fun DefaultChatComponent.loadBottomMessages( extra = "targetChatId=$targetChatId cachedMessages=${cachedMessages.size}" ) if (scrollCommand != null) { - _state.update { - it.copy( - pendingScrollCommand = scrollCommand, - viewportPhase = ChatViewportPhase.Restoring - ) - } + restoreViewport(scrollCommand) ChatConversationLog.logViewportState( event = "load_bottom_cached_restore", state = _state.value, @@ -552,63 +852,106 @@ private suspend fun DefaultChatComponent.loadBottomMessages( } } - val olderPage = repositoryMessage.getMessagesOlder(targetChatId, 0, PAGE_SIZE, threadId) + val localPage = loadHistoryPage( + HistoryRequest( + key = historyConversationKey(targetChatId, threadId), + anchor = HistoryAnchor.Latest, + direction = HistoryDirection.Initial, + limit = PAGE_SIZE, + source = HistorySource.TdlibLocal + ) + ) + if (localPage.messages.isNotEmpty()) { + hasCachedPreview = true + previewMessages = localPage.messages + updateMessages(localPage.messages, replace = true) + refreshCachedSenderProfiles(localPage.messages) + ChatConversationLog.logViewportState( + event = "load_bottom_tdlib_local_preview", + state = _state.value, + componentInstanceId = componentInstanceId, + extra = "targetChatId=$targetChatId localMessages=${localPage.messages.size}" + ) + } + + val olderPage = if (shouldRequestInitialNetwork(localPage, PAGE_SIZE, threadId)) { + loadHistoryPage( + HistoryRequest( + key = historyConversationKey(targetChatId, threadId), + anchor = HistoryAnchor.Latest, + direction = HistoryDirection.Initial, + limit = PAGE_SIZE, + source = HistorySource.TdlibNetwork + ) + ) + } else { + localPage + } val messages = olderPage.messages - val isRemoteSameAsCachedPreview = hasCachedPreview && cachedMessages.isNotEmpty() && - messages.size == cachedMessages.size && - messages.zip(cachedMessages).all { (remote, cached) -> - remote.id == cached.id && !cached.hasUnresolvableCachedMedia() + val isReconciliationSameAsPreview = hasCachedPreview && previewMessages.isNotEmpty() && + messages.size == previewMessages.size && + messages.zip(previewMessages).all { (reconciled, cached) -> + reconciled.id == cached.id && !cached.hasUnresolvableCachedMedia() } - val isOldestLoaded = if (isRemoteSameAsCachedPreview) { + val isOldestLoaded = if (isReconciliationSameAsPreview) { false } else { - olderPage.reachedOldest + olderPage.olderBoundary is BoundaryState.Reached } - if (!isRemoteSameAsCachedPreview) { + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setBoundaries( + generation = loadingGeneration, + oldestLoaded = isOldestLoaded, + latestLoaded = true + ) + } + + if (!isReconciliationSameAsPreview) { _state.update { - it.copy( + ConversationViewportReducer.contentReady( + state = it, isAtBottom = true, - isLatestLoaded = true, - isOldestLoaded = isOldestLoaded, - scrollToMessageId = null, - highlightRequest = null + legacyOldestLoaded = isOldestLoaded.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + }, + legacyLatestLoaded = true.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + } ) } val shouldReplaceCachedPreview = !hasCachedPreview || messages.isNotEmpty() updateMessages(messages, replace = shouldReplaceCachedPreview) refreshCachedSenderProfiles(messages) ChatConversationLog.logViewportState( - event = "load_bottom_remote_applied", + event = "load_bottom_reconciliation_applied", state = _state.value, componentInstanceId = componentInstanceId, - extra = "targetChatId=$targetChatId remoteMessages=${messages.size} replace=${shouldReplaceCachedPreview} reachedOldest=${olderPage.reachedOldest}" + extra = "targetChatId=$targetChatId source=${olderPage.source} messages=${messages.size} replace=${shouldReplaceCachedPreview} reachedOldest=${olderPage.olderBoundary is BoundaryState.Reached}" ) } else { _state.update { - it.copy( + ConversationViewportReducer.contentReady( + state = it, isAtBottom = true, - isLatestLoaded = true, - isOldestLoaded = false, - scrollToMessageId = null, - highlightRequest = null + legacyOldestLoaded = false.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + }, + legacyLatestLoaded = true.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + } ) } ChatConversationLog.logViewportState( - event = "load_bottom_remote_same_as_cache", + event = "load_bottom_reconciliation_same_as_preview", state = _state.value, componentInstanceId = componentInstanceId, - extra = "targetChatId=$targetChatId remoteMessages=${messages.size}" + extra = "targetChatId=$targetChatId source=${olderPage.source} messages=${messages.size}" ) } if (scrollCommand != null) { - _state.update { - it.copy( - pendingScrollCommand = scrollCommand, - viewportPhase = ChatViewportPhase.Restoring - ) - } + restoreViewport(scrollCommand) ChatConversationLog.logViewportState( event = "load_bottom_restore", state = _state.value, @@ -634,17 +977,27 @@ private suspend fun DefaultChatComponent.loadAroundMessage( lastLoadedOlderId = 0L lastLoadedNewerId = 0L var hasTargetPreview = false - val cachedMessages = - repositoryMessage.getCachedMessagesAround(chatId, messageId, PAGE_SIZE, threadId) + val cachedMessages = loadHistoryPage( + HistoryRequest( + key = historyConversationKey(chatId, threadId), + anchor = HistoryAnchor.Message(messageId), + direction = HistoryDirection.Around, + limit = PAGE_SIZE, + source = HistorySource.RoomSnapshot + ) + ).messages if (cachedMessages.any { it.id == messageId }) { hasTargetPreview = true _state.update { - it.copy( + ConversationViewportReducer.contentReady( + state = it, isAtBottom = false, - isLatestLoaded = false, - isOldestLoaded = false, - scrollToMessageId = null, - highlightRequest = null + legacyOldestLoaded = false.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + }, + legacyLatestLoaded = false.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + } ) } updateMessages(cachedMessages, replace = true) @@ -656,12 +1009,7 @@ private suspend fun DefaultChatComponent.loadAroundMessage( extra = "targetMessageId=$messageId cachedMessages=${cachedMessages.size}" ) if (scrollCommand != null) { - _state.update { - it.copy( - pendingScrollCommand = scrollCommand, - viewportPhase = ChatViewportPhase.Restoring - ) - } + restoreViewport(scrollCommand) ChatConversationLog.logViewportState( event = "load_around_cached_restore", state = _state.value, @@ -671,15 +1019,33 @@ private suspend fun DefaultChatComponent.loadAroundMessage( } } - val messages = repositoryMessage.getMessagesAround(chatId, messageId, PAGE_SIZE, threadId) + val messages = loadHistoryPage( + HistoryRequest( + key = historyConversationKey(chatId, threadId), + anchor = HistoryAnchor.Message(messageId), + direction = HistoryDirection.Around, + limit = PAGE_SIZE, + source = HistorySource.TdlibNetwork + ) + ).messages if (messages.isNotEmpty()) { + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setBoundaries( + generation = loadingGeneration, + oldestLoaded = false, + latestLoaded = false + ) + } _state.update { - it.copy( + ConversationViewportReducer.contentReady( + state = it, isAtBottom = false, - isLatestLoaded = false, - isOldestLoaded = false, - scrollToMessageId = null, - highlightRequest = null + legacyOldestLoaded = false.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + }, + legacyLatestLoaded = false.takeIf { + conversationPipelineMode != ConversationPipelineMode.New + } ) } updateMessages(messages, replace = !hasTargetPreview) @@ -691,12 +1057,7 @@ private suspend fun DefaultChatComponent.loadAroundMessage( extra = "targetMessageId=$messageId remoteMessages=${messages.size} replace=${!hasTargetPreview}" ) if (!hasTargetPreview && scrollCommand != null) { - _state.update { - it.copy( - pendingScrollCommand = scrollCommand, - viewportPhase = ChatViewportPhase.Restoring - ) - } + restoreViewport(scrollCommand) ChatConversationLog.logViewportState( event = "load_around_remote_restore", state = _state.value, @@ -790,16 +1151,11 @@ internal fun DefaultChatComponent.scheduleUnreadBackfill(maxPages: Int) { } internal fun DefaultChatComponent.requestMessageHighlight(messageId: Long) { - _state.update { currentState -> - val nextToken = currentState.highlightRequestToken + 1L - currentState.copy( - highlightRequest = org.monogram.presentation.features.chats.conversation.MessageHighlightRequest( - messageId = messageId, - token = nextToken - ), - highlightRequestToken = nextToken - ) - } + _state.update { ConversationViewportReducer.requestHighlight(it, messageId) } +} + +private fun DefaultChatComponent.restoreViewport(command: ChatScrollCommand) { + _state.update { ConversationViewportReducer.restore(it, command) } } internal enum class ScrollToBottomHandling { @@ -818,9 +1174,9 @@ internal fun ChatComponent.State.resolveScrollToBottomHandling(): ScrollToBottom internal fun ChatComponent.State.enqueueRuntimeScrollToBottom( animated: Boolean = true ): ChatComponent.State { - return copy( - isAtBottom = true, - pendingScrollCommand = ChatScrollCommand.ScrollToBottom(animated = animated) + return ConversationViewportReducer.enqueue( + state = copy(isAtBottom = true), + command = ChatScrollCommand.ScrollToBottom(animated = animated) ) } @@ -839,16 +1195,14 @@ private fun DefaultChatComponent.queueJumpToLoadedMessage( animated: Boolean = true ) { _state.update { - it.copy( - isAtBottom = false, - viewportPhase = ChatViewportPhase.Restoring, - pendingScrollCommand = ChatScrollCommand.JumpToMessage( + ConversationViewportReducer.restore( + state = it.copy(isAtBottom = false), + command = ChatScrollCommand.JumpToMessage( messageId = messageId, highlight = highlight, align = align, animated = animated - ), - highlightRequest = null + ) ) } } @@ -873,9 +1227,13 @@ internal fun DefaultChatComponent.loadMoreMessages() { inFlightOlderAnchorId = requestedAnchorId } + val generation = loadingGeneration loadMoreJob = scope.launch { - _state.update { it.copy(isLoadingOlder = true) } + if (conversationPipelineMode != ConversationPipelineMode.New) { + _state.update { it.copy(isLoadingOlder = true) } + } try { + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } val currentState = _state.value val isComments = currentState.rootMessage != null val threadId = currentState.effectiveThreadId() @@ -894,7 +1252,11 @@ internal fun DefaultChatComponent.loadMoreMessages() { inFlightOlderAnchorId = anchorId if (anchorId == 0L) { - _state.update { it.copy(isOldestLoaded = true) } + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.markBoundaryReached(generation, HistoryDirection.Older) + } else { + _state.update { it.copy(isOldestLoaded = true) } + } return@launch } @@ -904,13 +1266,17 @@ internal fun DefaultChatComponent.loadMoreMessages() { while (!isOldestLoaded && attempts < 5) { attempts++ + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } val beforeSize = _state.value.messages.size - val olderPage = repositoryMessage.getMessagesOlder( - targetChatId, - currentAnchorId, - PAGE_SIZE, - threadId + val olderPage = loadHistoryPage( + HistoryRequest( + key = historyConversationKey(targetChatId, threadId), + anchor = HistoryAnchor.Message(currentAnchorId), + direction = HistoryDirection.Older, + limit = PAGE_SIZE, + source = HistorySource.TdlibNetwork + ) ) val olderMessages = olderPage.messages @@ -930,27 +1296,41 @@ internal fun DefaultChatComponent.loadMoreMessages() { val afterSize = _state.value.messages.size val listGrew = afterSize > beforeSize - isOldestLoaded = olderPage.reachedOldest || (olderPage.isRemote && !hasOlderProgress) + val isRemote = olderPage.source == HistorySource.TdlibNetwork + isOldestLoaded = olderPage.olderBoundary is BoundaryState.Reached || + (isRemote && !hasOlderProgress) if (hasOlderProgress) { lastLoadedOlderId = nextOlderAnchorId currentAnchorId = nextOlderAnchorId } - if (!olderPage.isRemote && olderMessages.isEmpty()) { + if (!isRemote && olderMessages.isEmpty()) { break } if (isOldestLoaded || listGrew) break } - _state.update { it.copy(isOldestLoaded = isOldestLoaded) } + if (isLoadingGenerationCurrent(generation)) { + if (conversationPipelineMode == ConversationPipelineMode.New) { + if (isOldestLoaded) { + conversationSession.markBoundaryReached(generation, HistoryDirection.Older) + } + } else { + _state.update { it.copy(isOldestLoaded = isOldestLoaded) } + } + } } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to load more messages", e) lastLoadedOlderId = 0L } finally { inFlightOlderAnchorId = 0L - _state.update { it.copy(isLoadingOlder = false) } + if (isLoadingGenerationCurrent(generation)) { + if (conversationPipelineMode != ConversationPipelineMode.New) { + _state.update { it.copy(isLoadingOlder = false) } + } + } } } } @@ -970,16 +1350,24 @@ internal fun DefaultChatComponent.loadNewerMessages() { inFlightNewerAnchorId = requestedAnchorId } + val generation = loadingGeneration loadNewerJob = scope.launch { - _state.update { it.copy(isLoadingNewer = true) } + if (conversationPipelineMode != ConversationPipelineMode.New) { + _state.update { it.copy(isLoadingNewer = true) } + } try { + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } loadNewerMessagesPage() } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to load newer messages", e) lastLoadedNewerId = 0L } finally { inFlightNewerAnchorId = 0L - _state.update { it.copy(isLoadingNewer = false) } + if (isLoadingGenerationCurrent(generation)) { + if (conversationPipelineMode != ConversationPipelineMode.New) { + _state.update { it.copy(isLoadingNewer = false) } + } + } } } } @@ -998,15 +1386,28 @@ private suspend fun DefaultChatComponent.loadNewerMessagesPage(): Boolean { } if (anchorId != 0L && anchorId == lastLoadedNewerId) { - _state.update { it.copy(isLatestLoaded = true) } + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.markBoundaryReached(loadingGeneration, HistoryDirection.Newer) + } else { + _state.update { it.copy(isLatestLoaded = true) } + } return true } - val newerMessages = - repositoryMessage.getMessagesNewer(targetChatId, anchorId, PAGE_SIZE, threadId) + val newerPage = loadHistoryPage( + HistoryRequest( + key = historyConversationKey(targetChatId, threadId), + anchor = HistoryAnchor.Message(anchorId), + direction = HistoryDirection.Newer, + limit = PAGE_SIZE, + source = HistorySource.TdlibNetwork + ) + ) + val newerMessages = newerPage.messages val isLatestLoaded = - newerMessages.size < PAGE_SIZE || - (newerMessages.isNotEmpty() && newerMessages.all { msg -> currentMessages.any { it.id == msg.id } }) + newerPage.source == HistorySource.TdlibNetwork && + (newerMessages.size < PAGE_SIZE || + (newerMessages.isNotEmpty() && newerMessages.all { msg -> currentMessages.any { it.id == msg.id } })) if (newerMessages.isNotEmpty()) { updateMessages(newerMessages) @@ -1014,7 +1415,13 @@ private suspend fun DefaultChatComponent.loadNewerMessagesPage(): Boolean { lastLoadedNewerId = anchorId } - _state.update { it.copy(isLatestLoaded = isLatestLoaded) } + if (conversationPipelineMode == ConversationPipelineMode.New) { + if (isLatestLoaded) { + conversationSession.markBoundaryReached(loadingGeneration, HistoryDirection.Newer) + } + } else { + _state.update { it.copy(isLatestLoaded = isLatestLoaded) } + } return isLatestLoaded } @@ -1037,6 +1444,7 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { } cancelAllLoadingJobs() + val generation = loadingGeneration messageLoadingJob = scope.launch { ChatConversationLog.logViewportState( event = "scroll_to_message_reset_before", @@ -1044,14 +1452,17 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { componentInstanceId = componentInstanceId, extra = "messageId=$messageId" ) + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading( + generation, + loading = true, + resetBoundaries = true + ) + } _state.update { - it.copy( - isLoading = true, - isOldestLoaded = false, - isLatestLoaded = false, - pendingScrollCommand = null, - viewportPhase = ChatViewportPhase.Initializing, - highlightRequest = null + ConversationViewportReducer.beginLoad( + state = it, + legacyOwnsLoadingState = conversationPipelineMode != ConversationPipelineMode.New ) } ChatConversationLog.logViewportState( @@ -1084,7 +1495,13 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { ) Log.e("DefaultChatComponent", "Failed to scroll to message", e) } finally { - _state.update { it.copy(isLoading = false) } + if (isActive && isLoadingGenerationCurrent(generation)) { + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading(generation, loading = false) + } else { + _state.update { it.copy(isLoading = false) } + } + } ChatConversationLog.logViewportState( event = "scroll_to_message_reload_finish", state = _state.value, @@ -1126,6 +1543,7 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { } if (currentState.isLoading) return cancelAllLoadingJobs() + val generation = loadingGeneration messageLoadingJob = scope.launch { ChatConversationLog.logViewportState( event = "scroll_to_bottom_reset_before", @@ -1133,13 +1551,17 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { componentInstanceId = componentInstanceId, extra = "isComments=$isComments" ) + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading( + generation, + loading = true, + resetBoundaries = true + ) + } _state.update { - it.copy( - isLoading = true, - isOldestLoaded = false, - isLatestLoaded = false, - pendingScrollCommand = null, - viewportPhase = ChatViewportPhase.Initializing + ConversationViewportReducer.beginLoad( + state = it, + legacyOwnsLoadingState = conversationPipelineMode != ConversationPipelineMode.New ) } ChatConversationLog.logViewportState( @@ -1173,7 +1595,13 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { ) Log.e("DefaultChatComponent", "Failed to scroll to bottom", e) } finally { - _state.update { it.copy(isLoading = false) } + if (isActive && isLoadingGenerationCurrent(generation)) { + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading(generation, loading = false) + } else { + _state.update { it.copy(isLoading = false) } + } + } ChatConversationLog.logViewportState( event = "scroll_to_bottom_reload_finish", state = _state.value, @@ -1184,6 +1612,10 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { } internal fun DefaultChatComponent.cancelAllLoadingJobs() { + loadingGeneration += 1L + if (conversationPipelineMode != ConversationPipelineMode.Legacy) { + conversationSession.advanceGeneration(loadingGeneration) + } messageLoadingJob?.cancel() loadMoreJob?.cancel() loadNewerJob?.cancel() @@ -1197,7 +1629,9 @@ private fun DefaultChatComponent.ensureLatestWindowLoaded(maxPages: Int) { if (loadNewerJob?.isActive == true) return loadNewerJob = scope.launch { - _state.update { it.copy(isLoadingNewer = true) } + if (conversationPipelineMode != ConversationPipelineMode.New) { + _state.update { it.copy(isLoadingNewer = true) } + } try { backfillNewerMessages(maxPages = maxPages) } catch (e: CancellationException) { @@ -1207,7 +1641,9 @@ private fun DefaultChatComponent.ensureLatestWindowLoaded(maxPages: Int) { lastLoadedNewerId = 0L } finally { inFlightNewerAnchorId = 0L - _state.update { it.copy(isLoadingNewer = false) } + if (conversationPipelineMode != ConversationPipelineMode.New) { + _state.update { it.copy(isLoadingNewer = false) } + } } } } @@ -1228,6 +1664,7 @@ internal fun DefaultChatComponent.jumpToLatestInternal() { if (currentState.isLoading) return cancelAllLoadingJobs() + val generation = loadingGeneration messageLoadingJob = scope.launch { ChatConversationLog.logViewportState( event = "jump_to_latest_reset_before", @@ -1235,14 +1672,17 @@ internal fun DefaultChatComponent.jumpToLatestInternal() { componentInstanceId = componentInstanceId, extra = "isComments=$isComments" ) + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading( + generation, + loading = true, + resetBoundaries = true + ) + } _state.update { - it.withResetSavedBottomViewport().copy( - isLoading = true, - isOldestLoaded = false, - isLatestLoaded = false, - pendingScrollCommand = null, - viewportPhase = ChatViewportPhase.Initializing, - highlightRequest = null + ConversationViewportReducer.beginLoad( + state = it.withResetSavedBottomViewport(), + legacyOwnsLoadingState = conversationPipelineMode != ConversationPipelineMode.New ) } ChatConversationLog.logViewportState( @@ -1252,6 +1692,7 @@ internal fun DefaultChatComponent.jumpToLatestInternal() { extra = "isComments=$isComments" ) try { + check(isLoadingGenerationCurrent(generation)) { "Stale chat load generation" } if (isComments && threadId != null) { loadComments( targetChatId = targetChatId, @@ -1276,7 +1717,13 @@ internal fun DefaultChatComponent.jumpToLatestInternal() { ) Log.e("DefaultChatComponent", "Failed to jump to latest", e) } finally { - _state.update { it.copy(isLoading = false) } + if (isLoadingGenerationCurrent(generation)) { + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.setOperationLoading(generation, loading = false) + } else { + _state.update { it.copy(isLoading = false) } + } + } ChatConversationLog.logViewportState( event = "jump_to_latest_reload_finish", state = _state.value, @@ -1287,24 +1734,123 @@ internal fun DefaultChatComponent.jumpToLatestInternal() { } internal fun DefaultChatComponent.setupMessageCollectors() { + if (conversationPipelineMode == ConversationPipelineMode.New) { + conversationSession.state + .onEach { sessionState -> + if (sessionState.closed) return@onEach + _state.update { current -> + current.copy( + isLoading = sessionState.isLoadingInitial, + isLoadingOlder = sessionState.isLoadingOlder, + isLoadingNewer = sessionState.isLoadingNewer, + isOldestLoaded = sessionState.isOldestLoaded, + isLatestLoaded = sessionState.isLatestLoaded + ) + } + } + .launchIn(scope) + } + if (conversationPipelineMode != ConversationPipelineMode.Legacy) { + repositoryMessage.conversationUpdates + .onEach { update -> + val state = _state.value + val activeChatId = state.effectiveThreadChatId(chatId) + if (update.chatId != activeChatId) return@onEach + val scopedUpdate = when (update) { + is ConversationUpdate.Upsert -> state.isMessageInActiveThread( + chatId, + update.message + ) + + is ConversationUpdate.ReplaceTemporaryId -> + state.isMessageInActiveThread(chatId, update.message) + + is ConversationUpdate.SendFailed -> + state.isMessageInActiveThread(chatId, update.message) + + is ConversationUpdate.Delete, + is ConversationUpdate.InboxRead, + is ConversationUpdate.OutboxRead, + is ConversationUpdate.SendAcknowledged -> true + } + if (conversationPipelineMode == ConversationPipelineMode.New) { + applyConversationUpdateBookkeeping( + update = update, + remappedMessageIds = remappedMessageIds, + reactionUpdateSuppressedUntil = reactionUpdateSuppressedUntil + ) + } + if (!scopedUpdate) return@onEach + + val sessionState = conversationSession.applyUpdate( + update = update, + canInsertNewMessage = state.isAtBottom || state.isLatestLoaded || when (update) { + is ConversationUpdate.Upsert -> update.isNew && update.message.isOutgoing + is ConversationUpdate.ReplaceTemporaryId -> update.message.isOutgoing + else -> false + } + ) + if (conversationPipelineMode == ConversationPipelineMode.New && !sessionState.closed) { + val suppressReactionUpdate = (update as? ConversationUpdate.Upsert) + ?.takeUnless(ConversationUpdate.Upsert::isNew) + ?.message + ?.id + ?.let { messageId -> + val suppressUntil = reactionUpdateSuppressedUntil[messageId] + when { + suppressUntil == null -> false + System.currentTimeMillis() < suppressUntil -> true + else -> { + reactionUpdateSuppressedUntil.remove(messageId, suppressUntil) + false + } + } + } == true + _state.update { current -> + current.withConversationSessionUpdate( + sessionState = sessionState, + update = update, + rootChatId = chatId, + suppressReactionUpdate = suppressReactionUpdate + ) + } + when (update) { + is ConversationUpdate.Upsert -> { + if (update.isNew) requestSenderRefreshIfNeeded(update.message) + if (!update.isNew) handleEditedRichMessage(update.message) + } + + is ConversationUpdate.ReplaceTemporaryId -> + requestSenderRefreshIfNeeded(update.message) + + is ConversationUpdate.Delete, + is ConversationUpdate.InboxRead, + is ConversationUpdate.OutboxRead, + is ConversationUpdate.SendAcknowledged, + is ConversationUpdate.SendFailed -> Unit + } + } + } + .launchIn(scope) + } + repositoryMessage.newMessageFlow .onEach { message -> if (message.chatId == chatId || message.chatId == activeThreadChatId()) { if (resolveRemappedMessageId(message.id) != message.id) return@onEach val isCorrectThread = _state.value.isMessageInActiveThread(chatId, message) if (isCorrectThread) { - _state.update { state -> - state.withIncomingUnreadMessage( - rootChatId = chatId, - message = message - ) + if (conversationPipelineMode != ConversationPipelineMode.New) { + _state.update { state -> + state.withIncomingUnreadMessage( + rootChatId = chatId, + message = message + ) + } + updateMessages(listOf(message)) } - updateMessages(listOf(message)) - requestSenderRefreshIfNeeded(message) - _state.update { state -> - state.copy( - isLatestLoaded = if (message.isOutgoing || state.isAtBottom) true else state.isLatestLoaded - ) + if (conversationPipelineMode != ConversationPipelineMode.New) { + requestSenderRefreshIfNeeded(message) } } } @@ -1317,12 +1863,14 @@ internal fun DefaultChatComponent.setupMessageCollectors() { val oldId = event.oldMessageId val newMessage = event.message if (cId == chatId || cId == activeThreadChatId()) { - if (oldId != newMessage.id) { - remappedMessageIds[oldId] = newMessage.id - } else { - remappedMessageIds.remove(oldId) + if (conversationPipelineMode != ConversationPipelineMode.New) { + if (oldId != newMessage.id) { + remappedMessageIds[oldId] = newMessage.id + } else { + remappedMessageIds.remove(oldId) + } } - messageMutex.withLock { + if (conversationPipelineMode != ConversationPipelineMode.New) messageMutex.withLock { _state.update { state -> val isCorrectThread = state.isMessageInActiveThread(chatId, newMessage) @@ -1366,7 +1914,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { } val isCurrentThread = _state.value.isMessageInActiveThread(chatId, newMessage) - if (isCurrentThread) { + if (isCurrentThread && conversationPipelineMode != ConversationPipelineMode.New) { requestSenderRefreshIfNeeded(newMessage) } } @@ -1376,7 +1924,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { repositoryMessage.messageAcknowledgedFlow .onEach { event -> if (event.chatId != chatId && event.chatId != activeThreadChatId()) return@onEach - _state.update { state -> + if (conversationPipelineMode != ConversationPipelineMode.New) _state.update { state -> state.copy( outgoingMessageStates = OutgoingMessageReducer.acknowledged( current = state.outgoingMessageStates, @@ -1390,7 +1938,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { repositoryMessage.messageSendFailedFlow .onEach { event -> if (event.chatId != chatId && event.chatId != activeThreadChatId()) return@onEach - _state.update { state -> + if (conversationPipelineMode != ConversationPipelineMode.New) _state.update { state -> state.copy( outgoingMessageStates = OutgoingMessageReducer.failed( current = state.outgoingMessageStates, @@ -1554,9 +2102,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { message.copy(content = newContent) } AutoDownloadSuppression.suppress(cancelledFileId) - if (cancelledFileId != 0) { - mediaDownloadRetryCount.remove(cancelledFileId) - } } is MessageDownloadEvent.Completed -> { @@ -1564,7 +2109,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { val messageId = event.messageId val downloadedFileId = event.fileId val path = event.path - var fileIdToRetry: Int? = null var mainFileId = 0 var mainPathUpdated = false @@ -1574,13 +2118,17 @@ internal fun DefaultChatComponent.setupMessageCollectors() { val newContent = when (val content = message.content) { is MessageContent.Photo -> { - val isMainPhotoFile = - downloadedFileId == content.fileId || - (content.originalFileId != 0 && downloadedFileId == content.originalFileId) - if (isMainPhotoFile) { + val isOriginalFile = content.originalFileId != 0 && + downloadedFileId == content.originalFileId && + downloadedFileId != content.fileId + if (isOriginalFile) { + content.copy( + isDownloading = false, + downloadError = isError + ) + } else if (downloadedFileId == content.fileId) { mainFileId = downloadedFileId mainPathUpdated = true - if (isError) fileIdToRetry = downloadedFileId content.copy( path = finalPath ?: content.path, isDownloading = false, @@ -1595,7 +2143,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (downloadedFileId == content.fileId) { mainFileId = content.fileId mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId content.copy( path = finalPath, isDownloading = false, @@ -1610,7 +2157,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (downloadedFileId == content.fileId) { mainFileId = content.fileId mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId content.copy( path = finalPath, isDownloading = false, @@ -1625,7 +2171,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (downloadedFileId == content.fileId) { mainFileId = content.fileId mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId content.copy( path = finalPath, isDownloading = false, @@ -1640,7 +2185,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (downloadedFileId == content.fileId) { mainFileId = content.fileId mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId content.copy( path = finalPath, isDownloading = false, @@ -1655,7 +2199,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (downloadedFileId == content.fileId) { mainFileId = content.fileId mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId content.copy( path = finalPath, isDownloading = false, @@ -1670,7 +2213,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (downloadedFileId == content.fileId) { mainFileId = content.fileId mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId content.copy( path = finalPath, isDownloading = false, @@ -1688,11 +2230,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (path.isNotEmpty() && mainFileId != 0) { AutoDownloadSuppression.clear(mainFileId) - mediaDownloadRetryCount.remove(mainFileId) - } - - if (mainPathUpdated && path.isNotEmpty()) { - updateFullScreenImagePath(messageId, path) } if (path.isNotEmpty() && messageId in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { @@ -1703,29 +2240,6 @@ internal fun DefaultChatComponent.setupMessageCollectors() { refreshCachedSenderProfiles(_state.value.messages) } - fileIdToRetry?.let { - if (it != 0) { - val suppressed = AutoDownloadSuppression.isSuppressed(it) - if (!suppressed) { - val attempts = (mediaDownloadRetryCount[it] ?: 0) + 1 - mediaDownloadRetryCount[it] = attempts - if (attempts <= MAX_DOWNLOAD_RETRIES) { - onDownloadFile(it) - } else { - AutoDownloadSuppression.suppress(it) - Log.w( - "DownloadDebug", - "retryLimitReached: fileId=$it attempts=$attempts chatId=$chatId" - ) - } - } else { - Log.d( - "DownloadDebug", - "retrySkippedBySuppression: fileId=$it chatId=$chatId" - ) - } - } - } } } } @@ -1736,10 +2250,12 @@ internal fun DefaultChatComponent.setupMessageCollectors() { val cId = event.chatId val messageIds = event.messageIds if (cId == chatId || cId == activeThreadChatId()) { - messageIds.forEach(reactionUpdateSuppressedUntil::remove) - messageIds.forEach(remappedMessageIds::remove) - remappedMessageIds.entries.removeIf { (_, mappedId) -> mappedId in messageIds } - _state.update { currentState -> + if (conversationPipelineMode != ConversationPipelineMode.New) { + messageIds.forEach(reactionUpdateSuppressedUntil::remove) + messageIds.forEach(remappedMessageIds::remove) + remappedMessageIds.entries.removeIf { (_, mappedId) -> mappedId in messageIds } + } + if (conversationPipelineMode != ConversationPipelineMode.New) _state.update { currentState -> val currentMessages = currentState.messages.toMutableList() val removed = currentMessages.removeAll { messageIds.contains(it.id) } if (removed) { @@ -1758,7 +2274,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { repositoryMessage.messageEditedFlow .onEach { message -> if (_state.value.isMessageInActiveThread(chatId, message) || message.chatId == chatId) { - messageMutex.withLock { + if (conversationPipelineMode != ConversationPipelineMode.New) messageMutex.withLock { val targetMessageId = resolveRemappedMessageId(message.id) _state.update { currentState -> val now = System.currentTimeMillis() @@ -1818,7 +2334,9 @@ internal fun DefaultChatComponent.setupMessageCollectors() { }.clearPendingEditedMessage(targetMessageId) } } - handleEditedRichMessage(message) + if (conversationPipelineMode != ConversationPipelineMode.New) { + handleEditedRichMessage(message) + } } } .launchIn(scope) @@ -1832,7 +2350,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { repositoryMessage.messageReadFlow .onEach { readUpdate -> if (readUpdate.chatId == chatId || readUpdate.chatId == activeThreadChatId()) { - _state.update { currentState -> + if (conversationPipelineMode != ConversationPipelineMode.New) _state.update { currentState -> when (readUpdate) { is ReadUpdate.Inbox -> currentState.withInboxReadUpdate( readChatId = readUpdate.chatId, @@ -1945,28 +2463,30 @@ private fun DefaultChatComponent.updateInlineResultsWithFile(fileId: Int, newPat } } -private fun DefaultChatComponent.updateFullScreenImagePath(messageId: Long, newPath: String) { - _state.update { currentState -> - val currentImages = currentState.fullScreenImages ?: return@update currentState - val index = currentState.fullScreenImageMessageIds.indexOf(messageId) - if (index !in currentImages.indices) { - return@update currentState - } - - val updated = currentImages.toMutableList().apply { this[index] = newPath } - currentState.copy(fullScreenImages = updated) - } -} - -private inline fun DefaultChatComponent.updateMessageContent( +private suspend inline fun DefaultChatComponent.updateMessageContent( messageId: Long, noinline transform: (MessageModel) -> MessageModel ) { - scope.launch { + val targetMessageId = resolveRemappedMessageId(messageId) + if (conversationPipelineMode == ConversationPipelineMode.New) { + val sessionState = conversationSession.transformMessage( + chatId = activeThreadChatId(), + messageId = targetMessageId, + transform = transform + ) + if (!sessionState.closed) { + _state.update { currentState -> + if (currentState.messages == sessionState.messages) currentState + else currentState.copy(messages = sessionState.messages) + } + } + } else { messageMutex.withLock { - val targetMessageId = resolveRemappedMessageId(messageId) _state.update { currentState -> - currentState.withUpdatedMessage(targetMessageId, transform) + currentState.withUpdatedMessage( + targetMessageId, + transform + ) } } } @@ -2005,7 +2525,8 @@ internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { extra = "topicId=$topicId resolvedTopicId=${id ?: 0L}" ) _state.update { - it.copy( + ConversationViewportReducer.initialize( + it.copy( currentTopicId = id, currentThreadChatId = null, currentMessageThreadId = null, @@ -2013,9 +2534,8 @@ internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { isOldestLoaded = false, isLatestLoaded = false, rootMessage = null, - isAtBottom = id == null, - pendingScrollCommand = null, - viewportPhase = ChatViewportPhase.Initializing + isAtBottom = id == null + ) ) } ChatConversationLog.logViewportState( @@ -2050,7 +2570,8 @@ internal fun DefaultChatComponent.handleCommentsClick(messageId: Long) { extra = "messageId=$messageId threadChatId=${threadContext?.chatId ?: 0L} threadId=${threadContext?.threadId ?: messageId}" ) _state.update { - it.copy( + ConversationViewportReducer.initialize( + it.copy( currentTopicId = messageId, currentThreadChatId = threadContext?.chatId, currentMessageThreadId = threadContext?.threadId ?: messageId, @@ -2058,9 +2579,8 @@ internal fun DefaultChatComponent.handleCommentsClick(messageId: Long) { messages = emptyList(), isOldestLoaded = false, isLatestLoaded = false, - isAtBottom = false, - pendingScrollCommand = null, - viewportPhase = ChatViewportPhase.Initializing + isAtBottom = false + ) ) } ChatConversationLog.logViewportState( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/thread/ThreadContext.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/thread/ThreadContext.kt index 742947113..5b123f005 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/thread/ThreadContext.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/thread/ThreadContext.kt @@ -1,6 +1,8 @@ package org.monogram.presentation.features.chats.conversation.logic import org.monogram.domain.models.MessageModel +import org.monogram.domain.repository.ConversationKey +import org.monogram.domain.repository.ConversationScope import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.chats.conversation.DefaultChatComponent @@ -25,3 +27,18 @@ internal fun DefaultChatComponent.activeThreadChatId(): Long = _state.value.effectiveThreadChatId(chatId) internal fun DefaultChatComponent.activeThreadId(): Long? = _state.value.effectiveThreadId() + +internal fun DefaultChatComponent.historyConversationKey( + targetChatId: Long, + threadId: Long? +): ConversationKey { + val state = _state.value + val scope = when { + threadId == null -> ConversationScope.Main + state.currentMessageThreadId == threadId || state.rootMessage != null -> + ConversationScope.MessageThread(threadId) + + else -> ConversationScope.ForumTopic(threadId) + } + return ConversationKey(targetChatId, scope) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt index ee853796b..ab9251aa0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt @@ -336,20 +336,6 @@ fun PhotoItem( } } - LaunchedEffect(photo.path, photo.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (photo.path.isNullOrBlank() && !photo.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - photo.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) onDownloadPhoto(photo.fileId) - } - } - var itemPosition by remember { mutableStateOf(Offset.Zero) } var isRevealed by remember { mutableStateOf(!photo.hasSpoiler) } @@ -479,20 +465,6 @@ fun VideoItem( } } - LaunchedEffect(video.path, video.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (video.path.isNullOrBlank() && !video.isDownloading && !video.supportsStreaming && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - video.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) onDownloadVideo(video.fileId) - } - } - var itemPosition by remember { mutableStateOf(Offset.Zero) } var isRevealed by remember { mutableStateOf(!video.hasSpoiler) } @@ -713,20 +685,6 @@ fun VideoNoteItem( } } - LaunchedEffect(videoNote.path, videoNote.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (videoNote.path.isNullOrBlank() && !videoNote.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - videoNote.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) onVideoClick(msg) - } - } - var itemPosition by remember { mutableStateOf(Offset.Zero) } Box(modifier = modifier.clipToBounds()) { Crossfade( @@ -901,20 +859,6 @@ fun GifItem( } } - LaunchedEffect(gif.path, gif.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (gif.path.isNullOrBlank() && !gif.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - gif.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) onGifClick(msg) - } - } - var itemPosition by remember { mutableStateOf(Offset.Zero) } var isRevealed by remember { mutableStateOf(!gif.hasSpoiler) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt index fa1734bea..72b73c333 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt @@ -123,22 +123,6 @@ fun ChannelGifMessageBubble( } } - LaunchedEffect(content.path, content.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (content.path.isNullOrBlank() && !content.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - content.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) { - onGifClick(msg) - } - } - } - Column( modifier = modifier, horizontalAlignment = Alignment.Start diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt index b3e054edc..e1605b8b3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt @@ -122,20 +122,6 @@ fun ChannelPhotoMessageBubble( val hasCaption = content.caption.isNotEmpty() val showCaptionAboveMedia = hasCaption && content.showCaptionAboveMedia - LaunchedEffect(content.path, content.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (content.path.isNullOrBlank() && !content.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - content.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) onDownloadPhoto(content.fileId) - } - } - Column( modifier = modifier, horizontalAlignment = Alignment.Start diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt index b755bd742..3bcb5a141 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt @@ -148,20 +148,6 @@ fun ChannelVideoMessageBubble( } } - LaunchedEffect(content.path, content.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (content.path.isNullOrBlank() && !content.isDownloading && !content.supportsStreaming && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - content.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) onDownloadVideo(content.fileId) - } - } - Column( modifier = modifier .onGloballyPositioned { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVoiceMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVoiceMessageBubble.kt index d845010f5..92649311c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVoiceMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVoiceMessageBubble.kt @@ -71,29 +71,6 @@ fun ChannelVoiceMessageBubble( var bubblePosition by remember { mutableStateOf(Offset.Zero) } - LaunchedEffect( - content.path, - content.isDownloading, - autoDownloadFiles, - autoDownloadMobile, - autoDownloadWifi, - autoDownloadRoaming - ) { - val shouldDownload = if (autoDownloadFiles) { - when { - downloadUtils.isRoaming() -> autoDownloadRoaming - downloadUtils.isWifiConnected() -> autoDownloadWifi - else -> autoDownloadMobile - } - } else { - false - } - - if (shouldDownload && content.path == null && !content.isDownloading) { - onVoiceClick(msg) - } - } - Column( modifier = modifier .onGloballyPositioned { bubblePosition = it.positionInWindow() }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt index 0ee92084e..27c2dabfb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt @@ -303,31 +303,31 @@ internal fun ChatContentBody( val onVideoClick: (MessageModel, String?, String?) -> Unit = remember(component, scrollState) { { msg, path, caption -> - if (!currentIsVisible.value || currentShowInitialLoading.value || scrollState.isScrollInProgress) { - Unit - } else { - val videoContent = msg.content as? MessageContent.Video - val supportsStreaming = videoContent?.supportsStreaming ?: false - val validPath = path?.takeIf { File(it).exists() } + val videoContent = msg.content as? MessageContent.Video + val supportsStreaming = videoContent?.supportsStreaming ?: false + val validPath = path?.takeIf { File(it).exists() } - if (validPath != null || supportsStreaming) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenVideo( - path = validPath, - messageId = msg.id, - caption = caption - ) - } else { - val fileId = when (val content = msg.content) { - is MessageContent.Video -> content.fileId - is MessageContent.Gif -> content.fileId - else -> 0 - } - if (fileId != 0) { - component.onDownloadFile(fileId, userInitiated = true) - } + if (!currentIsVisible.value || currentShowInitialLoading.value) { + Unit + } else if (validPath == null && !supportsStreaming) { + val fileId = when (val content = msg.content) { + is MessageContent.Video -> content.fileId + is MessageContent.Gif -> content.fileId + else -> 0 + } + if (fileId != 0) { + component.onDownloadFile(fileId, userInitiated = true) } + } else if (scrollState.isScrollInProgress) { + Unit + } else { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenVideo( + path = validPath, + messageId = msg.id, + caption = caption + ) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt index 614fc9e6c..ab37540a1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt @@ -25,6 +25,9 @@ import org.monogram.presentation.features.chats.conversation.DefaultChatComponen import org.monogram.presentation.features.chats.conversation.logic.requestMessageHighlight import kotlin.math.abs +private const val MIN_DOWNLOAD_PREFETCH_ITEMS = 12 +private const val DOWNLOAD_PREFETCH_VIEWPORT_MULTIPLIER = 2 + internal fun shouldAutoFollowLatestAfterContentChange( previousLastGroupedMessageId: Long?, currentLastGroupedMessageId: Long?, @@ -586,7 +589,11 @@ internal fun ChatContentEffects( groupedMessages.size, firstGroupedMessageId, lastGroupedMessageId, - conversationItems + conversationItems, + state.autoDownloadFiles, + state.autoDownloadMobile, + state.autoDownloadWifi, + state.autoDownloadRoaming ) { if (!isViewportSettled) return@LaunchedEffect snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } @@ -628,8 +635,11 @@ internal fun ChatContentEffects( } } - val nearbyStart = (minIndex - 5).coerceAtLeast(0) - val nearbyEnd = maxIndex + 5 + val prefetchDistance = + (visibleItems.size * DOWNLOAD_PREFETCH_VIEWPORT_MULTIPLIER) + .coerceAtLeast(MIN_DOWNLOAD_PREFETCH_ITEMS) + val nearbyStart = (minIndex - prefetchDistance).coerceAtLeast(0) + val nearbyEnd = maxIndex + prefetchDistance for (index in nearbyStart..nearbyEnd) { if (index in minIndex..maxIndex) continue val grouped = when ( @@ -656,9 +666,10 @@ internal fun ChatContentEffects( .distinctUntilChanged() .debounce(100) .collect { (visibleIds, nearbyIds) -> - (component as? DefaultChatComponent)?.let { - it.repositoryMessage.updateVisibleRange(it.chatId, visibleIds, nearbyIds) - } + component.onMessageViewportChanged( + visibleMessageIds = visibleIds.toSet(), + nearbyMessageIds = nearbyIds.toSet() + ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt index 8367f771f..1efbd4f5c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt @@ -602,47 +602,6 @@ internal fun ChatContentList( } } - LaunchedEffect( - scrollState, - conversationItems, - groupedMessages, - state.isViewportSettled - ) { - if (!state.isViewportSettled || groupedMessages.isEmpty()) return@LaunchedEffect - snapshotFlow { scrollState.layoutInfo.visibleItemsInfo.map(LazyListItemInfo::index) } - .filter { it.isNotEmpty() } - .distinctUntilChanged() - .collect { visibleLazyIndices -> - val leadingItems = chatContentLeadingItemsCount( - isComments = isComments, - showNavPadding = showNavPadding, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, - isNearBottom = scrollState.isNearBottom(isComments = isComments), - hasMessages = groupedMessages.isNotEmpty() - ) - val visibleGroupedIndices = visibleLazyIndices.mapNotNull { lazyIndex -> - val groupedIndex = lazyIndexToGroupedIndex(lazyIndex, leadingItems) - groupedIndex.takeIf { it in groupedMessages.indices } - } - if (visibleGroupedIndices.isEmpty()) return@collect - val firstVisibleGroupedIndex = visibleGroupedIndices.minOrNull() ?: return@collect - val lastVisibleGroupedIndex = visibleGroupedIndices.maxOrNull() ?: return@collect - val nearbyStart = (firstVisibleGroupedIndex - 3).coerceAtLeast(0) - val nearbyEnd = - (lastVisibleGroupedIndex + 3).coerceAtMost(groupedMessages.lastIndex) - val visibleIds = - visibleGroupedIndices.mapTo(linkedSetOf()) { groupedMessages[it].firstMessageId } - val nearbyIds = - (nearbyStart..nearbyEnd).mapTo(linkedSetOf()) { groupedMessages[it].firstMessageId } - component.onMessageViewportChanged( - visibleMessageIds = visibleIds, - nearbyMessageIds = nearbyIds - ) - } - } - if (state.viewAsTopics && state.currentTopicId == null) { TopicsList( topics = state.topics, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt index 9bf56c78a..7684191bc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentViewers.kt @@ -22,12 +22,14 @@ import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.repository.TelegramLinkRepository +import org.monogram.domain.repository.FileRepository import org.monogram.presentation.R import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.chats.conversation.ui.message.PreviewImageViewerRequest import org.monogram.presentation.features.chats.conversation.ui.message.PreviewVideoViewerRequest import org.monogram.presentation.features.instantview.InstantViewer -import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.FullscreenImageItem +import org.monogram.presentation.features.viewers.ManagedImageViewer import org.monogram.presentation.features.viewers.VideoViewer import org.monogram.presentation.features.viewers.YouTubeViewer import org.monogram.presentation.features.webapp.MiniAppViewer @@ -191,11 +193,13 @@ internal fun ImagesOverlay( onDismissPreviewImages: () -> Unit = {} ) { val telegramLinkRepository: TelegramLinkRepository = koinInject() + val fileRepository: FileRepository = koinInject() val scope = rememberCoroutineScope() previewImages?.let { request -> if (request.images.isNotEmpty()) { - ImageViewer( + ManagedImageViewer( images = request.images, + fileRepository = fileRepository, startIndex = request.startIndex, onDismiss = onDismissPreviewImages, onForward = null, @@ -205,8 +209,8 @@ internal fun ImagesOverlay( ClipData.newPlainText("", AnnotatedString(request.sourceUrl)) ) }, - onCopyText = request.captions.firstOrNull() - ?.takeIf { !it.isNullOrBlank() } + onCopyText = request.images.firstOrNull()?.caption + ?.takeIf { it.isNotBlank() } ?.let { { _ -> localClipboard.nativeClipboard.setPrimaryClip( @@ -214,7 +218,6 @@ internal fun ImagesOverlay( ) } }, - captions = request.captions, downloadUtils = component.downloadUtils, showImageNumber = request.images.size > 1 ) @@ -222,15 +225,6 @@ internal fun ImagesOverlay( } state.fullScreenImages?.let { images -> - val autoDownload = - remember(state.autoDownloadWifi, state.autoDownloadRoaming, state.autoDownloadMobile) { - when { - component.downloadUtils.isWifiConnected() -> state.autoDownloadWifi - component.downloadUtils.isRoaming() -> state.autoDownloadRoaming - else -> state.autoDownloadMobile - } - } - val viewerItems = remember(images, state.fullScreenImageMessageIds, state.messages) { val messageMap = state.messages.associateBy { it.id } val items = if (state.fullScreenImageMessageIds.size == images.size) { @@ -251,7 +245,18 @@ internal fun ImagesOverlay( items.sortedBy { it.messageId } } - val viewerImages = remember(viewerItems) { viewerItems.map { it.path } } + val viewerImages = remember(viewerItems, state.fullScreenCaptions) { + viewerItems.mapIndexed { index, viewerItem -> + val photo = state.messages.firstOrNull { it.id == viewerItem.messageId } + ?.content as? MessageContent.Photo + FullscreenImageItem( + id = "message:${viewerItem.messageId.takeIf { it != 0L } ?: viewerItem.path}", + previewSource = viewerItem.path, + originalFileId = photo?.originalFileId ?: 0, + caption = state.fullScreenCaptions.getOrNull(index) + ) + } + } val imageMessageIds = remember(viewerItems) { viewerItems.map { it.messageId } } val startMessageId = state.fullScreenImageMessageIds.getOrNull(state.fullScreenStartIndex) @@ -275,32 +280,12 @@ internal fun ImagesOverlay( ?.let { id -> state.messages.firstOrNull { it.id == id } } } - val imageDownloadingStates = remember(imageMessageIds, state.messages) { - imageMessageIds.map { id -> - val content = state.messages.firstOrNull { it.id == id }?.content - when (content) { - is MessageContent.Photo -> content.isDownloading - else -> false - } - } - } - - val imageDownloadProgressStates = remember(imageMessageIds, state.messages) { - imageMessageIds.map { id -> - val content = state.messages.firstOrNull { it.id == id }?.content - when (content) { - is MessageContent.Photo -> content.downloadProgress - else -> 0f - } - } - } - if (viewerImages.isNotEmpty()) { - ImageViewer( + ManagedImageViewer( images = viewerImages, + fileRepository = fileRepository, startIndex = startIndex.coerceIn(0, viewerImages.lastIndex.coerceAtLeast(0)), onDismiss = component::onDismissImages, - autoDownload = autoDownload, onPageChanged = { index -> currentImageIndex = index imageMessageIds.getOrNull(index)?.takeIf { it != 0L } @@ -308,25 +293,25 @@ internal fun ImagesOverlay( imageMessageIds.getOrNull(index + 1)?.takeIf { it != 0L } ?.let(component::onDownloadHighRes) }, - onForward = { path -> - val msg = currentViewerMessage ?: state.messages.find { - it.content.matchesDisplayPath(path) - } + onForward = { item -> + val msg = + item.messageIdOrNull()?.let { id -> state.messages.find { it.id == id } } + ?: currentViewerMessage msg?.let { component.onForwardMessage(it) } }, - onDelete = { path -> - val msg = currentViewerMessage ?: state.messages.find { - it.content.matchesDisplayPath(path) - } + onDelete = { item -> + val msg = + item.messageIdOrNull()?.let { id -> state.messages.find { it.id == id } } + ?: currentViewerMessage if (msg?.isOutgoing == true) { component.onDeleteMessage(msg, true) component.onDismissImages() } }, - onCopyLink = { path -> - val msg = currentViewerMessage ?: state.messages.find { - it.content.matchesDisplayPath(path) - } + onCopyLink = { item -> + val msg = + item.messageIdOrNull()?.let { id -> state.messages.find { it.id == id } } + ?: currentViewerMessage val link = if (msg != null) { if (!state.isGroup && !state.isChannel) { "tg://openmessage?user_id=${state.chatId}&message_id=${msg.id shr 20}" @@ -334,7 +319,7 @@ internal fun ImagesOverlay( null } } else { - path + item.actionSource } if (link != null) { localClipboard.nativeClipboard.setPrimaryClip( @@ -351,15 +336,9 @@ internal fun ImagesOverlay( } } }, - onCopyText = { path -> - val msg = state.messages.find { - when (val content = it.content) { - is MessageContent.Photo -> content.path == path - is MessageContent.Video -> content.path == path - is MessageContent.Gif -> content.path == path - else -> false - } - } + onCopyText = { item -> + val msg = + item.messageIdOrNull()?.let { id -> state.messages.find { it.id == id } } val textToCopy = when (val content = msg?.content) { is MessageContent.Photo -> content.caption is MessageContent.Video -> content.caption @@ -372,28 +351,6 @@ internal fun ImagesOverlay( ) } }, - onVideoClick = { path -> - val msg = currentViewerMessage ?: state.messages.find { - it.content.matchesDisplayPath(path) - } - if (msg != null) { - val mediaPath = msg.displayMediaPathForViewer() ?: path - component.onOpenVideo( - path = mediaPath, - messageId = msg.id, - caption = when (val content = msg.content) { - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> null - } - ) - } else { - component.onOpenVideo(path = path, messageId = null, caption = null) - } - }, - captions = state.fullScreenCaptions, - imageDownloadingStates = imageDownloadingStates, - imageDownloadProgressStates = imageDownloadProgressStates, downloadUtils = component.downloadUtils ) } @@ -577,6 +534,9 @@ private data class ViewerMediaItem( val path: String ) +private fun FullscreenImageItem.messageIdOrNull(): Long? = + id.removePrefix("message:").toLongOrNull()?.takeIf { it != 0L } + private fun MessageModel.displayMediaPathForViewer(): String? { return when (val content = content) { is MessageContent.Photo -> content.path ?: content.thumbnailPath diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt index c5552ab0a..8ee9aae8a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt @@ -78,29 +78,6 @@ fun AudioMessageBubble( val smallCorner = 4.dp val tailCorner = 2.dp - LaunchedEffect( - content.path, - content.isDownloading, - autoDownloadFiles, - autoDownloadMobile, - autoDownloadWifi, - autoDownloadRoaming - ) { - val shouldDownload = if (autoDownloadFiles) { - when { - downloadUtils.isRoaming() -> autoDownloadRoaming - downloadUtils.isWifiConnected() -> autoDownloadWifi - else -> autoDownloadMobile - } - } else { - false - } - - if (shouldDownload && content.path == null && !content.isDownloading) { - onAudioClick(msg) - } - } - val bubbleShape = RoundedCornerShape( topStart = if (!isOutgoing && isSameSenderAbove) smallCorner else cornerRadius, topEnd = if (isOutgoing && isSameSenderAbove) smallCorner else cornerRadius, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt index bbb26bacc..f71cb8a3c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt @@ -84,35 +84,11 @@ fun DocumentMessageBubble( var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } - LaunchedEffect( - content.path, - content.isDownloading, - autoDownloadFiles, - autoDownloadMobile, - autoDownloadWifi, - autoDownloadRoaming - ) { + LaunchedEffect(content.path) { if (!content.path.isNullOrBlank()) { isAutoDownloadSuppressed = false AutoDownloadSuppression.clear(content.fileId) } - - val shouldDownload = if (autoDownloadFiles) { - when { - downloadUtils.isRoaming() -> autoDownloadRoaming - downloadUtils.isWifiConnected() -> autoDownloadWifi - else -> autoDownloadMobile - } - } else { - false - } - - if (shouldDownload && content.path == null && !content.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - content.fileId - ) - ) { - onDocumentClick(msg) - } } val bubbleShape = RoundedCornerShape( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt index 80ff0182c..aece48391 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt @@ -109,22 +109,6 @@ fun GifMessageBubble( } } - LaunchedEffect(content.path, content.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (content.path.isNullOrBlank() && !content.isDownloading && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - content.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) { - onGifClick(msg) - } - } - } - val topStart = if (!isOutgoing && isSameSenderAbove) smallCorner else cornerRadius val topEnd = if (isOutgoing && isSameSenderAbove) smallCorner else cornerRadius val bottomStart = if (!isOutgoing) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt index 951333b1a..f7a5db11e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt @@ -2,6 +2,7 @@ package org.monogram.presentation.features.chats.conversation.ui.message import org.monogram.domain.models.WebPage import org.monogram.presentation.core.util.namespacedCacheKey +import org.monogram.presentation.features.viewers.FullscreenImageItem import org.monogram.presentation.features.viewers.extractYouTubeId import java.net.URI @@ -16,8 +17,7 @@ internal sealed interface LinkPreviewAction { } internal data class PreviewImageViewerRequest( - val images: List, - val captions: List, + val images: List, val startIndex: Int = 0, val sourceUrl: String ) @@ -84,11 +84,19 @@ internal fun WebPage.resolveLinkPreview(): ResolvedLinkPreview { val viewerCaption = buildViewerCaption(meta = meta) val mediaAction = when { - !previewPhoto?.path.isNullOrBlank() -> { + !previewPhoto?.path.isNullOrBlank() || !previewPhoto?.thumbnailPath.isNullOrBlank() -> { LinkPreviewAction.OpenImageViewer( PreviewImageViewerRequest( - images = listOfNotNull(previewPhoto.path), - captions = listOf(viewerCaption), + images = listOf( + FullscreenImageItem( + id = "web:${previewPhoto.originalFileId.takeIf { it != 0 } ?: previewPhoto.fileId}:${sourceUrl}", + previewSource = previewPhoto.path + ?: previewPhoto.thumbnailPath.orEmpty(), + originalFileId = previewPhoto.originalFileId.takeIf { it != 0 } + ?: previewPhoto.fileId, + caption = viewerCaption + ) + ), sourceUrl = sourceUrl ) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt index ac06d29c4..0c2af7505 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt @@ -95,19 +95,6 @@ fun PhotoMessageBubble( } } - LaunchedEffect(content.path, content.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (content.path.isNullOrBlank() && !content.isDownloading && !AutoDownloadSuppression.isSuppressed(content.fileId)) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) { - onDownloadPhoto(content.fileId) - } - } - } - val topStart = if (!isOutgoing && isSameSenderAbove) smallCorner else cornerRadius val topEnd = if (isOutgoing && isSameSenderAbove) smallCorner else cornerRadius val bottomStart = if (!isOutgoing) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt index d9e0e7f74..0a8d72422 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/RichMessageBubble.kt @@ -34,6 +34,7 @@ import org.monogram.presentation.features.instantview.components.LocalFileReposi import org.monogram.presentation.features.instantview.components.LocalOnUrlClick import org.monogram.presentation.features.instantview.components.renderedTextOrNull import org.monogram.presentation.features.instantview.components.resolvePathForViewer +import org.monogram.presentation.features.viewers.FullscreenImageItem import org.monogram.presentation.features.stickers.ui.view.shimmerEffect @Composable @@ -62,19 +63,23 @@ internal fun RichMessageBubble( fun openRichPhoto(photo: WebPage.Photo, caption: String?, sourceUrl: String) { if (onLinkPreviewAction == null) return - scope.launch { - val path = - fileRepository.resolvePathForViewer(photo.fileId, photo.path) ?: return@launch - onLinkPreviewAction( - LinkPreviewAction.OpenImageViewer( - PreviewImageViewerRequest( - images = listOf(path), - captions = listOf(caption), - sourceUrl = sourceUrl.ifBlank { path } - ) + val previewPath = photo.path ?: photo.thumbnailPath.orEmpty() + onLinkPreviewAction( + LinkPreviewAction.OpenImageViewer( + PreviewImageViewerRequest( + images = listOf( + FullscreenImageItem( + id = "rich:${photo.originalFileId.takeIf { it != 0 } ?: photo.fileId}:$sourceUrl", + previewSource = previewPath, + originalFileId = photo.originalFileId.takeIf { it != 0 } + ?: photo.fileId, + caption = caption + ) + ), + sourceUrl = sourceUrl.ifBlank { previewPath } ) ) - } + ) } fun openRichVideo( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt index 0670c064c..670c4eecb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt @@ -122,22 +122,6 @@ fun VideoMessageBubble( } } - LaunchedEffect(content.path, content.isDownloading, autoDownloadMobile, autoDownloadWifi, autoDownloadRoaming) { - if (content.path.isNullOrBlank() && !content.isDownloading && !content.supportsStreaming && !isAutoDownloadSuppressed && !AutoDownloadSuppression.isSuppressed( - content.fileId - ) - ) { - val shouldDownload = when { - downloadUtils.isWifiConnected() -> autoDownloadWifi - downloadUtils.isRoaming() -> autoDownloadRoaming - else -> autoDownloadMobile - } - if (shouldDownload) { - onDownloadVideoState(content.fileId) - } - } - } - val topStart = if (!isOutgoing && isSameSenderAbove) smallCorner else cornerRadius val topEnd = if (isOutgoing && isSameSenderAbove) smallCorner else cornerRadius val bottomStart = if (!isOutgoing) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VoiceMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VoiceMessageBubble.kt index 3fee48f89..ed17d63fe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VoiceMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VoiceMessageBubble.kt @@ -79,29 +79,6 @@ fun VoiceMessageBubble( val smallCorner = 4.dp val tailCorner = 2.dp - LaunchedEffect( - content.path, - content.isDownloading, - autoDownloadFiles, - autoDownloadMobile, - autoDownloadWifi, - autoDownloadRoaming - ) { - val shouldDownload = if (autoDownloadFiles) { - when { - downloadUtils.isRoaming() -> autoDownloadRoaming - downloadUtils.isWifiConnected() -> autoDownloadWifi - else -> autoDownloadMobile - } - } else { - false - } - - if (shouldDownload && content.path == null && !content.isDownloading) { - onVoiceClick(msg) - } - } - val bubbleShape = RoundedCornerShape( topStart = if (!isOutgoing && isSameSenderAbove) smallCorner else cornerRadius, topEnd = if (isOutgoing && isSameSenderAbove) smallCorner else cornerRadius, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt index 26ad1515e..d402e725e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/DefaultChatListComponent.kt @@ -36,10 +36,16 @@ import org.monogram.domain.repository.ChatFolderRepository import org.monogram.domain.repository.ChatListRepository import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSearchRepository +import org.monogram.domain.repository.ConversationKey +import org.monogram.domain.repository.ConversationScope import org.monogram.domain.repository.ForumTopicsRepository import org.monogram.domain.repository.ForwardOptions import org.monogram.domain.repository.ForwardRequest import org.monogram.domain.repository.ForwardTarget +import org.monogram.domain.repository.HistoryAnchor +import org.monogram.domain.repository.HistoryDirection +import org.monogram.domain.repository.HistoryRequest +import org.monogram.domain.repository.HistorySource import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.StoryRepository import org.monogram.domain.repository.UpdateRepository @@ -1259,16 +1265,24 @@ class DefaultChatListComponent( private suspend fun prefetchMessagesForChat(chat: ChatModel) { coRunCatching { - messageRepository.getMessagesOlder( - chatId = chat.id, - fromMessageId = 0L, - limit = PREFETCH_PAGE_SIZE + messageRepository.getHistoryPage( + HistoryRequest( + key = ConversationKey(chat.id, ConversationScope.Main), + anchor = HistoryAnchor.Latest, + direction = HistoryDirection.Initial, + limit = PREFETCH_PAGE_SIZE, + source = HistorySource.TdlibNetwork + ) ) if (chat.unreadCount > 0 && chat.lastReadInboxMessageId > 0L) { - messageRepository.getMessagesNewer( - chatId = chat.id, - fromMessageId = chat.lastReadInboxMessageId, - limit = PREFETCH_PAGE_SIZE + messageRepository.getHistoryPage( + HistoryRequest( + key = ConversationKey(chat.id, ConversationScope.Main), + anchor = HistoryAnchor.Message(chat.lastReadInboxMessageId), + direction = HistoryDirection.Newer, + limit = PREFETCH_PAGE_SIZE, + source = HistorySource.TdlibNetwork + ) ) } }.onFailure { error -> diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt index bfcdd5494..5d9fd3f68 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/InstantViewer.kt @@ -154,7 +154,8 @@ import org.monogram.presentation.features.instantview.components.renderedTextOrN import org.monogram.presentation.features.instantview.components.resolvePathForViewer import org.monogram.presentation.features.instantview.components.richTextPlainText import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow -import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.FullscreenImageItem +import org.monogram.presentation.features.viewers.ManagedImageViewer import org.monogram.presentation.features.viewers.VideoViewer import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown import java.text.SimpleDateFormat @@ -190,14 +191,14 @@ fun InstantViewer( val scope = rememberCoroutineScope() val openPhotoFullscreen: (WebPage.Photo, PageBlockCaption) -> Unit = { photo, caption -> - scope.launch { - fileRepository.resolvePathForViewer(photo.fileId, photo.path)?.let { path -> - fullscreenMedia = InstantViewFullscreenMedia.Image( - path = path, - caption = caption.renderedTextOrNull() - ) - } - } + fullscreenMedia = InstantViewFullscreenMedia.Image( + FullscreenImageItem( + id = "instant:${photo.originalFileId.takeIf { it != 0 } ?: photo.fileId}:${photo.path.orEmpty()}", + previewSource = photo.path ?: photo.thumbnailPath.orEmpty(), + originalFileId = photo.originalFileId.takeIf { it != 0 } ?: photo.fileId, + caption = caption.renderedTextOrNull() + ) + ) } val openVideoFullscreen: (String?, Int, Boolean, String?) -> Unit = { path, fileId, supportsStreaming, caption -> @@ -516,10 +517,10 @@ fun InstantViewer( when (val media = fullscreenMedia) { is InstantViewFullscreenMedia.Image -> { - ImageViewer( - images = listOf(media.path), + ManagedImageViewer( + images = listOf(media.item), + fileRepository = fileRepository, onDismiss = { fullscreenMedia = null }, - captions = listOf(media.caption), downloadUtils = downloadUtils, showImageNumber = false ) @@ -1566,7 +1567,7 @@ fun InstantViewBlock( } private sealed interface InstantViewFullscreenMedia { - data class Image(val path: String, val caption: String?) : InstantViewFullscreenMedia + data class Image(val item: FullscreenImageItem) : InstantViewFullscreenMedia data class Video( val path: String, val caption: String?, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 9912acc2b..af54637e9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -23,6 +23,7 @@ import org.monogram.domain.models.ChatStatisticsModel import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.ProfilePhotoMedia import org.monogram.domain.models.StatisticsGraphModel import org.monogram.domain.models.UserTypeEnum import org.monogram.domain.repository.BotPreferencesProvider @@ -34,7 +35,13 @@ import org.monogram.domain.repository.ChatMembersFilter import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.ConversationKey +import org.monogram.domain.repository.ConversationScope import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.HistoryAnchor +import org.monogram.domain.repository.HistoryDirection +import org.monogram.domain.repository.HistoryRequest +import org.monogram.domain.repository.HistorySource import org.monogram.domain.repository.LocationRepository import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.PrivacyRepository @@ -345,20 +352,6 @@ class DefaultProfileComponent( val downloadedPath = awaitDownloadedPath(fileId) ?: return@launch withContext(Dispatchers.Main) { onFileDownloaded(fileId, downloadedPath) - - if (message.content is MessageContent.Photo) { - val currentImages = _state.value.fullScreenImages - if (currentImages != null && !_state.value.isViewingProfilePhotos) { - val allPhotos = _state.value.mediaMessages.filter { it.content is MessageContent.Photo } - val index = allPhotos.indexOfFirst { it.id == message.id } - - if (index != -1 && index < currentImages.size) { - val newImages = currentImages.toMutableList() - newImages[index] = downloadedPath - _state.update { it.copy(fullScreenImages = newImages) } - } - } - } } } } @@ -391,7 +384,7 @@ class DefaultProfileComponent( private fun updateMessagePathIfNeeded(msg: MessageModel, targetFileId: Int, newPath: String): MessageModel { val content = msg.content val shouldUpdate = when (content) { - is MessageContent.Photo -> content.fileId == targetFileId || content.originalFileId == targetFileId + is MessageContent.Photo -> content.fileId == targetFileId is MessageContent.Video -> content.fileId == targetFileId is MessageContent.Document -> content.fileId == targetFileId else -> false @@ -897,8 +890,7 @@ class DefaultProfileComponent( profilePhotoRepository.getChatProfilePhotos( chatId = resolvedChatId, offset = 0, - limit = PROFILE_PHOTOS_LIMIT, - ensureFullRes = false + limit = PROFILE_PHOTOS_LIMIT ) }.getOrDefault(emptyList()) } else { @@ -907,8 +899,7 @@ class DefaultProfileComponent( profilePhotoRepository.getUserProfilePhotos( userId = userId, offset = 0, - limit = PROFILE_PHOTOS_LIMIT, - ensureFullRes = false + limit = PROFILE_PHOTOS_LIMIT ) }.getOrDefault(emptyList()) } @@ -940,30 +931,7 @@ class DefaultProfileComponent( scope.launch { val bigFileId = messageRepository.getHighResFileId(chatId, message.id) if (bigFileId != null && bigFileId != 0) { - messageRepository.downloadFile(bigFileId, priority = 32) - val downloadedPath = awaitDownloadedPath(bigFileId) ?: return@launch - withContext(Dispatchers.Main) { - onFileDownloaded(bigFileId, downloadedPath) - val currentImages = _state.value.fullScreenImages - if (currentImages != null) { - val viewerItems = _state.value.mediaMessages - .asSequence() - .filter { it.content is MessageContent.Photo } - .mapNotNull { - val photo = - it.content as? MessageContent.Photo ?: return@mapNotNull null - photo.displayPath()?.let { path -> it.id to path } - } - .toList() - val index = viewerItems.indexOfFirst { it.first == message.id } - - if (index != -1 && index < currentImages.size) { - val newImages = currentImages.toMutableList() - newImages[index] = downloadedPath - _state.update { it.copy(fullScreenImages = newImages) } - } - } - } + updatePhotoOriginalFileId(message.id, bigFileId) } } @@ -1056,14 +1024,25 @@ class DefaultProfileComponent( val snapshot = _state.value val initialPhotos = snapshot.profilePhotos - if (initialPhotos.isNotEmpty()) { - openProfilePhotos(initialPhotos) + val availablePhotos = initialPhotos.filter { it.displayPath != null } + if (availablePhotos.isNotEmpty()) { + openProfilePhotos(availablePhotos) } else { val avatarPath = snapshot.user?.avatarPath?.takeIf { it.isNotBlank() } ?: snapshot.chat?.avatarPath?.takeIf { it.isNotBlank() } ?: snapshot.personalAvatarPath?.takeIf { it.isNotBlank() } ?: snapshot.chat?.personalAvatarPath?.takeIf { it.isNotBlank() } - avatarPath?.let { openProfilePhotos(listOf(it)) } + avatarPath?.let { + openProfilePhotos( + listOf( + ProfilePhotoMedia( + id = it.hashCode().toLong(), + previewPath = it, + originalFileId = 0 + ) + ) + ) + } } val isGroupOrChannel = isGroupOrChannelProfile(snapshot) @@ -1076,8 +1055,7 @@ class DefaultProfileComponent( profilePhotoRepository.getChatProfilePhotos( chatId = snapshot.chatId, offset = 0, - limit = PROFILE_PHOTOS_LIMIT, - ensureFullRes = true + limit = PROFILE_PHOTOS_LIMIT ) }.getOrDefault(emptyList()) } else { @@ -1087,8 +1065,7 @@ class DefaultProfileComponent( profilePhotoRepository.getUserProfilePhotos( userId = userId, offset = 0, - limit = PROFILE_PHOTOS_LIMIT, - ensureFullRes = true + limit = PROFILE_PHOTOS_LIMIT ) }.getOrDefault(emptyList()) } @@ -1110,7 +1087,7 @@ class DefaultProfileComponent( } } - private fun openProfilePhotos(photos: List) { + private fun openProfilePhotos(photos: List) { if (photos.isEmpty()) return _state.update { current -> applyProfilePhotosToViewer(current, photos) @@ -1119,12 +1096,13 @@ class DefaultProfileComponent( private fun applyProfilePhotosToViewer( state: ProfileComponent.State, - photos: List + photos: List ): ProfileComponent.State { val firstPhoto = photos.firstOrNull() ?: return state - if (firstPhoto.endsWith(".mp4", ignoreCase = true)) { + val firstPath = firstPhoto.displayPath ?: return state + if (firstPhoto.animationPath != null || firstPath.endsWith(".mp4", ignoreCase = true)) { return state.copy( - fullScreenVideoPath = firstPhoto, + fullScreenVideoPath = firstPhoto.animationPath ?: firstPath, fullScreenVideoCaption = null, fullScreenImages = null, fullScreenCaptions = emptyList(), @@ -1133,7 +1111,9 @@ class DefaultProfileComponent( ) } - val images = photos.filter { !it.endsWith(".mp4", ignoreCase = true) } + val images = photos.mapIndexedNotNull { index, media -> + media.previewPath ?: media.originalPath ?: state.fullScreenImages?.getOrNull(index) + } if (images.isEmpty()) return state val safeIndex = state.fullScreenStartIndex.coerceIn(0, images.lastIndex) @@ -1211,11 +1191,28 @@ class DefaultProfileComponent( scope.launch { val fileId = messageRepository.getHighResFileId(chatId, messageId) if (fileId != null && fileId != 0) { - messageRepository.downloadFile(fileId, priority = 32) + updatePhotoOriginalFileId(messageId, fileId) } } } + private fun updatePhotoOriginalFileId(messageId: Long, fileId: Int) { + _state.update { state -> + state.copy( + messageTabs = state.messageTabs.mapValues { (_, tab) -> + tab.copy( + items = tab.items.map { message -> + if (message.id != messageId) return@map message + val photo = + message.content as? MessageContent.Photo ?: return@map message + message.copy(content = photo.copy(originalFileId = fileId)) + } + ) + } + ) + } + } + override fun onAddToGifs(path: String) { scope.launch { gifRepository.addSavedGif(path) @@ -1709,8 +1706,16 @@ class DefaultProfileComponent( interaction } else { val preview = coRunCatching { - messageRepository - .getMessagesAround(chatId = chatId, messageId = interaction.objectId, limit = 1) + messageRepository.getHistoryPage( + HistoryRequest( + key = ConversationKey(chatId, ConversationScope.Main), + anchor = HistoryAnchor.Message(interaction.objectId), + direction = HistoryDirection.Around, + limit = 1, + source = HistorySource.TdlibNetwork + ) + ) + .messages .firstOrNull { it.id == interaction.objectId } ?.content ?.toStatisticsPreview() diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt index af2f415bd..6f892cbad 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt @@ -8,6 +8,7 @@ import org.monogram.domain.models.ChatRevenueStatisticsModel import org.monogram.domain.models.ChatStatisticsModel import org.monogram.domain.models.GroupMemberModel import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.ProfilePhotoMedia import org.monogram.domain.models.UserModel import org.monogram.domain.models.stories.ActiveStoryListModel import org.monogram.domain.models.stories.StoryModel @@ -120,7 +121,7 @@ interface ProfileComponent { val isSimilarChatsLoading: Boolean = false, val hasLoadedSimilarChats: Boolean = false, - val profilePhotos: List = emptyList(), + val profilePhotos: List = emptyList(), val personalAvatarPath: String? = null, val fullScreenImages: List? = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt index 92df1b016..365eb4489 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt @@ -43,7 +43,8 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.repository.TelegramLinkRepository import org.monogram.presentation.R import org.monogram.presentation.features.instantview.InstantViewer -import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.FullscreenImageItem +import org.monogram.presentation.features.viewers.ManagedImageViewer import org.monogram.presentation.features.viewers.VideoViewer import org.monogram.presentation.features.viewers.YouTubeViewer import org.monogram.presentation.features.webapp.MiniAppViewer @@ -189,14 +190,6 @@ private fun ImagesOverlay( exit = fadeOut() + scaleOut(targetScale = 0.9f) ) { state.fullScreenImages?.let { images -> - val autoDownload = remember(state.autoDownloadWifi, state.autoDownloadRoaming, state.autoDownloadMobile) { - when { - component.downloadUtils.isWifiConnected() -> state.autoDownloadWifi - component.downloadUtils.isRoaming() -> state.autoDownloadRoaming - else -> state.autoDownloadMobile - } - } - val viewerItems = remember(images, state.fullScreenImageMessageIds, state.mediaMessages) { if (state.fullScreenImageMessageIds.size == images.size) { state.fullScreenImageMessageIds.mapIndexed { index, messageId -> @@ -215,7 +208,25 @@ private fun ImagesOverlay( } } - val viewerImages = remember(viewerItems) { viewerItems.map { it.path } } + val viewerImages = + remember(viewerItems, state.profilePhotos, state.fullScreenCaptions) { + viewerItems.mapIndexed { index, viewerItem -> + val message = + state.mediaMessages.firstOrNull { it.id == viewerItem.messageId } + val photo = message?.content as? MessageContent.Photo + val profilePhoto = if (state.isViewingProfilePhotos) { + state.profilePhotos.getOrNull(index) + } else null + FullscreenImageItem( + id = if (profilePhoto != null) "profile:${profilePhoto.id}" else "message:${viewerItem.messageId}", + previewSource = viewerItem.path, + originalFileId = profilePhoto?.originalFileId ?: photo?.originalFileId + ?: 0, + originalPath = profilePhoto?.originalPath, + caption = state.fullScreenCaptions.getOrNull(index) + ) + } + } val imageMessageIds = remember(viewerItems) { viewerItems.map { it.messageId } } var currentImageIndex by remember(viewerImages, state.fullScreenStartIndex) { mutableIntStateOf( @@ -232,33 +243,13 @@ private fun ImagesOverlay( ?.let { id -> state.mediaMessages.firstOrNull { it.id == id } } } - val imageDownloadingStates = remember(imageMessageIds, state.mediaMessages) { - imageMessageIds.map { id -> - val content = state.mediaMessages.firstOrNull { it.id == id }?.content - when (content) { - is MessageContent.Photo -> content.isDownloading - else -> false - } - } - } - - val imageDownloadProgressStates = remember(imageMessageIds, state.mediaMessages) { - imageMessageIds.map { id -> - val content = state.mediaMessages.firstOrNull { it.id == id }?.content - when (content) { - is MessageContent.Photo -> content.downloadProgress - else -> 0f - } - } - } - if (viewerImages.isNotEmpty()) { Box(modifier = Modifier.fillMaxSize()) { - ImageViewer( + ManagedImageViewer( images = viewerImages, + fileRepository = component.messageRepository, startIndex = state.fullScreenStartIndex.coerceIn(0, viewerImages.lastIndex), onDismiss = component::onDismissImages, - autoDownload = autoDownload, onPageChanged = { index -> currentImageIndex = index val mediaTabState = state.messageTabState(ProfileTabKey.MEDIA) @@ -273,22 +264,28 @@ private fun ImagesOverlay( imageMessageIds.getOrNull(index + 1)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) } }, - onForward = { path -> - val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + onForward = { item -> + val msg = item.messageIdOrNull() + ?.let { id -> state.mediaMessages.find { it.id == id } } + ?: currentViewerMessage msg?.let { component.onForwardMessage(it) } }, - onDelete = { path -> - val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + onDelete = { item -> + val msg = item.messageIdOrNull() + ?.let { id -> state.mediaMessages.find { it.id == id } } + ?: currentViewerMessage if (msg?.isOutgoing == true) { component.onDeleteMessage(msg, true) component.onDismissImages() } }, - onCopyLink = { path -> - val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + onCopyLink = { item -> + val msg = item.messageIdOrNull() + ?.let { id -> state.mediaMessages.find { it.id == id } } + ?: currentViewerMessage if (msg == null) { localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(path)) + ClipData.newPlainText("", AnnotatedString(item.actionSource)) ) } else { scope.launch { @@ -303,8 +300,10 @@ private fun ImagesOverlay( } } }, - onCopyText = { path -> - val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + onCopyText = { item -> + val msg = item.messageIdOrNull() + ?.let { id -> state.mediaMessages.find { it.id == id } } + ?: currentViewerMessage val textToCopy = when (val content = msg?.content) { is MessageContent.Photo -> content.caption is MessageContent.Video -> content.caption @@ -317,26 +316,6 @@ private fun ImagesOverlay( ) } }, - onVideoClick = { path -> - val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } - if (msg != null) { - val mediaPath = msg.displayMediaPathForViewer() ?: path - component.onOpenVideo( - path = mediaPath, - messageId = msg.id, - caption = when (val content = msg.content) { - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> null - } - ) - } else { - component.onOpenVideo(path = path, messageId = null, caption = null) - } - }, - captions = state.fullScreenCaptions.filterNotNull(), - imageDownloadingStates = imageDownloadingStates, - imageDownloadProgressStates = imageDownloadProgressStates, downloadUtils = component.downloadUtils, showImageNumber = false ) @@ -521,3 +500,6 @@ private fun MessageContent.matchesDisplayPath(path: String): Boolean { else -> false } } + +private fun FullscreenImageItem.messageIdOrNull(): Long? = + id.removePrefix("message:").toLongOrNull()?.takeIf { it != 0L } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt index fbbaa7dca..133cd5c9f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/ProfileLogsContent.kt @@ -34,6 +34,7 @@ import org.monogram.presentation.features.profile.logs.components.DateHeader import org.monogram.presentation.features.profile.logs.components.FilterChipCompact import org.monogram.presentation.features.profile.logs.components.LogBubble import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.FullscreenImageItem import org.monogram.presentation.features.viewers.VideoViewer import java.text.SimpleDateFormat import java.util.* @@ -177,16 +178,14 @@ fun ProfileLogsContent(component: ProfileLogsComponent) { ) { state.fullScreenPhotoPath?.let { path -> ImageViewer( - images = listOf(path), + images = listOf(FullscreenImageItem.source(path, state.fullScreenPhotoCaption)), startIndex = 0, onDismiss = component::onDismissViewer, - autoDownload = true, onPageChanged = {}, onForward = { Toast.makeText(context, context.getString(R.string.logs_not_implemented), Toast.LENGTH_SHORT).show() }, onDelete = { Toast.makeText(context, context.getString(R.string.logs_not_implemented), Toast.LENGTH_SHORT).show() }, onCopyLink = { Toast.makeText(context, context.getString(R.string.logs_not_implemented), Toast.LENGTH_SHORT).show() }, onCopyText = { Toast.makeText(context, context.getString(R.string.logs_not_implemented), Toast.LENGTH_SHORT).show() }, - captions = listOfNotNull(state.fullScreenPhotoCaption), downloadUtils = component.downloadUtils ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageItem.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageItem.kt new file mode 100644 index 000000000..b637140b8 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageItem.kt @@ -0,0 +1,40 @@ +package org.monogram.presentation.features.viewers + +sealed interface FullscreenImageLoadState { + data object Preview : FullscreenImageLoadState + data class Loading(val progress: Float) : FullscreenImageLoadState + data object Ready : FullscreenImageLoadState + data object Error : FullscreenImageLoadState +} + +data class FullscreenImageItem( + val id: String, + val previewSource: String, + val originalFileId: Int = 0, + val originalPath: String? = null, + val caption: String? = null, + val loadState: FullscreenImageLoadState = if (originalPath != null) { + FullscreenImageLoadState.Ready + } else { + FullscreenImageLoadState.Preview + } +) { + val displaySource: String + get() = originalPath ?: previewSource + + val actionSource: String + get() = originalPath ?: previewSource + + val hasOriginalTarget: Boolean + get() = originalFileId != 0 && originalPath == null + + companion object { + fun source(path: String, caption: String? = null, id: String = path): FullscreenImageItem = + FullscreenImageItem( + id = id, + previewSource = path, + originalPath = path, + caption = caption + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageStateHolder.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageStateHolder.kt new file mode 100644 index 000000000..7bc000e1c --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/FullscreenImageStateHolder.kt @@ -0,0 +1,138 @@ +package org.monogram.presentation.features.viewers + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.monogram.domain.models.FileDownloadEvent +import org.monogram.domain.repository.FileRepository + +class FullscreenImageStateHolder( + initialItems: List, + private val fileRepository: FileRepository, + private val scope: CoroutineScope +) { + private val _items = MutableStateFlow(initialItems) + val items: StateFlow> = _items.asStateFlow() + + init { + scope.launch { + fileRepository.fileDownloadFlow.collectLatest(::handleFileEvent) + } + } + + fun updateItems(items: List) { + _items.value = items.map { incoming -> + val current = _items.value.firstOrNull { it.id == incoming.id } + if (current?.originalFileId == incoming.originalFileId && current.originalPath != null) { + incoming.copy( + originalPath = current.originalPath, + loadState = FullscreenImageLoadState.Ready + ) + } else if (current?.originalFileId == incoming.originalFileId && + current.loadState is FullscreenImageLoadState.Loading + ) { + incoming.copy(loadState = current.loadState) + } else { + incoming + } + } + } + + fun requestPage(index: Int) { + requestOriginal(index) + requestOriginal(index + 1) + } + + fun retry(id: String) { + val index = _items.value.indexOfFirst { it.id == id } + if (index >= 0) requestOriginal(index, force = true) + } + + fun markDecodeError(id: String) { + update(id) { item -> + if (item.originalPath != null) { + item.copy(originalPath = null, loadState = FullscreenImageLoadState.Error) + } else { + item + } + } + } + + private fun requestOriginal(index: Int, force: Boolean = false) { + val item = _items.value.getOrNull(index) ?: return + if (!item.hasOriginalTarget) return + if (!force && item.loadState is FullscreenImageLoadState.Loading) return + + update(item.id) { it.copy(loadState = FullscreenImageLoadState.Loading(0f)) } + scope.launch { + val cached = fileRepository.getFileInfo(item.originalFileId) + val cachedPath = cached?.local?.path?.takeIf { + cached.local.isDownloadingCompleted && it.isNotBlank() + } + if (cachedPath != null) { + markReady(item.originalFileId, cachedPath) + return@launch + } + + fileRepository.downloadFile( + fileId = item.originalFileId, + priority = 32, + userInitiated = true + ) + } + } + + private fun handleFileEvent(event: FileDownloadEvent) { + when (event) { + is FileDownloadEvent.Progress -> updateByFileId(event.fileId) { + if (it.originalPath != null || it.loadState is FullscreenImageLoadState.Ready) { + it + } else { + it.copy( + loadState = FullscreenImageLoadState.Loading( + event.progress.coerceIn( + 0f, + 1f + ) + ) + ) + } + } + + is FileDownloadEvent.Completed -> { + if (event.path.isBlank()) markError(event.fileId) else markReady( + event.fileId, + event.path + ) + } + + is FileDownloadEvent.Cancelled -> markError(event.fileId) + } + } + + private fun markReady(fileId: Int, path: String) { + updateByFileId(fileId) { + it.copy(originalPath = path, loadState = FullscreenImageLoadState.Ready) + } + } + + private fun markError(fileId: Int) { + updateByFileId(fileId) { it.copy(loadState = FullscreenImageLoadState.Error) } + } + + private fun updateByFileId( + fileId: Int, + transform: (FullscreenImageItem) -> FullscreenImageItem + ) { + _items.value = _items.value.map { item -> + if (item.originalFileId == fileId) transform(item) else item + } + } + + private fun update(id: String, transform: (FullscreenImageItem) -> FullscreenImageItem) { + _items.value = _items.value.map { item -> if (item.id == id) transform(item) else item } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt index 84167d4be..4e1f8dad2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/ImageViewer.kt @@ -8,33 +8,33 @@ import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.domain.repository.FileRepository import org.monogram.presentation.features.viewers.components.ImageOverlay import org.monogram.presentation.features.viewers.components.ImagePage @OptIn(ExperimentalFoundationApi::class) @Composable fun ImageViewer( - images: List, + images: List, startIndex: Int = 0, onDismiss: () -> Unit, - autoDownload: Boolean = true, onPageChanged: ((Int) -> Unit)? = null, - onForward: ((String) -> Unit)? = null, - onDelete: ((String) -> Unit)? = null, - onCopyLink: ((String) -> Unit)? = null, - onCopyText: ((String) -> Unit)? = null, - onVideoClick: ((String) -> Unit)? = null, - captions: List = emptyList(), - imageDownloadingStates: List = emptyList(), - imageDownloadProgressStates: List = emptyList(), + onForward: ((FullscreenImageItem) -> Unit)? = null, + onDelete: ((FullscreenImageItem) -> Unit)? = null, + onCopyLink: ((FullscreenImageItem) -> Unit)? = null, + onCopyText: ((FullscreenImageItem) -> Unit)? = null, + onRetry: ((String) -> Unit)? = null, + onOriginalDecodeError: ((String) -> Unit)? = null, downloadUtils: IDownloadUtils, showImageNumber: Boolean = true ) { @@ -69,14 +69,13 @@ fun ImageViewer( HorizontalPager( state = pagerState, key = { page -> - val path = images.getOrNull(page).orEmpty() - "image_page_${path}_$page" + images.getOrNull(page)?.id ?: "image_page_$page" }, pageSize = PageSize.Fill, beyondViewportPageCount = 0, userScrollEnabled = zoomState.scale.value == 1f && rootState.offsetY.value == 0f ) { page -> - val path = images.getOrNull(page) ?: return@HorizontalPager + val item = images.getOrNull(page) ?: return@HorizontalPager Box( modifier = Modifier @@ -84,9 +83,7 @@ fun ImageViewer( .clipToBounds() ) { ImagePage( - path = path, - isDownloading = imageDownloadingStates.getOrNull(page) == true, - downloadProgress = imageDownloadProgressStates.getOrNull(page) ?: 0f, + item = item, zoomState = zoomState, rootState = rootState, screenHeightPx = screenHeightPx, @@ -95,6 +92,8 @@ fun ImageViewer( onDismiss = onDismiss, showControls = showControls, onToggleControls = { showControls = !showControls }, + onRetry = { onRetry?.invoke(item.id) }, + onOriginalDecodeError = { onOriginalDecodeError?.invoke(item.id) }, pageIndex = page, pagerIndex = pagerState.currentPage ) @@ -106,7 +105,6 @@ fun ImageViewer( rootState = rootState, pagerState = pagerState, mediaItems = images, - captions = captions, showImageNumber = showImageNumber, onDismiss = onDismiss, showSettingsMenu = showSettingsMenu, @@ -120,3 +118,49 @@ fun ImageViewer( } } } + +@Composable +fun ManagedImageViewer( + images: List, + fileRepository: FileRepository, + startIndex: Int = 0, + onDismiss: () -> Unit, + onForward: ((FullscreenImageItem) -> Unit)? = null, + onDelete: ((FullscreenImageItem) -> Unit)? = null, + onCopyLink: ((FullscreenImageItem) -> Unit)? = null, + onCopyText: ((FullscreenImageItem) -> Unit)? = null, + downloadUtils: IDownloadUtils, + showImageNumber: Boolean = true, + onPageChanged: ((Int) -> Unit)? = null +) { + val scope = rememberCoroutineScope() + val holder = remember(fileRepository, scope) { + FullscreenImageStateHolder(images, fileRepository, scope) + } + val resolvedImages by holder.items.collectAsState() + var currentPage by remember { mutableIntStateOf(startIndex) } + + LaunchedEffect(images) { + holder.updateItems(images) + holder.requestPage(currentPage) + } + + ImageViewer( + images = resolvedImages, + startIndex = startIndex, + onDismiss = onDismiss, + onPageChanged = { index -> + currentPage = index + holder.requestPage(index) + onPageChanged?.invoke(index) + }, + onForward = onForward, + onDelete = onDelete, + onCopyLink = onCopyLink, + onCopyText = onCopyText, + onRetry = holder::retry, + onOriginalDecodeError = holder::markDecodeError, + downloadUtils = downloadUtils, + showImageNumber = showImageNumber + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt index 715e9245d..c57b4bd8d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt @@ -192,18 +192,20 @@ fun MediaViewer( } ImageViewer( - images = mediaItems, + images = mediaItems.mapIndexed { index, path -> + FullscreenImageItem.source( + path = path, + caption = captions.getOrNull(index), + id = "media:$index:$path" + ) + }, startIndex = resolvedIndex, onDismiss = onDismiss, - autoDownload = autoDownload, onPageChanged = onPageChanged, - onForward = onForward, - onDelete = onDelete, - onCopyLink = onCopyLink, - onCopyText = onCopyText, - captions = captions, - imageDownloadingStates = imageDownloadingStates, - imageDownloadProgressStates = imageDownloadProgressStates, + onForward = { onForward(it.actionSource) }, + onDelete = onDelete?.let { callback -> { item -> callback(item.actionSource) } }, + onCopyLink = onCopyLink?.let { callback -> { item -> callback(item.actionSource) } }, + onCopyText = onCopyText?.let { callback -> { item -> callback(item.actionSource) } }, downloadUtils = downloadUtils, showImageNumber = showImageNumber ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt index ebc2b0f17..d6d979ec9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageViewerComponents.kt @@ -85,12 +85,12 @@ import kotlinx.coroutines.launch import org.monogram.presentation.R import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow +import org.monogram.presentation.features.viewers.FullscreenImageItem +import org.monogram.presentation.features.viewers.FullscreenImageLoadState @Composable fun ImagePage( - path: String, - isDownloading: Boolean, - downloadProgress: Float, + item: FullscreenImageItem, zoomState: ZoomState, rootState: DismissRootState, screenHeightPx: Float, @@ -99,6 +99,8 @@ fun ImagePage( onDismiss: () -> Unit, showControls: Boolean, onToggleControls: () -> Unit, + onRetry: () -> Unit, + onOriginalDecodeError: () -> Unit, pageIndex: Int, pagerIndex: Int ) { @@ -130,9 +132,15 @@ fun ImagePage( } ) { ZoomableImage( - data = path, - isDownloading = isDownloading, - downloadProgress = downloadProgress, + previewData = item.previewSource, + originalData = item.originalPath, + isDownloading = item.loadState is FullscreenImageLoadState.Loading, + downloadProgress = (item.loadState as? FullscreenImageLoadState.Loading)?.progress + ?: 0f, + isError = item.loadState is FullscreenImageLoadState.Error, + isManagedOriginal = item.originalFileId != 0, + onRetry = onRetry, + onOriginalDecodeError = onOriginalDecodeError, zoomState = zoomState, pageIndex = pageIndex, pagerIndex = pagerIndex @@ -145,17 +153,16 @@ fun ImageOverlay( showControls: Boolean, rootState: DismissRootState, pagerState: PagerState, - mediaItems: List, - captions: List, + mediaItems: List, showImageNumber: Boolean, onDismiss: () -> Unit, showSettingsMenu: Boolean, onToggleSettings: () -> Unit, downloadUtils: IDownloadUtils, - onForward: ((String) -> Unit)?, - onDelete: ((String) -> Unit)?, - onCopyLink: ((String) -> Unit)?, - onCopyText: ((String) -> Unit)? + onForward: ((FullscreenImageItem) -> Unit)?, + onDelete: ((FullscreenImageItem) -> Unit)?, + onCopyLink: ((FullscreenImageItem) -> Unit)?, + onCopyText: ((FullscreenImageItem) -> Unit)? ) { val scope = rememberCoroutineScope() @@ -190,14 +197,14 @@ fun ImageOverlay( .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - val currentCaption = captions.getOrNull(pagerState.currentPage) + val currentCaption = mediaItems.getOrNull(pagerState.currentPage)?.caption if (!currentCaption.isNullOrBlank()) { ViewerCaption(caption = currentCaption, showGradient = false) } if (mediaItems.size > 1) { ThumbnailStrip( - images = mediaItems, + images = mediaItems.map { it.displaySource }, pagerState = pagerState, scope = scope ) @@ -246,14 +253,14 @@ fun ImageOverlay( val currentItem = mediaItems.getOrNull(currentIndex) if (currentItem != null) { - val currentCaption = captions.getOrNull(currentIndex) + val currentCaption = currentItem.caption ImageSettingsMenu( onDownload = { - downloadUtils.saveFileToDownloads(currentItem) + downloadUtils.saveFileToDownloads(currentItem.actionSource) onToggleSettings() }, onCopyImage = { - downloadUtils.copyImageToClipboard(currentItem) + downloadUtils.copyImageToClipboard(currentItem.actionSource) onToggleSettings() }, onCopyLink = { @@ -455,9 +462,14 @@ fun PageIndicator(modifier: Modifier = Modifier, current: Int, total: Int) { @Composable fun ZoomableImage( - data: Any, + previewData: Any, + originalData: Any?, isDownloading: Boolean, downloadProgress: Float, + isError: Boolean, + isManagedOriginal: Boolean, + onRetry: () -> Unit, + onOriginalDecodeError: () -> Unit, zoomState: ZoomState, pageIndex: Int, pagerIndex: Int @@ -465,13 +477,16 @@ fun ZoomableImage( val applyTransforms = pageIndex == pagerIndex val context = LocalContext.current - var displayedData by remember { mutableStateOf(data) } + var displayedData by remember(previewData) { mutableStateOf(previewData) } var pendingData by remember { mutableStateOf(null) } var isPendingReady by remember { mutableStateOf(false) } - LaunchedEffect(data) { - if (data != displayedData) { - pendingData = data + LaunchedEffect(originalData) { + if (originalData != null && originalData != displayedData) { + pendingData = originalData + isPendingReady = false + } else if (originalData == null) { + pendingData = null isPendingReady = false } } @@ -546,7 +561,14 @@ fun ZoomableImage( scaleX = zoomState.scale.value scaleY = zoomState.scale.value } + }, + onState = { state -> + if (isManagedOriginal && displayedData == originalData && + state is AsyncImagePainter.State.Error + ) { + onOriginalDecodeError() } + } ) pendingRequest?.let { request -> @@ -567,32 +589,37 @@ fun ZoomableImage( }, onState = { state -> isPendingReady = state is AsyncImagePainter.State.Success + if (isManagedOriginal && state is AsyncImagePainter.State.Error) { + onOriginalDecodeError() + } } ) } if (isDownloading) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - CircularWavyProgressIndicator( - modifier = Modifier.size(48.dp), - color = Color.White, - progress = { - if (isDownloading && downloadProgress in 0f..1f) { - downloadProgress - } else { - 0f - } + CircularWavyProgressIndicator( + modifier = Modifier.size(48.dp), + color = Color.White, + progress = { + if (downloadProgress in 0f..1f) { + downloadProgress + } else { + 0f } - ) - Text( - text = stringResource(R.string.viewer_loading_original), - color = Color.White, - style = MaterialTheme.typography.bodyMedium - ) - } + } + ) + } + + if (isError) { + Text( + text = stringResource(R.string.retry_action), + color = Color.White, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .background(Color.Black.copy(alpha = 0.6f), CircleShape) + .clickable(onClick = onRetry) + .padding(horizontal = 20.dp, vertical = 12.dp) + ) } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt index f5f032c79..1f831c54b 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt @@ -10,6 +10,7 @@ interface DebugComponent { fun onBackClicked() fun onCrashClicked() fun onForceSponsorSyncClicked() + fun onConversationPipelineKillSwitchChanged(forceLegacy: Boolean) fun onTestPushClicked() fun onAdBlockClicked() fun onDropDatabasesClicked() @@ -18,6 +19,8 @@ interface DebugComponent { fun onDropDatabaseCacheClicked() data class State( + val isConversationPipelineKillSwitchAvailable: Boolean = false, + val isLegacyConversationPipelineForced: Boolean = false, val pushProvider: PushProvider = PushProvider.FCM, val backgroundServiceEnabled: Boolean = false, val hideForegroundNotification: Boolean = false, diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt index 785e50fab..21eb3fc94 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.rounded.NotificationAdd import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Power import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.SwapVert import androidx.compose.material.icons.rounded.Storage import androidx.compose.material.icons.rounded.Sync import androidx.compose.material.icons.rounded.VisibilityOff @@ -53,6 +54,7 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem +import org.monogram.presentation.core.ui.SettingsSwitchTile import java.text.DateFormat import java.util.Date @@ -175,9 +177,24 @@ fun DebugContent(component: DebugComponent) { title = "Hide foreground notification", subtitle = state.hideForegroundNotification.toUiToggle(), iconBackgroundColor = Color(0xFF9E9E9E), - position = ItemPosition.BOTTOM, + position = if (state.isConversationPipelineKillSwitchAvailable) { + ItemPosition.MIDDLE + } else { + ItemPosition.BOTTOM + }, onClick = { } ) + if (state.isConversationPipelineKillSwitchAvailable) { + SettingsSwitchTile( + icon = Icons.Rounded.SwapVert, + title = stringResource(R.string.debug_legacy_chat_pipeline_title), + subtitle = stringResource(R.string.debug_legacy_chat_pipeline_subtitle), + checked = state.isLegacyConversationPipelineForced, + iconColor = Color(0xFFD32F2F), + position = ItemPosition.BOTTOM, + onCheckedChange = component::onConversationPipelineKillSwitchChanged + ) + } } item { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt index 978e7ba44..dca79a44e 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt @@ -7,7 +7,12 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import org.monogram.domain.repository.ConversationPipelineMode +import org.monogram.presentation.BuildConfig import org.monogram.presentation.core.util.componentScope +import org.monogram.presentation.core.util.conversationPipelineModeWithLegacyKillSwitch +import org.monogram.presentation.core.util.defaultConversationPipelineMode +import org.monogram.presentation.core.util.isConversationPipelineKillSwitchAvailable import org.monogram.presentation.root.AppComponentContext import java.io.File @@ -22,10 +27,25 @@ class DefaultDebugComponent( private val distrManager = container.utils.distrManager() private val pushDebugRepository = container.repositories.pushDebugRepository private val sponsorRepository = container.repositories.sponsorRepository + private val appPreferences = container.preferences.appPreferencesProvider private val scope = componentScope + private val conversationPipelineDefault = defaultConversationPipelineMode( + isOfficialTdlib = BuildConfig.IS_OFFICIAL_TDLIB, + isLibreRuntime = BuildConfig.IS_LIBRE_RUNTIME, + isDebug = BuildConfig.DEBUG + ) + private val isConversationPipelineKillSwitchAvailable = + isConversationPipelineKillSwitchAvailable( + isOfficialTdlib = BuildConfig.IS_OFFICIAL_TDLIB, + isLibreRuntime = BuildConfig.IS_LIBRE_RUNTIME + ) + private val _state = MutableValue( DebugComponent.State( + isConversationPipelineKillSwitchAvailable = isConversationPipelineKillSwitchAvailable, + isLegacyConversationPipelineForced = + appPreferences.conversationPipelineMode.value == ConversationPipelineMode.Legacy, isGmsAvailable = distrManager.isGmsAvailable(), isFcmAvailable = distrManager.isFcmAvailable(), isUnifiedPushDistributorAvailable = distrManager.isUnifiedPushDistributorAvailable(), @@ -35,6 +55,12 @@ class DefaultDebugComponent( override val state: Value = _state init { + appPreferences.conversationPipelineMode.onEach { mode -> + _state.update { + it.copy(isLegacyConversationPipelineForced = mode == ConversationPipelineMode.Legacy) + } + }.launchIn(scope) + pushDebugRepository.diagnostics.onEach { diagnostics -> _state.update { it.copy( @@ -83,6 +109,17 @@ class DefaultDebugComponent( messageDisplayer.show("Sponsor sync started") } + override fun onConversationPipelineKillSwitchChanged(forceLegacy: Boolean) { + if (!isConversationPipelineKillSwitchAvailable) return + appPreferences.setConversationPipelineMode( + conversationPipelineModeWithLegacyKillSwitch( + forceLegacy = forceLegacy, + defaultMode = conversationPipelineDefault + ) + ) + messageDisplayer.show("Chat pipeline mode will apply to newly opened chats") + } + override fun onTestPushClicked() { pushDebugRepository.triggerTestPush() messageDisplayer.show("Debug push dispatched") diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt index 811e44fb8..3c4d7f291 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/DefaultSettingsComponent.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.monogram.domain.managers.DomainManager import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.SponsorRepository import org.monogram.domain.repository.TelegramLinkRepository import org.monogram.domain.repository.UserProfileEditRepository @@ -18,6 +19,7 @@ import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext +import org.monogram.presentation.features.viewers.FullscreenImageItem class DefaultSettingsComponent( context: AppComponentContext, @@ -46,6 +48,8 @@ class DefaultSettingsComponent( private val domainManager: DomainManager = container.utils.domainManager() private val appPreferences: AppPreferences = container.preferences.appPreferences override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() + override val fileRepository: FileRepository = container.repositories.messageRepository + private val profilePhotoRepository = container.repositories.profilePhotoRepository private val _state = MutableValue(SettingsComponent.State()) override val state: Value = _state @@ -257,11 +261,36 @@ class DefaultSettingsComponent( ) } else { state.copy( - fullScreenImages = listOf(avatarPath), + fullScreenImages = listOf( + FullscreenImageItem.source( + path = avatarPath, + id = "settings-avatar:${user.id}" + ) + ), fullScreenVideoPath = null ) } } + + scope.launch { + val media = coRunCatching { + profilePhotoRepository.getUserProfilePhotos(user.id, limit = 1).firstOrNull() + }.getOrNull() ?: return@launch + val preview = media.previewPath ?: media.originalPath ?: avatarPath + _state.update { state -> + if (state.fullScreenImages == null) return@update state + state.copy( + fullScreenImages = listOf( + FullscreenImageItem( + id = "settings-avatar:${media.id}", + previewSource = preview, + originalFileId = media.originalFileId, + originalPath = media.originalPath + ) + ) + ) + } + } } override fun onDismissAvatarViewer() { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt index 51fcf6265..bf047e320 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsComponent.kt @@ -3,10 +3,13 @@ package org.monogram.presentation.settings.settings import com.arkivanov.decompose.value.Value import org.monogram.domain.models.UserModel import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.domain.repository.FileRepository +import org.monogram.presentation.features.viewers.FullscreenImageItem interface SettingsComponent { val state: Value val downloadUtils: IDownloadUtils + val fileRepository: FileRepository fun onBackClicked() fun onEditProfileClicked() @@ -48,7 +51,7 @@ interface SettingsComponent { val isSupportersLoading: Boolean = true, val isProjectChannelSubscribed: Boolean = false, val isMoreOptionsVisible: Boolean = false, - val fullScreenImages: List? = null, + val fullScreenImages: List? = null, val fullScreenVideoPath: String? = null ) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt index 9c584de9c..3c858804f 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt @@ -140,7 +140,7 @@ import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.features.stickers.ui.menu.EmojisGrid import org.monogram.presentation.features.stickers.ui.view.StickerImage -import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.ManagedImageViewer import org.monogram.presentation.features.viewers.VideoViewer import java.util.Locale import kotlin.math.roundToInt @@ -1050,8 +1050,9 @@ fun SettingsContent(component: SettingsComponent) { } state.fullScreenImages?.let { images -> - ImageViewer( + ManagedImageViewer( images = images, + fileRepository = component.fileRepository, onDismiss = component::onDismissAvatarViewer, downloadUtils = component.downloadUtils ) diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 2ae7938f0..71e2d9a0f 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -2189,6 +2189,8 @@ Sponsor Push Diagnostics Runtime Flags + Использовать прежний пайплайн чата + Аварийное переключение для новых открытий чата Push Environment UnifiedPush Details Sponsor diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 53f706531..b274613d6 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -816,6 +816,8 @@ Never synced Push Diagnostics Runtime Flags + Use legacy chat pipeline + Kill switch for newly opened chats Push Environment UnifiedPush Details Sponsor diff --git a/presentation/src/test/java/org/monogram/presentation/core/util/ConversationPipelineDefaultTest.kt b/presentation/src/test/java/org/monogram/presentation/core/util/ConversationPipelineDefaultTest.kt new file mode 100644 index 000000000..34fcd802f --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/core/util/ConversationPipelineDefaultTest.kt @@ -0,0 +1,115 @@ +package org.monogram.presentation.core.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.domain.repository.ConversationPipelineMode + +class ConversationPipelineDefaultTest { + @Test + fun `legacy kill switch overrides and restores rollout default`() { + assertEquals( + ConversationPipelineMode.Legacy, + conversationPipelineModeWithLegacyKillSwitch( + forceLegacy = true, + defaultMode = ConversationPipelineMode.New + ) + ) + assertEquals( + ConversationPipelineMode.New, + conversationPipelineModeWithLegacyKillSwitch( + forceLegacy = false, + defaultMode = ConversationPipelineMode.New + ) + ) + } + + @Test + fun `stored pipeline mode accepts legacy ordinal and stable name`() { + assertEquals( + ConversationPipelineMode.Legacy, + storedConversationPipelineMode( + ConversationPipelineMode.Legacy.ordinal, + ConversationPipelineMode.New + ) + ) + assertEquals( + ConversationPipelineMode.New, + storedConversationPipelineMode( + ConversationPipelineMode.New.name, + ConversationPipelineMode.Legacy + ) + ) + assertEquals( + ConversationPipelineMode.Shadow, + storedConversationPipelineMode("unknown", ConversationPipelineMode.Shadow) + ) + } + + @Test + fun `legacy kill switch is available only in official libre`() { + assertEquals(true, isConversationPipelineKillSwitchAvailable(true, true)) + assertEquals(false, isConversationPipelineKillSwitchAvailable(true, false)) + assertEquals(false, isConversationPipelineKillSwitchAvailable(false, true)) + assertEquals(false, isConversationPipelineKillSwitchAvailable(false, false)) + } + + @Test + fun `official libre defaults to new in every build type`() { + assertEquals( + ConversationPipelineMode.New, + defaultConversationPipelineMode( + isOfficialTdlib = true, + isLibreRuntime = true, + isDebug = false + ) + ) + assertEquals( + ConversationPipelineMode.New, + defaultConversationPipelineMode( + isOfficialTdlib = true, + isLibreRuntime = true, + isDebug = true + ) + ) + } + + @Test + fun `other debug variants retain shadow default`() { + assertEquals( + ConversationPipelineMode.Shadow, + defaultConversationPipelineMode( + isOfficialTdlib = true, + isLibreRuntime = false, + isDebug = true + ) + ) + assertEquals( + ConversationPipelineMode.Shadow, + defaultConversationPipelineMode( + isOfficialTdlib = false, + isLibreRuntime = true, + isDebug = true + ) + ) + } + + @Test + fun `other release variants retain legacy default`() { + assertEquals( + ConversationPipelineMode.Legacy, + defaultConversationPipelineMode( + isOfficialTdlib = true, + isLibreRuntime = false, + isDebug = false + ) + ) + assertEquals( + ConversationPipelineMode.Legacy, + defaultConversationPipelineMode( + isOfficialTdlib = false, + isLibreRuntime = true, + isDebug = false + ) + ) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationSessionTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationSessionTest.kt new file mode 100644 index 000000000..265bd6605 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationSessionTest.kt @@ -0,0 +1,281 @@ +package org.monogram.presentation.features.chats.conversation + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ConversationUpdate +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.repository.BoundaryState +import org.monogram.domain.repository.ConversationKey +import org.monogram.domain.repository.HistoryAnchor +import org.monogram.domain.repository.HistoryDirection +import org.monogram.domain.repository.HistoryPage +import org.monogram.domain.repository.HistoryRequest +import org.monogram.domain.repository.HistorySource +import org.monogram.presentation.features.chats.conversation.logic.withConversationSessionUpdate + +@OptIn(ExperimentalCoroutinesApi::class) +class ConversationSessionTest { + @Test + fun `equal requests share one loader call`() = runTest { + val result = CompletableDeferred() + var calls = 0 + val session = ConversationSession(this) { + calls++ + result.await() + } + val request = request(HistoryDirection.Older, anchorId = 20L) + + val first = async { session.loadHistory(1L, request) } + val second = async { session.loadHistory(1L, request) } + runCurrent() + + assertEquals(1, calls) + result.complete(page(10L)) + assertEquals(listOf(10L), first.await().messages.map(MessageModel::id)) + assertEquals(listOf(10L), second.await().messages.map(MessageModel::id)) + + session.close(2L) + advanceUntilIdle() + } + + @Test + fun `jump cancels active edge load`() = runTest { + val olderStarted = CompletableDeferred() + val olderResult = CompletableDeferred() + val session = ConversationSession(this) { request -> + when (request.direction) { + HistoryDirection.Older -> { + olderStarted.complete(Unit) + olderResult.await() + } + + HistoryDirection.Around -> page(50L) + else -> error("Unexpected direction ${request.direction}") + } + } + + val older = async { session.loadHistory(1L, request(HistoryDirection.Older, 20L)) } + olderStarted.await() + val jump = async { session.loadHistory(1L, request(HistoryDirection.Around, 50L)) } + runCurrent() + + assertEquals(listOf(50L), jump.await().messages.map(MessageModel::id)) + assertTrue(older.isCancelled) + + session.close(2L) + advanceUntilIdle() + } + + @Test + fun `jump cancels active initial load`() = runTest { + val initialStarted = CompletableDeferred() + val initialResult = CompletableDeferred() + val session = ConversationSession(this) { request -> + when (request.direction) { + HistoryDirection.Initial -> { + initialStarted.complete(Unit) + initialResult.await() + } + + HistoryDirection.Around -> page(50L) + else -> error("Unexpected direction ${request.direction}") + } + } + + val initial = async { session.loadHistory(1L, request(HistoryDirection.Initial)) } + initialStarted.await() + val jump = async { session.loadHistory(1L, request(HistoryDirection.Around, 50L)) } + runCurrent() + + assertEquals(listOf(50L), jump.await().messages.map(MessageModel::id)) + assertTrue(initial.isCancelled) + + session.close(2L) + advanceUntilIdle() + } + + @Test + fun `generation advance cancels old result before delivery`() = runTest { + val started = CompletableDeferred() + val result = CompletableDeferred() + val session = ConversationSession(this) { + started.complete(Unit) + result.await() + } + + val load = async { session.loadHistory(1L, request(HistoryDirection.Initial)) } + started.await() + session.advanceGeneration(2L) + runCurrent() + + assertTrue(load.isCancelled) + assertEquals(2L, session.state.value.generation) + + session.close(3L) + advanceUntilIdle() + } + + @Test + fun `session owns edge loading and boundary state`() = runTest { + val result = CompletableDeferred() + val session = ConversationSession(this) { result.await() } + val load = async { session.loadHistory(1L, request(HistoryDirection.Older, 20L)) } + runCurrent() + + assertTrue(session.state.value.isLoadingOlder) + result.complete( + HistoryPage( + messages = listOf(message(10L)), + olderBoundary = BoundaryState.Reached, + newerBoundary = BoundaryState.Gap(20L), + source = HistorySource.TdlibNetwork + ) + ) + load.await() + + assertTrue(!session.state.value.isLoadingOlder) + assertTrue(session.state.value.isOldestLoaded) + session.close(2L) + advanceUntilIdle() + } + + @Test + fun `history request completion does not clear operation loading`() = runTest { + val session = ConversationSession(this) { page(1L) } + session.setOperationLoading(1L, loading = true, resetBoundaries = true) + + session.loadHistory(1L, request(HistoryDirection.Initial)) + + assertTrue(session.state.value.isLoadingInitial) + assertTrue(!session.state.value.isLoadingHistoryRequest) + session.setOperationLoading(1L, loading = false) + session.close(2L) + advanceUntilIdle() + } + + @Test + fun `final reconciliation owns exact boundary state`() = runTest { + val session = ConversationSession(this) { page(1L) } + session.setOperationLoading(1L, loading = true, resetBoundaries = true) + session.loadHistory(1L, request(HistoryDirection.Initial)) + + session.setBoundaries(1L, oldestLoaded = false, latestLoaded = true) + + assertTrue(!session.state.value.isOldestLoaded) + assertTrue(session.state.value.isLatestLoaded) + session.close(2L) + advanceUntilIdle() + } + + @Test + fun `stale operation completion cannot clear current loading`() = runTest { + val session = ConversationSession(this) { page(1L) } + + session.setOperationLoading(1L, loading = true, resetBoundaries = true) + session.setOperationLoading(2L, loading = true, resetBoundaries = true) + session.setOperationLoading(1L, loading = false) + + assertTrue(session.state.value.isLoadingInitial) + assertEquals(2L, session.state.value.generation) + session.setOperationLoading(2L, loading = false) + assertTrue(!session.state.value.isLoadingInitial) + session.close(3L) + advanceUntilIdle() + } + + @Test + fun `media runtime transform is serialized by session`() = runTest { + val original = message(10L) + val session = ConversationSession(this) { page(1L) } + session.applySnapshot(1L, listOf(original), ascending = false) + + val transformed = session.transformMessage(1L, 10L) { current -> + current.copy(content = MessageContent.Text("updated")) + } + + assertEquals("updated", (transformed.messages.single().content as MessageContent.Text).text) + session.close(2L) + advanceUntilIdle() + } + + @Test + fun `close cancels active load and rejects its result`() = runTest { + val started = CompletableDeferred() + val result = CompletableDeferred() + val session = ConversationSession(this) { + started.complete(Unit) + result.await() + } + + val load = async { session.loadHistory(1L, request(HistoryDirection.Initial)) } + started.await() + session.close(2L) + runCurrent() + + assertTrue(load.isCancelled) + assertTrue(session.state.value.closed) + assertEquals(2L, session.state.value.generation) + + advanceUntilIdle() + } + + @Test + fun `new projection publishes actor terminal lifecycle`() { + val message = message(-10L, isOutgoing = true) + val key = OutgoingMessageReducer.Key(chatId = 1L, temporaryMessageId = -10L) + val sessionState = ConversationSessionState( + messages = listOf(message), + outgoingMessageStates = mapOf( + key to OutgoingMessageReducer.State.Failed(errorCode = 500, retryable = true) + ) + ) + + val projected = + ChatComponent.State(messages = listOf(message)).withConversationSessionUpdate( + sessionState = sessionState, + update = ConversationUpdate.SendFailed( + chatId = 1L, + temporaryMessageId = -10L, + message = message, + errorCode = 500 + ), + rootChatId = 1L + ) + + assertEquals(sessionState.outgoingMessageStates, projected.outgoingMessageStates) + } + + private fun request( + direction: HistoryDirection, + anchorId: Long? = null + ) = HistoryRequest( + key = ConversationKey(chatId = 1L), + anchor = anchorId?.let(HistoryAnchor::Message) ?: HistoryAnchor.Latest, + direction = direction, + limit = 50 + ) + + private fun page(messageId: Long) = HistoryPage( + messages = listOf(message(messageId)), + olderBoundary = BoundaryState.Open, + newerBoundary = BoundaryState.Open, + source = HistorySource.TdlibNetwork + ) + + private fun message(id: Long, isOutgoing: Boolean = false) = MessageModel( + id = id, + date = id.toInt(), + isOutgoing = isOutgoing, + senderName = "sender", + chatId = 1L, + content = MessageContent.Text("message") + ) +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducerTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducerTest.kt new file mode 100644 index 000000000..2f155a4ef --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ConversationViewportReducerTest.kt @@ -0,0 +1,127 @@ +package org.monogram.presentation.features.chats.conversation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConversationViewportReducerTest { + @Test + fun `begin load preserves actor owned loading and boundaries`() { + val state = ChatComponent.State( + isLoading = true, + isOldestLoaded = true, + isLatestLoaded = true, + pendingScrollCommand = ChatScrollCommand.ScrollToBottom(), + viewportPhase = ChatViewportPhase.Settled + ) + + val result = ConversationViewportReducer.beginLoad( + state = state, + legacyOwnsLoadingState = false + ) + + assertEquals(true, result.isLoading) + assertEquals(true, result.isOldestLoaded) + assertEquals(true, result.isLatestLoaded) + assertNull(result.pendingScrollCommand) + assertEquals(ChatViewportPhase.Initializing, result.viewportPhase) + } + + @Test + fun `begin load resets legacy owned loading and boundaries`() { + val result = ConversationViewportReducer.beginLoad( + state = ChatComponent.State(isOldestLoaded = true, isLatestLoaded = true), + legacyOwnsLoadingState = true + ) + + assertEquals(true, result.isLoading) + assertEquals(false, result.isOldestLoaded) + assertEquals(false, result.isLatestLoaded) + } + + @Test + fun `initialize clears one shot state`() { + val state = ChatComponent.State( + pendingScrollCommand = ChatScrollCommand.ScrollToBottom(), + viewportPhase = ChatViewportPhase.Restoring, + highlightRequest = MessageHighlightRequest(10L, 1L) + ) + + val result = ConversationViewportReducer.initialize(state) + + assertNull(result.pendingScrollCommand) + assertEquals(ChatViewportPhase.Initializing, result.viewportPhase) + assertNull(result.highlightRequest) + } + + @Test + fun `restore consume and settle form one shot lifecycle`() { + val command = ChatScrollCommand.JumpToMessage(10L, highlight = true) + val restoring = ConversationViewportReducer.restore(ChatComponent.State(), command) + + assertEquals(command, restoring.pendingScrollCommand) + assertEquals(ChatViewportPhase.Restoring, restoring.viewportPhase) + + val consumed = ConversationViewportReducer.consumeScrollCommand(restoring) + assertNull(consumed.pendingScrollCommand) + assertEquals(ChatViewportPhase.Restoring, consumed.viewportPhase) + + assertEquals( + ChatViewportPhase.Settled, + ConversationViewportReducer.settle(consumed).viewportPhase + ) + } + + @Test + fun `runtime enqueue preserves settled viewport`() { + val command = ChatScrollCommand.ScrollToBottom() + val result = ConversationViewportReducer.enqueue( + ChatComponent.State(viewportPhase = ChatViewportPhase.Settled), + command + ) + + assertEquals(command, result.pendingScrollCommand) + assertEquals(ChatViewportPhase.Settled, result.viewportPhase) + } + + @Test + fun `highlight requests use monotonic token and can be consumed`() { + val first = ConversationViewportReducer.requestHighlight(ChatComponent.State(), 10L) + val second = ConversationViewportReducer.requestHighlight(first, 20L) + + assertEquals(1L, first.highlightRequest?.token) + assertEquals(2L, second.highlightRequest?.token) + assertEquals(20L, second.highlightRequest?.messageId) + assertNull(ConversationViewportReducer.consumeHighlight(second).highlightRequest) + } + + @Test + fun `content ready consumes target and applies only legacy boundaries`() { + val state = ChatComponent.State( + scrollToMessageId = 10L, + highlightRequest = MessageHighlightRequest(10L, 1L), + isOldestLoaded = true, + isLatestLoaded = false + ) + + val actorOwned = ConversationViewportReducer.contentReady( + state = state, + isAtBottom = false + ) + val legacyOwned = ConversationViewportReducer.contentReady( + state = state, + isAtBottom = true, + legacyOldestLoaded = false, + legacyLatestLoaded = true + ) + + assertNull(actorOwned.scrollToMessageId) + assertNull(actorOwned.highlightRequest) + assertEquals(true, actorOwned.isOldestLoaded) + assertEquals(false, actorOwned.isLatestLoaded) + assertEquals(false, actorOwned.isAtBottom) + assertEquals(false, legacyOwned.isOldestLoaded) + assertEquals(true, legacyOwned.isLatestLoaded) + assertEquals(true, legacyOwned.isAtBottom) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt index 9cbb6da64..31067864b 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ChatInitialLoadPolicyTest.kt @@ -4,8 +4,34 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.repository.BoundaryState +import org.monogram.domain.repository.HistoryPage +import org.monogram.domain.repository.HistorySource class ChatInitialLoadPolicyTest { + @Test + fun `network reconciliation is skipped only for a full main chat local page`() { + val fullLocalPage = localPage(size = 50) + + assertFalse( + shouldRequestInitialNetwork( + fullLocalPage, + requestedLimit = 50, + threadId = null + ) + ) + assertTrue( + shouldRequestInitialNetwork( + localPage(size = 49), + requestedLimit = 50, + threadId = null + ) + ) + assertTrue(shouldRequestInitialNetwork(fullLocalPage, requestedLimit = 50, threadId = 10L)) + } + @Test fun `initial load starts when context has not started yet`() { val key = buildChatInitialLoadKey( @@ -109,4 +135,20 @@ class ChatInitialLoadPolicyTest { ) ) } + + private fun localPage(size: Int) = HistoryPage( + messages = (1..size).map { index -> + MessageModel( + id = index.toLong(), + date = index, + isOutgoing = false, + senderName = "sender", + chatId = 1L, + content = MessageContent.Text("message") + ) + }, + olderBoundary = BoundaryState.Open, + newerBoundary = BoundaryState.Open, + source = HistorySource.TdlibLocal + ) } diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ConversationSessionProjectionTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ConversationSessionProjectionTest.kt new file mode 100644 index 000000000..508e9f217 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/ConversationSessionProjectionTest.kt @@ -0,0 +1,266 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.ConversationUpdate +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageReactionModel +import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.features.chats.conversation.ChatComponent +import org.monogram.presentation.features.chats.conversation.ConversationSessionState +import org.monogram.presentation.features.chats.conversation.ConversationWindowReducer +import org.monogram.presentation.features.chats.conversation.OutgoingMessageReducer + +class ConversationSessionProjectionTest { + @Test + fun `conversation update bookkeeping remaps and cleans deleted ids`() { + val remappedIds = mutableMapOf(-10L to 10L, -20L to 20L) + val reactionSuppression = mutableMapOf(10L to 1L, 20L to 2L) + + applyConversationUpdateBookkeeping( + update = ConversationUpdate.ReplaceTemporaryId(CHAT_ID, -30L, textMessage(30L)), + remappedMessageIds = remappedIds, + reactionUpdateSuppressedUntil = reactionSuppression + ) + applyConversationUpdateBookkeeping( + update = ConversationUpdate.Delete(CHAT_ID, setOf(10L, -20L)), + remappedMessageIds = remappedIds, + reactionUpdateSuppressedUntil = reactionSuppression + ) + + assertEquals(mapOf(-30L to 30L), remappedIds) + assertEquals(mapOf(20L to 2L), reactionSuppression) + } + + @Test + fun `new session message owns window and preserves unread side effect`() { + val existing = textMessage(10L) + val incoming = textMessage(20L) + val projected = ChatComponent.State( + chatId = CHAT_ID, + messages = listOf(existing), + isAtBottom = false, + lastReadInboxMessageId = 10L + ).withConversationSessionUpdate( + sessionState = ConversationSessionState(messages = listOf(incoming, existing)), + update = ConversationUpdate.Upsert(CHAT_ID, incoming, isNew = true), + rootChatId = CHAT_ID + ) + + assertEquals(listOf(20L, 10L), projected.messages.map(MessageModel::id)) + assertEquals(1, projected.unreadCount) + assertEquals(1, projected.unreadSeparatorCount) + } + + @Test + fun `session edit preserves runtime media state and suppressed reaction`() { + val localReaction = MessageReactionModel(emoji = "x", count = 1, isChosen = true) + val remoteReaction = MessageReactionModel(emoji = "y", count = 2, isChosen = false) + val current = photoMessage( + caption = "before", + path = "cached.jpg", + isDownloading = true, + progress = 0.5f, + reactions = listOf(localReaction) + ) + val edited = photoMessage( + caption = "after", + path = null, + isDownloading = false, + progress = 0f, + reactions = listOf(remoteReaction) + ) + + val projected = ChatComponent.State(chatId = CHAT_ID, messages = listOf(current)) + .withConversationSessionUpdate( + sessionState = ConversationSessionState(messages = listOf(edited)), + update = ConversationUpdate.Upsert(CHAT_ID, edited, isNew = false), + rootChatId = CHAT_ID, + suppressReactionUpdate = true + ) + + val content = projected.messages.single().content as MessageContent.Photo + assertEquals("after", content.caption) + assertEquals("cached.jpg", content.path) + assertTrue(content.isDownloading) + assertEquals(0.5f, content.downloadProgress) + assertEquals(listOf(localReaction), projected.messages.single().reactions) + } + + @Test + fun `session edit preserves optimistic content until update is confirmed`() { + val current = textMessage(10L, "optimistic") + val stale = textMessage(10L, "stale") + + val projected = ChatComponent.State( + chatId = CHAT_ID, + messages = listOf(current), + pendingEditedMessageIds = setOf(10L) + ).withConversationSessionUpdate( + sessionState = ConversationSessionState(messages = listOf(stale)), + update = ConversationUpdate.Upsert(CHAT_ID, stale, isNew = true), + rootChatId = CHAT_ID + ) + + assertEquals( + "optimistic", + (projected.messages.single().content as MessageContent.Text).text + ) + assertTrue(10L in projected.pendingEditedMessageIds) + } + + @Test + fun `confirmed edit and delete clear pending edit markers`() { + val edited = textMessage(10L, "confirmed") + val confirmed = ChatComponent.State( + chatId = CHAT_ID, + messages = listOf(textMessage(10L, "optimistic")), + pendingEditedMessageIds = setOf(10L, 20L) + ).withConversationSessionUpdate( + sessionState = ConversationSessionState(messages = listOf(edited)), + update = ConversationUpdate.Upsert(CHAT_ID, edited, isNew = false), + rootChatId = CHAT_ID + ) + val deleted = confirmed.withConversationSessionUpdate( + sessionState = ConversationSessionState(messages = emptyList()), + update = ConversationUpdate.Delete(CHAT_ID, setOf(20L)), + rootChatId = CHAT_ID + ) + + assertFalse(10L in confirmed.pendingEditedMessageIds) + assertEquals("confirmed", (confirmed.messages.single().content as MessageContent.Text).text) + assertFalse(20L in deleted.pendingEditedMessageIds) + assertTrue(deleted.messages.isEmpty()) + } + + @Test + fun `session outgoing lifecycle replaces legacy projection`() { + val key = OutgoingMessageReducer.Key(CHAT_ID, -10L) + val projected = ChatComponent.State( + chatId = CHAT_ID, + outgoingMessageStates = mapOf(key to OutgoingMessageReducer.State.PendingLocal) + ).withConversationSessionUpdate( + sessionState = ConversationSessionState( + outgoingMessageStates = mapOf(key to OutgoingMessageReducer.State.Acknowledged) + ), + update = ConversationUpdate.SendAcknowledged(CHAT_ID, -10L), + rootChatId = CHAT_ID + ) + + assertEquals( + OutgoingMessageReducer.State.Acknowledged, + projected.outgoingMessageStates[key] + ) + } + + @Test + fun `outgoing message is projected away from latest boundary`() { + val existing = textMessage(10L) + val outgoing = textMessage(20L).copy( + isOutgoing = true, + sendingState = MessageSendingState.Pending + ) + val sessionState = ConversationWindowReducer.applyUpdate( + state = ConversationSessionState(messages = listOf(existing)), + update = ConversationUpdate.Upsert(CHAT_ID, outgoing, isNew = true), + canInsertNewMessage = true + ) + + val projected = ChatComponent.State( + chatId = CHAT_ID, + messages = listOf(existing), + isAtBottom = false, + isLatestLoaded = false + ).withConversationSessionUpdate( + sessionState = sessionState, + update = ConversationUpdate.Upsert(CHAT_ID, outgoing, isNew = true), + rootChatId = CHAT_ID + ) + + assertEquals(listOf(20L, 10L), projected.messages.map(MessageModel::id)) + assertTrue(projected.isLatestLoaded) + } + + @Test + fun `history merge preserves pending outgoing reactions and optimistic edit`() { + val reaction = MessageReactionModel(emoji = "x", count = 1, isChosen = true) + val pending = textMessage(-1L, "pending").copy( + isOutgoing = true, + sendingState = MessageSendingState.Pending + ) + val edited = textMessage(10L, "optimistic").copy(reactions = listOf(reaction)) + val merged = ChatComponent.State( + chatId = CHAT_ID, + messages = listOf(edited, pending), + pendingEditedMessageIds = setOf(10L) + ).mergeHistoryMessages( + filteredNewMessages = listOf(textMessage(10L, "stale"), textMessage(20L)), + replace = true + ) + + assertEquals(listOf(20L, 10L, -1L), merged.map(MessageModel::id)) + assertEquals("optimistic", (merged[1].content as MessageContent.Text).text) + assertEquals(listOf(reaction), merged[1].reactions) + assertEquals(MessageSendingState.Pending, merged[2].sendingState) + } + + private fun textMessage(id: Long, text: String = "message") = MessageModel( + id = id, + date = id.toInt(), + isOutgoing = false, + senderName = "sender", + chatId = CHAT_ID, + content = MessageContent.Text(text) + ) + + private fun photoMessage( + caption: String, + path: String?, + isDownloading: Boolean, + progress: Float, + reactions: List + ) = MessageModel( + id = 10L, + date = 10, + isOutgoing = false, + senderName = "sender", + chatId = CHAT_ID, + content = MessageContent.Photo( + path = path, + width = 100, + height = 100, + caption = caption, + isDownloading = isDownloading, + downloadProgress = progress, + fileId = 7 + ), + reactions = reactions, + sendingState = MessageSendingState.Pending + ) + + private fun pollMessage(voterCount: Int) = MessageModel( + id = 10L, + date = 10, + isOutgoing = false, + senderName = "sender", + chatId = CHAT_ID, + content = MessageContent.Poll( + id = 100L, + question = "question", + options = emptyList(), + totalVoterCount = voterCount, + isClosed = false, + isAnonymous = true, + type = org.monogram.domain.models.PollType.Regular(allowMultipleAnswers = false), + openPeriod = 0, + closeDate = 0 + ) + ) + + private companion object { + const val CHAT_ID = 1L + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActionsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActionsTest.kt index d7b07d512..8874cde45 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActionsTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActionsTest.kt @@ -47,7 +47,7 @@ class LinkPreviewActionsTest { } @Test - fun `photo thumbnail fallback keeps primary media action`() { + fun `photo thumbnail fallback opens image viewer`() { val preview = basePreview( photo = WebPage.Photo( path = null, @@ -63,7 +63,11 @@ class LinkPreviewActionsTest { val resolved = preview.resolveLinkPreview() assertEquals(LinkPreviewAction.OpenLink("https://example.com/post"), resolved.primaryAction) - assertEquals(resolved.primaryAction, resolved.mediaAction) + assertTrue(resolved.mediaAction is LinkPreviewAction.OpenImageViewer) + val image = + (resolved.mediaAction as LinkPreviewAction.OpenImageViewer).request.images.single() + assertEquals("/tmp/thumb.jpg", image.previewSource) + assertEquals(7, image.originalFileId) assertEquals("/tmp/thumb.jpg", resolved.thumbnailData) } From e3e60ce0c939398f0d9f40e4ff8493e10c9a5463 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:24:35 +0300 Subject: [PATCH 2/4] cleanup chats ui --- .../remote/TdMessageRemoteDataSource.kt | 36 +- .../monogram/data/infra/FileDownloadQueue.kt | 7 + .../data/infra/FileDownloadQueueTest.kt | 27 +- .../logic/files/MessageDownloadStatePolicy.kt | 64 +++ .../logic/message-loading/MessageLoading.kt | 153 ++----- .../ui/AlbumMessageBubbleContainer.kt | 28 +- .../conversation/ui/CompactMediaMosaic.kt | 397 +++++++++--------- .../conversation/ui/MessageBubbleContainer.kt | 48 ++- .../ui/MessageInteractionPolicy.kt | 26 ++ .../conversation/ui/MessageRowContracts.kt | 3 +- .../ui/channel/ChannelAlbumMessageBubble.kt | 51 ++- .../ui/channel/ChannelGifMessageBubble.kt | 44 +- .../channel/ChannelMessageBubbleContainer.kt | 46 +- .../ui/channel/ChannelPhotoMessageBubble.kt | 44 +- .../ui/channel/ChannelVideoMessageBubble.kt | 66 +-- .../ui/content/ChatContentBody.kt | 35 +- .../ui/content/ChatContentEffects.kt | 121 ++---- .../ui/content/ChatContentList.kt | 60 +-- .../content/ChatContentScrollCoordinator.kt | 120 ++++-- .../ui/message/AudioMessageBubble.kt | 23 +- .../ui/message/ChatAlbumMessageBubble.kt | 29 +- .../ui/message/DocumentMessageBubble.kt | 22 +- .../ui/message/GifMessageBubble.kt | 42 +- .../conversation/ui/message/LinkPreview.kt | 71 ++-- .../ui/message/LinkPreviewActions.kt | 31 +- .../ui/message/MediaLoadingComponents.kt | 127 +++++- .../ui/message/PaidMediaMessageBubble.kt | 72 +++- .../ui/message/PhotoMessageBubble.kt | 42 +- .../ui/message/TextMessageBubble.kt | 4 +- .../ui/message/VideoMessageBubble.kt | 66 +-- .../ui/message/VideoNoteBubble.kt | 26 +- .../logic/MessageDownloadStatePolicyTest.kt | 136 ++++++ .../content/ChatContentRuntimePolicyTest.kt | 64 +++ .../ChatContentScrollCoordinatorTest.kt | 75 ++++ .../ui/content/ChatMediaHelpersTest.kt | 31 ++ .../ui/message/MediaTapActionsTest.kt | 49 +++ 36 files changed, 1414 insertions(+), 872 deletions(-) create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/MessageDownloadStatePolicy.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageInteractionPolicy.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/MessageDownloadStatePolicyTest.kt diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index a00479598..1ae4aa4cf 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -37,9 +36,9 @@ import org.monogram.data.compat.buildRichMessageSourceMarkdown import org.monogram.data.compat.extractTextDraft import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.infra.AtomicSingleFlight import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler -import org.monogram.data.infra.AtomicSingleFlight import org.monogram.data.infra.OrderedEventFlow import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.WebPageMapper @@ -148,6 +147,10 @@ class TdMessageRemoteDataSource( init { fileDownloadQueue.setObserver(object : FileDownloadQueue.Observer { + override fun onDownloadQueued(fileId: Int) { + emitQueuedForFile(fileId) + } + override fun onDownloadCancelled(fileId: Int) { emitCancelledForFile(fileId) } @@ -2582,6 +2585,35 @@ class TdMessageRemoteDataSource( } } + private fun emitQueuedForFile(fileId: Int) { + if (fileId == 0) return + + scope.launch { + fileDownloads.enqueue( + FileDownloadEvent.Progress( + fileId = fileId, + progress = 0f + ) + ) + } + + val entries = fileIdToMessageMap[fileId] + if (!entries.isNullOrEmpty()) { + scope.launch { + entries.forEach { (chatId, messageId) -> + messageDownloads.enqueue( + MessageDownloadEvent.Progress( + chatId = chatId, + messageId = messageId, + fileId = fileId, + progress = 0f + ) + ) + } + } + } + } + companion object { internal val LIVE_MESSAGE_MAP_OPTIONS = MessageMapOptions( resolveReplyPreviewFromNetwork = false, diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index 3a7474f22..d6a239efe 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -30,6 +30,7 @@ class FileDownloadQueue( private val dispatcherProvider: DispatcherProvider ) : FileUpdateQueue { interface Observer { + fun onDownloadQueued(fileId: Int) = Unit fun onDownloadCancelled(fileId: Int) } @@ -615,6 +616,7 @@ class FileDownloadQueue( if (userInitiated) { pendingRequests.remove(fileId) activeRequests[fileId] = req + notifyDownloadQueued(fileId) scope.launch(dispatcherProvider.io) { processDownload(req) } @@ -639,6 +641,7 @@ class FileDownloadQueue( pendingRequests[fileId] = req } } + notifyDownloadQueued(fileId) trigger.trySend(Unit) } } @@ -691,6 +694,10 @@ class FileDownloadQueue( downloadWaiters.remove(fileId)?.complete(Unit) } + private fun notifyDownloadQueued(fileId: Int) { + observer?.onDownloadQueued(fileId) + } + fun notifyDownloadCancelled(fileId: Int) { downloadWaiters.remove(fileId)?.cancel() observer?.onDownloadCancelled(fileId) diff --git a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt index 2ddc33083..e64a402b0 100644 --- a/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt +++ b/data/src/test/java/org/monogram/data/infra/FileDownloadQueueTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.drinkless.tdlib.TdApi import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.monogram.core.DispatcherProvider @@ -20,6 +21,26 @@ import org.monogram.domain.repository.MediaAutoDownloadPolicy @OptIn(ExperimentalCoroutinesApi::class) class FileDownloadQueueTest { + @Test + fun `accepted manual download notifies queued state before tdlib progress`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val queue = createQueue(dispatcher, this) + val queued = mutableListOf() + queue.setObserver(object : FileDownloadQueue.Observer { + override fun onDownloadQueued(fileId: Int) { + queued += fileId + } + + override fun onDownloadCancelled(fileId: Int) = Unit + }) + + queue.enqueue(fileId = 41, priority = 32, userInitiated = true) + runCurrent() + + assertEquals(listOf(41), queued) + assertTrue(queue.isFileQueued(41)) + } + @Test fun `cancel pending download notifies observer without waiting for update file`() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) @@ -39,7 +60,7 @@ class FileDownloadQueueTest { runCurrent() assertEquals(listOf(42), cancelled) - assertTrue(!queue.isFileQueued(42)) + assertFalse(queue.isFileQueued(42)) } @Test @@ -70,7 +91,7 @@ class FileDownloadQueueTest { queue.enqueue(fileId = 91, type = FileDownloadQueue.DownloadType.STICKER) runCurrent() - assertTrue(!queue.isFileQueued(91)) + assertFalse(queue.isFileQueued(91)) } @Test @@ -173,7 +194,7 @@ class FileDownloadQueueTest { ) runCurrent() - assertTrue(!queue.isFileQueued(93)) + assertFalse(queue.isFileQueued(93)) } @Test diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/MessageDownloadStatePolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/MessageDownloadStatePolicy.kt new file mode 100644 index 000000000..02c43616c --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/files/MessageDownloadStatePolicy.kt @@ -0,0 +1,64 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.monogram.domain.models.MessageContent + +internal fun MessageContent.withFileDownloadState( + fileId: Int, + isDownloading: Boolean, + progress: Float +): MessageContent { + if (fileId == 0) return this + val normalizedProgress = progress.coerceIn(0f, 1f) + + return when (this) { + is MessageContent.Photo -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + is MessageContent.Video -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + is MessageContent.VideoNote -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + is MessageContent.Document -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + is MessageContent.Audio -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + is MessageContent.Gif -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + is MessageContent.Voice -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + is MessageContent.Sticker -> if (this.fileId == fileId) copy( + isDownloading = isDownloading, + downloadProgress = normalizedProgress, + downloadError = false + ) else this + + else -> this + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt index efd8707c7..d43a23801 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-loading/MessageLoading.kt @@ -11,12 +11,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.monogram.domain.repository.ConversationPipelineMode -import org.monogram.domain.repository.BoundaryState -import org.monogram.domain.repository.HistoryAnchor -import org.monogram.domain.repository.HistoryDirection -import org.monogram.domain.repository.HistoryRequest -import org.monogram.domain.repository.HistorySource import org.monogram.core.perf.ChatOpenPerfBridge import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.ConversationUpdate @@ -26,6 +20,12 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageReactionModel import org.monogram.domain.models.MessageSendingState import org.monogram.domain.models.UserModel +import org.monogram.domain.repository.BoundaryState +import org.monogram.domain.repository.ConversationPipelineMode +import org.monogram.domain.repository.HistoryAnchor +import org.monogram.domain.repository.HistoryDirection +import org.monogram.domain.repository.HistoryRequest +import org.monogram.domain.repository.HistorySource import org.monogram.domain.repository.ReadUpdate import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppression import org.monogram.presentation.features.chats.conversation.ChatComponent @@ -39,7 +39,6 @@ import org.monogram.presentation.features.chats.conversation.ConversationViewpor import org.monogram.presentation.features.chats.conversation.DefaultChatComponent import org.monogram.presentation.features.chats.conversation.OutgoingMessageReducer import org.monogram.presentation.features.chats.conversation.ScrollAlign -import org.monogram.presentation.features.chats.conversation.logic.historyConversationKey import java.io.File import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds @@ -1980,128 +1979,28 @@ internal fun DefaultChatComponent.setupMessageCollectors() { if (event.chatId != chatId) return@onEach updateMessageContent(event.messageId) { message -> val isDownloading = event.progress < 1f - val newContent = when (val content = message.content) { - is MessageContent.Photo -> content.copy( - isDownloading = isDownloading, - downloadProgress = event.progress, - downloadError = false - ) - - is MessageContent.Video -> content.copy( - isDownloading = isDownloading, - downloadProgress = event.progress, - downloadError = false - ) - - is MessageContent.VideoNote -> content.copy( + message.copy( + content = message.content.withFileDownloadState( + fileId = event.fileId, isDownloading = isDownloading, - downloadProgress = event.progress, - downloadError = false + progress = event.progress ) - - is MessageContent.Document -> content.copy( - isDownloading = isDownloading, - downloadProgress = event.progress, - downloadError = false - ) - - is MessageContent.Gif -> content.copy( - isDownloading = isDownloading, - downloadProgress = event.progress, - downloadError = false - ) - - is MessageContent.Voice -> content.copy( - isDownloading = isDownloading, - downloadProgress = event.progress, - downloadError = false - ) - - is MessageContent.Sticker -> content.copy( - isDownloading = isDownloading, - downloadProgress = event.progress, - downloadError = false - ) - - else -> content - } - message.copy(content = newContent) + ) } } is MessageDownloadEvent.Cancelled -> { if (event.chatId != chatId) return@onEach - var cancelledFileId = 0 updateMessageContent(event.messageId) { message -> - val newContent = when (val content = message.content) { - is MessageContent.Photo -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } - - is MessageContent.Video -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } - - is MessageContent.VideoNote -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } - - is MessageContent.Document -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } - - is MessageContent.Gif -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } - - is MessageContent.Voice -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } - - is MessageContent.Sticker -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } - - else -> content - } - message.copy(content = newContent) + message.copy( + content = message.content.withFileDownloadState( + fileId = event.fileId, + isDownloading = false, + progress = 0f + ) + ) } - AutoDownloadSuppression.suppress(cancelledFileId) + AutoDownloadSuppression.suppress(event.fileId) } is MessageDownloadEvent.Completed -> { @@ -2209,6 +2108,20 @@ internal fun DefaultChatComponent.setupMessageCollectors() { } } + is MessageContent.Audio -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } + is MessageContent.Sticker -> { if (downloadedFileId == content.fileId) { mainFileId = content.fileId diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt index 37281850f..0ddaadc5e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/AlbumMessageBubbleContainer.kt @@ -58,6 +58,7 @@ internal fun AlbumMessageBubbleContainer( onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, onReplyClick: (Offset, IntSize, Offset) -> Unit, + onMessageClick: ((MessageModel, Offset, IntSize, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset, IntSize, Offset) -> Unit)? = null, onGoToReply: (MessageModel) -> Unit = {}, onReactionClick: (Long, String) -> Unit = { _, _ -> }, @@ -104,6 +105,7 @@ internal fun AlbumMessageBubbleContainer( val coroutineScope = rememberCoroutineScope() val layoutTracker = remember { MessageBubbleLayoutTracker() } val onReplyClickState by rememberUpdatedState(onReplyClick) + val onMessageClickState by rememberUpdatedState(onMessageClick) val onMessageLongPressState by rememberUpdatedState(onMessageLongPress) val onPositionChangeState by rememberUpdatedState(onPositionChange) val selectionBadgeAlignment = if (behavior.isChannel || isOutgoing) { @@ -242,6 +244,16 @@ internal fun AlbumMessageBubbleContainer( offset ) }, + onMessageClick = { tappedMessage, offset -> + (onMessageClickState ?: { _, position, size, click -> + onReplyClickState(position, size, click) + })( + tappedMessage, + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + offset + ) + }, onMessageLongPress = { tappedMessage, offset -> if (onMessageLongPressState != null) { onMessageLongPressState?.invoke( @@ -268,7 +280,8 @@ internal fun AlbumMessageBubbleContainer( fontSize = appearance.fontSize, bubbleRadius = appearance.bubbleRadius, downloadUtils = downloadUtils, - isAnyViewerOpen = behavior.isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen, + animationsEnabled = appearance.animationsEnabled ) } else { ChatAlbumMessageBubble( @@ -296,6 +309,16 @@ internal fun AlbumMessageBubbleContainer( offset ) }, + onMessageClick = { tappedMessage, offset -> + (onMessageClickState ?: { _, position, size, click -> + onReplyClickState(position, size, click) + })( + tappedMessage, + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + offset + ) + }, onMessageLongPress = { tappedMessage, offset -> if (onMessageLongPressState != null) { onMessageLongPressState?.invoke( @@ -319,7 +342,8 @@ internal fun AlbumMessageBubbleContainer( modifier = Modifier, fontSize = appearance.fontSize, downloadUtils = downloadUtils, - isAnyViewerOpen = behavior.isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen, + animationsEnabled = appearance.animationsEnabled ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt index ab9251aa0..b425ca63a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt @@ -1,12 +1,10 @@ package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box @@ -39,7 +37,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -51,9 +48,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.rememberAsyncImagePainter -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState @@ -69,6 +63,25 @@ import org.monogram.presentation.features.chats.conversation.ui.message.ChatTime import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground import org.monogram.presentation.features.chats.conversation.ui.message.SpoilerWrapper +import org.monogram.presentation.features.chats.conversation.ui.message.StableMediaImage + +internal enum class CompactMosaicLayout { + Empty, + Single, + Two, + Three, + Four, + FivePlus +} + +internal fun resolveCompactMosaicLayout(itemCount: Int): CompactMosaicLayout = when (itemCount) { + 1 -> CompactMosaicLayout.Single + 2 -> CompactMosaicLayout.Two + 3 -> CompactMosaicLayout.Three + 4 -> CompactMosaicLayout.Four + in 5..Int.MAX_VALUE -> CompactMosaicLayout.FivePlus + else -> CompactMosaicLayout.Empty +} @Composable fun CompactMediaMosaic( @@ -93,7 +106,8 @@ fun CompactMediaMosaic( autoDownloadWifi: Boolean = false, autoDownloadRoaming: Boolean = false, toProfile: (Long) -> Unit = {}, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { val count = messages.size val spacing = 2.dp @@ -122,7 +136,8 @@ fun CompactMediaMosaic( autoDownloadWifi = autoDownloadWifi, autoDownloadRoaming = autoDownloadRoaming, modifier = Modifier.fillMaxSize(), - downloadUtils = downloadUtils + downloadUtils = downloadUtils, + animationsEnabled = animationsEnabled ) } @@ -141,7 +156,8 @@ fun CompactMediaMosaic( autoDownloadRoaming = autoDownloadRoaming, modifier = Modifier.fillMaxSize(), downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = isAnyViewerOpen, + animationsEnabled = animationsEnabled ) } @@ -159,7 +175,8 @@ fun CompactMediaMosaic( autoDownloadRoaming = autoDownloadRoaming, modifier = Modifier.fillMaxSize(), downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = isAnyViewerOpen, + animationsEnabled = animationsEnabled ) } @@ -177,7 +194,8 @@ fun CompactMediaMosaic( autoDownloadRoaming = autoDownloadRoaming, modifier = Modifier.fillMaxSize(), downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = isAnyViewerOpen, + animationsEnabled = animationsEnabled ) } @@ -201,8 +219,8 @@ fun CompactMediaMosaic( } Column(Modifier.fillMaxWidth()) { - when (count) { - 1 -> { + when (resolveCompactMosaicLayout(count)) { + CompactMosaicLayout.Single -> { Box(Modifier .fillMaxWidth() .aspectRatio(1.0f.coerceIn(0.8f, 1.5f))) { @@ -210,7 +228,7 @@ fun CompactMediaMosaic( } } - 2 -> { + CompactMosaicLayout.Two -> { Row(Modifier .fillMaxWidth() .height(200.dp)) { @@ -224,7 +242,7 @@ fun CompactMediaMosaic( } } - 3 -> { + CompactMosaicLayout.Three -> { Column(Modifier .fillMaxWidth() .aspectRatio(1f)) { @@ -246,7 +264,7 @@ fun CompactMediaMosaic( } } - 4 -> { + CompactMosaicLayout.Four -> { Column(Modifier .fillMaxWidth() .aspectRatio(1f)) { @@ -272,7 +290,7 @@ fun CompactMediaMosaic( } } - else -> { + CompactMosaicLayout.FivePlus -> { Column(Modifier .fillMaxWidth() .aspectRatio(0.9f)) { @@ -301,6 +319,8 @@ fun CompactMediaMosaic( } } } + + CompactMosaicLayout.Empty -> Unit } } } @@ -318,9 +338,9 @@ fun PhotoItem( autoDownloadRoaming: Boolean, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop, - downloadUtils: IDownloadUtils + downloadUtils: IDownloadUtils, + animationsEnabled: Boolean = true ) { - val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(photo.path) } val hasPath = !stablePath.isNullOrBlank() val photoCacheKey = remember(stablePath, photo.fileId) { @@ -339,88 +359,61 @@ fun PhotoItem( var itemPosition by remember { mutableStateOf(Offset.Zero) } var isRevealed by remember { mutableStateOf(!photo.hasSpoiler) } - Box(modifier = modifier.clipToBounds()) { - Crossfade( - targetState = hasPath, - animationSpec = tween(300), - label = "PhotoLoading" - ) { resolved -> - if (resolved && !stablePath.isNullOrBlank()) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - photoCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } + Box( + modifier = modifier + .clipToBounds() + .onGloballyPositioned { itemPosition = it.positionInWindow() } + .pointerInput(hasPath, photo.isDownloading, isRevealed) { + detectTapGestures( + onTap = { + when { + !isRevealed -> isRevealed = true + hasPath -> onPhotoClick(msg) + photo.isDownloading -> { + isAutoDownloadSuppressed = true + AutoDownloadSuppression.suppress(photo.fileId) + onCancelDownload(photo.fileId) } - .crossfade(false) - .build() - ), - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { itemPosition = it.positionInWindow() } - .pointerInput(Unit) { - detectTapGestures( - onTap = { - if (!isRevealed) isRevealed = true - else onPhotoClick(msg) - }, - onLongPress = { offset -> onLongClick(msg, itemPosition + offset) } - ) - }, - contentScale = contentScale - ) - } else { - Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures( - onTap = { - if (!isRevealed) { - isRevealed = true - } else if (photo.isDownloading) { - isAutoDownloadSuppressed = true - AutoDownloadSuppression.suppress(photo.fileId) - onCancelDownload(photo.fileId) - } else { - isAutoDownloadSuppressed = false - AutoDownloadSuppression.clear(photo.fileId) - onDownloadPhoto(photo.fileId) - } - }, - onLongPress = { offset -> onLongClick(msg, itemPosition + offset) } - ) - }, - contentAlignment = Alignment.Center - ) { - MediaLoadingBackground( - previewData = photo.minithumbnail, - contentScale = contentScale - ) - MediaLoadingAction( - isDownloading = photo.isDownloading, - progress = photo.downloadProgress, - idleIcon = Icons.Default.Download, - idleContentDescription = stringResource(R.string.cd_download), - onCancelClick = { - isAutoDownloadSuppressed = true - AutoDownloadSuppression.suppress(photo.fileId) - onCancelDownload(photo.fileId) - }, - onIdleClick = { - isAutoDownloadSuppressed = false - AutoDownloadSuppression.clear(photo.fileId) - onDownloadPhoto(photo.fileId) + else -> { + isAutoDownloadSuppressed = false + AutoDownloadSuppression.clear(photo.fileId) + onDownloadPhoto(photo.fileId) + } } - ) - } + }, + onLongPress = { offset -> onLongClick(msg, itemPosition + offset) } + ) } + ) { + StableMediaImage( + previewModel = photo.thumbnailPath ?: photo.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = photoCacheKey, + contentScale = contentScale, + contentDescription = null, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) + + if (!hasPath) { + MediaLoadingAction( + isDownloading = photo.isDownloading, + progress = photo.downloadProgress, + idleIcon = Icons.Default.Download, + idleContentDescription = stringResource(R.string.cd_download), + modifier = Modifier.align(Alignment.Center), + onCancelClick = { + isAutoDownloadSuppressed = true + AutoDownloadSuppression.suppress(photo.fileId) + onCancelDownload(photo.fileId) + }, + onIdleClick = { + isAutoDownloadSuppressed = false + AutoDownloadSuppression.clear(photo.fileId) + onDownloadPhoto(photo.fileId) + } + ) } SpoilerWrapper(isRevealed = isRevealed) { @@ -444,7 +437,8 @@ fun VideoItem( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(video.path) } @@ -470,16 +464,22 @@ fun VideoItem( Box(modifier = modifier.clipToBounds()) { val canStream = video.supportsStreaming && video.fileId != 0 - Crossfade( - targetState = hasPath || canStream, - animationSpec = tween(300), - label = "VideoLoading" - ) { targetHasPathOrStreaming -> + StableMediaImage( + previewModel = video.thumbnailPath ?: video.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = videoCacheKey, + contentScale = contentScale, + contentDescription = null, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) + Box(modifier = Modifier.fillMaxSize()) { + val targetHasPathOrStreaming = hasPath || canStream if (targetHasPathOrStreaming) { if (autoplayVideos) { val videoPath = stablePath ?: video.fileId.takeIf { it != 0 } ?.let { "http://streaming/$it" } - if (videoPath == null) return@Crossfade + if (videoPath == null) return@Box VideoStickerPlayer( path = videoPath, type = VideoType.Gif, @@ -503,66 +503,9 @@ fun VideoItem( animate = !isAnyViewerOpen, contentScale = contentScale, fileId = if (stablePath == null && video.fileId != 0) video.fileId else 0, - thumbnailData = video.minithumbnail + thumbnailData = video.thumbnailPath ?: video.minithumbnail ) } else { - if (hasPath) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - videoCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(true) - .build() - ), - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { itemPosition = it.positionInWindow() } - .pointerInput(Unit) { - detectTapGestures( - onTap = { - if (!isRevealed) isRevealed = true - else onVideoClick(msg) - }, - onLongPress = { offset -> - onLongClick( - msg, - itemPosition + offset - ) - } - ) - }, - contentScale = contentScale - ) - } else { - if (video.minithumbnail != null) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(video.minithumbnail) - .apply { - videoMiniCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .build() - ), - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .blur(10.dp), - contentScale = contentScale - ) - } - } - Box( modifier = Modifier .align(Alignment.Center) @@ -620,7 +563,7 @@ fun VideoItem( contentAlignment = Alignment.Center ) { MediaLoadingBackground( - previewData = video.minithumbnail, + previewData = video.thumbnailPath ?: video.minithumbnail, contentScale = contentScale ) @@ -650,6 +593,34 @@ fun VideoItem( } } + Box( + modifier = Modifier + .matchParentSize() + .onGloballyPositioned { itemPosition = it.positionInWindow() } + .pointerInput(hasPath, canStream, video.isDownloading, isRevealed) { + detectTapGestures( + onTap = { + when { + !isRevealed -> isRevealed = true + hasPath || canStream -> onVideoClick(msg) + video.isDownloading -> { + isAutoDownloadSuppressed = true + AutoDownloadSuppression.suppress(video.fileId) + onCancelDownload(video.fileId) + } + + else -> { + isAutoDownloadSuppressed = false + AutoDownloadSuppression.clear(video.fileId) + onDownloadVideo(video.fileId) + } + } + }, + onLongPress = { offset -> onLongClick(msg, itemPosition + offset) } + ) + } + ) + SpoilerWrapper(isRevealed = isRevealed) { Box(modifier = Modifier.fillMaxSize()) } @@ -670,7 +641,8 @@ fun VideoNoteItem( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(videoNote.path) } @@ -687,11 +659,20 @@ fun VideoNoteItem( var itemPosition by remember { mutableStateOf(Offset.Zero) } Box(modifier = modifier.clipToBounds()) { - Crossfade( - targetState = stablePath, - animationSpec = tween(300), - label = "VideoNoteLoading" - ) { path -> + val path = stablePath + val videoNoteCacheKey = remember(path, videoNote.fileId) { + namespacedCacheKey("mosaic_video_note:${videoNote.fileId}", path) + } + StableMediaImage( + previewModel = videoNote.thumbnail, + fullResolutionModel = path, + cacheKey = videoNoteCacheKey, + contentScale = contentScale, + contentDescription = null, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) + Box(modifier = Modifier.fillMaxSize()) { if (!path.isNullOrBlank()) { if (autoplayVideos) { VideoStickerPlayer( @@ -717,41 +698,6 @@ fun VideoNoteItem( thumbnailData = videoNote.thumbnail ) } else { - val model = videoNote.thumbnail ?: path - val videoNoteCacheKey = remember(model, videoNote.fileId) { - namespacedCacheKey("mosaic_video_note:${videoNote.fileId}", model) - } - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(model) - .apply { - videoNoteCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(true) - .build() - ), - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { itemPosition = it.positionInWindow() } - .pointerInput(Unit) { - detectTapGestures( - onTap = { onVideoClick(msg) }, - onLongPress = { offset -> - onLongClick( - msg, - itemPosition + offset - ) - } - ) - }, - contentScale = contentScale - ) - Box( modifier = Modifier .align(Alignment.Center) @@ -828,6 +774,28 @@ fun VideoNoteItem( } } } + + Box( + modifier = Modifier + .matchParentSize() + .onGloballyPositioned { itemPosition = it.positionInWindow() } + .pointerInput(path, videoNote.isDownloading) { + detectTapGestures( + onTap = { + if (videoNote.isDownloading && path.isNullOrBlank()) { + isAutoDownloadSuppressed = true + AutoDownloadSuppression.suppress(videoNote.fileId) + onCancelDownload(videoNote.fileId) + } else { + isAutoDownloadSuppressed = false + AutoDownloadSuppression.clear(videoNote.fileId) + onVideoClick(msg) + } + }, + onLongPress = { offset -> onLongClick(msg, itemPosition + offset) } + ) + } + ) } } @@ -845,7 +813,8 @@ fun GifItem( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { var stablePath by remember(msg.id) { mutableStateOf(gif.path) } !stablePath.isNullOrBlank() @@ -863,11 +832,19 @@ fun GifItem( var isRevealed by remember { mutableStateOf(!gif.hasSpoiler) } Box(modifier = modifier.clipToBounds()) { - Crossfade( - targetState = stablePath, - animationSpec = tween(300), - label = "GifLoading" - ) { path -> + val path = stablePath + StableMediaImage( + previewModel = gif.minithumbnail, + fullResolutionModel = path, + cacheKey = remember(path, gif.fileId) { + namespacedCacheKey("mosaic_gif:${gif.fileId}", path) + }, + contentScale = contentScale, + contentDescription = null, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) + Box(modifier = Modifier.fillMaxSize()) { if (!path.isNullOrBlank()) { VideoStickerPlayer( path = path, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt index bb9d1f71d..51f39ec42 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageBubbleContainer.kt @@ -136,24 +136,27 @@ internal fun MessageBubbleContainer( } val onReplySwipeState by rememberUpdatedState(onReplySwipe) - val onBubbleClick: (Offset) -> Unit = remember(msg.id) { + val onBubbleClick: (Offset) -> Unit = remember(msg.id, behavior.isSelectionMode) { { offset -> - onReplyClickState( + val callback = when (resolveMessageInteraction( + messageId = msg.id, + isLongPress = false, + isSelectionMode = behavior.isSelectionMode, + entityTapConsumed = false + ).action) { + MessageInteractionAction.OpenOptions -> onReplyClickState + MessageInteractionAction.ToggleSelection -> onMessageLongPressState + ?: onReplyClickState + + MessageInteractionAction.None -> null + } + callback?.invoke( layoutTracker.bubblePosition, layoutTracker.bubbleSize, layoutTracker.bubblePosition + offset ) } } - remember(msg.id) { - { - onReplyClickState( - layoutTracker.bubblePosition, - layoutTracker.bubbleSize, - layoutTracker.bubblePosition + (layoutTracker.bubbleSize.toSize() / 2f).toOffset() - ) - } - } val onBubbleLongClick: (Offset) -> Unit = remember(msg.id) { { offset -> (onMessageLongPressState ?: onReplyClickState)( @@ -537,6 +540,7 @@ private fun MessageContentSelector( bubbleRadius = appearance.bubbleRadius, isGroup = isGroup, showLinkPreviews = appearance.showLinkPreviews, + animationsEnabled = appearance.animationsEnabled, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, onLinkPreviewAction = onLinkPreviewAction, @@ -603,13 +607,15 @@ private fun MessageContentSelector( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onCancelDownload = onCancelDownload, + onClick = onBubbleClick, onLongClick = onBubbleLongClick, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils + downloadUtils = downloadUtils, + animationsEnabled = appearance.animationsEnabled ) } @@ -629,6 +635,7 @@ private fun MessageContentSelector( onVideoClick = onVideoClick, onDownloadVideo = onDownloadVideo, onCancelDownload = onCancelDownload, + onClick = onBubbleClick, onLongClick = onBubbleLongClick, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, @@ -636,7 +643,8 @@ private fun MessageContentSelector( onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = isAnyViewerOpen, + animationsEnabled = appearance.animationsEnabled ) } @@ -649,7 +657,8 @@ private fun MessageContentSelector( onCancelDownload = onCancelDownload, onLongClick = onBubbleLongClick, onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) } + onReactionClick = { onReactionClick(msg.id, it) }, + animationsEnabled = appearance.animationsEnabled ) } @@ -693,6 +702,7 @@ private fun MessageContentSelector( autoplayGifs = appearance.autoplayGifs, onGifClick = onVideoClick, onCancelDownload = onCancelDownload, + onClick = onBubbleClick, onLongClick = onBubbleLongClick, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, @@ -700,7 +710,8 @@ private fun MessageContentSelector( onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = isAnyViewerOpen, + animationsEnabled = appearance.animationsEnabled ) } @@ -720,6 +731,7 @@ private fun MessageContentSelector( autoDownloadRoaming = appearance.autoDownloadRoaming, onDocumentClick = onDocumentClick, onCancelDownload = onCancelDownload, + onClick = onBubbleClick, onLongClick = onBubbleLongClick, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, @@ -744,6 +756,7 @@ private fun MessageContentSelector( autoDownloadRoaming = appearance.autoDownloadRoaming, onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, + onClick = onBubbleClick, onLongClick = onBubbleLongClick, onReplyClick = onGoToReply, onReactionClick = { onReactionClick(msg.id, it) }, @@ -850,10 +863,13 @@ private fun MessageContentSelector( onPhotoClick = onPhotoClick, onVideoClick = onVideoClick, onOpenBuy = { onPaidMediaBuy(msg) }, + onClick = onBubbleClick, onLongClick = onBubbleCenterLongClick, + onReplyClick = onGoToReply, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, - isGroup = isGroup + isGroup = isGroup, + animationsEnabled = appearance.animationsEnabled ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageInteractionPolicy.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageInteractionPolicy.kt new file mode 100644 index 000000000..d0902635a --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageInteractionPolicy.kt @@ -0,0 +1,26 @@ +package org.monogram.presentation.features.chats.conversation.ui + +internal enum class MessageInteractionAction { + None, + OpenOptions, + ToggleSelection +} + +internal data class MessageInteractionResolution( + val action: MessageInteractionAction, + val messageId: Long +) + +internal fun resolveMessageInteraction( + messageId: Long, + isLongPress: Boolean, + isSelectionMode: Boolean, + entityTapConsumed: Boolean +): MessageInteractionResolution { + val action = when { + entityTapConsumed -> MessageInteractionAction.None + isLongPress || isSelectionMode -> MessageInteractionAction.ToggleSelection + else -> MessageInteractionAction.OpenOptions + } + return MessageInteractionResolution(action = action, messageId = messageId) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageRowContracts.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageRowContracts.kt index a838d7f90..2fca006d5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageRowContracts.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/MessageRowContracts.kt @@ -17,7 +17,8 @@ internal data class MessageAppearanceConfig( val autoDownloadWifi: Boolean = false, val autoDownloadRoaming: Boolean = false, val autoDownloadFiles: Boolean = false, - val showReactions: Boolean = true + val showReactions: Boolean = true, + val animationsEnabled: Boolean = true ) @Immutable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt index f11c900f5..faa5913db 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelAlbumMessageBubble.kt @@ -82,13 +82,15 @@ fun ChannelAlbumMessageBubble( onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, onReplyClick: (MessageModel) -> Unit = {}, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReactionClick: (String) -> Unit = {}, onCommentsClick: (Long) -> Unit = {}, showComments: Boolean = true, toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { if (messages.isEmpty()) return @@ -111,6 +113,7 @@ fun ChannelAlbumMessageBubble( onDocumentClick = onDocumentClick, onCancelDownload = onCancelDownload, onLongClick = onLongClick, + onMessageClick = onMessageClick, onMessageLongPress = onMessageLongPress, onReplyClick = onReplyClick, onReactionClick = onReactionClick, @@ -138,6 +141,7 @@ fun ChannelAlbumMessageBubble( onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, onLongClick = onLongClick, + onMessageClick = onMessageClick, onMessageLongPress = onMessageLongPress, onReplyClick = onReplyClick, onReactionClick = onReactionClick, @@ -263,11 +267,17 @@ fun ChannelAlbumMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg ?: lastMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg ?: lastMsg, clickPosition) } else { onLongClick(clickPosition) } @@ -317,7 +327,8 @@ fun ChannelAlbumMessageBubble( autoDownloadRoaming = autoDownloadRoaming, toProfile = toProfile, downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = isAnyViewerOpen, + animationsEnabled = animationsEnabled ) if (caption.isNotEmpty() && !showCaptionAboveMedia) { @@ -350,11 +361,17 @@ fun ChannelAlbumMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg ?: lastMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg ?: lastMsg, clickPosition) } else { onLongClick(clickPosition) } @@ -413,6 +430,7 @@ fun ChannelDocumentAlbumBubble( onDocumentClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, onLongClick: (Offset) -> Unit, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReplyClick: (MessageModel) -> Unit, onReactionClick: (String) -> Unit, @@ -569,11 +587,17 @@ fun ChannelDocumentAlbumBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg, clickPosition) } else { onLongClick(clickPosition) } @@ -625,6 +649,7 @@ fun ChannelAudioAlbumBubble( onAudioClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, onLongClick: (Offset) -> Unit, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReplyClick: (MessageModel) -> Unit, onReactionClick: (String) -> Unit, @@ -784,11 +809,17 @@ fun ChannelAudioAlbumBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg, clickPosition) } else { onLongClick(clickPosition) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt index 72b73c333..475cd6e55 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelGifMessageBubble.kt @@ -1,7 +1,6 @@ package org.monogram.presentation.features.chats.conversation.ui.channel import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box @@ -42,9 +41,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import coil3.compose.rememberAsyncImagePainter -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -57,11 +53,11 @@ import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppres import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction -import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView import org.monogram.presentation.features.chats.conversation.ui.message.MessageText import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.StableMediaImage import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageTextRenderData @Composable @@ -81,6 +77,7 @@ fun ChannelGifMessageBubble( isSameSenderBelow: Boolean = false, onGifClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, + onClick: (Offset) -> Unit = {}, onLongClick: (Offset) -> Unit, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, @@ -90,7 +87,8 @@ fun ChannelGifMessageBubble( showReactions: Boolean = true, toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { val context = LocalContext.current val cornerRadius = bubbleRadius.dp @@ -203,7 +201,7 @@ fun ChannelGifMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(gifPosition + offset) }, + onClick = { offset -> onClick(gifPosition + offset) }, onLongClick = { offset -> onLongClick(gifPosition + offset) } ) } @@ -240,6 +238,15 @@ fun ChannelGifMessageBubble( .onGloballyPositioned { gifPosition = it.positionInWindow() } ) { + StableMediaImage( + previewModel = content.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = gifCacheKey, + contentScale = ContentScale.Fit, + contentDescription = content.caption, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) if (hasPath) { if (autoplayGifs) { stablePath?.let { path -> @@ -254,24 +261,6 @@ fun ChannelGifMessageBubble( ) } } else { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - gifCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(false) - .build() - ), - contentDescription = content.caption, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit - ) - Box( modifier = Modifier .align(Alignment.Center) @@ -309,11 +298,6 @@ fun ChannelGifMessageBubble( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - MediaLoadingBackground( - previewData = content.minithumbnail, - contentScale = ContentScale.Fit - ) - MediaLoadingAction( isDownloading = content.isDownloading, progress = content.downloadProgress, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt index e4beee477..7d84f6c73 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelMessageBubbleContainer.kt @@ -282,6 +282,13 @@ internal fun ChannelMessageBubbleContainer( onPhotoClick = onPhotoClick, onDownloadPhoto = onDownloadPhoto, onCancelDownload = onCancelDownload, + onClick = { offset -> + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) + }, onLongClick = { offset -> (onMessageLongPressState ?: onReplyClickState)( layoutTracker.bubblePosition, @@ -296,7 +303,8 @@ internal fun ChannelMessageBubbleContainer( toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils + downloadUtils = downloadUtils, + animationsEnabled = appearance.animationsEnabled ) } @@ -316,6 +324,13 @@ internal fun ChannelMessageBubbleContainer( onVideoClick = onVideoClick, onDownloadVideo = onDownloadVideo, onCancelDownload = onCancelDownload, + onClick = { offset -> + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) + }, onLongClick = { offset -> (onMessageLongPressState ?: onReplyClickState)( layoutTracker.bubblePosition, @@ -331,7 +346,8 @@ internal fun ChannelMessageBubbleContainer( onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), downloadUtils = downloadUtils, - isAnyViewerOpen = behavior.isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen, + animationsEnabled = appearance.animationsEnabled ) } @@ -350,6 +366,13 @@ internal fun ChannelMessageBubbleContainer( autoDownloadRoaming = appearance.autoDownloadRoaming, onDocumentClick = onDocumentClick, onCancelDownload = onCancelDownload, + onClick = { offset -> + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) + }, onLongClick = { offset -> (onMessageLongPressState ?: onReplyClickState)( layoutTracker.bubblePosition, @@ -381,13 +404,20 @@ internal fun ChannelMessageBubbleContainer( autoDownloadRoaming = appearance.autoDownloadRoaming, onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, - onLongClick = { offset -> + onClick = { offset -> onReplyClickState( layoutTracker.bubblePosition, layoutTracker.bubbleSize, layoutTracker.bubblePosition + offset ) }, + onLongClick = { offset -> + (onMessageLongPressState ?: onReplyClickState)( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) + }, toProfile = toProfile, onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), @@ -445,6 +475,13 @@ internal fun ChannelMessageBubbleContainer( autoplayGifs = appearance.autoplayGifs, onGifClick = onVideoClick, onCancelDownload = onCancelDownload, + onClick = { offset -> + onReplyClickState( + layoutTracker.bubblePosition, + layoutTracker.bubbleSize, + layoutTracker.bubblePosition + offset + ) + }, onLongClick = { offset -> (onMessageLongPressState ?: onReplyClickState)( layoutTracker.bubblePosition, @@ -460,7 +497,8 @@ internal fun ChannelMessageBubbleContainer( onForwardOriginClick = onForwardOriginClick, modifier = Modifier.fillMaxWidth(), downloadUtils = downloadUtils, - isAnyViewerOpen = behavior.isAnyViewerOpen + isAnyViewerOpen = behavior.isAnyViewerOpen, + animationsEnabled = appearance.animationsEnabled ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt index e1605b8b3..62e498ac1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelPhotoMessageBubble.kt @@ -36,9 +36,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -49,11 +46,11 @@ import org.monogram.presentation.features.chats.conversation.AutoDownloadSuppres import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction -import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView import org.monogram.presentation.features.chats.conversation.ui.message.MessageText import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.StableMediaImage import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageTextRenderData @Composable @@ -71,6 +68,7 @@ fun ChannelPhotoMessageBubble( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, + onClick: (Offset) -> Unit = {}, onLongClick: (Offset) -> Unit, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, @@ -81,7 +79,8 @@ fun ChannelPhotoMessageBubble( toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, modifier: Modifier = Modifier, - downloadUtils: IDownloadUtils + downloadUtils: IDownloadUtils, + animationsEnabled: Boolean = true ) { val context = LocalContext.current val cornerRadius = bubbleRadius.dp @@ -195,7 +194,7 @@ fun ChannelPhotoMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(imagePosition + offset) }, + onClick = { offset -> onClick(imagePosition + offset) }, onLongClick = { offset -> onLongClick(imagePosition + offset) } ) } @@ -275,30 +274,15 @@ fun ChannelPhotoMessageBubble( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - if (!hasPath) { - MediaLoadingBackground( - previewData = content.minithumbnail, - contentScale = ContentScale.Fit - ) - } - - if (hasPath) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - photoCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(false) - .build(), - contentDescription = content.caption, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit - ) - } + StableMediaImage( + previewModel = content.thumbnailPath ?: content.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = photoCacheKey, + contentScale = ContentScale.Fit, + contentDescription = content.caption, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) if (!hasPath) { MediaLoadingAction( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt index 3bcb5a141..82d2718b7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt @@ -1,6 +1,5 @@ package org.monogram.presentation.features.chats.conversation.ui.channel -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -49,9 +48,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import coil3.compose.rememberAsyncImagePainter -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -66,11 +62,11 @@ import org.monogram.presentation.features.chats.conversation.ui.content.resolveV import org.monogram.presentation.features.chats.conversation.ui.message.BigEmojiContent import org.monogram.presentation.features.chats.conversation.ui.message.ForwardContent import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingAction -import org.monogram.presentation.features.chats.conversation.ui.message.MediaLoadingBackground import org.monogram.presentation.features.chats.conversation.ui.message.MessageMetadata import org.monogram.presentation.features.chats.conversation.ui.message.MessageReactionsView import org.monogram.presentation.features.chats.conversation.ui.message.MessageText import org.monogram.presentation.features.chats.conversation.ui.message.ReplyContent +import org.monogram.presentation.features.chats.conversation.ui.message.StableMediaImage import org.monogram.presentation.features.chats.conversation.ui.message.rememberMessageTextRenderData @Composable @@ -90,6 +86,7 @@ fun ChannelVideoMessageBubble( onVideoClick: (MessageModel) -> Unit, onDownloadVideo: (Int) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, + onClick: (Offset) -> Unit = {}, onLongClick: (Offset) -> Unit, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, @@ -100,7 +97,8 @@ fun ChannelVideoMessageBubble( toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { val cornerRadius = bubbleRadius.dp val smallCorner = (bubbleRadius / 4f).coerceAtLeast(4f).dp @@ -225,7 +223,7 @@ fun ChannelVideoMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(videoPosition + offset) }, + onClick = { offset -> onClick(videoPosition + offset) }, onLongClick = { offset -> onLongClick(videoPosition + offset) } ) } @@ -283,6 +281,15 @@ fun ChannelVideoMessageBubble( ) { val canStream = content.supportsStreaming && content.fileId != 0 + StableMediaImage( + previewModel = content.thumbnailPath ?: content.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = videoCacheKey, + contentScale = ContentScale.Crop, + contentDescription = content.caption, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) if (hasPath || canStream) { if (autoplayVideos) { val videoPath = stablePath ?: content.fileId.takeIf { it != 0 } @@ -303,7 +310,7 @@ fun ChannelVideoMessageBubble( } }, fileId = if (stablePath == null && content.fileId != 0) content.fileId else 0, - thumbnailData = content.minithumbnail + thumbnailData = content.thumbnailPath ?: content.minithumbnail ) // Volume Toggle @@ -324,44 +331,6 @@ fun ChannelVideoMessageBubble( ) } } else { - if (hasPath) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - videoCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(false) - .build() - ), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - if (content.minithumbnail != null) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(content.minithumbnail) - .apply { - videoMiniCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .build() - ), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - } Box( modifier = Modifier .align(Alignment.Center) @@ -405,11 +374,6 @@ fun ChannelVideoMessageBubble( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - MediaLoadingBackground( - previewData = content.minithumbnail, - contentScale = ContentScale.Crop - ) - MediaLoadingAction( isDownloading = content.isDownloading, progress = content.downloadProgress, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt index 27c2dabfb..4d608acdc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentBody.kt @@ -313,6 +313,7 @@ internal fun ChatContentBody( val fileId = when (val content = msg.content) { is MessageContent.Video -> content.fileId is MessageContent.Gif -> content.fileId + is MessageContent.VideoNote -> content.fileId else -> 0 } if (fileId != 0) { @@ -368,22 +369,28 @@ internal fun ChatContentBody( } val onAudioClick: (MessageModel) -> Unit = remember(component) { { msg -> - val audio = msg.content as? MessageContent.Audio - if (audio != null) { - val validPath = audio.path?.takeIf { File(it).exists() } - if (validPath != null) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenVideo( - path = validPath, - messageId = msg.id, - caption = audio.caption - ) - } else { - component.onDownloadFile(audio.fileId, userInitiated = true) + when (val content = msg.content) { + is MessageContent.Audio -> { + val validPath = content.path?.takeIf { File(it).exists() } + if (validPath != null) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenVideo( + path = validPath, + messageId = msg.id, + caption = content.caption + ) + } else { + component.onDownloadFile(content.fileId, userInitiated = true) + } + } + + is MessageContent.Voice -> if (content.path.isNullOrBlank()) { + component.onDownloadFile(content.fileId, userInitiated = true) } + + else -> Unit } - Unit } } val onMessageOptionsClick: (MessageModel, Offset, IntSize, Offset) -> Unit = diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt index ab37540a1..34714194a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentEffects.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.conversation.ui.content +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -28,6 +29,12 @@ import kotlin.math.abs private const val MIN_DOWNLOAD_PREFETCH_ITEMS = 12 private const val DOWNLOAD_PREFETCH_VIEWPORT_MULTIPLIER = 2 +internal fun selectLatestGroupedMessageId( + firstGroupedMessageId: Long?, + lastGroupedMessageId: Long?, + isComments: Boolean +): Long? = if (isComments) lastGroupedMessageId else firstGroupedMessageId + internal fun shouldAutoFollowLatestAfterContentChange( previousLastGroupedMessageId: Long?, currentLastGroupedMessageId: Long?, @@ -47,7 +54,7 @@ internal fun shouldAutoFollowLatestAfterContentChange( if (previousLastGroupedMessageId == null || currentLastGroupedMessageId == null) return false if (previousLastGroupedMessageId == currentLastGroupedMessageId) return false if (pendingScrollCommand != null) return false - if (isLoadingOlder || isLoadingNewer || isScrollInProgress || showInitialLoading) return false + if (isLoading || isLoadingOlder || isLoadingNewer || isScrollInProgress || showInitialLoading) return false if (!isLatestLoaded) return false return true } @@ -85,11 +92,14 @@ internal fun shouldRetainBottomAlignmentAfterContentChange( isLoadingOlder: Boolean, isLoadingNewer: Boolean, isScrollInProgress: Boolean, + isDragged: Boolean = false, + hasUserScrolledAwayFromBottom: Boolean = false, bottomAlignmentDeltaPx: Float?, alignmentTolerancePx: Float = 1f ): Boolean { if (viewportPhase != ChatViewportPhase.Settled) return false if (pendingScrollCommand != null) return false + if (isDragged || hasUserScrolledAwayFromBottom) return false if (!stateIsAtBottom && !measuredIsAtBottom) return false if (isLoading || isLoadingOlder || isLoadingNewer || isScrollInProgress) return false val delta = bottomAlignmentDeltaPx ?: return false @@ -140,11 +150,11 @@ internal fun ChatContentEffects( val isViewportSettled = effectsEnabled && state.viewportPhase == ChatViewportPhase.Settled val firstGroupedMessageId = groupedMessages.firstOrNull()?.firstMessageId val lastGroupedMessageId = groupedMessages.lastOrNull()?.firstMessageId - val latestGroupedMessageId = if (isComments) { - lastGroupedMessageId - } else { - firstGroupedMessageId - } + val latestGroupedMessageId = selectLatestGroupedMessageId( + firstGroupedMessageId = firstGroupedMessageId, + lastGroupedMessageId = lastGroupedMessageId, + isComments = isComments + ) val conversationItems = remember( groupedMessages, state.channelSponsoredMessages, @@ -677,8 +687,13 @@ internal fun ChatContentEffects( effectsEnabled, state.viewportPhase, state.pendingScrollCommand, - groupedMessages.size, - state.isLatestLoaded + groupedMessages, + state.isLatestLoaded, + state.isLoading, + state.isLoadingOlder, + state.isLoadingNewer, + isDragged, + hasUserScrolledAwayFromBottom ) { if (!isViewportSettled) return@LaunchedEffect if (isComments) return@LaunchedEffect @@ -697,78 +712,34 @@ internal fun ChatContentEffects( isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, isScrollInProgress = scrollState.isScrollInProgress, + isDragged = isDragged, + hasUserScrolledAwayFromBottom = hasUserScrolledAwayFromBottom, bottomAlignmentDeltaPx = bottomAlignmentDelta ) ) { - val bottomMessageLazyIndex = chatContentLeadingItemsCount( - isComments = false, - showNavPadding = false, - isLoadingOlder = state.isLoadingOlder, - isLoadingNewer = state.isLoadingNewer, - isAtBottom = state.isAtBottom, - isNearBottom = scrollState.isNearBottom(isComments = false), - hasMessages = groupedMessages.isNotEmpty() - ) - scrollState.scrollToChatBottomStaged( - isComments = isComments, - animated = false, - bottomTargetIndex = bottomMessageLazyIndex - ) - } - } - - LaunchedEffect( - effectsEnabled, - state.viewportPhase, - scrollState, - isComments, - state.pendingScrollCommand, - state.isLatestLoaded, - state.isAtBottom, - isDragged, - hasUserScrolledAwayFromBottom - ) { - if (!isViewportSettled || isComments) return@LaunchedEffect - - snapshotFlow { - val target = scrollState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 } - Triple( - scrollState.layoutInfo.totalItemsCount, - target?.offset, - target?.size - ) - } - .distinctUntilChanged() - .collectLatest { (totalItems, targetOffset, targetSize) -> - val currentState = latestUiState.value - if ( - totalItems <= 0 || - targetOffset == null || - targetSize == null || - currentState.pendingScrollCommand != null || - !currentState.isAtBottom || - !currentState.isLatestLoaded || - currentState.isLoadingOlder || - currentState.isLoadingNewer || - isDragged || - hasUserScrolledAwayFromBottom || - scrollState.isScrollInProgress - ) { - return@collectLatest - } - - val measuredAtBottom = scrollState.isAtBottom( - isComments = false, - isLatestLoaded = currentState.isLatestLoaded - ) - if (!measuredAtBottom) { - scrollState.scrollToChatBottomStaged( + withFrameNanos { } + val currentState = latestUiState.value + val correctedDelta = scrollState.bottomAlignmentDelta(isComments = false) + if (shouldRetainBottomAlignmentAfterContentChange( + viewportPhase = currentState.viewportPhase, + pendingScrollCommand = currentState.pendingScrollCommand, + stateIsAtBottom = currentState.isAtBottom, + measuredIsAtBottom = scrollState.isAtBottom( isComments = false, - animated = false, - bottomTargetIndex = 0 - ) - } + isLatestLoaded = currentState.isLatestLoaded + ), + isLoading = currentState.isLoading, + isLoadingOlder = currentState.isLoadingOlder, + isLoadingNewer = currentState.isLoadingNewer, + isScrollInProgress = scrollState.isScrollInProgress, + isDragged = isDragged, + hasUserScrolledAwayFromBottom = hasUserScrolledAwayFromBottom, + bottomAlignmentDeltaPx = correctedDelta + ) + ) { + scrollState.scrollBy(correctedDelta!!) } + } } LaunchedEffect(isDragged) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt index 1efbd4f5c..0fc736d28 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentList.kt @@ -13,7 +13,6 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -1061,8 +1060,6 @@ private fun MessageRowItem( val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val highlightAccent = if (isDarkTheme) Color(0xFFFFD54F) else Color(0xFFFFB300) val highlightBackground = remember { androidx.compose.animation.Animatable(Color.Transparent) } - val highlightBorderAlpha = remember { Animatable(0f) } - val highlightScale = remember { Animatable(1f) } var handledHighlightToken by remember(mainMsg.id) { mutableLongStateOf(Long.MIN_VALUE) } LaunchedEffect(mainMsg.id, canReportVisibleMessagesAsRead) { @@ -1095,33 +1092,14 @@ private fun MessageRowItem( handledHighlightToken = request.token highlightBackground.stop() - highlightBorderAlpha.stop() - highlightScale.stop() - highlightBackground.snapTo(Color.Transparent) - highlightBorderAlpha.snapTo(0f) - highlightScale.snapTo(1f) highlightBackground.animateTo( highlightAccent.copy(alpha = if (isDarkTheme) 0.20f else 0.16f), - animationSpec = tween(220) + animationSpec = tween(180) ) - val borderInJob = launch { highlightBorderAlpha.animateTo(1f, animationSpec = tween(180)) } - val scaleInJob = launch { highlightScale.animateTo(1.012f, animationSpec = tween(220)) } - borderInJob.join() - scaleInJob.join() - - kotlinx.coroutines.delay(950) - - val scaleOutJob = launch { highlightScale.animateTo(1f, animationSpec = tween(260)) } - val borderOutJob = - launch { highlightBorderAlpha.animateTo(0f, animationSpec = tween(1800)) } - val backgroundOutJob = launch { - highlightBackground.animateTo(Color.Transparent, animationSpec = tween(2200)) - } - scaleOutJob.join() - borderOutJob.join() - backgroundOutJob.join() + kotlinx.coroutines.delay(700) + highlightBackground.animateTo(Color.Transparent, animationSpec = tween(700)) component.onHighlightConsumed() } @@ -1143,26 +1121,14 @@ private fun MessageRowItem( animationSpec = tween(durationMillis = 180), label = "selectionAlpha" ) - val borderColor by animateColorAsState( - targetValue = if (highlightBorderAlpha.value > 0f) { - highlightAccent.copy(alpha = 0.95f * highlightBorderAlpha.value) - } else { - Color.Transparent - }, - animationSpec = tween(durationMillis = 220), - label = "highlightBorder" - ) Box( modifier = Modifier .fillMaxWidth() .graphicsLayer { - scaleX = highlightScale.value - scaleY = highlightScale.value alpha = itemAlpha.value translationY = offsetY.value } .background(backgroundColor, rowShape) - .border(width = 1.5.dp, color = borderColor, shape = rowShape) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -1568,6 +1534,13 @@ private fun MessageBubbleSwitcher( ) } }, + onMessageClick = { tappedMessage, pos, size, click -> + if (behavior.isSelectionMode) { + component.onToggleMessageSelection(tappedMessage.id) + } else { + onMessageOptionsClick(tappedMessage, pos, size, click) + } + }, onMessageLongPress = { tappedMessage, _, _, _ -> component.onToggleMessageSelection(tappedMessage.id) }, @@ -1769,7 +1742,8 @@ private fun ChatMessageListUiState.toAppearanceConfig(): MessageAppearanceConfig autoDownloadWifi = autoDownloadWifi, autoDownloadRoaming = autoDownloadRoaming, autoDownloadFiles = autoDownloadFiles, - showReactions = showReactions + showReactions = showReactions, + animationsEnabled = isChatAnimationsEnabled ) private fun ChatMessageListUiState.toBehaviorConfig( @@ -1860,7 +1834,7 @@ private fun handleVideoClick(msg: MessageModel, onVideoClick: (MessageModel, Str } } -private fun handleVideoTap( +internal fun handleVideoTap( msg: MessageModel, onDownloadVideo: (Int) -> Unit, onVideoClick: (MessageModel, String?, String?) -> Unit @@ -1875,6 +1849,14 @@ private fun handleVideoTap( } is MessageContent.Gif -> onVideoClick(msg, content.path, content.caption) + is MessageContent.VideoNote -> { + if (content.path.isNullOrBlank()) { + onDownloadVideo(content.fileId) + } else { + onVideoClick(msg, content.path, null) + } + } + else -> {} } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt index 971fab361..e63862ac1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinator.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Immutable import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull @@ -36,6 +37,22 @@ internal data class ScrollToMessagePlan( val shouldAnimateToIndex: Boolean ) +@Immutable +internal data class BottomCorrectionPlan( + val targetIndex: Int, + val coarseIndex: Int?, + val shouldScrollToTarget: Boolean, + val alignmentDelta: Float? +) + +internal fun calculateChatBottomTargetIndex( + totalItemsCount: Int, + isComments: Boolean +): Int? { + if (totalItemsCount <= 0) return null + return if (isComments) totalItemsCount - 1 else 0 +} + internal fun calculateBottomAlignmentDelta( viewportStart: Int, viewportEnd: Int, @@ -94,9 +111,42 @@ internal fun buildBottomCoarseScrollIndex( } } +internal fun buildBottomCorrectionPlan( + currentFirstVisibleIndex: Int, + targetIndex: Int, + totalItemsCount: Int, + isComments: Boolean, + visibleTargetDelta: Float? +): BottomCorrectionPlan? { + if (totalItemsCount <= 0) return null + + val boundedTargetIndex = targetIndex.coerceIn(0, totalItemsCount - 1) + if (visibleTargetDelta != null) { + return BottomCorrectionPlan( + targetIndex = boundedTargetIndex, + coarseIndex = null, + shouldScrollToTarget = false, + alignmentDelta = visibleTargetDelta.takeIf(::needsBottomAlignmentCorrection) + ) + } + + return BottomCorrectionPlan( + targetIndex = boundedTargetIndex, + coarseIndex = buildBottomCoarseScrollIndex( + currentFirstVisibleIndex = currentFirstVisibleIndex, + targetIndex = boundedTargetIndex, + totalItemsCount = totalItemsCount, + isComments = isComments + ), + shouldScrollToTarget = true, + alignmentDelta = null + ) +} + internal fun LazyListState.bottomAlignmentDelta(isComments: Boolean): Float? { val info = layoutInfo - val targetIndex = if (isComments) info.totalItemsCount - 1 else 0 + val targetIndex = + calculateChatBottomTargetIndex(info.totalItemsCount, isComments) ?: return null val targetInfo = info.visibleItemsInfo.firstOrNull { it.index == targetIndex } ?: return null return calculateBottomAlignmentDelta( viewportStart = info.viewportStartOffset, @@ -230,66 +280,47 @@ internal suspend fun LazyListState.scrollToChatBottomStaged( val total = layoutInfo.totalItemsCount if (total <= 0) return - val targetIndex = (bottomTargetIndex ?: if (isComments) total - 1 else 0) + val targetIndex = + (bottomTargetIndex ?: calculateChatBottomTargetIndex(total, isComments) ?: return) .coerceIn(0, total - 1) val visibleTargetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == targetIndex } - if (visibleTargetInfo != null) { - val visibleDelta = calculateBottomAlignmentDelta( + val visibleDelta = visibleTargetInfo?.let { + calculateBottomAlignmentDelta( viewportStart = layoutInfo.viewportStartOffset, viewportEnd = layoutInfo.viewportEndOffset, - itemOffset = visibleTargetInfo.offset, - itemSize = visibleTargetInfo.size, + itemOffset = it.offset, + itemSize = it.size, isComments = isComments ) - if (!needsBottomAlignmentCorrection(visibleDelta)) return - - if (shouldUseVisibleBottomFastPath( - targetAlreadyVisible = true, - bottomAlignmentDelta = visibleDelta - ) - ) { - if (animated) { - animateScrollBy(visibleDelta) - } else { - scrollBy(visibleDelta) - } - val remainingDelta = bottomAlignmentDelta(isComments = isComments) - if (remainingDelta == null || !needsBottomAlignmentCorrection(remainingDelta)) return - } } - - buildBottomCoarseScrollIndex( + val plan = buildBottomCorrectionPlan( currentFirstVisibleIndex = firstVisibleItemIndex, targetIndex = targetIndex, totalItemsCount = total, - isComments = isComments - )?.let { coarseIndex -> + isComments = isComments, + visibleTargetDelta = visibleDelta + ) ?: return + + plan.coarseIndex?.let { coarseIndex -> scrollToItem(coarseIndex) } - if (animated) { - animateScrollToItem(targetIndex) + if (plan.shouldScrollToTarget) { + if (animated) { + animateScrollToItem(plan.targetIndex) + } else { + scrollToItem(plan.targetIndex) + } } else { - scrollToItem(targetIndex) - } - - val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == targetIndex } - if (targetInfo != null) { - val delta = calculateBottomAlignmentDelta( - viewportStart = layoutInfo.viewportStartOffset, - viewportEnd = layoutInfo.viewportEndOffset, - itemOffset = targetInfo.offset, - itemSize = targetInfo.size, - isComments = isComments - ) - if (needsBottomAlignmentCorrection(delta)) { - scrollBy(delta) + plan.alignmentDelta?.let { delta -> + if (animated) animateScrollBy(delta) else scrollBy(delta) } - val remainingDelta = bottomAlignmentDelta(isComments = isComments) - if (remainingDelta == null || !needsBottomAlignmentCorrection(remainingDelta)) return } - scrollToItem(targetIndex) + withFrameNanos { } + bottomAlignmentDelta(isComments = isComments) + ?.takeIf(::needsBottomAlignmentCorrection) + ?.let { scrollBy(it) } } internal suspend fun LazyListState.scrollToChatStartStaged( @@ -312,7 +343,6 @@ internal suspend fun LazyListState.scrollToChatStartStaged( } } - scrollToItem(0) } internal suspend fun awaitGroupedIndex( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt index 8ee9aae8a..eae6650af 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/AudioMessageBubble.kt @@ -25,7 +25,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -288,6 +287,7 @@ fun AudioAlbumBubble( onAudioClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, onLongClick: (Offset) -> Unit, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReplyClick: (MessageModel) -> Unit, onReactionClick: (String) -> Unit, @@ -403,11 +403,17 @@ fun AudioAlbumBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg, clickPosition) } else { onLongClick(clickPosition) } @@ -449,6 +455,7 @@ fun ChannelAudioAlbumBubble( onAudioClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, onLongClick: (Offset) -> Unit, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReplyClick: (MessageModel) -> Unit, onReactionClick: (String) -> Unit, @@ -555,11 +562,17 @@ fun ChannelAudioAlbumBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg, clickPosition) } else { onLongClick(clickPosition) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt index a968dbf7f..5095171f0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/ChatAlbumMessageBubble.kt @@ -57,6 +57,7 @@ fun ChatAlbumMessageBubble( onAudioClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, onLongClick: (Offset) -> Unit, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, @@ -65,7 +66,8 @@ fun ChatAlbumMessageBubble( fontSize: Float = 16f, letterSpacing: Float = 0f, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { if (messages.isEmpty()) return @@ -88,6 +90,7 @@ fun ChatAlbumMessageBubble( onDocumentClick = onDocumentClick, onCancelDownload = onCancelDownload, onLongClick = onLongClick, + onMessageClick = onMessageClick, onMessageLongPress = onMessageLongPress, onReplyClick = onReplyClick, onReactionClick = onReactionClick, @@ -115,6 +118,7 @@ fun ChatAlbumMessageBubble( onAudioClick = onAudioClick, onCancelDownload = onCancelDownload, onLongClick = onLongClick, + onMessageClick = onMessageClick, onMessageLongPress = onMessageLongPress, onReplyClick = onReplyClick, onReactionClick = onReactionClick, @@ -250,11 +254,17 @@ fun ChatAlbumMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg ?: lastMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg ?: lastMsg, clickPosition) } else { onLongClick(clickPosition) } @@ -303,7 +313,8 @@ fun ChatAlbumMessageBubble( autoDownloadRoaming = autoDownloadRoaming, toProfile = toProfile, downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen + isAnyViewerOpen = isAnyViewerOpen, + animationsEnabled = animationsEnabled ) if (caption.isNotEmpty() && !showCaptionAboveMedia) { @@ -337,11 +348,17 @@ fun ChatAlbumMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg ?: lastMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg ?: lastMsg, clickPosition) } else { onLongClick(clickPosition) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt index f71cb8a3c..d69f6107f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/DocumentMessageBubble.kt @@ -309,6 +309,7 @@ fun DocumentAlbumBubble( onDocumentClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, onLongClick: (Offset) -> Unit, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReplyClick: (MessageModel) -> Unit, onReactionClick: (String) -> Unit, @@ -426,11 +427,17 @@ fun DocumentAlbumBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg, clickPosition) } else { onLongClick(clickPosition) } @@ -472,6 +479,7 @@ fun ChannelDocumentAlbumBubble( onDocumentClick: (MessageModel) -> Unit, onCancelDownload: (Int) -> Unit, onLongClick: (Offset) -> Unit, + onMessageClick: ((MessageModel, Offset) -> Unit)? = null, onMessageLongPress: ((MessageModel, Offset) -> Unit)? = null, onReplyClick: (MessageModel) -> Unit, onReactionClick: (String) -> Unit, @@ -579,11 +587,17 @@ fun ChannelDocumentAlbumBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(bubblePosition + offset) }, + onClick = { offset -> + val clickPosition = bubblePosition + offset + (onMessageClick ?: { _, click -> onLongClick(click) })( + captionMsg, + clickPosition + ) + }, onLongClick = { offset -> val clickPosition = bubblePosition + offset if (onMessageLongPress != null) { - onMessageLongPress(lastMsg, clickPosition) + onMessageLongPress(captionMsg, clickPosition) } else { onLongClick(clickPosition) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt index aece48391..78416466d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/GifMessageBubble.kt @@ -3,7 +3,6 @@ package org.monogram.presentation.features.chats.conversation.ui.message import androidx.annotation.OptIn -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box @@ -48,9 +47,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.media3.common.util.UnstableApi -import coil3.compose.rememberAsyncImagePainter -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -79,6 +75,7 @@ fun GifMessageBubble( modifier: Modifier = Modifier, onGifClick: (MessageModel) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, + onClick: (Offset) -> Unit = {}, onLongClick: (Offset) -> Unit, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, @@ -87,7 +84,8 @@ fun GifMessageBubble( toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { val context = LocalContext.current val cornerRadius = 18.dp @@ -221,7 +219,7 @@ fun GifMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(gifPosition + offset) }, + onClick = { offset -> onClick(gifPosition + offset) }, onLongClick = { offset -> onLongClick(gifPosition + offset) } ) } @@ -252,6 +250,15 @@ fun GifMessageBubble( .onGloballyPositioned { gifPosition = it.positionInWindow() } ) { + StableMediaImage( + previewModel = content.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = gifCacheKey, + contentScale = ContentScale.Fit, + contentDescription = content.caption, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) if (!stablePath.isNullOrBlank()) { if (autoplayGifs) { VideoStickerPlayer( @@ -264,24 +271,6 @@ fun GifMessageBubble( thumbnailData = content.minithumbnail ) } else { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - gifCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(false) - .build() - ), - contentDescription = content.caption, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit - ) - Box( modifier = Modifier .align(Alignment.Center) @@ -319,11 +308,6 @@ fun GifMessageBubble( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - MediaLoadingBackground( - previewData = content.minithumbnail, - contentScale = ContentScale.Fit - ) - MediaLoadingAction( isDownloading = content.isDownloading, progress = content.downloadProgress, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreview.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreview.kt index 8fb3b135d..ee241b47e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreview.kt @@ -36,14 +36,10 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.WebPage import org.monogram.presentation.R @@ -54,7 +50,8 @@ internal fun LinkPreview( isOutgoing: Boolean, modifier: Modifier = Modifier, onAction: (LinkPreviewAction) -> Unit, - onLongClick: (() -> Unit)? = null + onLongClick: (() -> Unit)? = null, + animationsEnabled: Boolean = true ) { val resolved = remember(webPage) { webPage.resolveLinkPreview() } val hasTitle = !resolved.meta.title.isNullOrBlank() @@ -112,8 +109,10 @@ internal fun LinkPreview( } Spacer(modifier = Modifier.width(8.dp)) LinkPreviewSmallImage( - thumbnailData = resolved.thumbnailData, - thumbnailCacheKey = resolved.thumbnailCacheKey, + previewData = resolved.previewData, + fullResolutionData = resolved.fullResolutionData, + mediaCacheKey = resolved.mediaCacheKey, + animationsEnabled = animationsEnabled, onTap = { onAction(resolved.mediaAction) }, onLongClick = onLongClick ) @@ -134,8 +133,10 @@ internal fun LinkPreview( if (resolved.hasMedia) { Spacer(modifier = Modifier.height(8.dp)) LinkPreviewLargeMedia( - thumbnailData = resolved.thumbnailData, - thumbnailCacheKey = resolved.thumbnailCacheKey, + previewData = resolved.previewData, + fullResolutionData = resolved.fullResolutionData, + mediaCacheKey = resolved.mediaCacheKey, + animationsEnabled = animationsEnabled, aspectRatio = resolved.aspectRatio, showPlayOverlay = resolved.showPlayOverlay, duration = webPage.duration, @@ -218,13 +219,13 @@ private fun LinkPreviewTextContent( @Composable private fun LinkPreviewSmallImage( - thumbnailData: Any?, - thumbnailCacheKey: String?, + previewData: Any?, + fullResolutionData: Any?, + mediaCacheKey: String?, + animationsEnabled: Boolean, onTap: () -> Unit, onLongClick: (() -> Unit)? ) { - val context = LocalContext.current - Box( modifier = Modifier .size(54.dp) @@ -232,36 +233,30 @@ private fun LinkPreviewSmallImage( .background(MaterialTheme.colorScheme.surfaceVariant) .previewTapTarget(onTap = onTap, onLongClick = onLongClick) ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(thumbnailData) - .apply { - thumbnailCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(true) - .build(), + StableMediaImage( + previewModel = previewData, + fullResolutionModel = fullResolutionData, + cacheKey = mediaCacheKey, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, + animationsEnabled = animationsEnabled ) } } @Composable private fun LinkPreviewLargeMedia( - thumbnailData: Any?, - thumbnailCacheKey: String?, + previewData: Any?, + fullResolutionData: Any?, + mediaCacheKey: String?, + animationsEnabled: Boolean, aspectRatio: Float, showPlayOverlay: Boolean, duration: Int, onTap: () -> Unit, onLongClick: (() -> Unit)? ) { - val context = LocalContext.current - Box( modifier = Modifier .fillMaxWidth() @@ -270,20 +265,14 @@ private fun LinkPreviewLargeMedia( .background(MaterialTheme.colorScheme.surfaceVariant) .previewTapTarget(onTap = onTap, onLongClick = onLongClick) ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(thumbnailData) - .apply { - thumbnailCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(true) - .build(), + StableMediaImage( + previewModel = previewData, + fullResolutionModel = fullResolutionData, + cacheKey = mediaCacheKey, contentDescription = null, modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, + animationsEnabled = animationsEnabled ) if (showPlayOverlay) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt index f7a5db11e..dc805d513 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/LinkPreviewActions.kt @@ -45,12 +45,16 @@ internal data class ResolvedLinkPreview( val showPlayOverlay: Boolean, val showInstantViewButton: Boolean, val aspectRatio: Float, - val thumbnailData: Any?, - val thumbnailCacheKey: String?, + val previewData: Any?, + val fullResolutionData: Any?, + val mediaCacheKey: String?, val viewerCaption: String?, val sourceUrl: String, val meta: LinkPreviewMeta -) +) { + val thumbnailData: Any? get() = fullResolutionData ?: previewData + val thumbnailCacheKey: String? get() = mediaCacheKey +} internal fun WebPage.resolveLinkPreview(): ResolvedLinkPreview { val previewType = type @@ -129,11 +133,12 @@ internal fun WebPage.resolveLinkPreview(): ResolvedLinkPreview { else -> primaryAction } - val thumbnailData = resolveThumbnailData(isYouTube = isYouTube) + val previewData = resolvePreviewData() + val fullResolutionData = resolveFullResolutionData(isYouTube = isYouTube) ?: previewData val showPlayOverlay = isVideoLike val hasTextContent = !siteName.isNullOrBlank() || !title.isNullOrBlank() || !description.isNullOrBlank() - val hasMedia = thumbnailData != null + val hasMedia = fullResolutionData != null || previewData != null val isSmallMedia = previewPhoto != null && !showPlayOverlay && hasTextContent val aspectRatio = resolveAspectRatio() @@ -145,8 +150,9 @@ internal fun WebPage.resolveLinkPreview(): ResolvedLinkPreview { showPlayOverlay = showPlayOverlay, showInstantViewButton = isInstantView && sourceUrl.isNotBlank(), aspectRatio = aspectRatio, - thumbnailData = thumbnailData, - thumbnailCacheKey = namespacedCacheKey("link_preview_media", thumbnailData), + previewData = previewData, + fullResolutionData = fullResolutionData, + mediaCacheKey = namespacedCacheKey("link_preview_media", fullResolutionData), viewerCaption = viewerCaption, sourceUrl = sourceUrl, meta = meta @@ -265,14 +271,19 @@ private fun WebPage.isVideoLike( } } -private fun WebPage.resolveThumbnailData(isYouTube: Boolean): Any? { - return photo?.path - ?: photo?.thumbnailPath +private fun WebPage.resolvePreviewData(): Any? { + return photo?.thumbnailPath ?: photo?.minithumbnail ?: video?.thumbnailPath ?: video?.minithumbnail ?: animation?.thumbnailPath ?: animation?.minithumbnail +} + +private fun WebPage.resolveFullResolutionData(isYouTube: Boolean): Any? { + return photo?.path + ?: video?.thumbnailPath + ?: animation?.thumbnailPath ?: resolveYouTubeThumbnail(isYouTube = isYouTube) ?: sticker?.path } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaLoadingComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaLoadingComponents.kt index f88c469e2..d0ad7ee1c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaLoadingComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaLoadingComponents.kt @@ -1,10 +1,7 @@ package org.monogram.presentation.features.chats.conversation.ui.message -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -21,23 +18,130 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import org.monogram.presentation.R import org.monogram.presentation.core.util.namespacedCacheKey +internal enum class StableMediaPainterState { + Empty, + Loading, + Error, + Success +} + +internal enum class StableMediaVisualState { + Preview, + FullImageTransitioning, + FullImage +} + +internal data class StableMediaVisualResolution( + val visualState: StableMediaVisualState, + val readyModelKey: String? +) + +internal fun resolveStableMediaVisualState( + fullModelKey: String?, + painterState: StableMediaPainterState, + readyModelKey: String?, + animationsEnabled: Boolean +): StableMediaVisualResolution { + if (fullModelKey == null) { + return StableMediaVisualResolution(StableMediaVisualState.Preview, null) + } + if (painterState != StableMediaPainterState.Success) { + return StableMediaVisualResolution(StableMediaVisualState.Preview, null) + } + val modelWasReady = readyModelKey == fullModelKey + return StableMediaVisualResolution( + visualState = if (modelWasReady || !animationsEnabled) { + StableMediaVisualState.FullImage + } else { + StableMediaVisualState.FullImageTransitioning + }, + readyModelKey = fullModelKey + ) +} + +@Composable +internal fun StableMediaImage( + previewModel: Any?, + fullResolutionModel: Any?, + cacheKey: String?, + contentScale: ContentScale, + contentDescription: String?, + animationsEnabled: Boolean, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var readyModel by remember { mutableStateOf(null) } + LaunchedEffect(fullResolutionModel) { + if (readyModel != fullResolutionModel) readyModel = null + } + val isReady = fullResolutionModel != null && readyModel == fullResolutionModel + val fullImageAlpha by animateFloatAsState( + targetValue = if (isReady) 1f else 0f, + animationSpec = if (animationsEnabled) tween(durationMillis = 140) else snap(), + label = "StableMediaFullImageAlpha" + ) + + Box(modifier = modifier) { + MediaLoadingBackground( + previewData = previewModel, + contentScale = contentScale, + modifier = Modifier.fillMaxSize() + ) + if (fullResolutionModel != null) { + val request = remember(fullResolutionModel, cacheKey) { + ImageRequest.Builder(context) + .data(fullResolutionModel) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + } + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = fullImageAlpha }, + onState = { state -> + if (state is AsyncImagePainter.State.Success) { + readyModel = fullResolutionModel + } else { + readyModel = null + } + } + ) + } + } +} + @Composable fun MediaLoadingBackground( previewData: Any?, @@ -49,17 +153,6 @@ fun MediaLoadingBackground( val previewCacheKey = remember(previewData) { namespacedCacheKey("media_loading_preview", previewData) } - val pulse = rememberInfiniteTransition(label = "MediaLoadingPulse") - val pulseAlpha = pulse.animateFloat( - initialValue = 0.06f, - targetValue = 0.16f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 900, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "MediaLoadingPulseAlpha" - ) - Box( modifier = modifier .fillMaxSize() @@ -91,7 +184,7 @@ fun MediaLoadingBackground( modifier = Modifier .fillMaxSize() .drawBehind { - drawRect(Color.Black.copy(alpha = pulseAlpha.value)) + drawRect(Color.Black.copy(alpha = 0.08f)) } ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt index 94b061ee7..eec1825a7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PaidMediaMessageBubble.kt @@ -20,16 +20,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.PaidMediaItem +import org.monogram.presentation.core.util.namespacedCacheKey @Composable fun PaidMediaMessageBubble( @@ -41,12 +43,16 @@ fun PaidMediaMessageBubble( onPhotoClick: (MessageModel) -> Unit, onVideoClick: (MessageModel) -> Unit, onOpenBuy: () -> Unit, + onClick: (androidx.compose.ui.geometry.Offset) -> Unit = {}, onLongClick: () -> Unit = {}, + onReplyClick: (MessageModel) -> Unit = {}, toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, - isGroup: Boolean = false + isGroup: Boolean = false, + animationsEnabled: Boolean = true ) { val bubbleShape = RoundedCornerShape(18.dp) + val revealedSpoilers = remember { mutableStateListOf() } Surface( shape = bubbleShape, color = if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh @@ -67,15 +73,25 @@ fun PaidMediaMessageBubble( onForwardClick = onForwardOriginClick ) } - msg.replyToMsg?.let { ReplyContent(it, isOutgoing, onClick = { onLongClick() }) } + msg.replyToMsg?.let { ReplyContent(it, isOutgoing, onClick = { onReplyClick(it) }) } if (content.caption.isNotBlank() && content.showCaptionAboveMedia) { MessageText( - text = buildAnnotatedMessageTextWithEmoji(content.caption, content.entities), + text = buildAnnotatedMessageTextWithEmoji( + text = content.caption, + entities = content.entities, + isOutgoing = isOutgoing, + revealedSpoilers = revealedSpoilers + ), rawText = content.caption, inlineContent = rememberMessageInlineContent(content.entities, 16f), style = MaterialTheme.typography.bodyMedium, - entities = content.entities + entities = content.entities, + onSpoilerClick = { index -> + if (!revealedSpoilers.remove(index)) revealedSpoilers.add(index) + }, + onClick = onClick, + onLongClick = { onLongClick() } ) } @@ -84,6 +100,7 @@ fun PaidMediaMessageBubble( PaidMediaGridItem( item = item, locked = true, + animationsEnabled = animationsEnabled, onClick = when (item) { is PaidMediaItem.Photo -> { { onPhotoClick(msg) } @@ -101,11 +118,21 @@ fun PaidMediaMessageBubble( if (content.caption.isNotBlank() && !content.showCaptionAboveMedia) { MessageText( - text = buildAnnotatedMessageTextWithEmoji(content.caption, content.entities), + text = buildAnnotatedMessageTextWithEmoji( + text = content.caption, + entities = content.entities, + isOutgoing = isOutgoing, + revealedSpoilers = revealedSpoilers + ), rawText = content.caption, inlineContent = rememberMessageInlineContent(content.entities, 16f), style = MaterialTheme.typography.bodyMedium, - entities = content.entities + entities = content.entities, + onSpoilerClick = { index -> + if (!revealedSpoilers.remove(index)) revealedSpoilers.add(index) + }, + onClick = onClick, + onLongClick = { onLongClick() } ) } } @@ -116,6 +143,7 @@ fun PaidMediaMessageBubble( private fun PaidMediaGridItem( item: PaidMediaItem, locked: Boolean, + animationsEnabled: Boolean, onClick: () -> Unit ) { Box( @@ -127,18 +155,32 @@ private fun PaidMediaGridItem( contentAlignment = Alignment.Center ) { when (item) { - is PaidMediaItem.Photo -> AsyncImage( - model = item.path ?: item.thumbnailPath, + is PaidMediaItem.Photo -> StableMediaImage( + previewModel = item.thumbnailPath ?: item.minithumbnail, + fullResolutionModel = item.path, + cacheKey = remember(item.fileId, item.path) { + namespacedCacheKey("paid_photo:${item.fileId}", item.path) + }, + contentScale = ContentScale.Crop, contentDescription = null, - modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.Crop + animationsEnabled = animationsEnabled, + modifier = Modifier + .fillMaxWidth() + .height(112.dp) ) - is PaidMediaItem.Video -> AsyncImage( - model = item.path ?: item.thumbnailPath ?: item.coverPath, + is PaidMediaItem.Video -> StableMediaImage( + previewModel = item.thumbnailPath ?: item.minithumbnail, + fullResolutionModel = item.path ?: item.coverPath, + cacheKey = remember(item.fileId, item.path, item.coverPath) { + namespacedCacheKey("paid_video:${item.fileId}", item.path ?: item.coverPath) + }, + contentScale = ContentScale.Crop, contentDescription = null, - modifier = Modifier.fillMaxWidth(), - contentScale = ContentScale.Crop + animationsEnabled = animationsEnabled, + modifier = Modifier + .fillMaxWidth() + .height(112.dp) ) else -> Text("Paid media", color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt index 0c2af7505..e17ab3f44 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/PhotoMessageBubble.kt @@ -39,9 +39,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -67,6 +64,7 @@ fun PhotoMessageBubble( onPhotoClick: (MessageModel) -> Unit, onDownloadPhoto: (Int) -> Unit = {}, onCancelDownload: (Int) -> Unit = {}, + onClick: (Offset) -> Unit = {}, onLongClick: (Offset) -> Unit, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, @@ -75,7 +73,8 @@ fun PhotoMessageBubble( toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, modifier: Modifier = Modifier, - downloadUtils: IDownloadUtils + downloadUtils: IDownloadUtils, + animationsEnabled: Boolean = true ) { val context = LocalContext.current val cornerRadius = 18.dp @@ -219,7 +218,7 @@ fun PhotoMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClick(imagePosition + offset) }, + onClick = { offset -> onClick(imagePosition + offset) }, onLongClick = { offset -> onLongClick(imagePosition + offset) } ) } @@ -269,30 +268,15 @@ fun PhotoMessageBubble( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - if (!hasPath) { - MediaLoadingBackground( - previewData = content.minithumbnail, - contentScale = ContentScale.Fit - ) - } - - if (hasPath) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - photoCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(false) - .build(), - contentDescription = content.caption, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit - ) - } + StableMediaImage( + previewModel = content.thumbnailPath ?: content.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = photoCacheKey, + contentScale = ContentScale.Fit, + contentDescription = content.caption, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) if (!hasPath) { MediaLoadingAction( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt index 6388b551b..a2f2294f5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/TextMessageBubble.kt @@ -35,6 +35,7 @@ internal fun TextMessageBubble( isGroup: Boolean = false, bubbleRadius: Float = 18f, showLinkPreviews: Boolean = true, + animationsEnabled: Boolean = true, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, onLinkPreviewAction: ((LinkPreviewAction) -> Unit)? = null, @@ -212,7 +213,8 @@ internal fun TextMessageBubble( webPage = webPage, isOutgoing = msg.isOutgoing, onAction = { onLinkPreviewAction?.invoke(it) }, - onLongClick = onLinkPreviewLongClick + onLongClick = onLinkPreviewLongClick, + animationsEnabled = animationsEnabled ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt index 670c4eecb..bc7b0ae39 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt @@ -1,6 +1,5 @@ package org.monogram.presentation.features.chats.conversation.ui.message -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -53,9 +52,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import coil3.compose.rememberAsyncImagePainter -import coil3.request.ImageRequest -import coil3.request.crossfade import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -88,6 +84,7 @@ fun VideoMessageBubble( onDownloadVideo: (Int) -> Unit = {}, modifier: Modifier = Modifier, onCancelDownload: (Int) -> Unit = {}, + onClick: (Offset) -> Unit = {}, onLongClick: (Offset) -> Unit, onReplyClick: (MessageModel) -> Unit = {}, onReactionClick: (String) -> Unit = {}, @@ -96,7 +93,8 @@ fun VideoMessageBubble( toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, + animationsEnabled: Boolean = true ) { val cornerRadius = 18.dp val smallCorner = 4.dp @@ -248,7 +246,7 @@ fun VideoMessageBubble( revealedSpoilers.add(index) } }, - onClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) }, + onClick = { offset -> onClick(layoutTracker.videoPosition + offset) }, onLongClick = { offset -> onLongClickState(layoutTracker.videoPosition + offset) } ) } @@ -282,6 +280,15 @@ fun VideoMessageBubble( ) { val canStream = content.supportsStreaming && content.fileId != 0 + StableMediaImage( + previewModel = content.thumbnailPath ?: content.minithumbnail, + fullResolutionModel = stablePath, + cacheKey = videoCacheKey, + contentScale = ContentScale.Crop, + contentDescription = content.caption, + animationsEnabled = animationsEnabled, + modifier = Modifier.fillMaxSize() + ) if (hasPath || canStream) { if (autoplayVideos) { val videoPath = stablePath ?: content.fileId.takeIf { it != 0 } @@ -302,7 +309,7 @@ fun VideoMessageBubble( } }, fileId = if (stablePath == null && content.fileId != 0) content.fileId else 0, - thumbnailData = content.minithumbnail + thumbnailData = content.thumbnailPath ?: content.minithumbnail ) VideoMuteToggle( @@ -313,45 +320,6 @@ fun VideoMessageBubble( onToggle = { isMuted = !isMuted } ) } else { - if (hasPath) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(stablePath) - .apply { - videoCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .crossfade(false) - .build() - ), - contentDescription = content.caption, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - if (content.minithumbnail != null) { - Image( - painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(context) - .data(content.minithumbnail) - .apply { - videoMiniCacheKey?.let { - memoryCacheKey(it) - diskCacheKey(it) - } - } - .build() - ), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } - } - Box( modifier = Modifier .align(Alignment.Center) @@ -491,12 +459,6 @@ private fun VideoLoadingLayer( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - MediaLoadingBackground( - previewData = content.minithumbnail, - contentScale = ContentScale.Crop, - previewBlur = 14.dp - ) - Box( modifier = Modifier .fillMaxSize() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoNoteBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoNoteBubble.kt index 715cc616e..f61f78fb9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoNoteBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoNoteBubble.kt @@ -3,7 +3,6 @@ package org.monogram.presentation.features.chats.conversation.ui.message import androidx.annotation.OptIn -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -52,13 +51,13 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.rememberAsyncImagePainter import org.koin.compose.koinInject import org.monogram.domain.models.ForwardInfo import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager +import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.conversation.ui.InlineVideoPlayer import org.monogram.presentation.features.stickers.ui.view.shimmerEffect import java.io.File @@ -77,7 +76,8 @@ fun VideoNoteBubble( onReactionClick: (String) -> Unit = {}, toProfile: (Long) -> Unit = {}, onForwardOriginClick: (ForwardInfo) -> Unit = {}, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + animationsEnabled: Boolean = true ) { val size = 260.dp var notePosition by remember { mutableStateOf(Offset.Zero) } @@ -164,6 +164,17 @@ fun VideoNoteBubble( ) } ) { + StableMediaImage( + previewModel = content.thumbnail, + fullResolutionModel = videoPath, + cacheKey = remember(videoPath, content.fileId) { + namespacedCacheKey("chat_video_note:${content.fileId}", videoPath) + }, + contentScale = ContentScale.Crop, + contentDescription = null, + animationsEnabled = animationsEnabled, + modifier = Modifier.matchParentSize() + ) if (canRenderInlineVideo) { val resolvedVideoPath = requireNotNull(videoPath) LaunchedEffect(isVisible) { @@ -196,14 +207,7 @@ fun VideoNoteBubble( ) } else { - if (content.thumbnail != null) { - Image( - painter = rememberAsyncImagePainter(content.thumbnail), - contentDescription = null, - modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop - ) - } else { + if (content.thumbnail == null) { Box( modifier = Modifier .matchParentSize() diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/MessageDownloadStatePolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/MessageDownloadStatePolicyTest.kt new file mode 100644 index 000000000..9e9003c1e --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/logic/MessageDownloadStatePolicyTest.kt @@ -0,0 +1,136 @@ +package org.monogram.presentation.features.chats.conversation.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.MessageContent + +class MessageDownloadStatePolicyTest { + + @Test + fun `queued progress marks every downloadable message type as downloading`() { + downloadableContent().forEach { content -> + val queued = content.withFileDownloadState(FILE_ID, isDownloading = true, progress = 0f) + val state = queued.downloadState() + + assertTrue(queued::class.simpleName, state.isDownloading) + assertEquals(queued::class.simpleName, 0f, state.progress) + assertFalse(queued::class.simpleName, state.hasError) + } + } + + @Test + fun `cancel clears queued state for every downloadable message type`() { + downloadableContent().forEach { content -> + val cancelled = content + .withFileDownloadState(FILE_ID, isDownloading = true, progress = 0.4f) + .withFileDownloadState(FILE_ID, isDownloading = false, progress = 0f) + val state = cancelled.downloadState() + + assertFalse(cancelled::class.simpleName, state.isDownloading) + assertEquals(cancelled::class.simpleName, 0f, state.progress) + } + } + + @Test + fun `download event for another file leaves media unchanged`() { + downloadableContent().forEach { content -> + assertSame( + content::class.simpleName, + content, + content.withFileDownloadState(FILE_ID + 1, isDownloading = true, progress = 0f) + ) + } + } + + private fun downloadableContent(): List = listOf( + MessageContent.Photo( + path = null, + width = 100, + height = 100, + downloadError = true, + fileId = FILE_ID + ), + MessageContent.Video( + path = null, + width = 100, + height = 100, + duration = 1, + downloadError = true, + fileId = FILE_ID + ), + MessageContent.VideoNote( + path = null, + thumbnail = null, + duration = 1, + length = 100, + downloadError = true, + fileId = FILE_ID + ), + MessageContent.Document( + path = null, + fileName = "file.bin", + mimeType = "application/octet-stream", + size = 10, + downloadError = true, + fileId = FILE_ID + ), + MessageContent.Audio( + path = null, + duration = 1, + title = "Audio", + performer = "Artist", + fileName = "audio.mp3", + mimeType = "audio/mpeg", + size = 10, + downloadError = true, + fileId = FILE_ID + ), + MessageContent.Gif( + path = null, + width = 100, + height = 100, + downloadError = true, + fileId = FILE_ID + ), + MessageContent.Voice( + path = null, + duration = 1, + downloadError = true, + fileId = FILE_ID + ), + MessageContent.Sticker( + id = 1, + setId = 1, + path = null, + width = 100, + height = 100, + downloadError = true, + fileId = FILE_ID + ) + ) + + private fun MessageContent.downloadState(): DownloadState = when (this) { + is MessageContent.Photo -> DownloadState(isDownloading, downloadProgress, downloadError) + is MessageContent.Video -> DownloadState(isDownloading, downloadProgress, downloadError) + is MessageContent.VideoNote -> DownloadState(isDownloading, downloadProgress, downloadError) + is MessageContent.Document -> DownloadState(isDownloading, downloadProgress, downloadError) + is MessageContent.Audio -> DownloadState(isDownloading, downloadProgress, downloadError) + is MessageContent.Gif -> DownloadState(isDownloading, downloadProgress, downloadError) + is MessageContent.Voice -> DownloadState(isDownloading, downloadProgress, downloadError) + is MessageContent.Sticker -> DownloadState(isDownloading, downloadProgress, downloadError) + else -> error("Unsupported test content ${this::class.simpleName}") + } + + private data class DownloadState( + val isDownloading: Boolean, + val progress: Float, + val hasError: Boolean + ) + + private companion object { + const val FILE_ID = 42 + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt index 9b019559d..19bebebf0 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentRuntimePolicyTest.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.conversation.ui.content +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -150,6 +151,49 @@ class ChatContentRuntimePolicyTest { ) } + @Test + fun `bottom correction is rejected while dragging or after user moved away`() { + val base = shouldRetainBottomAlignmentAfterContentChange( + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + stateIsAtBottom = true, + measuredIsAtBottom = false, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + bottomAlignmentDeltaPx = 18f + ) + val whileDragging = shouldRetainBottomAlignmentAfterContentChange( + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + stateIsAtBottom = true, + measuredIsAtBottom = false, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + isDragged = true, + bottomAlignmentDeltaPx = 18f + ) + val afterScrollAway = shouldRetainBottomAlignmentAfterContentChange( + viewportPhase = ChatViewportPhase.Settled, + pendingScrollCommand = null, + stateIsAtBottom = true, + measuredIsAtBottom = false, + isLoading = false, + isLoadingOlder = false, + isLoadingNewer = false, + isScrollInProgress = false, + hasUserScrolledAwayFromBottom = true, + bottomAlignmentDeltaPx = 18f + ) + + assertTrue(base) + assertFalse(whileDragging) + assertFalse(afterScrollAway) + } + @Test fun `shouldAutoFollowLatestAfterContentChange follows when last grouped message changes and follow is armed`() { assertTrue( @@ -300,6 +344,26 @@ class ChatContentRuntimePolicyTest { ) } + @Test + fun `comments preserve their independent latest-message orientation`() { + assertEquals( + 10L, + selectLatestGroupedMessageId( + firstGroupedMessageId = 10L, + lastGroupedMessageId = 20L, + isComments = false + ) + ) + assertEquals( + 20L, + selectLatestGroupedMessageId( + firstGroupedMessageId = 10L, + lastGroupedMessageId = 20L, + isComments = true + ) + ) + } + @Test fun `near-bottom follow is independent from exact-bottom correction`() { assertTrue( diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinatorTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinatorTest.kt index 0d741fbaf..c39bd7c5f 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinatorTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentScrollCoordinatorTest.kt @@ -178,6 +178,81 @@ class ChatContentScrollCoordinatorTest { assertEquals(111, commentsCoarseIndex) } + @Test + fun `visible bottom target uses delta correction without an index jump`() { + val plan = requireNotNull( + buildBottomCorrectionPlan( + currentFirstVisibleIndex = 0, + targetIndex = 0, + totalItemsCount = 120, + isComments = false, + visibleTargetDelta = 24f + ) + ) + + assertEquals(24f, plan.alignmentDelta) + assertFalse(plan.shouldScrollToTarget) + assertNull(plan.coarseIndex) + } + + @Test + fun `already aligned bottom target produces no mutation`() { + val plan = requireNotNull( + buildBottomCorrectionPlan( + currentFirstVisibleIndex = 0, + targetIndex = 0, + totalItemsCount = 120, + isComments = false, + visibleTargetDelta = 0.5f + ) + ) + + assertNull(plan.alignmentDelta) + assertFalse(plan.shouldScrollToTarget) + assertNull(plan.coarseIndex) + } + + @Test + fun `far bottom target uses one coarse jump before target alignment`() { + val plan = requireNotNull( + buildBottomCorrectionPlan( + currentFirstVisibleIndex = 70, + targetIndex = 0, + totalItemsCount = 120, + isComments = false, + visibleTargetDelta = null + ) + ) + + assertEquals(8, plan.coarseIndex) + assertTrue(plan.shouldScrollToTarget) + assertNull(plan.alignmentDelta) + } + + @Test + fun `residual correction never restarts from an index jump`() { + val residual = requireNotNull( + buildBottomCorrectionPlan( + currentFirstVisibleIndex = 0, + targetIndex = 0, + totalItemsCount = 120, + isComments = false, + visibleTargetDelta = 3f + ) + ) + + assertNull(residual.coarseIndex) + assertFalse(residual.shouldScrollToTarget) + assertEquals(3f, residual.alignmentDelta) + } + + @Test + fun `regular and comment bottom target indices preserve orientation`() { + assertEquals(0, calculateChatBottomTargetIndex(totalItemsCount = 10, isComments = false)) + assertEquals(9, calculateChatBottomTargetIndex(totalItemsCount = 10, isComments = true)) + assertNull(calculateChatBottomTargetIndex(totalItemsCount = 0, isComments = false)) + } + @Test fun `chatContentLeadingItemsCount accounts for root loading-newer item`() { val leadingItems = chatContentLeadingItemsCount( diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt index 925ec73ab..78a450dc3 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatMediaHelpersTest.kt @@ -7,10 +7,12 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.conversation.ui.CompactMosaicLayout import org.monogram.presentation.features.chats.conversation.ui.message.prefersExpandedBubbleWidth import org.monogram.presentation.features.chats.conversation.ui.message.resolveMediaBubbleAspectRatio import org.monogram.presentation.features.chats.conversation.ui.message.resolveMediaBubbleHeight import org.monogram.presentation.features.chats.conversation.ui.message.resolveMediaBubbleWidth +import org.monogram.presentation.features.chats.conversation.ui.resolveCompactMosaicLayout class ChatMediaHelpersTest { @Test @@ -165,6 +167,35 @@ class ChatMediaHelpersTest { assertEquals(320.dp, resolvedHeight) } + @Test + fun `invalid dimensions share a stable fallback ratio`() { + assertEquals(1f, resolveMediaBubbleAspectRatio(0, 0), 0.0001f) + assertEquals(1f, resolveMediaBubbleAspectRatio(-1, 200), 0.0001f) + assertEquals(1f, resolveMediaBubbleAspectRatio(200, -1), 0.0001f) + } + + @Test + fun `extreme aspect ratios remain clamped`() { + assertEquals(2f, resolveMediaBubbleAspectRatio(10_000, 1), 0.0001f) + assertEquals(0.5f, resolveMediaBubbleAspectRatio(1, 10_000), 0.0001f) + } + + @Test + fun `compact mosaic layout maps supported item counts`() { + val expectedLayouts = listOf( + 0 to CompactMosaicLayout.Empty, + 1 to CompactMosaicLayout.Single, + 2 to CompactMosaicLayout.Two, + 3 to CompactMosaicLayout.Three, + 4 to CompactMosaicLayout.Four, + 5 to CompactMosaicLayout.FivePlus + ) + + expectedLayouts.forEach { (itemCount, expectedLayout) -> + assertEquals(expectedLayout, resolveCompactMosaicLayout(itemCount)) + } + } + private fun message(content: MessageContent, id: Long = 1L): MessageModel = MessageModel( id = id, diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaTapActionsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaTapActionsTest.kt index 76c9dabe7..91602da9f 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaTapActionsTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/ui/message/MediaTapActionsTest.kt @@ -2,7 +2,10 @@ package org.monogram.presentation.features.chats.conversation.ui.message import org.junit.Assert.assertEquals import org.junit.Test +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel import org.monogram.presentation.features.chats.conversation.ui.content.VideoTapAction +import org.monogram.presentation.features.chats.conversation.ui.content.handleVideoTap import org.monogram.presentation.features.chats.conversation.ui.content.resolveVideoTapAction class MediaTapActionsTest { @@ -41,4 +44,50 @@ class MediaTapActionsTest { assertEquals(VideoTapAction.Open, action) } + + @Test + fun `video note without path requests its file download`() { + var downloadedFileId = 0 + + handleVideoTap( + msg = videoNoteMessage(path = null), + onDownloadVideo = { downloadedFileId = it }, + onVideoClick = { _, _, _ -> error("Video note without a path must not open") } + ) + + assertEquals(FILE_ID, downloadedFileId) + } + + @Test + fun `downloaded video note opens its local path`() { + var openedPath: String? = null + + handleVideoTap( + msg = videoNoteMessage(path = VIDEO_NOTE_PATH), + onDownloadVideo = { error("Downloaded video note must not enqueue again") }, + onVideoClick = { _, path, _ -> openedPath = path } + ) + + assertEquals(VIDEO_NOTE_PATH, openedPath) + } + + private fun videoNoteMessage(path: String?) = MessageModel( + id = 1, + date = 1, + isOutgoing = false, + senderName = "sender", + chatId = 1, + content = MessageContent.VideoNote( + path = path, + thumbnail = null, + duration = 1, + length = 100, + fileId = FILE_ID + ) + ) + + private companion object { + const val FILE_ID = 73 + const val VIDEO_NOTE_PATH = "/tmp/video-note.mp4" + } } From c79283175b1bcfd7cf93b970d15fe6bbb2558b22 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:11:41 +0300 Subject: [PATCH 3/4] fix queue of video streaming --- .../data/datasource/FileDataSource.kt | 10 + .../data/datasource/TdFileDataSource.kt | 23 +- .../datasource/TelegramStreamingDataSource.kt | 131 ++--- .../remote/TdMessageRemoteDataSource.kt | 260 +++++----- .../monogram/data/infra/FileDownloadQueue.kt | 473 +++++++++++++----- .../monogram/data/infra/FileObserverHub.kt | 8 +- .../monogram/data/infra/FileUpdateHandler.kt | 180 ++++--- .../monogram/data/infra/OrderedEventFlow.kt | 42 ++ .../data/infra/FileUpdateHandlerTest.kt | 2 + 9 files changed, 734 insertions(+), 395 deletions(-) diff --git a/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt b/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt index 543f9d36c..e11a4a949 100644 --- a/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/FileDataSource.kt @@ -5,6 +5,16 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.infra.FileDownloadQueue interface FileDataSource { + fun acquireStreamingDemand(fileId: Int): Boolean + fun releaseStreamingDemand(fileId: Int) + suspend fun downloadStreamingRange( + fileId: Int, + priority: Int, + offset: Long, + limit: Long, + timeoutMs: Long + ): FileDownloadQueue.StreamingRangeResult + suspend fun downloadFile( fileId: Int, priority: Int, diff --git a/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt b/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt index bf91937c3..5506df139 100644 --- a/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/TdFileDataSource.kt @@ -13,6 +13,27 @@ class TdFileDataSource( private val gateway: TelegramGateway, private val fileDownloadQueue: FileDownloadQueue ) : FileDataSource { + override fun acquireStreamingDemand(fileId: Int): Boolean = + fileDownloadQueue.acquireStreamingDemand(fileId) + + override fun releaseStreamingDemand(fileId: Int) { + fileDownloadQueue.releaseStreamingDemand(fileId) + } + + override suspend fun downloadStreamingRange( + fileId: Int, + priority: Int, + offset: Long, + limit: Long, + timeoutMs: Long + ): FileDownloadQueue.StreamingRangeResult = fileDownloadQueue.downloadStreamingRange( + fileId = fileId, + priority = priority, + offset = offset, + limit = limit, + timeoutMs = timeoutMs + ) + override suspend fun downloadFile( fileId: Int, priority: Int, @@ -64,7 +85,7 @@ class TdFileDataSource( return getFile(fileId) } - override suspend fun cancelDownload(fileId: Int): TdApi.Ok? { + override suspend fun cancelDownload(fileId: Int): TdApi.Ok { fileDownloadQueue.cancelDownload(fileId, force = true) return TdApi.Ok() } diff --git a/data/src/main/java/org/monogram/data/datasource/TelegramStreamingDataSource.kt b/data/src/main/java/org/monogram/data/datasource/TelegramStreamingDataSource.kt index 77446e6a4..80d695bc9 100644 --- a/data/src/main/java/org/monogram/data/datasource/TelegramStreamingDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/TelegramStreamingDataSource.kt @@ -7,9 +7,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.BaseDataSource import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSpec -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.data.infra.FileDownloadQueue import java.io.IOException @@ -33,6 +31,7 @@ class TelegramStreamingDataSource( private var position: Long = 0 private var bytesRemaining: Long = 0 private var opened = false + private var demandAcquired = false private var internalBuffer: ByteArray? = null private var bufferOffset: Int = 0 @@ -40,44 +39,41 @@ class TelegramStreamingDataSource( private val PREFETCH_SIZE = 512 * 1024L private val PREFIX_WAIT_TIMEOUT_MS = 5_000L - private val PREFIX_WAIT_POLL_MS = 80L override fun open(dataSpec: DataSpec): Long { this.dataSpec = dataSpec this.position = dataSpec.position transferInitializing(dataSpec) + try { + if (!fileDataSource.acquireStreamingDemand(fileId)) { + throw IOException("Streaming demand rejected for fileId: $fileId") + } + demandAcquired = true - file = runBlocking { fileDataSource.getFile(fileId) } - val f = file ?: throw IOException("File not found for fileId: $fileId") - - val totalSize = kotlin.math.max(f.size, f.expectedSize) - if (totalSize <= 0) { - throw IOException("Failed to get file size for fileId: $fileId") - } - - bytesRemaining = if (dataSpec.length != C.LENGTH_UNSET.toLong()) { - dataSpec.length - } else { - totalSize - dataSpec.position - } + file = runBlocking { fileDataSource.getFile(fileId) } + val f = file ?: throw IOException("File not found for fileId: $fileId") + val totalSize = kotlin.math.max(f.size, f.expectedSize) + if (totalSize <= 0) { + throw IOException("Failed to get file size for fileId: $fileId") + } - if (bytesRemaining > 0) { - runBlocking { - fileDataSource.downloadFile( - fileId = fileId, - priority = 16, - offset = position, - limit = kotlin.math.min(PREFETCH_SIZE, bytesRemaining), - synchronous = false, - type = FileDownloadQueue.DownloadType.VIDEO - ) + bytesRemaining = if (dataSpec.length != C.LENGTH_UNSET.toLong()) { + dataSpec.length + } else { + (totalSize - dataSpec.position).coerceAtLeast(0L) } - } - opened = true - transferStarted(dataSpec) - return bytesRemaining + opened = true + transferStarted(dataSpec) + return bytesRemaining + } catch (error: Exception) { + releaseStreamingDemand() + this.dataSpec = null + file = null + if (error is IOException) throw error + throw IOException("Failed to open streaming file $fileId", error) + } } override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int { @@ -90,51 +86,29 @@ class TelegramStreamingDataSource( runBlocking { try { val targetSize = bytesToFetch.toLong() - - if (getAvailableFromPosition(position) < targetSize) { - fileDataSource.downloadFile( - fileId = fileId, - priority = 24, - offset = position, - limit = targetSize, - synchronous = false, - type = FileDownloadQueue.DownloadType.VIDEO + val range = fileDataSource.downloadStreamingRange( + fileId = fileId, + priority = 24, + offset = position, + limit = targetSize, + timeoutMs = PREFIX_WAIT_TIMEOUT_MS + ) + if (range.outcome != FileDownloadQueue.StreamingRangeOutcome.COMPLETED) { + throw IOException( + "Streaming range ${range.outcome.name.lowercase()} for fileId: $fileId" ) - waitForPrefix(position, targetSize) } - var filePart = fileDataSource.readFilePart(fileId, position, targetSize) + val filePart = fileDataSource.readFilePart(fileId, position, targetSize) if (filePart?.data?.isEmpty() != false) { - fileDataSource.downloadFile( - fileId = fileId, - priority = 24, - offset = position, - limit = targetSize, - synchronous = false, - type = FileDownloadQueue.DownloadType.VIDEO - ) - waitForPrefix(position, targetSize) - filePart = fileDataSource.readFilePart(fileId, position, targetSize) + throw IOException("Streaming range completed without readable data for fileId: $fileId") } - internalBuffer = filePart?.data + internalBuffer = filePart.data bufferOffset = 0 bufferLength = internalBuffer?.size ?: 0 - - if (bufferLength > 0 && bytesRemaining > bufferLength) { - val nextOffset = position + bufferLength - val nextLimit = kotlin.math.min(PREFETCH_SIZE, bytesRemaining - bufferLength) - if (nextLimit > 0) { - fileDataSource.downloadFile( - fileId = fileId, - priority = 16, - offset = nextOffset, - limit = nextLimit, - synchronous = false, - type = FileDownloadQueue.DownloadType.VIDEO - ) - } - } + } catch (error: IOException) { + throw error } catch (e: Exception) { throw IOException("Error reading file part: ${e.message}", e) } @@ -154,20 +128,6 @@ class TelegramStreamingDataSource( return bytesToRead } - private suspend fun waitForPrefix(offset: Long, size: Long): Boolean { - return withTimeoutOrNull(PREFIX_WAIT_TIMEOUT_MS) { - while (getAvailableFromPosition(offset) < size) { - delay(PREFIX_WAIT_POLL_MS) - } - true - } == true - } - - private suspend fun getAvailableFromPosition(offset: Long): Long { - val prefix = fileDataSource.getFileDownloadedPrefixSize(fileId, offset) - return (prefix?.size ?: 0L).coerceAtLeast(0L) - } - override fun getUri(): Uri? = dataSpec?.uri override fun close() { @@ -175,6 +135,7 @@ class TelegramStreamingDataSource( opened = false transferEnded() } + releaseStreamingDemand() dataSpec = null file = null @@ -182,4 +143,10 @@ class TelegramStreamingDataSource( bufferOffset = 0 bufferLength = 0 } -} \ No newline at end of file + + private fun releaseStreamingDemand() { + if (!demandAcquired) return + demandAcquired = false + fileDataSource.releaseStreamingDemand(fileId) + } +} diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index 1ae4aa4cf..ff95c3a7a 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -38,7 +40,9 @@ import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.AtomicSingleFlight import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileTerminalUpdate import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.infra.LatestByKeyEventFlow import org.monogram.data.infra.OrderedEventFlow import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.WebPageMapper @@ -115,10 +119,34 @@ class TdMessageRemoteDataSource( extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - private val fileDownloads = OrderedEventFlow(scope) - override val fileDownloadFlow = fileDownloads.events - private val messageDownloads = OrderedEventFlow(scope) - override val messageDownloadFlow = messageDownloads.events + private val fileDownloadTerminals = OrderedEventFlow(scope) + private val fileDownloadProgress = LatestByKeyEventFlow( + scope = scope, + keyOf = { it.fileId }, + shouldEmit = { event -> + fileDownloadQueue.getCachedFile(event.fileId)?.local?.isDownloadingCompleted != true + } + ) + override val fileDownloadFlow = merge(fileDownloadTerminals.events, fileDownloadProgress.events) + .filter { event -> + event !is FileDownloadEvent.Progress || + fileDownloadQueue.getCachedFile(event.fileId)?.local?.isDownloadingCompleted != true + } + private val messageDownloadTerminals = OrderedEventFlow(scope) + private val messageDownloadProgress = + LatestByKeyEventFlow, MessageDownloadEvent.Progress>( + scope = scope, + keyOf = { Triple(it.chatId, it.messageId, it.fileId) }, + shouldEmit = { event -> + fileDownloadQueue.getCachedFile(event.fileId)?.local?.isDownloadingCompleted != true + } + ) + override val messageDownloadFlow = + merge(messageDownloadTerminals.events, messageDownloadProgress.events) + .filter { event -> + event !is MessageDownloadEvent.Progress || + fileDownloadQueue.getCachedFile(event.fileId)?.local?.isDownloadingCompleted != true + } private val messageDeletes = OrderedEventFlow(scope) override val messageDeletedFlow = messageDeletes.events private val messageIdUpdates = OrderedEventFlow(scope) @@ -142,7 +170,6 @@ class TdMessageRemoteDataSource( private val fileIdToMessageMap = fileDownloadQueue.registry.fileIdToMessageMap private val messageUpdateJobs = ConcurrentHashMap, Job>() private val lastProgressMap = ConcurrentHashMap() - private val lastDownloadActiveMap = ConcurrentHashMap() private val lastCancelledEmissionAt = ConcurrentHashMap() init { @@ -165,7 +192,10 @@ class TdMessageRemoteDataSource( } } scope.launch(dispatcherProvider.io) { - fileUpdateHandler.fileUpdates.collect(::projectFileUpdate) + fileUpdateHandler.fileTerminalUpdates.collect(::projectTerminalFileUpdate) + } + scope.launch(dispatcherProvider.io) { + fileUpdateHandler.fileProgressUpdates.collect(::projectFileProgressUpdate) } } @@ -2367,127 +2397,105 @@ class TdMessageRemoteDataSource( fun waitForUpload(fileId: Int): CompletableDeferred = fileDownloadQueue.waitForUpload(fileId) - private fun projectFileUpdate(file: TdApi.File) { - val isDC = file.local?.isDownloadingCompleted == true - val isD = file.local?.isDownloadingActive == true - val wasDownloading = lastDownloadActiveMap[file.id] == true - if (isD) { - lastDownloadActiveMap[file.id] = true - } else { - lastDownloadActiveMap.remove(file.id) - } - val isCancelled = wasDownloading && !isD && !isDC - val isUC = file.remote?.isUploadingCompleted == true - val isU = file.remote?.isUploadingActive == true - - if (isD || isDC || isCancelled) { + private fun projectTerminalFileUpdate(update: FileTerminalUpdate) { + val file = update.file + if (update.downloadCompleted || update.downloadCancelled) { Log.d( "DownloadDebug", - "td.updateFile: fileId=${file.id} isD=$isD isDC=$isDC isCancelled=$isCancelled downloaded=${file.local?.downloadedSize ?: 0}/${file.size} pathEmpty=${file.local?.path.isNullOrEmpty()}" + "td.updateFile: fileId=${file.id} completed=${update.downloadCompleted} " + + "cancelled=${update.downloadCancelled} downloaded=${file.local?.downloadedSize ?: 0}/${file.size} " + + "pathEmpty=${file.local?.path.isNullOrEmpty()}" ) } - if (isDC) { + if (update.downloadCompleted) { lastProgressMap.remove(file.id) - scope.launch { - fileDownloads.enqueue( - FileDownloadEvent.Completed( - fileId = file.id, - path = file.local?.path ?: "" - ) + fileDownloadProgress.remove(file.id) + fileDownloadTerminals.enqueue( + FileDownloadEvent.Completed( + fileId = file.id, + path = file.local?.path ?: "" ) - fileDownloads.enqueue( - FileDownloadEvent.Progress( - fileId = file.id, - progress = 1.0f - ) - ) - } + ) fileUpdateHandler.fileIdToCustomEmojiId[file.id]?.let { customEmojiId -> fileUpdateHandler.customEmojiPaths[customEmojiId] = file.local?.path ?: "" } val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { - scope.launch { - entries.forEach { (chatId, messageId) -> - messageDownloads.enqueue( - MessageDownloadEvent.Completed( - chatId = chatId, - messageId = messageId, - fileId = file.id, - path = file.local?.path ?: "" - ) - ) - messageDownloads.enqueue( - MessageDownloadEvent.Progress( - chatId = chatId, - messageId = messageId, - fileId = file.id, - progress = 1.0f - ) + entries.forEach { (chatId, messageId) -> + messageDownloadProgress.remove(Triple(chatId, messageId, file.id)) + messageDownloadTerminals.enqueue( + MessageDownloadEvent.Completed( + chatId = chatId, + messageId = messageId, + fileId = file.id, + path = file.local?.path ?: "" ) - } + ) } } else if (fileDownloadQueue.registry.standaloneFileIds.contains(file.id)) { fileDownloadQueue.registry.standaloneFileIds.remove(file.id) } updateMessageWithFile(file.id) - } else if (isD) { - val p = - if (file.size > 0 && file.local != null) file.local.downloadedSize.toFloat() / file.size.toFloat() else 0f - val pInt = (p * 100).toInt() - if (lastProgressMap[file.id] != pInt) { - lastProgressMap[file.id] = pInt - scope.launch { - fileDownloads.enqueue( - FileDownloadEvent.Progress( - fileId = file.id, - progress = p - ) - ) - } - val entries = fileIdToMessageMap[file.id] - if (!entries.isNullOrEmpty()) { - scope.launch { - entries.forEach { (chatId, messageId) -> - messageDownloads.enqueue( - MessageDownloadEvent.Progress( - chatId = chatId, - messageId = messageId, - fileId = file.id, - progress = p - ) - ) - } - } - } - } - } else if (isCancelled) { + } else if (update.downloadCancelled) { lastProgressMap.remove(file.id) Log.d("DownloadDebug", "td.downloadCancelled.emit: fileId=${file.id}") emitCancelledForFile(file.id) } - if (isUC) { + if (update.uploadCompleted) { lastProgressMap.remove(file.id xor 0x55555555) val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { - scope.launch { + entries.forEach { (chatId, messageId) -> + messageUploadProgressFlow.tryEmit( + MessageUploadProgressEvent( + chatId = chatId, + messageId = messageId, + fileId = file.id, + progress = 1.0f + ) + ) + } + } + updateMessageWithFile(file.id) + } + } + + private fun projectFileProgressUpdate(emitted: TdApi.File) { + val file = fileDownloadQueue.getCachedFile(emitted.id) ?: emitted + val isDownloaded = file.local?.isDownloadingCompleted == true + val isDownloading = file.local?.isDownloadingActive == true + if (isDownloading && !isDownloaded) { + val p = + if (file.size > 0 && file.local != null) file.local.downloadedSize.toFloat() / file.size.toFloat() else 0f + val pInt = (p * 100).toInt() + if (lastProgressMap[file.id] != pInt) { + lastProgressMap[file.id] = pInt + fileDownloadProgress.enqueue( + FileDownloadEvent.Progress( + fileId = file.id, + progress = p + ) + ) + val entries = fileIdToMessageMap[file.id] + if (!entries.isNullOrEmpty()) { entries.forEach { (chatId, messageId) -> - messageUploadProgressFlow.emit( - MessageUploadProgressEvent( + messageDownloadProgress.enqueue( + MessageDownloadEvent.Progress( chatId = chatId, messageId = messageId, fileId = file.id, - progress = 1.0f + progress = p ) ) } } } - updateMessageWithFile(file.id) - } else if (isU) { + } + + if (file.remote?.isUploadingActive == true && file.remote?.isUploadingCompleted != true) { val p = if (file.size > 0 && file.remote != null) file.remote.uploadedSize.toFloat() / file.size.toFloat() else 0f val pInt = (p * 100).toInt() @@ -2495,17 +2503,15 @@ class TdMessageRemoteDataSource( lastProgressMap[file.id xor 0x55555555] = pInt val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { - scope.launch { - entries.forEach { (chatId, messageId) -> - messageUploadProgressFlow.emit( - MessageUploadProgressEvent( - chatId = chatId, - messageId = messageId, - fileId = file.id, - progress = p - ) + entries.forEach { (chatId, messageId) -> + messageUploadProgressFlow.tryEmit( + MessageUploadProgressEvent( + chatId = chatId, + messageId = messageId, + fileId = file.id, + progress = p ) - } + ) } } } @@ -2565,22 +2571,20 @@ class TdMessageRemoteDataSource( if (previous != null && now - previous < 250L) return lastCancelledEmissionAt[fileId] = now - scope.launch { - fileDownloads.enqueue(FileDownloadEvent.Cancelled(fileId)) - } + fileDownloadProgress.remove(fileId) + fileDownloadTerminals.enqueue(FileDownloadEvent.Cancelled(fileId)) val entries = fileIdToMessageMap[fileId] if (!entries.isNullOrEmpty()) { - scope.launch { - entries.forEach { (chatId, messageId) -> - messageDownloads.enqueue( - MessageDownloadEvent.Cancelled( - chatId = chatId, - messageId = messageId, - fileId = fileId - ) + entries.forEach { (chatId, messageId) -> + messageDownloadProgress.remove(Triple(chatId, messageId, fileId)) + messageDownloadTerminals.enqueue( + MessageDownloadEvent.Cancelled( + chatId = chatId, + messageId = messageId, + fileId = fileId ) - } + ) } } } @@ -2588,28 +2592,24 @@ class TdMessageRemoteDataSource( private fun emitQueuedForFile(fileId: Int) { if (fileId == 0) return - scope.launch { - fileDownloads.enqueue( - FileDownloadEvent.Progress( - fileId = fileId, - progress = 0f - ) + fileDownloadProgress.enqueue( + FileDownloadEvent.Progress( + fileId = fileId, + progress = 0f ) - } + ) val entries = fileIdToMessageMap[fileId] if (!entries.isNullOrEmpty()) { - scope.launch { - entries.forEach { (chatId, messageId) -> - messageDownloads.enqueue( - MessageDownloadEvent.Progress( - chatId = chatId, - messageId = messageId, - fileId = fileId, - progress = 0f - ) + entries.forEach { (chatId, messageId) -> + messageDownloadProgress.enqueue( + MessageDownloadEvent.Progress( + chatId = chatId, + messageId = messageId, + fileId = fileId, + progress = 0f ) - } + ) } } } diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index d6a239efe..8045f7880 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -20,7 +20,10 @@ import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway import org.monogram.domain.repository.MediaAutoDownloadPolicy import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong class FileDownloadQueue( private val gateway: TelegramGateway, @@ -36,6 +39,14 @@ class FileDownloadQueue( enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } + enum class DemandOrigin { AUTOMATIC, MANUAL, STREAMING } + + enum class StreamingRangeOutcome { COMPLETED, CANCELLED, TIMED_OUT, REJECTED } + + data class StreamingRangeResult(val outcome: StreamingRangeOutcome) { + val accepted: Boolean get() = outcome != StreamingRangeOutcome.REJECTED + } + enum class MediaKind { PHOTO, VIDEO, GIF, VOICE, VIDEO_NOTE, DOCUMENT, AUDIO, STICKER, OTHER } enum class DemandRole { PRIMARY, PREVIEW, MANUAL_ONLY } data class MediaDescriptor( @@ -54,9 +65,12 @@ class FileDownloadQueue( val synchronous: Boolean = false, val createdAt: Long = System.currentTimeMillis(), val availableAt: Long = System.currentTimeMillis(), - val isManual: Boolean = false, + val origin: DemandOrigin = DemandOrigin.AUTOMATIC, + val generation: Long, val retryCount: Int = 0 ) : Comparable { + val isManual: Boolean get() = origin == DemandOrigin.MANUAL + override fun compareTo(other: DownloadRequest): Int { if (this.isManual != other.isManual) return if (this.isManual) -1 else 1 val p = other.priority.compareTo(priority) @@ -83,6 +97,10 @@ class FileDownloadQueue( private val uploadWaiters = ConcurrentHashMap>() private val lastProgressAt = ConcurrentHashMap() private val stalledRecoveryAt = ConcurrentHashMap() + private val requestGeneration = AtomicLong() + private val streamingConsumers = ConcurrentHashMap() + private val streamingRangeWaiters = + ConcurrentHashMap>() private val openChatIds = ConcurrentHashMap.newKeySet() private val visibleMessageIds = ConcurrentHashMap>() @@ -239,7 +257,7 @@ class FileDownloadQueue( try { val finalFile = withTimeoutOrNull(10000) { gateway.execute(TdApi.GetFile(fileId)) } if (finalFile != null) { - cache.fileCache[fileId] = finalFile + updateFileCache(finalFile) if (finalFile.local.isDownloadingCompleted) { notifyDownloadComplete(fileId) } else if (!finalFile.local.isDownloadingActive && !hasPendingOrActiveRequest(fileId)) { @@ -268,7 +286,8 @@ class FileDownloadQueue( val backoffMs = calculateBackoffMs(req, nextRetry, errorCode) val nextReq = req.copy( retryCount = nextRetry, - availableAt = System.currentTimeMillis() + backoffMs + availableAt = System.currentTimeMillis() + backoffMs, + generation = requestGeneration.incrementAndGet() ) stateMutex.withLock { pendingRequests[req.fileId] = nextReq @@ -282,6 +301,7 @@ class FileDownloadQueue( } else { failedRequests[req.fileId] = req downloadWaiters.remove(req.fileId)?.cancel() + completeStreamingWaiters(req.fileId, StreamingRangeOutcome.CANCELLED) } } @@ -292,7 +312,11 @@ class FileDownloadQueue( notFoundCooldownUntil[req.fileId] = availableAt if (nextRetry <= MAX_RECOVERY_RETRIES) { - val nextReq = req.copy(retryCount = nextRetry, availableAt = availableAt) + val nextReq = req.copy( + retryCount = nextRetry, + availableAt = availableAt, + generation = requestGeneration.incrementAndGet() + ) stateMutex.withLock { pendingRequests[req.fileId] = nextReq failedRequests.remove(req.fileId) @@ -305,6 +329,7 @@ class FileDownloadQueue( } else { failedRequests[req.fileId] = req.copy(availableAt = availableAt) downloadWaiters.remove(req.fileId)?.cancel() + completeStreamingWaiters(req.fileId, StreamingRangeOutcome.CANCELLED) } } @@ -355,9 +380,20 @@ class FileDownloadQueue( val lastProgress = lastProgressAt[req.fileId] ?: req.createdAt if (now - lastProgress >= timeoutMs) { if (req.retryCount >= MAX_RECOVERY_RETRIES) { - activeRequests.remove(req.fileId, req) - failedRequests[req.fileId] = req - downloadWaiters.remove(req.fileId)?.cancel() + var removed = false + activeRequests.computeIfPresent(req.fileId) { _, active -> + if (active.generation == req.generation) { + removed = true + null + } else { + active + } + } + if (removed) { + failedRequests[req.fileId] = req + downloadWaiters.remove(req.fileId)?.cancel() + completeStreamingWaiters(req.fileId, StreamingRangeOutcome.CANCELLED) + } return@forEach } val recoveredAt = stalledRecoveryAt[req.fileId] ?: 0L @@ -370,15 +406,16 @@ class FileDownloadQueue( if (active.createdAt != req.createdAt || active.availableAt != req.availableAt) return@withLock false activeRequests.remove(req.fileId) - pendingRequests[req.fileId] = mergeRequests( - pendingRequests[req.fileId] ?: req, - req.copy( + val recovered = req.copy( priority = if (req.type == DownloadType.STICKER) maxOf(req.priority, 32) else maxOf(req.priority, 16), availableAt = System.currentTimeMillis() + 250L, createdAt = System.currentTimeMillis(), + generation = requestGeneration.incrementAndGet(), retryCount = req.retryCount + 1 ) - ) + pendingRequests[req.fileId] = pendingRequests[req.fileId] + ?.let { pending -> mergeRequests(recovered, pending) } + ?: recovered true } @@ -398,7 +435,10 @@ class FileDownloadQueue( private suspend fun finishTask(request: DownloadRequest) { stateMutex.withLock { - activeRequests.remove(request.fileId, request) + val active = activeRequests[request.fileId] + if (active?.generation == request.generation) { + activeRequests.remove(request.fileId, active) + } } stalledRecoveryAt.remove(request.fileId) trigger.trySend(Unit) @@ -407,15 +447,33 @@ class FileDownloadQueue( private suspend fun waitForRequestedRange(req: DownloadRequest) { while (true) { val cached = cache.fileCache[req.fileId] - if (cached?.local?.isDownloadingCompleted == true) return + if (cached?.local?.isDownloadingCompleted == true) { + completeStreamingWaiters(req.fileId, StreamingRangeOutcome.COMPLETED) + return + } + + val current = stateMutex.withLock { + activeRequests[req.fileId]?.takeIf { it.generation == req.generation } + } + if (current == null) { + completeStreamingWaiters(req.fileId, StreamingRangeOutcome.CANCELLED) + return + } + if (current.limit <= 0L) { + delay(80L) + continue + } val prefix = withContext(dispatcherProvider.io) { coRunCatching { - gateway.execute(TdApi.GetFileDownloadedPrefixSize(req.fileId, req.offset)) + gateway.execute(TdApi.GetFileDownloadedPrefixSize(req.fileId, current.offset)) }.getOrNull() } val available = (prefix?.size ?: 0L).coerceAtLeast(0L) - if (available >= req.limit) return + if (available >= current.limit) { + completeStreamingWaitersInRange(req.fileId, current.offset, current.limit) + return + } delay(80L) } @@ -423,7 +481,15 @@ class FileDownloadQueue( override fun updateFileCache(file: TdApi.File) { val oldFile = cache.fileCache[file.id] + if ( + oldFile?.local?.isDownloadingCompleted == true && + file.local.isDownloadingActive && + !file.local.isDownloadingCompleted + ) { + return + } cache.fileCache[file.id] = file + completeSatisfiedStreamingWaiters(file) val now = System.currentTimeMillis() if (file.local.downloadedSize > (oldFile?.local?.downloadedSize ?: -1)) { lastProgressAt[file.id] = now @@ -453,7 +519,7 @@ class FileDownloadQueue( fun isFileQueued(fileId: Int) = pendingRequests.containsKey(fileId) || activeRequests.containsKey(fileId) - fun getCachedFile(fileId: Int): TdApi.File? = cache.fileCache[fileId] + override fun getCachedFile(fileId: Int): TdApi.File? = cache.fileCache[fileId] fun getCachedPath(fileId: Int): String? = cache.fileCache[fileId]?.local?.path?.takeIf { it.isNotEmpty() } @@ -524,128 +590,298 @@ class FileDownloadQueue( userInitiated: Boolean = false ) { scope.launch(dispatcherProvider.default) { - if (!ignoreSuppression && suppressedAutoDownloadIds.contains(fileId)) { - return@launch - } - - // User intent is an explicit signal, not something inferred from a magic priority. - // It used to be `priority >= 32`, but viewport auto-download also passes 32, so every - // thumbnail scrolled past was marked "manual" and became un-evictable. - val isManualRequest = userInitiated - if (isManualRequest) manualDownloadIds.add(fileId) - - val registeredMessages = registry.getMessages(fileId) - if ( - !isManualRequest && - !synchronous && - registeredMessages.isNotEmpty() && - !hasViewportDemand(fileId) - ) { - return@launch - } - - val cooldownUntil = notFoundCooldownUntil[fileId] - if (!isManualRequest && cooldownUntil != null && cooldownUntil > System.currentTimeMillis()) { - return@launch - } - if (cooldownUntil != null && cooldownUntil <= System.currentTimeMillis()) { - notFoundCooldownUntil.remove(fileId) - } + enqueueInternal( + fileId = fileId, + priority = priority, + type = type, + offset = offset, + limit = limit, + synchronous = synchronous, + ignoreSuppression = ignoreSuppression, + origin = if (userInitiated) DemandOrigin.MANUAL else DemandOrigin.AUTOMATIC + ) + } + } - if (registeredMessages.isEmpty()) registry.standaloneFileIds.add(fileId) - if (type != DownloadType.DEFAULT || !fileDownloadTypes.containsKey(fileId)) { - fileDownloadTypes[fileId] = type - } + private suspend fun enqueueInternal( + fileId: Int, + priority: Int, + type: DownloadType, + offset: Long, + limit: Long, + synchronous: Boolean, + ignoreSuppression: Boolean, + origin: DemandOrigin + ): Boolean { + if (!ignoreSuppression && suppressedAutoDownloadIds.contains(fileId)) return false + if (origin == DemandOrigin.STREAMING && (limit <= 0L || !hasStreamingDemand(fileId))) return false + + if (origin == DemandOrigin.MANUAL) manualDownloadIds.add(fileId) + + val registeredMessages = registry.getMessages(fileId) + if ( + origin == DemandOrigin.AUTOMATIC && + !synchronous && + registeredMessages.isNotEmpty() && + !hasViewportDemand(fileId) + ) { + return false + } - val cached = cache.fileCache[fileId] - if (cached?.local?.isDownloadingCompleted == true) { - notifyDownloadComplete(fileId) - manualDownloadIds.remove(fileId) - return@launch - } + val cooldownUntil = notFoundCooldownUntil[fileId] + if (origin != DemandOrigin.MANUAL && cooldownUntil != null && cooldownUntil > System.currentTimeMillis()) { + return false + } + if (cooldownUntil != null && cooldownUntil <= System.currentTimeMillis()) { + notFoundCooldownUntil.remove(fileId) + } - val resolvedType = if (type == DownloadType.DEFAULT) { - fileDownloadTypes[fileId] ?: DownloadType.DEFAULT - } else { - type - } + if (registeredMessages.isEmpty() && origin != DemandOrigin.STREAMING) { + registry.standaloneFileIds.add(fileId) + } + if (type != DownloadType.DEFAULT || !fileDownloadTypes.containsKey(fileId)) { + fileDownloadTypes[fileId] = type + } - val finalOffset = if (resolvedType == DownloadType.STICKER || resolvedType == DownloadType.VIDEO_NOTE) 0L else offset - val finalLimit = if (resolvedType == DownloadType.STICKER || resolvedType == DownloadType.VIDEO_NOTE) 0L else limit + val cached = cache.fileCache[fileId] + if (cached?.local?.isDownloadingCompleted == true) { + notifyDownloadComplete(fileId) + manualDownloadIds.remove(fileId) + return true + } - val prio = calculatePriority(fileId).coerceAtLeast(priority) - val isManual = manualDownloadIds.contains(fileId) - val req = DownloadRequest( - fileId = fileId, - priority = prio, - type = resolvedType, - offset = finalOffset, - limit = finalLimit, - synchronous = synchronous, - createdAt = System.currentTimeMillis(), - availableAt = System.currentTimeMillis(), - isManual = isManual - ) + val resolvedType = if (type == DownloadType.DEFAULT) { + fileDownloadTypes[fileId] ?: DownloadType.DEFAULT + } else { + type + } + val fixedRangeType = + resolvedType == DownloadType.STICKER || resolvedType == DownloadType.VIDEO_NOTE + val now = System.currentTimeMillis() + val req = DownloadRequest( + fileId = fileId, + priority = calculatePriority(fileId).coerceAtLeast(priority), + type = resolvedType, + offset = if (fixedRangeType) 0L else offset, + limit = if (fixedRangeType) 0L else limit, + synchronous = synchronous, + createdAt = now, + availableAt = now, + origin = origin, + generation = requestGeneration.incrementAndGet() + ) - stateMutex.withLock { - val active = activeRequests[fileId] - if (active != null) { - val merged = mergeRequests(active, req) - val shouldKick = merged != active - if (shouldKick) { - activeRequests[fileId] = merged - scope.launch(dispatcherProvider.io) { - try { - gateway.execute( - TdApi.DownloadFile( - fileId, - merged.priority, - merged.offset, - merged.limit, - merged.synchronous - ) - ) - } catch (_: Exception) { - } - } - } - return@launch + var startImmediately: DownloadRequest? = null + var kickActive: DownloadRequest? = null + var notifyQueued = false + val accepted = stateMutex.withLock { + val active = activeRequests[fileId] + if (active != null) { + val merged = mergeRequests(active, req) + if (merged != active) { + activeRequests[fileId] = merged + kickActive = merged } - - // Explicit user actions bypass the automatic-download caps. - if (userInitiated) { - pendingRequests.remove(fileId) - activeRequests[fileId] = req - notifyDownloadQueued(fileId) - scope.launch(dispatcherProvider.io) { - processDownload(req) - } - return@launch - } - + true + } else if (origin == DemandOrigin.MANUAL) { + pendingRequests.remove(fileId) + activeRequests[fileId] = req + startImmediately = req + notifyQueued = true + true + } else { val pending = pendingRequests[fileId] if (pending != null) { pendingRequests[fileId] = mergeRequests(pending, req) + true } else { - // Never silently drop a synchronous request: callers await waitForDownload(), - // and abandoning it here leaves that deferred uncompleted forever. - if (!isManual && !synchronous && resolvedType == DownloadType.DEFAULT) { + if (origin == DemandOrigin.AUTOMATIC && !synchronous && resolvedType == DownloadType.DEFAULT) { val pendingDefaultCount = pendingRequests.values.count { !it.isManual && it.type == DownloadType.DEFAULT } if (req.priority < 32 && pendingDefaultCount >= maxPendingDefaultAutoDownloads) { downloadWaiters.remove(fileId)?.cancel() - return@launch + return@withLock false } } pendingRequests[fileId] = req + notifyQueued = true + true + } + } + } + + kickActive?.let { merged -> + scope.launch(dispatcherProvider.io) { + coRunCatching { + gateway.execute( + TdApi.DownloadFile( + fileId, + merged.priority, + merged.offset, + merged.limit, + merged.synchronous + ) + ) + } + } + } + startImmediately?.let { request -> + scope.launch(dispatcherProvider.io) { processDownload(request) } + } + if (accepted && notifyQueued) notifyDownloadQueued(fileId) + if (accepted && startImmediately == null) trigger.trySend(Unit) + return accepted + } + + private data class StreamingRangeWaiter( + val offset: Long, + val limit: Long, + val completion: CompletableDeferred = CompletableDeferred() + ) + + fun acquireStreamingDemand(fileId: Int): Boolean { + if (fileId == 0) return false + streamingConsumers.compute(fileId) { _, count -> + (count ?: AtomicInteger()).also { it.incrementAndGet() } + } + return true + } + + fun releaseStreamingDemand(fileId: Int) { + var releasedLast = false + streamingConsumers.computeIfPresent(fileId) { _, count -> + if (count.decrementAndGet() <= 0) { + releasedLast = true + null + } else { + count + } + } + if (releasedLast) { + scope.launch(dispatcherProvider.default) { cancelStreamingRequestIfUnused(fileId) } + } + } + + suspend fun downloadStreamingRange( + fileId: Int, + priority: Int, + offset: Long, + limit: Long, + timeoutMs: Long + ): StreamingRangeResult { + if (fileId == 0 || offset < 0L || limit <= 0L || timeoutMs <= 0L || !hasStreamingDemand( + fileId + ) + ) { + return StreamingRangeResult(StreamingRangeOutcome.REJECTED) + } + + cache.fileCache[fileId]?.takeIf { it.hasRange(offset, limit) }?.let { + return StreamingRangeResult(StreamingRangeOutcome.COMPLETED) + } + + val waiter = StreamingRangeWaiter(offset, limit) + streamingRangeWaiters.computeIfAbsent(fileId) { CopyOnWriteArrayList() }.add(waiter) + val accepted = enqueueInternal( + fileId = fileId, + priority = priority, + type = DownloadType.VIDEO, + offset = offset, + limit = limit, + synchronous = false, + ignoreSuppression = true, + origin = DemandOrigin.STREAMING + ) + if (!accepted) { + removeStreamingWaiter(fileId, waiter) + return StreamingRangeResult(StreamingRangeOutcome.REJECTED) + } + + cache.fileCache[fileId]?.let(::completeSatisfiedStreamingWaiters) + val outcome = withTimeoutOrNull(timeoutMs) { waiter.completion.await() } + ?: StreamingRangeOutcome.TIMED_OUT + removeStreamingWaiter(fileId, waiter) + return StreamingRangeResult(outcome) + } + + internal fun streamingConsumerCount(fileId: Int): Int = + streamingConsumers[fileId]?.get() ?: 0 + + private fun hasStreamingDemand(fileId: Int): Boolean = streamingConsumerCount(fileId) > 0 + + private suspend fun cancelStreamingRequestIfUnused(fileId: Int) { + if (hasStreamingDemand(fileId) || manualDownloadIds.contains(fileId) || hasViewportDemand( + fileId + ) + ) return + + var cancelTdLib = false + var removed = false + stateMutex.withLock { + pendingRequests[fileId]?.takeIf { it.origin == DemandOrigin.STREAMING }?.let { + pendingRequests.remove(fileId, it) + removed = true + } + activeRequests[fileId]?.takeIf { it.origin == DemandOrigin.STREAMING }?.let { + activeRequests.remove(fileId, it) + cancelTdLib = true + removed = true + } + } + if (cancelTdLib) { + coRunCatching { + withContext(dispatcherProvider.io) { + gateway.execute(TdApi.CancelDownloadFile(fileId, false)) } } - notifyDownloadQueued(fileId) + } + if (removed) { + completeStreamingWaiters(fileId, StreamingRangeOutcome.CANCELLED) + notifyDownloadCancelled(fileId) trigger.trySend(Unit) } } + private fun completeSatisfiedStreamingWaiters(file: TdApi.File) { + val waiters = streamingRangeWaiters[file.id] ?: return + waiters.forEach { waiter -> + if (file.hasRange(waiter.offset, waiter.limit)) { + waiter.completion.complete(StreamingRangeOutcome.COMPLETED) + } + } + } + + private fun completeStreamingWaiters(fileId: Int, outcome: StreamingRangeOutcome) { + streamingRangeWaiters.remove(fileId)?.forEach { it.completion.complete(outcome) } + } + + private fun completeStreamingWaitersInRange(fileId: Int, offset: Long, limit: Long) { + val end = offset + limit + if (end < offset) return + streamingRangeWaiters[fileId]?.forEach { waiter -> + val waiterEnd = waiter.offset + waiter.limit + if (waiter.offset >= offset && waiterEnd >= waiter.offset && waiterEnd <= end) { + waiter.completion.complete(StreamingRangeOutcome.COMPLETED) + } + } + } + + private fun removeStreamingWaiter(fileId: Int, waiter: StreamingRangeWaiter) { + streamingRangeWaiters[fileId]?.let { waiters -> + waiters.remove(waiter) + if (waiters.isEmpty()) streamingRangeWaiters.remove(fileId, waiters) + } + } + + private fun TdApi.File.hasRange(offset: Long, limit: Long): Boolean { + if (local.isDownloadingCompleted) return true + val rangeStart = local.downloadOffset + val rangeEnd = rangeStart + local.downloadedPrefixSize + val requestedEnd = offset + limit + return offset >= rangeStart && requestedEnd >= offset && requestedEnd <= rangeEnd + } + fun cancelDownload(fileId: Int, force: Boolean = false, suppress: Boolean = true) { if (!force && manualDownloadIds.contains(fileId)) return @@ -665,6 +901,7 @@ class FileDownloadQueue( failedRequests.remove(fileId) } Log.d("DownloadDebug", "queue.cancel.cleared: fileId=$fileId") + completeStreamingWaiters(fileId, StreamingRangeOutcome.CANCELLED) notifyDownloadCancelled(fileId) } } @@ -692,6 +929,7 @@ class FileDownloadQueue( override fun notifyDownloadComplete(fileId: Int) { downloadWaiters.remove(fileId)?.complete(Unit) + completeStreamingWaiters(fileId, StreamingRangeOutcome.COMPLETED) } private fun notifyDownloadQueued(fileId: Int) { @@ -713,6 +951,7 @@ class FileDownloadQueue( private fun isStillRelevant(fileId: Int): Boolean { if (manualDownloadIds.contains(fileId)) return true + if (hasStreamingDemand(fileId)) return true if (registry.sponsoredFileIds.contains(fileId)) return true if (registry.standaloneFileIds.contains(fileId)) return true return hasViewportDemand(fileId) @@ -782,7 +1021,11 @@ class FileDownloadQueue( private fun mergeRequests(old: DownloadRequest, new: DownloadRequest): DownloadRequest { val p = maxOf(old.priority, new.priority) - val isManual = old.isManual || new.isManual + val origin = when { + old.origin == DemandOrigin.MANUAL || new.origin == DemandOrigin.MANUAL -> DemandOrigin.MANUAL + old.origin == DemandOrigin.STREAMING || new.origin == DemandOrigin.STREAMING -> DemandOrigin.STREAMING + else -> DemandOrigin.AUTOMATIC + } val curEnd = if (old.limit == 0L) Long.MAX_VALUE else old.offset + old.limit val newEnd = if (new.limit == 0L) Long.MAX_VALUE else new.offset + new.limit @@ -792,7 +1035,7 @@ class FileDownloadQueue( return old.copy( priority = p, - isManual = isManual, + origin = origin, offset = start, limit = limit, availableAt = minOf(old.availableAt, new.availableAt) diff --git a/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt b/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt index d2990c493..b1d4bb91e 100644 --- a/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt +++ b/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi @@ -24,8 +25,11 @@ class FileObserverHub( val uploadProgress: Float ) - val fileStates: Flow = fileUpdateHandler.fileUpdates - .map { it.toState() } + val fileStates: Flow = merge( + fileUpdateHandler.fileTerminalUpdates.map { it.file }, + fileUpdateHandler.fileProgressUpdates + ) + .map { emitted -> (queue.getCachedFile(emitted.id) ?: emitted).toState() } .distinctUntilChanged() fun observeFile(fileId: Int): Flow = flow { diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt index 466ddbb4c..514ac4ee1 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -1,19 +1,25 @@ package org.monogram.data.infra import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.filter import org.drinkless.tdlib.TdApi import org.monogram.data.gateway.UpdateDispatcher +import java.util.concurrent.ConcurrentHashMap interface FileUpdateQueue { fun updateFileCache(file: TdApi.File) + fun getCachedFile(fileId: Int): TdApi.File? fun notifyDownloadComplete(fileId: Int) fun notifyUploadComplete(fileId: Int) } +internal data class FileTerminalUpdate( + val file: TdApi.File, + val downloadCompleted: Boolean, + val downloadCancelled: Boolean, + val uploadCompleted: Boolean +) + class FileUpdateHandler( private val registry: FileMessageRegistry, private val queue: FileUpdateQueue, @@ -23,26 +29,48 @@ class FileUpdateHandler( val customEmojiPaths = SynchronizedLruMap(CUSTOM_EMOJI_CACHE_SIZE) val fileIdToCustomEmojiId = SynchronizedLruMap(FILE_TO_EMOJI_CACHE_SIZE) - private val _downloadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val completedDownloadIds = ConcurrentHashMap.newKeySet() + private val downloadingFileIds = ConcurrentHashMap.newKeySet() + private val _downloadProgress = LatestByKeyEventFlow>( + scope = scope, + keyOf = { it.first } + ) private val downloadCompletions = OrderedEventFlow>(scope) - private val _fileDownloadProgress = - MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val _fileDownloadProgress = LatestByKeyEventFlow>( + scope = scope, + keyOf = { it.first }, + shouldEmit = { (fileId, _) -> + queue.getCachedFile(fileId.toInt())?.local?.isDownloadingCompleted != true + } + ) private val fileDownloadCompletions = OrderedEventFlow>(scope) - private val _uploadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - private val fileUpdateEvents = OrderedEventFlow(scope) + private val _uploadProgress = LatestByKeyEventFlow>( + scope = scope, + keyOf = { it.first } + ) + private val terminalFileUpdates = OrderedEventFlow(scope) + private val progressFileUpdates = LatestByKeyEventFlow( + scope = scope, + keyOf = { it.id }, + shouldEmit = { file -> + file.local?.isDownloadingActive != true || + queue.getCachedFile(file.id)?.local?.isDownloadingCompleted != true + } + ) - val downloadProgress = _downloadProgress.asSharedFlow() + val downloadProgress = _downloadProgress.events val downloadCompleted = downloadCompletions.events - val fileDownloadProgress = _fileDownloadProgress.asSharedFlow() + val fileDownloadProgress = _fileDownloadProgress.events.filter { (fileId, _) -> + queue.getCachedFile(fileId.toInt())?.local?.isDownloadingCompleted != true + } val fileDownloadCompleted = fileDownloadCompletions.events - val uploadProgress = _uploadProgress.asSharedFlow() - val fileUpdates = fileUpdateEvents.events + val uploadProgress = _uploadProgress.events + internal val fileTerminalUpdates = terminalFileUpdates.events + internal val fileProgressUpdates = progressFileUpdates.events init { - // The isDownloadingCompleted / isUploadingCompleted edge is what resolves download - // waiters and records the local path; losing it strands whatever was waiting. - // Progress ticks in between are conflatable, but the edge is not, so the whole - // stream goes through a lossless lane. + // The completion/cancellation edge resolves waiters and records the local path, so it + // stays lossless. Progress is independently conflated by file ID below. updates.lane( name = "files", scope = scope, @@ -54,63 +82,85 @@ class FileUpdateHandler( private fun handle(file: TdApi.File) { queue.updateFileCache(file) - fileUpdateEvents.enqueue(file) + val current = queue.getCachedFile(file.id) ?: file + if ( + current.local?.isDownloadingCompleted == true && + file.local?.isDownloadingActive == true && + file.local?.isDownloadingCompleted != true + ) { + return + } - val downloading = file.local?.isDownloadingActive == true - val downloadDone = file.local?.isDownloadingCompleted == true - val uploading = file.remote?.isUploadingActive == true - val uploadDone = file.remote?.isUploadingCompleted == true + val downloading = current.local?.isDownloadingActive == true + val downloadDone = current.local?.isDownloadingCompleted == true + val uploading = current.remote?.isUploadingActive == true + val uploadDone = current.remote?.isUploadingCompleted == true + val wasDownloading = downloadingFileIds.contains(current.id) + val downloadCancelled = wasDownloading && !downloading && !downloadDone + val downloadCompletionEdge = downloadDone && completedDownloadIds.add(current.id) + + if (downloading) downloadingFileIds.add(current.id) else downloadingFileIds.remove(current.id) + if (downloadDone) { + progressFileUpdates.remove(current.id) + _fileDownloadProgress.remove(current.id.toLong()) + } else if (!downloading) { + completedDownloadIds.remove(current.id) + } + + if (downloadCompletionEdge || downloadCancelled || uploadDone || (!downloading && !uploading)) { + terminalFileUpdates.enqueue( + FileTerminalUpdate(current, downloadCompletionEdge, downloadCancelled, uploadDone) + ) + } + if (downloading || (uploading && !uploadDone)) { + progressFileUpdates.enqueue(current) + } - if (downloadDone) queue.notifyDownloadComplete(file.id) + if (downloadDone) queue.notifyDownloadComplete(current.id) - if (uploadDone) queue.notifyUploadComplete(file.id) + if (uploadDone) queue.notifyUploadComplete(current.id) - val entries = registry.getMessages(file.id) + val entries = registry.getMessages(current.id) if (entries.isNotEmpty()) { - scope.launch { - if (downloadDone) { - handleCustomEmoji(file.id, file.local?.path ?: "") - fileDownloadCompletions.enqueue(file.id.toLong() to (file.local?.path ?: "")) - _fileDownloadProgress.emit(file.id.toLong() to 1f) - entries.forEach { (_, msgId) -> - downloadCompletions.enqueue(msgId to (file.local?.path ?: "")) - _downloadProgress.emit(msgId to 1f) - } - } else if (downloading) { - val progress = if (file.size > 0) file.local!!.downloadedSize.toFloat() / file.size else 0f - _fileDownloadProgress.emit(file.id.toLong() to progress) - entries.forEach { (_, msgId) -> _downloadProgress.emit(msgId to progress) } - } - if (uploadDone) { - entries.forEach { (_, msgId) -> _uploadProgress.emit(msgId to 1f) } - } else if (uploading) { - val progress = if (file.size > 0) file.remote!!.uploadedSize.toFloat() / file.size else 0f - entries.forEach { (_, msgId) -> _uploadProgress.emit(msgId to progress) } + if (downloadCompletionEdge) { + handleCustomEmoji(current.id, current.local?.path ?: "") + fileDownloadCompletions.enqueue(current.id.toLong() to (current.local?.path ?: "")) + entries.forEach { (_, msgId) -> + _downloadProgress.remove(msgId) + downloadCompletions.enqueue(msgId to (current.local?.path ?: "")) } + } else if (downloading && current.id !in completedDownloadIds) { + val progress = + if (current.size > 0) current.local!!.downloadedSize.toFloat() / current.size else 0f + _fileDownloadProgress.enqueue(current.id.toLong() to progress) + entries.forEach { (_, msgId) -> _downloadProgress.enqueue(msgId to progress) } } - } else if (registry.standaloneFileIds.contains(file.id)) { - scope.launch { - if (downloadDone) { - fileDownloadCompletions.enqueue(file.id.toLong() to (file.local?.path ?: "")) - _fileDownloadProgress.emit(file.id.toLong() to 1f) - downloadCompletions.enqueue(file.id.toLong() to (file.local?.path ?: "")) - _downloadProgress.emit(file.id.toLong() to 1f) - registry.standaloneFileIds.remove(file.id) - } else if (downloading) { - val progress = if (file.size > 0) file.local!!.downloadedSize.toFloat() / file.size else 0f - _fileDownloadProgress.emit(file.id.toLong() to progress) - _downloadProgress.emit(file.id.toLong() to progress) - } + if (uploadDone) { + entries.forEach { (_, msgId) -> _uploadProgress.enqueue(msgId to 1f) } + } else if (uploading) { + val progress = + if (current.size > 0) current.remote!!.uploadedSize.toFloat() / current.size else 0f + entries.forEach { (_, msgId) -> _uploadProgress.enqueue(msgId to progress) } + } + } else if (registry.standaloneFileIds.contains(current.id)) { + if (downloadCompletionEdge) { + fileDownloadCompletions.enqueue(current.id.toLong() to (current.local?.path ?: "")) + downloadCompletions.enqueue(current.id.toLong() to (current.local?.path ?: "")) + _downloadProgress.remove(current.id.toLong()) + registry.standaloneFileIds.remove(current.id) + } else if (downloading && current.id !in completedDownloadIds) { + val progress = + if (current.size > 0) current.local!!.downloadedSize.toFloat() / current.size else 0f + _fileDownloadProgress.enqueue(current.id.toLong() to progress) + _downloadProgress.enqueue(current.id.toLong() to progress) } } else { - scope.launch { - if (downloadDone) { - fileDownloadCompletions.enqueue(file.id.toLong() to (file.local?.path ?: "")) - _fileDownloadProgress.emit(file.id.toLong() to 1f) - } else if (downloading) { - val progress = if (file.size > 0) file.local!!.downloadedSize.toFloat() / file.size else 0f - _fileDownloadProgress.emit(file.id.toLong() to progress) - } + if (downloadCompletionEdge) { + fileDownloadCompletions.enqueue(current.id.toLong() to (current.local?.path ?: "")) + } else if (downloading && current.id !in completedDownloadIds) { + val progress = + if (current.size > 0) current.local!!.downloadedSize.toFloat() / current.size else 0f + _fileDownloadProgress.enqueue(current.id.toLong() to progress) } } } diff --git a/data/src/main/java/org/monogram/data/infra/OrderedEventFlow.kt b/data/src/main/java/org/monogram/data/infra/OrderedEventFlow.kt index db29e94fe..dfbb4a5aa 100644 --- a/data/src/main/java/org/monogram/data/infra/OrderedEventFlow.kt +++ b/data/src/main/java/org/monogram/data/infra/OrderedEventFlow.kt @@ -1,11 +1,13 @@ package org.monogram.data.infra import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** Lossless ordered bridge for terminal events emitted from non-suspending callbacks. */ internal class OrderedEventFlow(scope: CoroutineScope) { @@ -24,3 +26,43 @@ internal class OrderedEventFlow(scope: CoroutineScope) { fun enqueue(event: T): Boolean = ingress.trySend(event).isSuccess } + +/** Keeps only the newest pending progress value for each key. */ +internal class LatestByKeyEventFlow( + scope: CoroutineScope, + private val keyOf: (T) -> K, + private val shouldEmit: (T) -> Boolean = { true } +) { + private val latest = ConcurrentHashMap() + private val signal = Channel(Channel.CONFLATED) + private val _events = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val events: SharedFlow = _events.asSharedFlow() + + init { + scope.launch { + for (ignored in signal) { + do { + val batch = latest.entries.toList() + batch.forEach { (key, event) -> + if (latest.remove(key, event) && shouldEmit(event)) { + _events.emit(event) + } + } + } while (latest.isNotEmpty()) + } + } + } + + fun enqueue(event: T) { + latest[keyOf(event)] = event + signal.trySend(Unit) + } + + fun remove(key: K) { + latest.remove(key) + } +} diff --git a/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt b/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt index 8e2536445..8b7f309fe 100644 --- a/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt @@ -96,6 +96,8 @@ class FileUpdateHandlerTest { override fun updateFileCache(file: TdApi.File) = Unit + override fun getCachedFile(fileId: Int): TdApi.File? = null + override fun notifyDownloadComplete(fileId: Int) { completedDownloads += fileId } From 48000cecfdba7b2875a0ce75a3531ef874e2df10 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:25:57 +0300 Subject: [PATCH 4/4] cleanup service messages component --- .../chats/conversation/ui/ServiceMessage.kt | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ServiceMessage.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ServiceMessage.kt index ac334322e..7d1cf2be2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ServiceMessage.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/ServiceMessage.kt @@ -1,8 +1,8 @@ package org.monogram.presentation.features.chats.conversation.ui import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -23,6 +24,7 @@ import androidx.compose.material.icons.rounded.Security import androidx.compose.material.icons.rounded.SmartToy import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -30,8 +32,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.monogram.domain.models.MessageContent import org.monogram.domain.models.ServiceEmphasis import org.monogram.domain.models.ServiceKind @@ -40,58 +44,65 @@ import org.monogram.domain.models.ServiceKind fun ServiceMessage(service: MessageContent.Service, modifier: Modifier = Modifier) { val accent = serviceAccentColor(service.kind, service.emphasis) val icon = serviceIcon(service.kind) + val subtitle = service.subtitle?.takeIf { it.isNotBlank() } Box( modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), contentAlignment = Alignment.Center ) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(22.dp)) - .background(accent.copy(alpha = 0.12f)) - .border( - width = 1.dp, - color = accent.copy(alpha = 0.18f), - shape = RoundedCornerShape(22.dp) - ) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(accent.copy(alpha = 0.16f)), - contentAlignment = Alignment.Center + BoxWithConstraints(modifier = Modifier.widthIn(max = 560.dp)) { + val textMaxWidth = (maxWidth - 62.dp).coerceAtLeast(1.dp) + + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.72f), + shape = RoundedCornerShape(16.dp) ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = accent, - modifier = Modifier.size(16.dp) - ) - } + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = if (subtitle == null) Alignment.CenterVertically else Alignment.Top + ) { + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(accent.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = accent, + modifier = Modifier.size(16.dp) + ) + } - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(10.dp)) - Column(horizontalAlignment = Alignment.Start) { - Text( - text = service.text, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Start - ) - val subtitle = service.subtitle?.takeIf { it.isNotBlank() } - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f), - textAlign = TextAlign.Start, - modifier = Modifier.padding(top = 2.dp) - ) + Column( + modifier = Modifier.widthIn(max = textMaxWidth), + horizontalAlignment = Alignment.Start + ) { + Text( + text = service.text, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp + ), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy(lineHeight = 17.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Start, + modifier = Modifier.padding(top = 3.dp) + ) + } + } } } }