diff --git a/build.gradle.kts b/build.gradle.kts index e74822328..533bed1ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,6 +46,12 @@ val appAssemblyVariants = } } +val verifyBeforeAssemble = + providers.gradleProperty("verifyBeforeAssemble") + .map { it.equals("true", ignoreCase = true) } + .orElse(false) + .get() + appAssemblyVariants.forEach { variant -> val verifyTask = tasks.register("verify${variant.name}BeforeAssemble") { group = "verification" @@ -59,8 +65,10 @@ appAssemblyVariants.forEach { variant -> ) } - project(":app").tasks.matching { it.name == "assemble${variant.name}" }.configureEach { - dependsOn(verifyTask) + if (verifyBeforeAssemble) { + project(":app").tasks.matching { it.name == "assemble${variant.name}" }.configureEach { + dependsOn(verifyTask) + } } } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index d2847168d..658bd27d3 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) + implementation(libs.androidx.work.runtime.ktx) ksp(libs.androidx.room.compiler) testImplementation(libs.junit) diff --git a/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt b/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt index 13b7cb201..e6ecaf760 100644 --- a/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt +++ b/data/src/firebase/java/org/monogram/data/service/FcmPushService.kt @@ -3,23 +3,17 @@ package org.monogram.data.service import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import org.koin.android.ext.android.inject -import org.monogram.data.di.TdNotificationManager -import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.PushProcessingCoordinator import org.monogram.domain.repository.AppPreferencesProvider class FcmPushService : FirebaseMessagingService() { - private val gateway: TelegramGateway by inject() private val appPreferences: AppPreferencesProvider by inject() - private val notificationManager: TdNotificationManager by inject() - private val pushSyncTrigger: PushSyncTrigger by inject() + private val pushCoordinator: PushProcessingCoordinator by inject() private val delegate by lazy { BaseFcmPushService( context = this, - gateway = gateway, appPreferences = appPreferences, - notificationManager = notificationManager, - pushSyncTrigger = pushSyncTrigger + pushCoordinator = pushCoordinator ) } 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 e18b237b4..c9d69a2cd 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 @@ -74,6 +74,59 @@ interface ChatLocalDataSource { ) suspend fun deleteMessage(chatId: Long, messageId: Long) + + suspend fun deleteMessages(chatId: Long, messageIds: List) { + messageIds.forEach { messageId -> deleteMessage(chatId, messageId) } + } + + suspend fun applyMessageCacheMutations(mutations: List) { + mutations.forEach { mutation -> + when (mutation) { + is MessageCacheMutation.Persist -> replaceMessage(mutation.message) + is MessageCacheMutation.ReplaceId -> replaceMessageId( + mutation.chatId, + mutation.oldMessageId, + mutation.message + ) + + is MessageCacheMutation.UpdateContent -> updateMessageContent( + mutation.chatId, + mutation.messageId, + mutation.content, + mutation.contentType, + mutation.contentMeta, + mutation.mediaFileId, + mutation.mediaPath, + mutation.editDate + ) + + is MessageCacheMutation.UpdateInteraction -> updateInteractionInfo( + mutation.chatId, + mutation.messageId, + mutation.viewCount, + mutation.forwardCount, + mutation.replyCount + ) + + is MessageCacheMutation.MarkRead -> markAsRead( + mutation.chatId, + mutation.upToMessageId + ) + + is MessageCacheMutation.DeleteMessages -> deleteMessages( + mutation.chatId, + mutation.messageIds + ) + + is MessageCacheMutation.UpdateMediaPath -> updateMediaPath( + mutation.chatId, + mutation.messageId, + mutation.fileId, + mutation.path + ) + } + } + } suspend fun clearMessagesForChat(chatId: Long) suspend fun getChatFullInfo(chatId: Long): ChatFullInfoEntity? 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 new file mode 100644 index 000000000..c056eff43 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheMutation.kt @@ -0,0 +1,43 @@ +package org.monogram.data.datasource.cache + +import org.monogram.data.db.model.MessageEntity + +sealed interface MessageCacheMutation { + data class Persist(val message: MessageEntity) : MessageCacheMutation + + data class ReplaceId( + val chatId: Long, + val oldMessageId: Long, + val message: MessageEntity + ) : MessageCacheMutation + + data class UpdateContent( + val chatId: Long, + val messageId: Long, + val content: String, + val contentType: String, + val contentMeta: String?, + val mediaFileId: Int, + val mediaPath: String?, + val editDate: Int + ) : MessageCacheMutation + + data class UpdateInteraction( + val chatId: Long, + val messageId: Long, + val viewCount: Int, + val forwardCount: Int, + val replyCount: Int + ) : MessageCacheMutation + + data class MarkRead(val chatId: Long, val upToMessageId: Long) : MessageCacheMutation + + data class DeleteMessages(val chatId: Long, val messageIds: List) : MessageCacheMutation + + data class UpdateMediaPath( + val chatId: Long, + val messageId: Long, + val fileId: Int, + val path: String + ) : MessageCacheMutation +} 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 new file mode 100644 index 000000000..b84ac90ab --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/cache/MessageCacheWriter.kt @@ -0,0 +1,103 @@ +package org.monogram.data.datasource.cache + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicInteger + +internal class MessageCacheWriter( + scope: CoroutineScope, + private val applyBatch: suspend (List) -> Unit, + private val maxBatchSize: Int = DEFAULT_MAX_BATCH_SIZE +) { + data class Stats( + val batches: Long = 0, + val mutations: Long = 0, + val lastBatchSize: Int = 0, + val lastLatencyMs: Long = 0, + val failures: Long = 0, + val retries: Long = 0, + val enqueued: Long = 0, + val pending: Int = 0, + val dropped: Long = 0 + ) + + private val mutations = Channel(Channel.UNLIMITED) + private val pending = AtomicInteger() + private val _stats = MutableStateFlow(Stats()) + val stats: StateFlow = _stats.asStateFlow() + + init { + scope.launch { + for (first in mutations) { + pending.decrementAndGet() + val batch = ArrayList(maxBatchSize) + batch += first + while (batch.size < maxBatchSize) { + mutations.tryReceive().getOrNull()?.let { mutation -> + pending.decrementAndGet() + batch += mutation + } ?: break + } + + val startedAt = System.nanoTime() + var retries = 0L + var failure: Throwable? = null + var attempt = 0 + do { + failure = runCatching { applyBatch(batch) }.exceptionOrNull() + attempt++ + if (failure != null && attempt < MAX_ATTEMPTS) { + retries++ + delay(RETRY_DELAY_MS) + } + } while (failure != null && attempt < MAX_ATTEMPTS) + if (failure != null) Log.e( + TAG, + "Message cache batch failed: size=${batch.size}", + failure + ) + _stats.update { previous -> + previous.copy( + batches = previous.batches + 1, + mutations = previous.mutations + batch.size, + lastBatchSize = batch.size, + lastLatencyMs = (System.nanoTime() - startedAt) / 1_000_000, + failures = previous.failures + if (failure == null) 0 else 1, + retries = previous.retries + retries, + pending = pending.get() + ) + } + } + } + } + + fun enqueue(mutation: MessageCacheMutation): Boolean { + pending.incrementAndGet() + if (mutations.trySend(mutation).isSuccess) { + _stats.update { stats -> + stats.copy(enqueued = stats.enqueued + 1, pending = pending.get()) + } + return true + } + + pending.decrementAndGet() + _stats.update { stats -> + stats.copy(dropped = stats.dropped + 1, pending = pending.get()) + } + return false + } + + 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 + } +} 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 b1787e639..7d912ee5f 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 @@ -104,9 +104,22 @@ class RoomChatLocalDataSource( override suspend fun getMessagesByIds(chatId: Long, messageIds: List) = if (messageIds.isEmpty()) emptyList() else messageDao.getMessagesByIds(chatId, messageIds) - override suspend fun insertMessage(message: MessageEntity) = messageDao.insertMessage(message) + override suspend fun insertMessage(message: MessageEntity) { + database.withTransaction { + messageDao.insertMessage(message) + cleanupMessageCache(message.chatId) + } + } - override suspend fun insertMessages(messages: List) = messageDao.insertMessages(messages) + override suspend fun insertMessages(messages: List) { + if (messages.isEmpty()) return + database.withTransaction { + messageDao.insertMessages(messages) + for (chatId in messages.asSequence().map(MessageEntity::chatId).distinct()) { + cleanupMessageCache(chatId) + } + } + } override suspend fun replaceMessage(message: MessageEntity) = messageDao.insertMessage(message) @@ -163,6 +176,81 @@ class RoomChatLocalDataSource( override suspend fun deleteMessage(chatId: Long, messageId: Long) = messageDao.deleteMessage(chatId, messageId) + override suspend fun deleteMessages(chatId: Long, messageIds: List) { + if (messageIds.isNotEmpty()) { + messageDao.deleteMessages(chatId, messageIds) + } + } + + override suspend fun applyMessageCacheMutations(mutations: List) { + if (mutations.isEmpty()) return + + database.withTransaction { + mutations.forEach { mutation -> + when (mutation) { + is MessageCacheMutation.Persist -> messageDao.insertMessage(mutation.message) + is MessageCacheMutation.ReplaceId -> { + messageDao.deleteMessage(mutation.chatId, mutation.oldMessageId) + messageDao.insertMessage(mutation.message) + } + + is MessageCacheMutation.UpdateContent -> messageDao.updateContent( + mutation.chatId, + mutation.messageId, + mutation.content, + mutation.contentType, + mutation.contentMeta, + 0, + null, + mutation.editDate + ) + + is MessageCacheMutation.UpdateInteraction -> messageDao.updateInteractionInfo( + mutation.chatId, + mutation.messageId, + mutation.viewCount, + mutation.forwardCount, + mutation.replyCount + ) + + is MessageCacheMutation.MarkRead -> messageDao.markAsRead( + mutation.chatId, + mutation.upToMessageId + ) + + is MessageCacheMutation.DeleteMessages -> { + if (mutation.messageIds.isNotEmpty()) { + messageDao.deleteMessages(mutation.chatId, mutation.messageIds) + } + } + + is MessageCacheMutation.UpdateMediaPath -> Unit + } + } + for (chatId in mutations.asSequence().map(::mutationChatId).distinct()) { + cleanupMessageCache(chatId) + } + } + } + + private suspend fun cleanupMessageCache(chatId: Long) { + messageDao.cleanupChat( + chatId = chatId, + keepCount = MESSAGE_CACHE_ROWS_PER_CHAT, + olderThan = System.currentTimeMillis() - MESSAGE_CACHE_TTL_MS + ) + } + + 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 + } + override suspend fun clearMessagesForChat(chatId: Long) = messageDao.clearMessagesForChat(chatId) override suspend fun getChatFullInfo(chatId: Long): ChatFullInfoEntity? = chatFullInfoDao.getChatFullInfo(chatId) @@ -186,4 +274,9 @@ class RoomChatLocalDataSource( messageDao.deleteExpired(timestamp) } } -} \ No newline at end of file + + private companion object { + const val MESSAGE_CACHE_ROWS_PER_CHAT = 1_000 + const val MESSAGE_CACHE_TTL_MS = 90L * 24 * 60 * 60 * 1_000 + } +} diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt index 084a5c8c5..f3350b1a7 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt @@ -5,17 +5,17 @@ import org.monogram.data.db.dao.UserDao import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.db.model.UserEntity import org.monogram.data.db.model.UserFullInfoEntity +import org.monogram.data.infra.SynchronizedLruMap import org.monogram.data.mapper.user.extractPersonalAvatarPath import org.monogram.data.mapper.user.toEntity import org.monogram.data.mapper.user.toTdApi -import java.util.concurrent.ConcurrentHashMap class RoomUserLocalDataSource( private val userDao: UserDao, private val userFullInfoDao: UserFullInfoDao ) : UserLocalDataSource { - private val fullInfos = ConcurrentHashMap() - private val users = ConcurrentHashMap() + private val fullInfos = SynchronizedLruMap(FULL_INFO_SNAPSHOT_LIMIT) + private val users = SynchronizedLruMap(USER_SNAPSHOT_LIMIT) override suspend fun getUser(userId: Long): TdApi.User? { users[userId]?.let { return it } @@ -54,7 +54,7 @@ class RoomUserLocalDataSource( override suspend fun getAllUsers(): Collection { val dbUsers = userDao.getAllUsers().map { it.toTdApi() } dbUsers.forEach { users[it.id] = it } - return users.values + return dbUsers } override suspend fun clearAll() { @@ -86,10 +86,29 @@ class RoomUserLocalDataSource( userFullInfoDao.clearAll() } + override suspend fun updateUserStatus(userId: Long, status: TdApi.UserStatus): TdApi.User? { + val user = users[userId] ?: getUser(userId) ?: return null + user.status = status + val statusType = when (status) { + is TdApi.UserStatusOnline -> "ONLINE" + is TdApi.UserStatusRecently -> "RECENTLY" + is TdApi.UserStatusLastWeek -> "LAST_WEEK" + is TdApi.UserStatusLastMonth -> "LAST_MONTH" + else -> "OFFLINE" + } + val lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L + userDao.updateStatus(userId, statusType, lastSeen) + return user + } + override suspend fun clearCachedAvatarPaths() { userDao.clearAvatarPaths() - users.values.forEach { user -> - user.profilePhoto = null - } + // The bounded snapshot is derived from Room and will be refreshed lazily. + users.clear() + } + + private companion object { + const val USER_SNAPSHOT_LIMIT = 1_024 + const val FULL_INFO_SNAPSHOT_LIMIT = 256 } } diff --git a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt index 9aef0942c..d9e38c6b7 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt @@ -7,6 +7,8 @@ import org.monogram.data.db.model.UserFullInfoEntity interface UserLocalDataSource { suspend fun getUser(userId: Long): TdApi.User? suspend fun putUser(user: TdApi.User) + suspend fun updateUserStatus(userId: Long, status: TdApi.UserStatus): TdApi.User? = + getUser(userId)?.also { it.status = status } suspend fun getUserFullInfo(userId: Long): TdApi.UserFullInfo? suspend fun putUserFullInfo(userId: Long, info: TdApi.UserFullInfo) suspend fun getAllUsers(): Collection 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 ea406ef94..723eeb07b 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 @@ -1,6 +1,7 @@ package org.monogram.data.datasource.remote import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import org.drinkless.tdlib.TdApi import org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType import org.monogram.domain.models.FileDownloadEvent @@ -9,6 +10,8 @@ import org.monogram.domain.models.MessageDownloadEvent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageIdUpdatedEvent import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendAcknowledgedEvent +import org.monogram.domain.models.MessageSendFailedEvent import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel @@ -31,6 +34,10 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { val messageDownloadFlow: Flow val messageDeletedFlow: Flow val messageIdUpdateFlow: Flow + val messageAcknowledgedFlow: Flow + get() = emptyFlow() + val messageSendFailedFlow: Flow + get() = emptyFlow() val pinnedMessageFlow: Flow val mediaUpdateFlow: Flow fun registerFileForMessage(fileId: Int, chatId: Long, messageId: Long) @@ -98,6 +105,7 @@ interface MessageRemoteDataSource : DraftLinkPreviewRemoteDataSource { threadId: Long?, sendOptions: MessageSendOptions ): TdApi.Message? + suspend fun retryFailedMessage(chatId: Long, temporaryMessageId: Long) = Unit suspend fun sendVideo( chatId: Long, diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRetryRequest.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRetryRequest.kt new file mode 100644 index 000000000..6f1aeec54 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRetryRequest.kt @@ -0,0 +1,13 @@ +package org.monogram.data.datasource.remote + +import org.drinkless.tdlib.TdApi + +internal fun resendTemporaryMessageRequest( + chatId: Long, + temporaryMessageId: Long +): TdApi.ResendMessages = TdApi.ResendMessages( + chatId, + longArrayOf(temporaryMessageId), + null, + 0L +) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/OrderedEventFlow.kt b/data/src/main/java/org/monogram/data/datasource/remote/OrderedEventFlow.kt new file mode 100644 index 000000000..51112bf42 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/OrderedEventFlow.kt @@ -0,0 +1,25 @@ +package org.monogram.data.datasource.remote + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +internal class OrderedEventFlow(scope: CoroutineScope) { + private val ingress = Channel(Channel.UNLIMITED) + private val _events = MutableSharedFlow() + + val events: SharedFlow = _events.asSharedFlow() + + init { + scope.launch { + for (event in ingress) { + _events.emit(event) + } + } + } + + fun enqueue(event: T): Boolean = ingress.trySend(event).isSuccess +} 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 ba791d649..ee15eafdb 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 @@ -38,6 +38,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.OrderedEventFlow import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.WebPageMapper import org.monogram.data.repository.DraftLinkPreviewResolver @@ -51,6 +52,8 @@ import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.MessageIdUpdatedEvent import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendAcknowledgedEvent +import org.monogram.domain.models.MessageSendFailedEvent import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel @@ -91,25 +94,21 @@ class TdMessageRemoteDataSource( private val sendQueue = Channel Unit>(Channel.BUFFERED) override val newMessageFlow = MutableSharedFlow() override val messageEditedFlow = MutableSharedFlow() - override val messageReadFlow = MutableSharedFlow( - replay = 1, - extraBufferCapacity = 32, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) + private val messageReads = OrderedEventFlow(scope) + override val messageReadFlow = messageReads.events override val messageUploadProgressFlow = MutableSharedFlow() - override val fileDownloadFlow = MutableSharedFlow( - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val messageDownloadFlow = MutableSharedFlow() - override val messageDeletedFlow = MutableSharedFlow( - extraBufferCapacity = 32, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val messageIdUpdateFlow = MutableSharedFlow( - extraBufferCapacity = 32, - 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 messageDeletes = OrderedEventFlow(scope) + override val messageDeletedFlow = messageDeletes.events + private val messageIdUpdates = OrderedEventFlow(scope) + override val messageIdUpdateFlow = messageIdUpdates.events + private val messageAcknowledgements = OrderedEventFlow(scope) + override val messageAcknowledgedFlow = messageAcknowledgements.events + private val messageSendFailures = OrderedEventFlow(scope) + override val messageSendFailedFlow = messageSendFailures.events override val pinnedMessageFlow = MutableSharedFlow( extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST @@ -855,6 +854,10 @@ class TdMessageRemoteDataSource( return lastMessage } + override suspend fun retryFailedMessage(chatId: Long, temporaryMessageId: Long) { + gateway.execute(resendTemporaryMessageRequest(chatId, temporaryMessageId)) + } + override suspend fun sendRichMessage( chatId: Long, markdown: String, @@ -2005,7 +2008,7 @@ class TdMessageRemoteDataSource( scope.launch(dispatcherProvider.io) { try { val model = mapMessageToModel(message) - messageIdUpdateFlow.emit( + messageIdUpdates.enqueue( MessageIdUpdatedEvent( chatId = message.chatId, oldMessageId = update.oldMessageId, @@ -2015,10 +2018,27 @@ class TdMessageRemoteDataSource( } catch (e: Exception) { Log.e("TdMessageRemote", "Error handling SendSucceeded", e) } } } + + is TdApi.UpdateMessageSendAcknowledged -> { + messageAcknowledgements.enqueue( + MessageSendAcknowledgedEvent( + chatId = update.chatId, + temporaryMessageId = update.messageId + ) + ) + } is TdApi.UpdateMessageSendFailed -> { cache.putMessage(update.message) scope.launch(dispatcherProvider.io) { val model = mapMessageToModel(update.message) + messageSendFailures.enqueue( + MessageSendFailedEvent( + chatId = update.message.chatId, + temporaryMessageId = update.oldMessageId, + message = model, + errorCode = update.error?.code ?: 0 + ) + ) messageEditedFlow.emit(model) } } @@ -2131,13 +2151,25 @@ class TdMessageRemoteDataSource( is TdApi.UpdateChatReadOutbox -> { cache.updateChat(update.chatId) { it.lastReadOutboxMessageId = update.lastReadOutboxMessageId } scope.launch(dispatcherProvider.io) { - messageReadFlow.emit(ReadUpdate.Outbox(update.chatId, update.lastReadOutboxMessageId)) + messageReads.enqueue( + ReadUpdate.Outbox( + update.chatId, + update.lastReadOutboxMessageId + ) + ) refreshAndEmitMessage(update.chatId, update.lastReadOutboxMessageId) } } is TdApi.UpdateChatReadInbox -> { cache.updateChat(update.chatId) { it.lastReadInboxMessageId = update.lastReadInboxMessageId } - scope.launch(dispatcherProvider.io) { messageReadFlow.emit(ReadUpdate.Inbox(update.chatId, update.lastReadInboxMessageId)) } + scope.launch(dispatcherProvider.io) { + messageReads.enqueue( + ReadUpdate.Inbox( + update.chatId, + update.lastReadInboxMessageId + ) + ) + } } is TdApi.UpdateDeleteMessages -> { if (!update.fromCache) { @@ -2145,7 +2177,7 @@ class TdMessageRemoteDataSource( scope.launch(dispatcherProvider.io) { messageIds.forEach { cache.removeMessage(update.chatId, it) } removeMessagesFromCache(update.chatId, messageIds) - messageDeletedFlow.emit( + messageDeletes.enqueue( MessageDeletedEvent( chatId = update.chatId, messageIds = messageIds @@ -2267,13 +2299,13 @@ class TdMessageRemoteDataSource( fileDownloadQueue.notifyDownloadComplete(file.id) lastProgressMap.remove(file.id) scope.launch { - fileDownloadFlow.emit( + fileDownloads.enqueue( FileDownloadEvent.Completed( fileId = file.id, path = file.local?.path ?: "" ) ) - fileDownloadFlow.emit( + fileDownloads.enqueue( FileDownloadEvent.Progress( fileId = file.id, progress = 1.0f @@ -2288,7 +2320,7 @@ class TdMessageRemoteDataSource( if (!entries.isNullOrEmpty()) { scope.launch { entries.forEach { (chatId, messageId) -> - messageDownloadFlow.emit( + messageDownloads.enqueue( MessageDownloadEvent.Completed( chatId = chatId, messageId = messageId, @@ -2296,7 +2328,7 @@ class TdMessageRemoteDataSource( path = file.local?.path ?: "" ) ) - messageDownloadFlow.emit( + messageDownloads.enqueue( MessageDownloadEvent.Progress( chatId = chatId, messageId = messageId, @@ -2317,7 +2349,7 @@ class TdMessageRemoteDataSource( if (lastProgressMap[file.id] != pInt) { lastProgressMap[file.id] = pInt scope.launch { - fileDownloadFlow.emit( + fileDownloads.enqueue( FileDownloadEvent.Progress( fileId = file.id, progress = p @@ -2328,7 +2360,7 @@ class TdMessageRemoteDataSource( if (!entries.isNullOrEmpty()) { scope.launch { entries.forEach { (chatId, messageId) -> - messageDownloadFlow.emit( + messageDownloads.enqueue( MessageDownloadEvent.Progress( chatId = chatId, messageId = messageId, @@ -2445,7 +2477,7 @@ class TdMessageRemoteDataSource( if (!entries.isNullOrEmpty()) { scope.launch { entries.forEach { (chatId, messageId) -> - messageDownloadFlow.emit( + messageDownloads.enqueue( MessageDownloadEvent.Cancelled( chatId = chatId, messageId = messageId, diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt index 62aabe621..660881c60 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdUserRemoteDataSource.kt @@ -158,9 +158,6 @@ class TdUserRemoteDataSource( override suspend fun getPremiumFeatures(source: TdApi.PremiumSource): TdApi.PremiumFeatures? = coRunCatching { gateway.execute(TdApi.GetPremiumFeatures(source)) }.getOrNull() - override suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? = - coRunCatching { gateway.execute(TdApi.GetPremiumLimit(limitType)) }.getOrNull() - override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) { coRunCatching { gateway.execute(TdApi.ToggleHasSponsoredMessagesEnabled(enabled)) } } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt index a7262c559..18c1f7f81 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/UserRemoteDataSource.kt @@ -31,7 +31,6 @@ interface UserRemoteDataSource { suspend fun getBasicGroupMembers(basicGroupId: Long): TdApi.BasicGroupFullInfo? suspend fun getPremiumState(): TdApi.PremiumState? suspend fun getPremiumFeatures(source: TdApi.PremiumSource): TdApi.PremiumFeatures? - suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? suspend fun setSponsoredMessagesEnabled(enabled: Boolean) suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo? suspend fun getChatStatistics(chatId: Long, isDark: Boolean): TdApi.ChatStatistics? 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 4e899a1e5..aef9cff88 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 @@ -162,10 +162,12 @@ interface MessageDao { """ DELETE FROM messages WHERE chatId = :chatId - AND id NOT IN ( - SELECT id FROM messages WHERE chatId = :chatId ORDER BY date DESC LIMIT :keepCount + AND ( + createdAt < :olderThan + OR id NOT IN ( + SELECT id FROM messages WHERE chatId = :chatId ORDER BY date DESC LIMIT :keepCount + ) ) - AND createdAt < :olderThan """ ) suspend fun cleanupChat(chatId: Long, keepCount: Int, olderThan: Long) @@ -179,6 +181,9 @@ interface MessageDao { @Query("DELETE FROM messages WHERE chatId = :chatId AND id = :messageId") suspend fun deleteMessage(chatId: Long, messageId: Long) + @Query("DELETE FROM messages WHERE chatId = :chatId AND id IN (:messageIds)") + suspend fun deleteMessages(chatId: Long, messageIds: List) + @Query("DELETE FROM messages WHERE chatId = :chatId") suspend fun clearMessagesForChat(chatId: Long) diff --git a/data/src/main/java/org/monogram/data/db/dao/UserDao.kt b/data/src/main/java/org/monogram/data/db/dao/UserDao.kt index bb2af9302..64dd45d97 100644 --- a/data/src/main/java/org/monogram/data/db/dao/UserDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/UserDao.kt @@ -14,6 +14,9 @@ interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertUser(user: UserEntity) + @Query("UPDATE users SET statusType = :statusType, lastSeen = :lastSeen WHERE id = :userId") + suspend fun updateStatus(userId: Long, statusType: String, lastSeen: Long) + @Query("SELECT * FROM users") suspend fun getAllUsers(): List diff --git a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt index c24aa1d86..11b956853 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -33,8 +33,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull @@ -47,6 +45,7 @@ import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.notifications.NotificationMuteDecision import org.monogram.data.notifications.NotificationMuteResolver +import org.monogram.data.notifications.NotificationRenderBatcher import org.monogram.data.notifications.NotificationScopeState import org.monogram.data.notifications.TdlibNotificationStateStore import org.monogram.data.push.FcmRuntime @@ -80,8 +79,13 @@ class TdNotificationManager( ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val notificationManager = NotificationManagerCompat.from(context) - private val notificationStateVersion = MutableStateFlow(0L) - private val notificationStateCounter = AtomicLong(0L) + private val notificationStatePreferences = context.getSharedPreferences( + NOTIFICATION_STATE_PREFS, + Context.MODE_PRIVATE + ) + private val notificationStateCounter = AtomicLong( + notificationStatePreferences.getLong(KEY_NOTIFICATION_STATE_VERSION, 0L) + ) private val userCache = ConcurrentHashMap() private val chatCache = ConcurrentHashMap() private val messagesHistory = ConcurrentHashMap>() @@ -97,6 +101,11 @@ class TdNotificationManager( private val notificationSettingsCache = ConcurrentHashMap() private val scopeNotificationsEnabled = ConcurrentHashMap() private val loadedScopeSettings = ConcurrentHashMap.newKeySet() + private val nativeRenderBatcher = NotificationRenderBatcher(scope) { chatIds -> + for (chatId in chatIds) { + renderNativeNotifications(chatId) + } + } @Volatile private var myUserId: Long = 0L @@ -113,6 +122,10 @@ class TdNotificationManager( const val SUMMARY_ID = 0 const val KEY_TEXT_REPLY = "key_text_reply" + private const val NOTIFICATION_STATE_PREFS = "tdlib_notification_state" + private const val KEY_NOTIFICATION_STATE_VERSION = "version" + private const val KEY_NOTIFICATION_STATE_FINGERPRINT = "fingerprint" + private const val ACTION_DEDUP_TTL_MS = 5 * 60_000L } private data class NotificationHistoryEntry( @@ -298,7 +311,7 @@ class TdNotificationManager( ) Log.d( TAG, - "RegisterDevice success for UnifiedPush endpoint=${endpoint.take(120)}" + "RegisterDevice success for UnifiedPush" ) }.onFailure { Log.e(TAG, "UnifiedPush registration failed", it) } } @@ -377,14 +390,6 @@ class TdNotificationManager( return resolveMuteDecision(chat).isMuted } - fun currentNotificationStateVersion(): Long = notificationStateVersion.value - - suspend fun awaitNotificationStateChange(afterVersion: Long, timeoutMs: Long): Boolean { - return withTimeoutOrNull(timeoutMs) { - notificationStateVersion.first { it > afterVersion } - } != null - } - private fun resolveMuteDecision(chat: TdApi.Chat): NotificationMuteDecision { return muteResolver.resolve( chat = chat, @@ -423,6 +428,16 @@ class TdNotificationManager( removeRenderedNotification(chatId, notificationId) } + /** Returns false when Android redelivers the same notification action. */ + @Synchronized + fun consumeNotificationAction(action: String, chatId: Long, notificationId: Int): Boolean { + val now = System.currentTimeMillis() + val key = "action:$action:$chatId:$notificationId" + if (notificationStatePreferences.getLong(key, 0L) > now) return false + notificationStatePreferences.edit().putLong(key, now + ACTION_DEDUP_TTL_MS).apply() + return true + } + private fun removeRenderedNotification(chatId: Long, notificationId: Int) { activeNotifications[chatId]?.remove(notificationId) notificationManager.cancel(notificationId) @@ -444,28 +459,29 @@ class TdNotificationManager( } private suspend fun handleActiveNotifications(update: TdApi.UpdateActiveNotifications) { - markNotificationStateChanged() - nativeNotificationStateStore.replaceAll(update).forEach { chatId -> - renderNativeNotifications(chatId) - } + val affectedChatIds = nativeNotificationStateStore.replaceAll(update) + persistNotificationState() + nativeRenderBatcher.enqueue(affectedChatIds) } private suspend fun handleNotificationGroupUpdate(update: TdApi.UpdateNotificationGroup) { - markNotificationStateChanged() - nativeNotificationStateStore.apply(update).forEach { chatId -> - renderNativeNotifications(chatId) - } + val affectedChatIds = nativeNotificationStateStore.apply(update) + persistNotificationState() + nativeRenderBatcher.enqueue(affectedChatIds) } private suspend fun handleNotificationUpdate(update: TdApi.UpdateNotification) { - markNotificationStateChanged() - nativeNotificationStateStore.apply(update).forEach { chatId -> - renderNativeNotifications(chatId) - } + val affectedChatIds = nativeNotificationStateStore.apply(update) + persistNotificationState() + nativeRenderBatcher.enqueue(affectedChatIds) } - private fun markNotificationStateChanged() { - notificationStateVersion.value = notificationStateCounter.incrementAndGet() + private fun persistNotificationState() { + val version = notificationStateCounter.incrementAndGet() + notificationStatePreferences.edit() + .putLong(KEY_NOTIFICATION_STATE_VERSION, version) + .putLong(KEY_NOTIFICATION_STATE_FINGERPRINT, nativeNotificationStateStore.fingerprint()) + .apply() } private fun handleFileUpdate(file: TdApi.File) { 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 91c66ee08..1364c7263 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -18,6 +18,7 @@ import org.monogram.data.datasource.TdFileDataSource 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.RoomChatLocalDataSource import org.monogram.data.datasource.cache.RoomStickerLocalDataSource import org.monogram.data.datasource.cache.RoomUserLocalDataSource @@ -73,6 +74,7 @@ import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileMessageRegistry import org.monogram.data.infra.FileObserverHub import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.infra.FileUpdateQueue import org.monogram.data.infra.NetworkSnapshotProvider import org.monogram.data.infra.OfflineWarmup import org.monogram.data.infra.SponsorSyncManager @@ -89,6 +91,8 @@ 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.notifications.NotificationMuteResolver +import org.monogram.data.push.PushProcessingCoordinator +import org.monogram.data.push.PushSyncRequester import org.monogram.data.push.PushSyncTrigger import org.monogram.data.push.UnifiedPushManager import org.monogram.data.repository.AttachMenuBotRepositoryImpl @@ -556,6 +560,8 @@ val dataModule = module { } single { PushSyncTrigger(connectionManager = get(), gateway = get()) } + single { get() } + single { PushProcessingCoordinator(androidContext(), get(), get()) } single { UnifiedPushManager(androidContext()) } single { NotificationMuteResolver() } @@ -650,6 +656,14 @@ val dataModule = module { ) } + single { + val localDataSource = get() + MessageCacheWriter( + scope = get(), + applyBatch = localDataSource::applyMessageCacheMutations + ) + } + single { NetworkStatisticsRepositoryImpl( remote = get(), @@ -707,6 +721,7 @@ val dataModule = module { dispatcherProvider = get(), scope = get(), chatLocalDataSource = get(), + messageCacheWriter = get(), userLocalDataSource = get(), stickerPathDao = get(), keyValueDao = get(), @@ -767,11 +782,13 @@ val dataModule = module { ) } + single { get() } + single { FileUpdateHandler( registry = get(), queue = get(), - updates = get(), + fileUpdatesSource = get().file, scope = get() ) } diff --git a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt index cfe5a6c83..651246045 100644 --- a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt +++ b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcher.kt @@ -1,6 +1,7 @@ package org.monogram.data.gateway import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import org.drinkless.tdlib.TdApi interface UpdateDispatcher { @@ -16,6 +17,8 @@ interface UpdateDispatcher { val notification: Flow val messageEdited: Flow val messageContent: Flow + val messageSendAcknowledged: Flow + get() = emptyFlow() val messageSendSucceeded: Flow val messageSendFailed: Flow val messageDeleted: Flow 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 c152dc863..8e2a938ee 100644 --- a/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt +++ b/data/src/main/java/org/monogram/data/gateway/UpdateDispatcherImpl.kt @@ -24,6 +24,7 @@ class UpdateDispatcherImpl( override val notification = flow() override val messageEdited = flow() override val messageContent = flow() + override val messageSendAcknowledged = flow() override val messageSendSucceeded = flow() override val messageSendFailed = flow() override val messageDeleted = flow() 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 8dc55fcf5..c38f40aa6 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -28,7 +28,7 @@ class FileDownloadQueue( private val cache: ChatCache, private val scope: CoroutineScope, private val dispatcherProvider: DispatcherProvider -) { +) : FileUpdateQueue { interface Observer { fun onDownloadCancelled(fileId: Int) } @@ -456,7 +456,7 @@ class FileDownloadQueue( } } - fun updateFileCache(file: TdApi.File) { + override fun updateFileCache(file: TdApi.File) { val oldFile = cache.fileCache[file.id] cache.fileCache[file.id] = file val now = System.currentTimeMillis() @@ -712,7 +712,7 @@ class FileDownloadQueue( return uploadWaiters.getOrPut(fileId) { CompletableDeferred() } } - fun notifyDownloadComplete(fileId: Int) { + override fun notifyDownloadComplete(fileId: Int) { downloadWaiters.remove(fileId)?.complete(Unit) } @@ -721,7 +721,7 @@ class FileDownloadQueue( observer?.onDownloadCancelled(fileId) } - fun notifyUploadComplete(fileId: Int) { + override fun notifyUploadComplete(fileId: Int) { uploadWaiters.remove(fileId)?.complete(Unit) } 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 8b9e54070..887b7a1d6 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -2,49 +2,51 @@ package org.monogram.data.infra import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.data.gateway.UpdateDispatcher + +interface FileUpdateQueue { + fun updateFileCache(file: TdApi.File) + fun notifyDownloadComplete(fileId: Int) + fun notifyUploadComplete(fileId: Int) +} class FileUpdateHandler( private val registry: FileMessageRegistry, - private val queue: FileDownloadQueue, - private val updates: UpdateDispatcher, + private val queue: FileUpdateQueue, + private val fileUpdatesSource: Flow, private val scope: CoroutineScope ) { 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 _downloadCompleted = MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val downloadCompletions = OrderedEventFlow>(scope) private val _fileDownloadProgress = MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) - private val _fileDownloadCompleted = - MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val fileDownloadCompletions = OrderedEventFlow>(scope) private val _uploadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - private val _fileUpdates = MutableSharedFlow( - extraBufferCapacity = 256, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) + private val fileUpdateEvents = OrderedEventFlow(scope) val downloadProgress = _downloadProgress.asSharedFlow() - val downloadCompleted = _downloadCompleted.asSharedFlow() + val downloadCompleted = downloadCompletions.events val fileDownloadProgress = _fileDownloadProgress.asSharedFlow() - val fileDownloadCompleted = _fileDownloadCompleted.asSharedFlow() + val fileDownloadCompleted = fileDownloadCompletions.events val uploadProgress = _uploadProgress.asSharedFlow() - val fileUpdates = _fileUpdates.asSharedFlow() + val fileUpdates = fileUpdateEvents.events init { scope.launch { - updates.file.collect { update -> handle(update.file) } + fileUpdatesSource.collect { update -> handle(update.file) } } } private fun handle(file: TdApi.File) { queue.updateFileCache(file) - _fileUpdates.tryEmit(file) + fileUpdateEvents.enqueue(file) val downloading = file.local?.isDownloadingActive == true val downloadDone = file.local?.isDownloadingCompleted == true @@ -60,10 +62,10 @@ class FileUpdateHandler( scope.launch { if (downloadDone) { handleCustomEmoji(file.id, file.local?.path ?: "") - _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) + fileDownloadCompletions.enqueue(file.id.toLong() to (file.local?.path ?: "")) _fileDownloadProgress.emit(file.id.toLong() to 1f) entries.forEach { (_, msgId) -> - _downloadCompleted.emit(msgId to (file.local?.path ?: "")) + downloadCompletions.enqueue(msgId to (file.local?.path ?: "")) _downloadProgress.emit(msgId to 1f) } } else if (downloading) { @@ -81,9 +83,9 @@ class FileUpdateHandler( } else if (registry.standaloneFileIds.contains(file.id)) { scope.launch { if (downloadDone) { - _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) + fileDownloadCompletions.enqueue(file.id.toLong() to (file.local?.path ?: "")) _fileDownloadProgress.emit(file.id.toLong() to 1f) - _downloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) + downloadCompletions.enqueue(file.id.toLong() to (file.local?.path ?: "")) _downloadProgress.emit(file.id.toLong() to 1f) registry.standaloneFileIds.remove(file.id) } else if (downloading) { @@ -95,7 +97,7 @@ class FileUpdateHandler( } else { scope.launch { if (downloadDone) { - _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) + 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 diff --git a/data/src/main/java/org/monogram/data/infra/LatestByKeyBatcher.kt b/data/src/main/java/org/monogram/data/infra/LatestByKeyBatcher.kt new file mode 100644 index 000000000..26e15ce56 --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/LatestByKeyBatcher.kt @@ -0,0 +1,47 @@ +package org.monogram.data.infra + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +internal class LatestByKeyBatcher( + scope: CoroutineScope, + private val debounceMs: Long = DEFAULT_DEBOUNCE_MS, + private val onBatch: suspend (Map) -> Unit +) { + private val latest = ConcurrentHashMap() + private val signals = Channel(Channel.CONFLATED) + + init { + scope.launch { + for (ignored in signals) { + if (debounceMs > 0L) delay(debounceMs) + val snapshot = latest.toMap() + snapshot.forEach { (key, value) -> latest.remove(key, value) } + if (snapshot.isNotEmpty()) { + runCatching { onBatch(snapshot) } + .onFailure { error -> + Log.e( + TAG, + "Failed to apply latest keyed batch", + error + ) + } + } + } + } + } + + fun offer(key: K, value: V): Boolean { + latest[key] = value + return signals.trySend(Unit).isSuccess + } + + private companion object { + const val TAG = "LatestByKeyBatcher" + const val DEFAULT_DEBOUNCE_MS = 8L + } +} diff --git a/data/src/main/java/org/monogram/data/infra/OrderedEventFlow.kt b/data/src/main/java/org/monogram/data/infra/OrderedEventFlow.kt new file mode 100644 index 000000000..db29e94fe --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/OrderedEventFlow.kt @@ -0,0 +1,26 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +/** Lossless ordered bridge for terminal events emitted from non-suspending callbacks. */ +internal class OrderedEventFlow(scope: CoroutineScope) { + private val ingress = Channel(Channel.UNLIMITED) + private val _events = MutableSharedFlow() + + val events: SharedFlow = _events.asSharedFlow() + + init { + scope.launch { + for (event in ingress) { + _events.emit(event) + } + } + } + + fun enqueue(event: T): Boolean = ingress.trySend(event).isSuccess +} diff --git a/data/src/main/java/org/monogram/data/infra/OutboxLifecycleMetrics.kt b/data/src/main/java/org/monogram/data/infra/OutboxLifecycleMetrics.kt new file mode 100644 index 000000000..3b4e9d271 --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/OutboxLifecycleMetrics.kt @@ -0,0 +1,63 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.ArrayDeque +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.ceil + +internal class OutboxLifecycleMetrics( + private val nowMs: () -> Long = System::currentTimeMillis +) { + data class Snapshot( + val active: Int = 0, + val completed: Long = 0, + val p50Ms: Long = 0, + val p95Ms: Long = 0, + val p99Ms: Long = 0 + ) + + private val startedAtByMessage = ConcurrentHashMap() + private val samplesMs = ArrayDeque() + private var completed = 0L + private val _snapshot = MutableStateFlow(Snapshot()) + val snapshot: StateFlow = _snapshot.asStateFlow() + + fun recordPending(chatId: Long, temporaryMessageId: Long, startedAtMs: Long = nowMs()) { + startedAtByMessage.putIfAbsent(Key(chatId, temporaryMessageId), startedAtMs) + publish() + } + + fun recordTerminal(chatId: Long, temporaryMessageId: Long, completedAtMs: Long = nowMs()) { + val startedAtMs = startedAtByMessage.remove(Key(chatId, temporaryMessageId)) ?: return + synchronized(samplesMs) { + if (samplesMs.size == MAX_SAMPLES) samplesMs.removeFirst() + samplesMs.addLast((completedAtMs - startedAtMs).coerceAtLeast(0L)) + completed++ + } + publish() + } + + private fun publish() { + val latencies = synchronized(samplesMs) { samplesMs.sorted() } + _snapshot.value = Snapshot( + active = startedAtByMessage.size, + completed = completed, + p50Ms = percentile(latencies, 0.50), + p95Ms = percentile(latencies, 0.95), + p99Ms = percentile(latencies, 0.99) + ) + } + + private fun percentile(samples: List, percentile: Double): Long { + if (samples.isEmpty()) return 0L + return samples[(ceil(samples.size * percentile).toInt() - 1).coerceIn(0, samples.lastIndex)] + } + + private data class Key(val chatId: Long, val temporaryMessageId: Long) + + private companion object { + const val MAX_SAMPLES = 256 + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt index 80c557225..566f22ab2 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt @@ -1,7 +1,10 @@ package org.monogram.data.mapper.user import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.PremiumFeatureModel import org.monogram.domain.models.PremiumFeatureType +import org.monogram.domain.models.PremiumFeaturesModel +import org.monogram.domain.models.PremiumLimitModel import org.monogram.domain.models.PremiumLimitType import org.monogram.domain.models.PremiumPaymentOptionModel import org.monogram.domain.models.PremiumSource @@ -10,32 +13,98 @@ import org.monogram.domain.models.PremiumStateModel fun TdApi.PremiumState.toDomain() : PremiumStateModel = PremiumStateModel( state = this.state.text, - animations = this.animations.map { it.feature.toDomain() }, + animations = this.animations.map { it.feature.toDomain().type }, paymentOptions = this.paymentOptions.map { option -> PremiumPaymentOptionModel( currency = option.paymentOption.currency, amount = option.paymentOption.amount, + discountPercentage = option.paymentOption.discountPercentage, monthCount = option.paymentOption.monthCount, storeProductId = option.paymentOption.storeProductId, - paymentLink = option.paymentOption.paymentLink?.toString() + paymentLink = option.paymentOption.paymentLink?.toString(), + isCurrent = option.isCurrent, + isUpgrade = option.isUpgrade, + lastTransactionId = option.lastTransactionId.takeIf { it.isNotBlank() } ) - } + }, + businessAnimations = this.businessAnimations.map { it.feature.javaClass.simpleName } ) -fun TdApi.PremiumFeature.toDomain() : PremiumFeatureType = when (this) { - is TdApi.PremiumFeatureIncreasedLimits -> PremiumFeatureType.DOUBLE_LIMITS - is TdApi.PremiumFeatureVoiceRecognition -> PremiumFeatureType.VOICE_TO_TEXT - is TdApi.PremiumFeatureImprovedDownloadSpeed -> PremiumFeatureType.FASTER_DOWNLOAD - is TdApi.PremiumFeatureRealTimeChatTranslation -> PremiumFeatureType.TRANSLATION - is TdApi.PremiumFeatureCustomEmoji -> PremiumFeatureType.ANIMATED_EMOJI - is TdApi.PremiumFeatureAdvancedChatManagement -> PremiumFeatureType.ADVANCED_CHAT_MANAGEMENT - is TdApi.PremiumFeatureDisabledAds -> PremiumFeatureType.NO_ADS - is TdApi.PremiumFeatureUniqueReactions -> PremiumFeatureType.INFINITE_REACTIONS - is TdApi.PremiumFeatureProfileBadge -> PremiumFeatureType.BADGE - is TdApi.PremiumFeatureAppIcons -> PremiumFeatureType.APP_ICONS - is TdApi.PremiumFeatureEmojiStatus -> PremiumFeatureType.PROFILE_BADGE - else -> PremiumFeatureType.UNKNOWN -} +fun TdApi.PremiumFeatures.toDomain(): PremiumFeaturesModel = + PremiumFeaturesModel( + features = features.map { it.toDomain() }, + limits = limits.map { it.toDomain() }, + paymentLink = paymentLink?.toString() + ) + +fun TdApi.PremiumFeature.toDomain(): PremiumFeatureModel = + PremiumFeatureModel( + type = when (getConstructor()) { + TdApi.PremiumFeatureIncreasedLimits.CONSTRUCTOR -> PremiumFeatureType.DOUBLE_LIMITS + TdApi.PremiumFeatureIncreasedUploadFileSize.CONSTRUCTOR -> PremiumFeatureType.INCREASED_UPLOAD_FILE_SIZE + TdApi.PremiumFeatureImprovedDownloadSpeed.CONSTRUCTOR -> PremiumFeatureType.FASTER_DOWNLOAD + TdApi.PremiumFeatureVoiceRecognition.CONSTRUCTOR -> PremiumFeatureType.VOICE_TO_TEXT + TdApi.PremiumFeatureDisabledAds.CONSTRUCTOR -> PremiumFeatureType.NO_ADS + TdApi.PremiumFeatureUniqueReactions.CONSTRUCTOR -> PremiumFeatureType.INFINITE_REACTIONS + TdApi.PremiumFeatureUniqueStickers.CONSTRUCTOR -> PremiumFeatureType.UNIQUE_STICKERS + TdApi.PremiumFeatureCustomEmoji.CONSTRUCTOR -> PremiumFeatureType.ANIMATED_EMOJI + TdApi.PremiumFeatureAdvancedChatManagement.CONSTRUCTOR -> PremiumFeatureType.ADVANCED_CHAT_MANAGEMENT + TdApi.PremiumFeatureProfileBadge.CONSTRUCTOR -> PremiumFeatureType.BADGE + TdApi.PremiumFeatureEmojiStatus.CONSTRUCTOR -> PremiumFeatureType.PROFILE_BADGE + TdApi.PremiumFeatureAnimatedProfilePhoto.CONSTRUCTOR -> PremiumFeatureType.ANIMATED_PROFILE_PHOTO + TdApi.PremiumFeatureForumTopicIcon.CONSTRUCTOR -> PremiumFeatureType.FORUM_TOPIC_ICON + TdApi.PremiumFeatureAppIcons.CONSTRUCTOR -> PremiumFeatureType.APP_ICONS + TdApi.PremiumFeatureRealTimeChatTranslation.CONSTRUCTOR -> PremiumFeatureType.TRANSLATION + TdApi.PremiumFeatureUpgradedStories.CONSTRUCTOR -> PremiumFeatureType.UPGRADED_STORIES + TdApi.PremiumFeatureChatBoost.CONSTRUCTOR -> PremiumFeatureType.CHAT_BOOST + TdApi.PremiumFeatureAccentColor.CONSTRUCTOR -> PremiumFeatureType.ACCENT_COLOR + TdApi.PremiumFeatureBackgroundForBoth.CONSTRUCTOR -> PremiumFeatureType.BACKGROUND_FOR_BOTH + TdApi.PremiumFeatureSavedMessagesTags.CONSTRUCTOR -> PremiumFeatureType.SAVED_MESSAGES_TAGS + TdApi.PremiumFeatureMessagePrivacy.CONSTRUCTOR -> PremiumFeatureType.MESSAGE_PRIVACY + TdApi.PremiumFeatureLastSeenTimes.CONSTRUCTOR -> PremiumFeatureType.LAST_SEEN_TIMES + TdApi.PremiumFeatureBusiness.CONSTRUCTOR -> PremiumFeatureType.BUSINESS + TdApi.PremiumFeatureMessageEffects.CONSTRUCTOR -> PremiumFeatureType.MESSAGE_EFFECTS + TdApi.PremiumFeatureChecklists.CONSTRUCTOR -> PremiumFeatureType.CHECKLISTS + TdApi.PremiumFeaturePaidMessages.CONSTRUCTOR -> PremiumFeatureType.PAID_MESSAGES + TdApi.PremiumFeatureProtectPrivateChatContent.CONSTRUCTOR -> PremiumFeatureType.PROTECT_PRIVATE_CHAT_CONTENT + TdApi.PremiumFeatureTextComposition.CONSTRUCTOR -> PremiumFeatureType.TEXT_COMPOSITION + TdApi.PremiumFeatureRichMessages.CONSTRUCTOR -> PremiumFeatureType.RICH_MESSAGES + else -> PremiumFeatureType.UNKNOWN + }, + apiName = javaClass.simpleName + ) + +fun TdApi.PremiumLimit.toDomain(): PremiumLimitModel = + PremiumLimitModel( + type = when (type.getConstructor()) { + TdApi.PremiumLimitTypeSupergroupCount.CONSTRUCTOR -> PremiumLimitType.SUPERGROUP_COUNT + TdApi.PremiumLimitTypePinnedChatCount.CONSTRUCTOR -> PremiumLimitType.PINNED_CHAT_COUNT + TdApi.PremiumLimitTypeCreatedPublicChatCount.CONSTRUCTOR -> PremiumLimitType.CREATED_PUBLIC_CHAT_COUNT + TdApi.PremiumLimitTypeSavedAnimationCount.CONSTRUCTOR -> PremiumLimitType.SAVED_ANIMATION_COUNT + TdApi.PremiumLimitTypeFavoriteStickerCount.CONSTRUCTOR -> PremiumLimitType.FAVORITE_STICKER_COUNT + TdApi.PremiumLimitTypeChatFolderCount.CONSTRUCTOR -> PremiumLimitType.CHAT_FOLDER_COUNT + TdApi.PremiumLimitTypeChatFolderChosenChatCount.CONSTRUCTOR -> PremiumLimitType.CHAT_FOLDER_CHOSEN_CHAT_COUNT + TdApi.PremiumLimitTypePinnedArchivedChatCount.CONSTRUCTOR -> PremiumLimitType.PINNED_ARCHIVED_CHAT_COUNT + TdApi.PremiumLimitTypePinnedSavedMessagesTopicCount.CONSTRUCTOR -> PremiumLimitType.PINNED_SAVED_MESSAGES_TOPIC_COUNT + TdApi.PremiumLimitTypeMessageTextLength.CONSTRUCTOR -> PremiumLimitType.MESSAGE_TEXT_LENGTH + TdApi.PremiumLimitTypeCaptionLength.CONSTRUCTOR -> PremiumLimitType.CAPTION_LENGTH + TdApi.PremiumLimitTypeBioLength.CONSTRUCTOR -> PremiumLimitType.BIO_LENGTH + TdApi.PremiumLimitTypeChatFolderInviteLinkCount.CONSTRUCTOR -> PremiumLimitType.CHAT_FOLDER_INVITE_LINK_COUNT + TdApi.PremiumLimitTypeShareableChatFolderCount.CONSTRUCTOR -> PremiumLimitType.SHAREABLE_CHAT_FOLDER_COUNT + TdApi.PremiumLimitTypeActiveStoryCount.CONSTRUCTOR -> PremiumLimitType.ACTIVE_STORY_COUNT + TdApi.PremiumLimitTypeWeeklyPostedStoryCount.CONSTRUCTOR -> PremiumLimitType.WEEKLY_POSTED_STORY_COUNT + TdApi.PremiumLimitTypeMonthlyPostedStoryCount.CONSTRUCTOR -> PremiumLimitType.MONTHLY_POSTED_STORY_COUNT + TdApi.PremiumLimitTypeStoryCaptionLength.CONSTRUCTOR -> PremiumLimitType.STORY_CAPTION_LENGTH + TdApi.PremiumLimitTypeStorySuggestedReactionAreaCount.CONSTRUCTOR -> PremiumLimitType.STORY_SUGGESTED_REACTION_AREA_COUNT + TdApi.PremiumLimitTypeSimilarChatCount.CONSTRUCTOR -> PremiumLimitType.SIMILAR_CHAT_COUNT + TdApi.PremiumLimitTypeOwnedBotCount.CONSTRUCTOR -> PremiumLimitType.OWNED_BOT_COUNT + TdApi.PremiumLimitTypeCustomTextCompositionStyleCount.CONSTRUCTOR -> PremiumLimitType.CUSTOM_TEXT_COMPOSITION_STYLE_COUNT + else -> PremiumLimitType.UNKNOWN + }, + defaultValue = defaultValue, + premiumValue = premiumValue, + apiName = type.javaClass.simpleName + ) fun PremiumSource.toApi(): TdApi.PremiumSource? = when (this) { PremiumSource.SETTINGS -> TdApi.PremiumSourceSettings() @@ -44,14 +113,3 @@ fun PremiumSource.toApi(): TdApi.PremiumSource? = when (this) { PremiumSource.LINK -> TdApi.PremiumSourceLink() else -> null } - -fun PremiumLimitType.toApi(): TdApi.PremiumLimitType? = when (this) { - PremiumLimitType.SUPERGROUP_COUNT -> TdApi.PremiumLimitTypeSupergroupCount() - PremiumLimitType.CHAT_FOLDER_COUNT -> TdApi.PremiumLimitTypeChatFolderCount() - PremiumLimitType.PINNED_CHAT_COUNT -> TdApi.PremiumLimitTypePinnedChatCount() - PremiumLimitType.CREATED_PUBLIC_CHAT_COUNT -> TdApi.PremiumLimitTypeCreatedPublicChatCount() - PremiumLimitType.CHAT_FOLDER_INVITE_LINK_COUNT -> TdApi.PremiumLimitTypeChatFolderInviteLinkCount() - PremiumLimitType.SHAREABLE_CHAT_FOLDER_COUNT -> TdApi.PremiumLimitTypeShareableChatFolderCount() - PremiumLimitType.ACTIVE_STORY_COUNT -> TdApi.PremiumLimitTypeActiveStoryCount() - else -> null -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationRenderBatcher.kt b/data/src/main/java/org/monogram/data/notifications/NotificationRenderBatcher.kt new file mode 100644 index 000000000..c068bbad6 --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/NotificationRenderBatcher.kt @@ -0,0 +1,40 @@ +package org.monogram.data.notifications + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** Coalesces related TDLib notification updates before the Android render pass. */ +internal class NotificationRenderBatcher( + scope: CoroutineScope, + private val batchWindowMs: Long = DEFAULT_BATCH_WINDOW_MS, + private val render: suspend (Set) -> Unit +) { + private val trigger = Channel(Channel.CONFLATED) + private val pendingChatIds = linkedSetOf() + + init { + scope.launch { + for (ignored in trigger) { + delay(batchWindowMs) + val batch = synchronized(pendingChatIds) { + pendingChatIds.toSet().also { pendingChatIds.clear() } + } + if (batch.isNotEmpty()) render(batch) + } + } + } + + fun enqueue(chatIds: Set) { + if (chatIds.isEmpty()) return + synchronized(pendingChatIds) { + pendingChatIds += chatIds + } + trigger.trySend(Unit) + } + + private companion object { + const val DEFAULT_BATCH_WINDOW_MS = 16L + } +} diff --git a/data/src/main/java/org/monogram/data/notifications/TdlibNotificationStateStore.kt b/data/src/main/java/org/monogram/data/notifications/TdlibNotificationStateStore.kt index 2763130bf..4ebd4091d 100644 --- a/data/src/main/java/org/monogram/data/notifications/TdlibNotificationStateStore.kt +++ b/data/src/main/java/org/monogram/data/notifications/TdlibNotificationStateStore.kt @@ -17,6 +17,13 @@ class TdlibNotificationStateStore { @Synchronized fun hasNativeSync(): Boolean = hasNativeSync + @Synchronized + fun fingerprint(): Long = groups.values.fold(17L) { hash, group -> + var next = hash * 31 + group.id + next = next * 31 + group.notifications.size + group.notifications.keys.fold(next) { value, notificationId -> value * 31 + notificationId } + } + @Synchronized fun reset() { groups.clear() diff --git a/data/src/main/java/org/monogram/data/push/ConflatedSyncRequestQueue.kt b/data/src/main/java/org/monogram/data/push/ConflatedSyncRequestQueue.kt new file mode 100644 index 000000000..8b7857d86 --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/ConflatedSyncRequestQueue.kt @@ -0,0 +1,32 @@ +package org.monogram.data.push + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class ConflatedSyncRequestQueue( + scope: CoroutineScope, + private val minIntervalMs: Long, + private val execute: suspend (String) -> Unit +) { + private val requests = Channel(Channel.CONFLATED) + + init { + scope.launch { + var lastCompletedAt = 0L + for (reason in requests) { + val waitMs = (lastCompletedAt + minIntervalMs - System.currentTimeMillis()) + .coerceAtLeast(0L) + if (waitMs > 0L) { + delay(waitMs) + } + + execute(reason) + lastCompletedAt = System.currentTimeMillis() + } + } + } + + fun request(reason: String): Boolean = requests.trySend(reason).isSuccess +} diff --git a/data/src/main/java/org/monogram/data/push/PushProcessingCoordinator.kt b/data/src/main/java/org/monogram/data/push/PushProcessingCoordinator.kt new file mode 100644 index 000000000..18067bb09 --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/PushProcessingCoordinator.kt @@ -0,0 +1,398 @@ +package org.monogram.data.push + +import android.content.Context +import android.os.PowerManager +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull +import org.drinkless.tdlib.TdApi +import org.koin.core.context.GlobalContext +import org.monogram.data.gateway.TdLibException +import org.monogram.data.gateway.TelegramGateway +import java.security.MessageDigest +import java.util.ArrayDeque +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.ceil + +private const val DEDUP_TTL_MS = 5 * 60_000L +private const val DEDUP_PREFS = "push_dedup" +private const val DEDUP_KEY_PREFIX = "expiry:" + +interface PushDeduplicator { + fun accept(key: String): Boolean +} + +interface PushWorkScheduler { + fun enqueue(uniqueName: String, policy: ExistingWorkPolicy, request: OneTimeWorkRequest) +} + +interface PushWakeLock { + val isHeld: Boolean + fun release() +} + +fun interface PushWakeLockProvider { + fun acquire(context: Context, timeoutMs: Long): PushWakeLock? +} + +data class PushProcessingTimeouts( + val processMs: Long = 8_000L, + val wakeLockMs: Long = 15_000L +) + +class PushProcessingMetrics { + data class Snapshot( + val received: Long, + val deduplicated: Long, + val workerAttempts: Long, + val outcomes: Map, + val fallbackReasons: Map, + val p50LatencyMs: Long, + val p95LatencyMs: Long, + val p99LatencyMs: Long + ) + + private val received = AtomicLong() + private val deduplicated = AtomicLong() + private val workerAttempts = AtomicLong() + private val outcomes = linkedMapOf() + private val fallbackReasons = linkedMapOf() + private val latenciesMs = ArrayDeque() + + fun recordReceipt() { + received.incrementAndGet() + } + + fun recordDeduplicated() { + deduplicated.incrementAndGet() + } + + fun recordWorkerAttempt() { + workerAttempts.incrementAndGet() + } + + @Synchronized + fun recordCompletion(result: PushProcessingCoordinator.Result, latencyMs: Long) { + outcomes[result] = (outcomes[result] ?: 0) + 1 + if (latenciesMs.size == MAX_SAMPLES) latenciesMs.removeFirst() + latenciesMs.addLast(latencyMs.coerceAtLeast(0L)) + } + + @Synchronized + fun recordFallback(reason: String) { + fallbackReasons[reason] = (fallbackReasons[reason] ?: 0) + 1 + } + + @Synchronized + fun snapshot(): Snapshot { + val sortedLatencies = latenciesMs.sorted() + return Snapshot( + received = received.get(), + deduplicated = deduplicated.get(), + workerAttempts = workerAttempts.get(), + outcomes = outcomes.toMap(), + fallbackReasons = fallbackReasons.toMap(), + p50LatencyMs = percentile(sortedLatencies, 0.50), + p95LatencyMs = percentile(sortedLatencies, 0.95), + p99LatencyMs = percentile(sortedLatencies, 0.99) + ) + } + + private fun percentile(samples: List, percentile: Double): Long { + if (samples.isEmpty()) return 0L + val index = (ceil(samples.size * percentile).toInt() - 1).coerceIn(0, samples.lastIndex) + return samples[index] + } + + private companion object { + const val MAX_SAMPLES = 256 + } +} + +/** Shared, PII-safe push entry point used by FCM and UnifiedPush. */ +class PushProcessingCoordinator( + private val context: Context, + private val gateway: TelegramGateway, + private val pushSyncTrigger: PushSyncRequester, + private val workScheduler: PushWorkScheduler = AndroidPushWorkScheduler(context), + private val deduplicator: PushDeduplicator = SharedPreferencesPushDeduplicator(context), + private val wakeLockProvider: PushWakeLockProvider = AndroidPushWakeLockProvider, + private val timeouts: PushProcessingTimeouts = PushProcessingTimeouts(), + val metrics: PushProcessingMetrics = PushProcessingMetrics() +) { + enum class Provider { FCM, UNIFIED_PUSH } + + fun enqueue( + provider: Provider, + payload: String?, + accountScope: String = DEFAULT_ACCOUNT_SCOPE + ) { + metrics.recordReceipt() + val diagnosticId = payload?.takeIf(String::isNotBlank)?.let(::diagnosticId) ?: "empty" + if (!deduplicator.accept("$accountScope:$diagnosticId")) { + metrics.recordDeduplicated() + Log.d(TAG, "Duplicate push ignored: provider=$provider id=$diagnosticId") + return + } + enqueueWork( + uniqueName = "$PUSH_WORK_PREFIX$accountScope", + provider = provider, + payload = payload, + reconciliation = false + ) + } + + fun enqueueReconciliation(accountScope: String = DEFAULT_ACCOUNT_SCOPE) { + enqueueWork( + uniqueName = "$RECONCILIATION_WORK_PREFIX$accountScope", + provider = Provider.FCM, + payload = null, + reconciliation = true + ) + } + + fun enqueueFcmTokenRegistration(token: String) { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) + .setInputData( + Data.Builder() + .putString(KEY_OPERATION, OPERATION_REGISTER_FCM_TOKEN) + .putString(KEY_FCM_TOKEN, token) + .build() + ) + .build() + workScheduler.enqueue(FCM_TOKEN_WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + + suspend fun process( + payload: String?, + reconciliation: Boolean, + receivedAtMs: Long = System.currentTimeMillis() + ): Result { + val startedAt = System.nanoTime() + if (reconciliation || payload.isNullOrBlank()) { + val reason = if (reconciliation) "push_reconciliation" else "push_empty_payload" + requestFallback(reason) + return Result.RetryScheduled.also { + metrics.recordCompletion( + it, + System.currentTimeMillis() - receivedAtMs + ) + } + } + if (!gateway.isAuthenticated.value) { + return Result.AuthGone.also { + metrics.recordCompletion( + it, + System.currentTimeMillis() - receivedAtMs + ) + } + } + + val wakeLock = wakeLockProvider.acquire(context, timeouts.wakeLockMs) + var outcome = Result.Failed + return try { + val response = withTimeoutOrNull(timeouts.processMs) { + runCatching { gateway.execute(TdApi.ProcessPushNotification(payload)) } + } + when { + response == null -> { + requestFallback("push_process_timeout") + Result.RetryScheduled + } + + response.isFailure -> { + val error = response.exceptionOrNull() + when { + error.isAuthRelated() -> Result.AuthGone + error.isUnsupportedPayload() -> { + requestFallback("push_unsupported_payload") + Result.UnsupportedPayload + } + + else -> { + requestFallback("push_process_failure") + Result.RetryScheduled + } + } + } + + else -> Result.Processed + } + .also { outcome = it } + } catch (e: CancellationException) { + throw e + } finally { + if (wakeLock?.isHeld == true) wakeLock.release() + val elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + metrics.recordCompletion(outcome, System.currentTimeMillis() - receivedAtMs) + Log.d(TAG, "Push processing finished: durationMs=$elapsedMs") + } + } + + private fun enqueueWork( + uniqueName: String, + provider: Provider, + payload: String?, + reconciliation: Boolean + ) { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS) + .setInputData( + Data.Builder() + .putString(KEY_PROVIDER, provider.name) + .putString(KEY_PAYLOAD, payload) + .putBoolean(KEY_RECONCILIATION, reconciliation) + .putLong(KEY_RECEIVED_AT, System.currentTimeMillis()) + .build() + ) + .build() + workScheduler.enqueue(uniqueName, ExistingWorkPolicy.APPEND_OR_REPLACE, request) + } + + enum class Result { Processed, UnsupportedPayload, RetryScheduled, AuthGone, Failed } + + internal fun recordWorkerAttempt() { + metrics.recordWorkerAttempt() + } + + private fun requestFallback(reason: String) { + metrics.recordFallback(reason) + pushSyncTrigger.requestSync(reason) + } + + private fun Throwable?.isAuthRelated(): Boolean { + val error = (this as? TdLibException)?.error ?: return false + return error.code == 401 || error.message.orEmpty() + .contains("authorization", ignoreCase = true) + } + + private fun Throwable?.isUnsupportedPayload(): Boolean { + val error = (this as? TdLibException)?.error ?: return false + val message = error.message.orEmpty().lowercase() + return error.code == 400 && + message.contains("push") && + (message.contains("unsupported") || message.contains("invalid")) + } + + companion object { + private const val TAG = "PushCoordinator" + private const val DEFAULT_ACCOUNT_SCOPE = "default" + private const val PUSH_WORK_PREFIX = "push-process:" + private const val RECONCILIATION_WORK_PREFIX = "push-reconcile:" + private const val KEY_PROVIDER = "provider" + private const val KEY_PAYLOAD = "payload" + private const val KEY_RECONCILIATION = "reconciliation" + private const val KEY_RECEIVED_AT = "received_at" + private const val KEY_OPERATION = "operation" + private const val KEY_FCM_TOKEN = "fcm_token" + private const val OPERATION_REGISTER_FCM_TOKEN = "register_fcm_token" + private const val FCM_TOKEN_WORK_NAME = "push-register-fcm-token" + internal fun diagnosticId(payload: String): String = MessageDigest.getInstance("SHA-256") + .digest(payload.toByteArray(Charsets.UTF_8)) + .take(8) + .joinToString(separator = "") { byte -> "%02x".format(byte) } + } + + class PushProcessingWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + if (inputData.getString(KEY_OPERATION) == OPERATION_REGISTER_FCM_TOKEN) { + val token = inputData.getString(KEY_FCM_TOKEN) + ?: return Result.failure() + val gateway = GlobalContext.get().get() + if (!gateway.isAuthenticated.value) return Result.retry() + return runCatching { + gateway.execute( + TdApi.RegisterDevice( + TdApi.DeviceTokenFirebaseCloudMessaging(token, true), + longArrayOf() + ) + ) + }.fold( + onSuccess = { Result.success() }, + onFailure = { error -> + if ((error as? TdLibException)?.error?.code == 401) { + Result.retry() + } else { + Result.retry() + } + } + ) + } + val coordinator = GlobalContext.get().get() + coordinator.recordWorkerAttempt() + return when (coordinator.process( + payload = inputData.getString(KEY_PAYLOAD), + reconciliation = inputData.getBoolean(KEY_RECONCILIATION, false), + receivedAtMs = inputData.getLong(KEY_RECEIVED_AT, System.currentTimeMillis()) + )) { + PushProcessingCoordinator.Result.Processed, + PushProcessingCoordinator.Result.UnsupportedPayload, + PushProcessingCoordinator.Result.AuthGone -> + Result.success() + + PushProcessingCoordinator.Result.RetryScheduled, + PushProcessingCoordinator.Result.Failed -> + Result.retry() + } + } + } +} + +private class AndroidPushWorkScheduler(context: Context) : PushWorkScheduler { + private val workManager = WorkManager.getInstance(context) + + override fun enqueue( + uniqueName: String, + policy: ExistingWorkPolicy, + request: OneTimeWorkRequest + ) { + workManager.enqueueUniqueWork(uniqueName, policy, request) + } +} + +private class SharedPreferencesPushDeduplicator(context: Context) : PushDeduplicator { + private val preferences = context.getSharedPreferences(DEDUP_PREFS, Context.MODE_PRIVATE) + + @Synchronized + override fun accept(key: String): Boolean { + val now = System.currentTimeMillis() + val storageKey = "$DEDUP_KEY_PREFIX${PushProcessingCoordinator.diagnosticId(key)}" + val expiry = preferences.getLong(storageKey, 0L) + if (expiry > now) return false + preferences.edit().putLong(storageKey, now + DEDUP_TTL_MS).apply() + return true + } +} + +private object AndroidPushWakeLockProvider : PushWakeLockProvider { + override fun acquire(context: Context, timeoutMs: Long): PushWakeLock? { + val wakeLock = (context.getSystemService(Context.POWER_SERVICE) as? PowerManager) + ?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "monogram:PushWorker") + ?: return null + wakeLock.setReferenceCounted(false) + wakeLock.acquire(timeoutMs) + return object : PushWakeLock { + override val isHeld: Boolean get() = wakeLock.isHeld + override fun release() = wakeLock.release() + } + } +} diff --git a/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt index 33b1cb4f3..6d7f5bef4 100644 --- a/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt +++ b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt @@ -5,58 +5,46 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import org.drinkless.tdlib.TdApi import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.ConnectionManager -import java.util.concurrent.atomic.AtomicLong + +interface PushSyncRequester { + fun requestSync(reason: String) +} class PushSyncTrigger( private val connectionManager: ConnectionManager, private val gateway: TelegramGateway -) { +) : PushSyncRequester { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val lastSyncAt = AtomicLong(0L) - - fun requestSync(reason: String) { - val now = System.currentTimeMillis() - val previous = lastSyncAt.get() - if (now - previous < MIN_SYNC_GAP_MS) { - Log.d(TAG, "Skip push sync by rate limit: reason=$reason") - return + private val requests = ConflatedSyncRequestQueue( + scope = scope, + minIntervalMs = MIN_SYNC_GAP_MS, + execute = ::runSync + ) + + override fun requestSync(reason: String) { + if (!requests.request(reason)) { + Log.w(TAG, "Unable to enqueue push sync request") } - if (!lastSyncAt.compareAndSet(previous, now)) { - Log.d(TAG, "Skip push sync by CAS race: reason=$reason") + } + + private suspend fun runSync(reason: String) { + if (!gateway.isAuthenticated.value) { + Log.d(TAG, "Skip push sync: not authenticated, reason=$reason") return } - scope.launch { - if (!gateway.isAuthenticated.value) { - Log.d(TAG, "Skip push sync: not authenticated, reason=$reason") - return@launch - } - - Log.d(TAG, "Triggering TDLib sync from push: reason=$reason") - connectionManager.retryConnection() - - delay(PUSH_SYNC_DELAY_MS) - - val me = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { - runCatching { gateway.execute(TdApi.GetMe()) }.getOrNull() - } - if (me != null) { - Log.d(TAG, "Push sync probe success: me=${me.id}") - } else { - Log.w(TAG, "Push sync probe failed (GetMe timeout/error)") - } - } + Log.d(TAG, "Triggering TDLib sync from push: reason=$reason") + connectionManager.retryConnection() + + delay(PUSH_SYNC_DELAY_MS) + Log.d(TAG, "Requested TDLib connection recovery: reason=$reason") } private companion object { const val TAG = "PushSyncTrigger" const val MIN_SYNC_GAP_MS = 1500L const val PUSH_SYNC_DELAY_MS = 350L - const val REQUEST_TIMEOUT_MS = 5000L } } diff --git a/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt index 1909b770c..ef32eecc3 100644 --- a/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt +++ b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt @@ -115,7 +115,7 @@ class UnifiedPushManager( .apply() _endpoint.value = value _status.value = Status.REGISTERED - Log.d(TAG, "UnifiedPush endpoint saved: ${value.take(140)}") + Log.d(TAG, "UnifiedPush endpoint saved") } fun onRegistrationFailed(reason: FailedReason?) { 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 260f11993..777443333 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -30,6 +30,8 @@ import org.monogram.data.compat.isPromptBasedAiSupported import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.datasource.cache.MessageCacheMutation +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 @@ -40,6 +42,7 @@ 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.infra.OutboxLifecycleMetrics import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.SponsoredMessageMapper import org.monogram.data.mapper.TdFileHelper @@ -100,6 +103,7 @@ internal class MessageRepositoryImpl( private val dispatcherProvider: DispatcherProvider, private val scope: CoroutineScope, private val chatLocalDataSource: ChatLocalDataSource, + private val messageCacheWriter: MessageCacheWriter, private val userLocalDataSource: UserLocalDataSource, private val stickerPathDao: StickerPathDao, private val keyValueDao: KeyValueDao, @@ -116,6 +120,7 @@ internal class MessageRepositoryImpl( ConcurrentHashMap() private val chatOpenOwnershipMutex = Mutex() private val chatOpenOwnershipRegistry = ChatOpenOwnershipRegistry() + internal val outboxLifecycleMetrics = OutboxLifecycleMetrics() private val _textCompositionStyles = MutableStateFlow>(emptyList()) private val hardResetFlagKey = "cache_hard_reset_v2" @@ -129,6 +134,8 @@ internal class MessageRepositoryImpl( override val messageReadFlow = messageRemoteDataSource.messageReadFlow override val messageDeletedFlow = messageRemoteDataSource.messageDeletedFlow override val messageIdUpdateFlow = messageRemoteDataSource.messageIdUpdateFlow + override val messageAcknowledgedFlow = messageRemoteDataSource.messageAcknowledgedFlow + override val messageSendFailedFlow = messageRemoteDataSource.messageSendFailedFlow override val pinnedMessageFlow = messageRemoteDataSource.pinnedMessageFlow override val mediaUpdateFlow = messageRemoteDataSource.mediaUpdateFlow override val textCompositionStyles = _textCompositionStyles.asStateFlow() @@ -233,30 +240,40 @@ internal class MessageRepositoryImpl( private suspend fun processCachedUpdate(update: TdApi.Update) { when (update) { is TdApi.UpdateNewMessage -> { + if (update.message.isOutgoing && update.message.sendingState is TdApi.MessageSendingStatePending) { + outboxLifecycleMetrics.recordPending(update.message.chatId, update.message.id) + } persistMappedMessage(update.message) } is TdApi.UpdateMessageSendSucceeded -> { + outboxLifecycleMetrics.recordTerminal(update.message.chatId, update.oldMessageId) val entity = messageMapper.mapToEntity(update.message, ::resolveSenderName) - chatLocalDataSource.replaceMessageId( + messageCacheWriter.enqueue( + MessageCacheMutation.ReplaceId( chatId = update.message.chatId, oldMessageId = update.oldMessageId, message = entity + ) ) } is TdApi.UpdateMessageSendFailed -> { + outboxLifecycleMetrics.recordTerminal(update.message.chatId, update.oldMessageId) val entity = messageMapper.mapToEntity(update.message, ::resolveSenderName) - chatLocalDataSource.replaceMessageId( + messageCacheWriter.enqueue( + MessageCacheMutation.ReplaceId( chatId = update.message.chatId, oldMessageId = update.oldMessageId, message = entity + ) ) } is TdApi.UpdateMessageContent -> { val extracted = messageMapper.extractCachedContent(update.newContent) - chatLocalDataSource.updateMessageContent( + messageCacheWriter.enqueue( + MessageCacheMutation.UpdateContent( chatId = update.chatId, messageId = update.messageId, content = extracted.text, @@ -265,6 +282,7 @@ internal class MessageRepositoryImpl( mediaFileId = extracted.fileId, mediaPath = extracted.path, editDate = cache.getMessage(update.chatId, update.messageId)?.editDate ?: 0 + ) ) } @@ -274,12 +292,14 @@ internal class MessageRepositoryImpl( is TdApi.UpdateMessageInteractionInfo -> { if (persistCachedMessage(update.chatId, update.messageId)) return - chatLocalDataSource.updateInteractionInfo( + messageCacheWriter.enqueue( + MessageCacheMutation.UpdateInteraction( chatId = update.chatId, messageId = update.messageId, viewCount = update.interactionInfo?.viewCount ?: 0, forwardCount = update.interactionInfo?.forwardCount ?: 0, replyCount = update.interactionInfo?.replyInfo?.replyCount ?: 0 + ) ) } @@ -320,14 +340,19 @@ internal class MessageRepositoryImpl( } is TdApi.UpdateChatReadInbox -> { - chatLocalDataSource.markAsRead(update.chatId, update.lastReadInboxMessageId) + messageCacheWriter.enqueue( + MessageCacheMutation.MarkRead(update.chatId, update.lastReadInboxMessageId) + ) } is TdApi.UpdateDeleteMessages -> { if (update.isPermanent) { - update.messageIds.forEach { messageId -> - chatLocalDataSource.deleteMessage(update.chatId, messageId) - } + messageCacheWriter.enqueue( + MessageCacheMutation.DeleteMessages( + update.chatId, + update.messageIds.toList() + ) + ) } } @@ -346,8 +371,8 @@ internal class MessageRepositoryImpl( } private suspend fun persistMappedMessage(message: TdApi.Message) { - chatLocalDataSource.replaceMessage( - messageMapper.mapToEntity(message, ::resolveSenderName) + messageCacheWriter.enqueue( + MessageCacheMutation.Persist(messageMapper.mapToEntity(message, ::resolveSenderName)) ) } @@ -453,6 +478,10 @@ internal class MessageRepositoryImpl( messageRemoteDataSource.sendMessage(chatId, text, replyToMsgId, entities, threadId, sendOptions) } + override suspend fun retryFailedMessage(chatId: Long, temporaryMessageId: Long) { + messageRemoteDataSource.retryFailedMessage(chatId, temporaryMessageId) + } + override suspend fun sendRichMessage( chatId: Long, markdown: String, diff --git a/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt index 0fd272779..0a6e80a91 100644 --- a/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/PremiumRepositoryImpl.kt @@ -3,8 +3,7 @@ package org.monogram.data.repository import org.monogram.data.datasource.remote.UserRemoteDataSource import org.monogram.data.mapper.user.toApi import org.monogram.data.mapper.user.toDomain -import org.monogram.domain.models.PremiumFeatureType -import org.monogram.domain.models.PremiumLimitType +import org.monogram.domain.models.PremiumFeaturesModel import org.monogram.domain.models.PremiumSource import org.monogram.domain.models.PremiumStateModel import org.monogram.domain.repository.PremiumRepository @@ -17,15 +16,9 @@ class PremiumRepositoryImpl( return state.toDomain() } - override suspend fun getPremiumFeatures(source: PremiumSource): List { - val tdSource = source.toApi() ?: return emptyList() - val result = remote.getPremiumFeatures(tdSource) ?: return emptyList() - return result.features.map { it.toDomain() } - } - - override suspend fun getPremiumLimit(limitType: PremiumLimitType): Int { - val tdType = limitType.toApi() ?: return 0 - return remote.getPremiumLimit(tdType)?.premiumValue ?: 0 + override suspend fun getPremiumFeatures(source: PremiumSource): PremiumFeaturesModel? { + val tdSource = source.toApi() ?: return null + return remote.getPremiumFeatures(tdSource)?.toDomain() } override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) { diff --git a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt index 1d8808f50..3747b3c6c 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt @@ -15,6 +15,8 @@ internal class UserMediaResolver( ) { private val avatarDownloadPriority = AVATAR_DOWNLOAD_PRIORITY private val avatarHdPrefetchPriority = AVATAR_HD_PREFETCH_PRIORITY + private val animatedAvatarPathCache = ConcurrentHashMap() + private val animatedAvatarLookupCache = ConcurrentHashMap() suspend fun resolveEmojiPath(user: TdApi.User): String? { val emojiId = user.extractEmojiStatusId() @@ -54,6 +56,8 @@ internal class UserMediaResolver( } suspend fun resolveAvatarPath(user: TdApi.User): String? { + resolveAnimatedAvatarPath(user)?.let { return it } + val bigPhoto = user.profilePhoto?.big val smallPhoto = user.profilePhoto?.small val bigDirectPath = bigPhoto?.local?.path?.takeIf { isValidFilePath(it) } @@ -119,6 +123,103 @@ internal class UserMediaResolver( return null } + private suspend fun resolveAnimatedAvatarPath(user: TdApi.User): String? { + val profilePhoto = user.profilePhoto ?: return null + if (!profilePhoto.hasAnimation || user.id <= 0L) return null + + animatedAvatarPathCache[user.id] + ?.takeIf { it.photoId == profilePhoto.id && isValidFilePath(it.path) } + ?.let { return it.path } + + animatedAvatarLookupCache[user.id] + ?.takeIf { it.photoId == profilePhoto.id } + ?.let { lookup -> + resolveAnimationPath( + user.id, + profilePhoto.id, + lookup.animationFileIds + )?.let { return it } + } + + val photos = coRunCatching { + (gateway.execute( + TdApi.GetUserProfilePhotos( + user.id, + 0, + USER_PROFILE_PHOTO_LOOKUP_LIMIT + ) + ) as? TdApi.ChatPhotos)?.photos.orEmpty() + }.getOrDefault(emptyArray()) + + val currentPhoto = photos.firstOrNull { it.id == profilePhoto.id } + ?: photos.firstOrNull() + ?: return null + + val animationFileIds = buildList { + currentPhoto.smallAnimation?.file?.id?.takeIf { it != 0 }?.let(::add) + currentPhoto.animation?.file?.id + ?.takeIf { it != 0 && it !in this } + ?.let(::add) + } + + if (animationFileIds.isEmpty()) return null + + animatedAvatarLookupCache[user.id] = AnimatedAvatarLookup( + photoId = currentPhoto.id, + animationFileIds = animationFileIds + ) + + currentPhoto.smallAnimation?.file?.local?.path + ?.takeIf { isValidFilePath(it) } + ?.also { cacheAnimatedAvatarPath(user.id, currentPhoto.id, it) } + ?.let { return it } + + currentPhoto.animation?.file?.local?.path + ?.takeIf { isValidFilePath(it) } + ?.also { cacheAnimatedAvatarPath(user.id, currentPhoto.id, it) } + ?.let { return it } + + return resolveAnimationPath(user.id, currentPhoto.id, animationFileIds) + } + + private suspend fun resolveAnimationPath( + userId: Long, + photoId: Long, + animationFileIds: List + ): String? { + animationFileIds.forEach { fileId -> + resolveDownloadedFilePath(fileId) + ?.also { cacheAnimatedAvatarPath(userId, photoId, it) } + ?.let { return it } + } + + animationFileIds.firstOrNull()?.let { primaryFileId -> + fileIdToUserIdMap[primaryFileId] = userId + fileQueue.enqueue( + primaryFileId, + avatarDownloadPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + + animationFileIds.drop(1).forEach { secondaryFileId -> + fileIdToUserIdMap[secondaryFileId] = userId + fileQueue.enqueue( + secondaryFileId, + avatarHdPrefetchPriority, + FileDownloadQueue.DownloadType.DEFAULT, + synchronous = false + ) + } + + return null + } + + private fun cacheAnimatedAvatarPath(userId: Long, photoId: Long, path: String) { + animatedAvatarPathCache[userId] = CachedAnimatedAvatar(photoId = photoId, path = path) + } + private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { if (fileId == null || fileId == 0) return null val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null @@ -132,9 +233,20 @@ internal class UserMediaResolver( companion object { private const val AVATAR_DOWNLOAD_PRIORITY = 24 private const val AVATAR_HD_PREFETCH_PRIORITY = 8 + private const val USER_PROFILE_PHOTO_LOOKUP_LIMIT = 10 } } +private data class CachedAnimatedAvatar( + val photoId: Long, + val path: String +) + +private data class AnimatedAvatarLookup( + val photoId: Long, + val animationFileIds: List +) + internal fun TdApi.User.extractEmojiStatusId(): Long { return when (val type = this.emojiStatus?.type) { is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt index 87074c336..16ea22671 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt @@ -222,7 +222,12 @@ class UserRepositoryImpl( val emojiPath = mediaResolver.resolveEmojiPath(user) val avatarPath = mediaResolver.resolveAvatarPath(user) val model = user.toDomain(fullInfo, emojiPath) - return if (avatarPath == null || avatarPath == model.avatarPath) model else model.copy(avatarPath = avatarPath) + if (avatarPath == null || avatarPath == model.avatarPath) { + return model + } + + persistResolvedAvatarPath(user.id, avatarPath) + return model.copy(avatarPath = avatarPath) } override fun getUserFlow(userId: Long): Flow = flow { @@ -243,6 +248,13 @@ class UserRepositoryImpl( userLocal.saveUser(existing.copy(personalAvatarPath = personalAvatarPath)) } + private suspend fun persistResolvedAvatarPath(userId: Long, avatarPath: String) { + if (!isValidFilePath(avatarPath)) return + val existing = userLocal.loadUser(userId) ?: return + if (existing.avatarPath == avatarPath) return + userLocal.saveUser(existing.copy(avatarPath = avatarPath)) + } + private suspend fun fetchAndCacheUser(userId: Long): TdApi.User? { if (userId <= 0 || isNegativeCached(missingUsersUntilMs, userId)) return null val user = remote.getUser(userId) @@ -373,7 +385,9 @@ class UserRepositoryImpl( } private fun TdApi.User.hasStablePhotoIdentity(): Boolean { - return (profilePhoto?.small?.id ?: 0) != 0 || (profilePhoto?.big?.id ?: 0) != 0 + return profilePhoto?.hasAnimation == true || + (profilePhoto?.small?.id ?: 0) != 0 || + (profilePhoto?.big?.id ?: 0) != 0 } private fun TdApi.User.hasUsableAvatarPath(): Boolean { diff --git a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt index d664b2734..e85c85417 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt @@ -7,6 +7,7 @@ import org.monogram.data.datasource.cache.UserLocalDataSource import org.monogram.data.db.dao.KeyValueDao import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileObserverHub +import org.monogram.data.infra.LatestByKeyBatcher import java.util.concurrent.ConcurrentHashMap internal class UserUpdateSynchronizer( @@ -23,6 +24,14 @@ internal class UserUpdateSynchronizer( ) { private val avatarFileIdToUserIds = ConcurrentHashMap>() private val userIdToAvatarFileIds = ConcurrentHashMap>() + private val userStatusBatcher = LatestByKeyBatcher(scope) { statuses -> + statuses.forEach { (userId, status) -> + userLocal.updateUserStatus(userId, status)?.let { cached -> + updateAvatarIndex(cached) + onUserIdChanged(cached.id) + } + } + } fun start() { scope.launch { @@ -40,11 +49,7 @@ internal class UserUpdateSynchronizer( scope.launch { updates.userStatus.collect { update -> - userLocal.getUser(update.userId)?.let { cached -> - cached.status = update.status - updateAvatarIndex(cached) - onUserUpdated(cached) - } + userStatusBatcher.offer(update.userId, update.status) } } diff --git a/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt b/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt index bbdc6f99c..5534386ce 100644 --- a/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt +++ b/data/src/main/java/org/monogram/data/service/BaseFcmPushService.kt @@ -1,53 +1,30 @@ package org.monogram.data.service import android.content.Context -import android.os.PowerManager import android.util.Log -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import org.drinkless.tdlib.TdApi import org.json.JSONObject -import org.monogram.data.di.TdNotificationManager -import org.monogram.data.gateway.TdLibException -import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.PushProcessingCoordinator import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider class BaseFcmPushService( private val context: Context, - private val gateway: TelegramGateway, private val appPreferences: AppPreferencesProvider, - private val notificationManager: TdNotificationManager, - private val pushSyncTrigger: PushSyncTrigger + private val pushCoordinator: PushProcessingCoordinator ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - fun handleNewToken(token: String) { - Log.d(TAG, "New FCM token: $token") + Log.d(TAG, "New FCM token received") if (appPreferences.pushProvider.value == PushProvider.FCM) { - scope.launch { - registerToken(token) - } + pushCoordinator.enqueueFcmTokenRegistration(token) } } fun handleMessage(data: Map) { - Log.d(TAG, "FCM message received: $data") + Log.d(TAG, "FCM message received: keyCount=${data.size}") if (appPreferences.pushProvider.value != PushProvider.FCM) return if (data.isEmpty()) return - val powerManager = - context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return - val wakeLock = - powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "monogram:FcmPushService") - .apply { setReferenceCounted(false) } - try { val json = JSONObject() for ((key, value) in data) { @@ -56,79 +33,20 @@ class BaseFcmPushService( val jsonPayload = json.toString() if (jsonPayload.isBlank()) return - wakeLock.acquire(PUSH_WAKE_LOCK_MS) - scope.launch { - val notificationStateVersion = notificationManager.currentNotificationStateVersion() - try { - withTimeout(PROCESS_PUSH_TIMEOUT_MS) { - gateway.execute(TdApi.ProcessPushNotification(jsonPayload)) - } - Log.d(TAG, "ProcessPushNotification success") - val updated = notificationManager.awaitNotificationStateChange( - afterVersion = notificationStateVersion, - timeoutMs = NOTIFICATION_SETTLE_TIMEOUT_MS - ) - if (!updated) { - Log.w( - TAG, - "No notification-state update after push, requesting sync fallback" - ) - pushSyncTrigger.requestSync("fcm_push_settle_timeout") - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e(TAG, "Error processing push", e) - if (!e.isAuthRelatedPushError()) { - pushSyncTrigger.requestSync("fcm_push_process_error") - } - } finally { - if (wakeLock.isHeld) { - wakeLock.release() - } - } - } + pushCoordinator.enqueue(PushProcessingCoordinator.Provider.FCM, jsonPayload) } catch (e: Exception) { Log.e(TAG, "Error preparing push payload", e) - if (wakeLock.isHeld) { - wakeLock.release() - } } } fun handleDeletedMessages() { Log.d(TAG, "FCM messages deleted") - } - - private suspend fun registerToken(token: String) { - if (!gateway.isAuthenticated.value) return - - try { - val result = gateway.execute( - TdApi.RegisterDevice( - TdApi.DeviceTokenFirebaseCloudMessaging(token, true), - longArrayOf() - ) - ) - Log.d(TAG, "RegisterDevice result: $result") - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e(TAG, "RegisterDevice failed", e) + if (appPreferences.pushProvider.value == PushProvider.FCM) { + pushCoordinator.enqueueReconciliation() } } - private fun Throwable.isAuthRelatedPushError(): Boolean { - val tdError = (this as? TdLibException)?.error ?: return false - val message = tdError.message.orEmpty().lowercase() - return tdError.code == 401 || - message.contains("unauthorized") || - message.contains("authorization") || - message.contains("not logged in") - } - private companion object { const val TAG = "FcmPushService" - const val PUSH_WAKE_LOCK_MS = 15_000L - const val PROCESS_PUSH_TIMEOUT_MS = 8_000L - const val NOTIFICATION_SETTLE_TIMEOUT_MS = 2_500L } } diff --git a/data/src/main/java/org/monogram/data/service/NotificationDismissReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationDismissReceiver.kt index 46267e7c9..8fd70267f 100644 --- a/data/src/main/java/org/monogram/data/service/NotificationDismissReceiver.kt +++ b/data/src/main/java/org/monogram/data/service/NotificationDismissReceiver.kt @@ -3,9 +3,9 @@ package org.monogram.data.service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import org.monogram.data.di.TdNotificationManager import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import org.monogram.data.di.TdNotificationManager class NotificationDismissReceiver : BroadcastReceiver(), KoinComponent { @@ -15,6 +15,12 @@ class NotificationDismissReceiver : BroadcastReceiver(), KoinComponent { val chatId = intent.getLongExtra("chat_id", 0L) val notificationId = intent.getIntExtra("notification_id", 0) if (chatId != 0L) { + if (!notificationManager.consumeNotificationAction( + "dismiss", + chatId, + notificationId + ) + ) return if (notificationId != 0) { notificationManager.removeNotification(chatId, notificationId) } else { diff --git a/data/src/main/java/org/monogram/data/service/NotificationReadReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationReadReceiver.kt index 4f361be7a..94860b3b4 100644 --- a/data/src/main/java/org/monogram/data/service/NotificationReadReceiver.kt +++ b/data/src/main/java/org/monogram/data/service/NotificationReadReceiver.kt @@ -20,6 +20,7 @@ class NotificationReadReceiver : BroadcastReceiver(), KoinComponent { val chatId = intent.getLongExtra("chat_id", 0L) val notificationId = intent.getIntExtra("notification_id", 0) if (chatId == 0L) return + if (!notificationManager.consumeNotificationAction("read", chatId, notificationId)) return goAsync { try { diff --git a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt index ec33c5955..9588e6d45 100644 --- a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt +++ b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt @@ -23,6 +23,7 @@ class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { val remoteInput = RemoteInput.getResultsFromIntent(intent) ?: return val replyText = remoteInput.getCharSequence(TdNotificationManager.KEY_TEXT_REPLY)?.toString() ?: return + if (!notificationManager.consumeNotificationAction("reply", chatId, notificationId)) return goAsync { try { diff --git a/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt index 05a6bae54..247cead47 100644 --- a/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt +++ b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt @@ -3,7 +3,7 @@ package org.monogram.data.service import android.util.Log import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.PushProcessingCoordinator import org.monogram.data.push.UnifiedPushManager import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider @@ -14,19 +14,13 @@ import org.unifiedpush.android.connector.data.PushMessage class UnifiedPushService : PushService(), KoinComponent { private val unifiedPushManager: UnifiedPushManager by inject() - private val pushSyncTrigger: PushSyncTrigger by inject() + private val pushCoordinator: PushProcessingCoordinator by inject() private val appPreferences: AppPreferencesProvider by inject() override fun onMessage(message: PushMessage, instance: String) { - val payload = - runCatching { message.content.toString(Charsets.UTF_8) }.getOrDefault("") Log.d( TAG, - "onMessage instance=$instance decrypted=${message.decrypted} bytes=${message.content.size} payload=${ - payload.take( - 96 - ) - }" + "onMessage instance=$instance decrypted=${message.decrypted} bytes=${message.content.size}" ) if (!isUnifiedPushSelected()) { @@ -35,25 +29,22 @@ class UnifiedPushService : PushService(), KoinComponent { } unifiedPushManager.markPushReceived() - val reason = - if (payload.startsWith("version=")) "unified_push_telegram_simple" else "unified_push_message" - pushSyncTrigger.requestSync(reason) + pushCoordinator.enqueue( + provider = PushProcessingCoordinator.Provider.UNIFIED_PUSH, + payload = message.content.toString(Charsets.UTF_8) + ) } override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { Log.d( TAG, - "onNewEndpoint instance=$instance temporary=${endpoint.temporary} hasPubKeySet=${endpoint.pubKeySet != null} url=${ - endpoint.url.take( - 120 - ) - }" + "onNewEndpoint instance=$instance temporary=${endpoint.temporary} hasPubKeySet=${endpoint.pubKeySet != null}" ) unifiedPushManager.onNewEndpoint(endpoint) if (!isUnifiedPushSelected()) return - pushSyncTrigger.requestSync("unified_push_new_endpoint") + pushCoordinator.enqueueReconciliation() } override fun onRegistrationFailed(reason: FailedReason, instance: String) { 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 new file mode 100644 index 000000000..cc0c7b445 --- /dev/null +++ b/data/src/test/java/org/monogram/data/datasource/cache/MessageCacheWriterTest.kt @@ -0,0 +1,69 @@ +package org.monogram.data.datasource.cache + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class MessageCacheWriterTest { + + @Test + fun `preserves mutation order and batches a burst`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val batches = mutableListOf>() + val writer = MessageCacheWriter( + scope = scope, + maxBatchSize = 3, + applyBatch = { batch -> batches += batch } + ) + val mutations = (1L..5L).map { messageId -> + MessageCacheMutation.MarkRead(chatId = 10L, upToMessageId = messageId) + } + + mutations.forEach { mutation -> assertTrue(writer.enqueue(mutation)) } + advanceUntilIdle() + + assertEquals(listOf(3, 2), batches.map { it.size }) + assertEquals(mutations, batches.flatten()) + scope.cancel() + } + + @Test + fun `retries failed batch and drains ten thousand queued mutations`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val batches = mutableListOf>() + var attempts = 0 + val writer = MessageCacheWriter( + scope = scope, + maxBatchSize = 64, + applyBatch = { batch -> + attempts++ + if (attempts == 1) error("transient cache failure") + batches += batch + } + ) + runCurrent() + val mutations = (1L..10_000L).map { messageId -> + MessageCacheMutation.MarkRead(chatId = 10L, upToMessageId = messageId) + } + + mutations.forEach { mutation -> assertTrue(writer.enqueue(mutation)) } + advanceUntilIdle() + + assertEquals(mutations, batches.flatten()) + assertEquals(1L, writer.stats.value.retries) + assertEquals(0L, writer.stats.value.failures) + assertEquals(mutations.size.toLong(), writer.stats.value.enqueued) + assertEquals(0, writer.stats.value.pending) + assertEquals(0L, writer.stats.value.dropped) + assertTrue(writer.stats.value.batches < mutations.size) + scope.cancel() + } +} diff --git a/data/src/test/java/org/monogram/data/datasource/remote/MessageRetryRequestTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/MessageRetryRequestTest.kt new file mode 100644 index 000000000..4174a6220 --- /dev/null +++ b/data/src/test/java/org/monogram/data/datasource/remote/MessageRetryRequestTest.kt @@ -0,0 +1,16 @@ +package org.monogram.data.datasource.remote + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class MessageRetryRequestTest { + + @Test + fun `restart retry resends the restored temporary id`() { + val request = resendTemporaryMessageRequest(chatId = 7L, temporaryMessageId = -101L) + + assertEquals(7L, request.chatId) + assertArrayEquals(longArrayOf(-101L), request.messageIds) + } +} diff --git a/data/src/test/java/org/monogram/data/datasource/remote/OrderedEventFlowTest.kt b/data/src/test/java/org/monogram/data/datasource/remote/OrderedEventFlowTest.kt new file mode 100644 index 000000000..0bb924135 --- /dev/null +++ b/data/src/test/java/org/monogram/data/datasource/remote/OrderedEventFlowTest.kt @@ -0,0 +1,36 @@ +package org.monogram.data.datasource.remote + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class OrderedEventFlowTest { + + @Test + fun `preserves all terminal events in enqueue order`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val flow = OrderedEventFlow(scope) + val received = mutableListOf() + val collector = scope.launch { + flow.events.take(128).collect(received::add) + } + runCurrent() + + repeat(128) { value -> assertTrue(flow.enqueue(value)) } + advanceUntilIdle() + + assertEquals((0 until 128).toList(), received) + collector.cancel() + scope.cancel() + } +} diff --git a/data/src/test/java/org/monogram/data/gateway/UpdateDispatcherImplTest.kt b/data/src/test/java/org/monogram/data/gateway/UpdateDispatcherImplTest.kt new file mode 100644 index 000000000..f43acde56 --- /dev/null +++ b/data/src/test/java/org/monogram/data/gateway/UpdateDispatcherImplTest.kt @@ -0,0 +1,112 @@ +package org.monogram.data.gateway + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpdateDispatcherImplTest { + + @Test + fun `routes send acknowledgement separately from terminal send updates`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val updates = MutableSharedFlow() + val dispatcher = UpdateDispatcherImpl(FakeTelegramGateway(updates)) + val received = mutableListOf() + val collector = scope.launch { + dispatcher.messageSendAcknowledged.collect(received::add) + } + runCurrent() + + updates.emit(TdApi.UpdateMessageSendAcknowledged(10L, -11L)) + advanceUntilIdle() + + assertEquals(1, received.size) + assertEquals(10L, received.single().chatId) + assertEquals(-11L, received.single().messageId) + collector.cancel() + scope.cancel() + } + + @Test + fun `message pipeline routes terminal burst without dropping a lifecycle branch`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val updates = MutableSharedFlow() + val dispatcher = UpdateDispatcherImpl(FakeTelegramGateway(updates)) + val newMessageIds = mutableListOf() + val succeededIds = mutableListOf() + val failedIds = mutableListOf() + val deletedIds = mutableListOf() + val collectors = listOf( + scope.launch { + dispatcher.newMessage.take(BURST_SIZE).collect { newMessageIds += it.message.id } + }, + scope.launch { + dispatcher.messageSendSucceeded.take(BURST_SIZE) + .collect { succeededIds += it.oldMessageId } + }, + scope.launch { + dispatcher.messageSendFailed.take(BURST_SIZE) + .collect { failedIds += it.oldMessageId } + }, + scope.launch { + dispatcher.messageDeleted.take(BURST_SIZE) + .collect { deletedIds += it.messageIds.single() } + } + ) + runCurrent() + + repeat(BURST_SIZE) { index -> + val id = index + 1L + updates.emit(TdApi.UpdateNewMessage(message(id))) + updates.emit(TdApi.UpdateMessageSendSucceeded(message(id), -id)) + updates.emit( + TdApi.UpdateMessageSendFailed( + message(id), + -id, + TdApi.Error(500, "temporary") + ) + ) + updates.emit(TdApi.UpdateDeleteMessages(CHAT_ID, longArrayOf(id), true, false)) + } + advanceUntilIdle() + + assertEquals((1L..BURST_SIZE.toLong()).toList(), newMessageIds) + assertEquals((-1L downTo -BURST_SIZE.toLong()).toList(), succeededIds) + assertEquals((-1L downTo -BURST_SIZE.toLong()).toList(), failedIds) + assertEquals((1L..BURST_SIZE.toLong()).toList(), deletedIds) + collectors.forEach { it.cancel() } + scope.cancel() + } + + private fun message(id: Long) = TdApi.Message().apply { + this.id = id + chatId = CHAT_ID + } + + private class FakeTelegramGateway( + override val updates: MutableSharedFlow + ) : TelegramGateway { + override val isAuthenticated = MutableStateFlow(false) + + override suspend fun execute(function: TdApi.Function): T { + error("Not used") + } + } + + private companion object { + const val CHAT_ID = 10L + const val BURST_SIZE = 128 + } +} diff --git a/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt b/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt new file mode 100644 index 000000000..e3031ce90 --- /dev/null +++ b/data/src/test/java/org/monogram/data/infra/FileUpdateHandlerTest.kt @@ -0,0 +1,88 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FileUpdateHandlerTest { + + @Test + fun `burst file completions preserve every terminal path and order`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val updates = MutableSharedFlow() + val queue = RecordingQueue() + val registry = FileMessageRegistry() + val handler = FileUpdateHandler( + registry = registry, + queue = queue, + fileUpdatesSource = updates, + scope = scope + ) + val fileCompleted = mutableListOf>() + val messageCompleted = mutableListOf>() + val collectors = listOf( + scope.launch { + handler.fileDownloadCompleted.take(BURST_SIZE).collect(fileCompleted::add) + }, + scope.launch { + handler.downloadCompleted.take(BURST_SIZE).collect(messageCompleted::add) + } + ) + runCurrent() + + repeat(BURST_SIZE) { index -> + val fileId = index + 1 + registry.register(fileId, CHAT_ID, (MESSAGE_ID_OFFSET + fileId).toLong()) + updates.emit(TdApi.UpdateFile(completedFile(fileId))) + } + advanceUntilIdle() + + assertEquals((1..BURST_SIZE).map { it.toLong() to "path_$it" }, fileCompleted) + assertEquals( + (1..BURST_SIZE).map { (MESSAGE_ID_OFFSET + it).toLong() to "path_$it" }, + messageCompleted + ) + assertEquals((1..BURST_SIZE).toList(), queue.completedDownloads) + collectors.forEach { it.cancel() } + scope.cancel() + } + + private fun completedFile(id: Int) = TdApi.File().apply { + this.id = id + size = 1 + local = TdApi.LocalFile().apply { + path = "path_$id" + isDownloadingCompleted = true + } + remote = TdApi.RemoteFile() + } + + private class RecordingQueue : FileUpdateQueue { + val completedDownloads = mutableListOf() + + override fun updateFileCache(file: TdApi.File) = Unit + + override fun notifyDownloadComplete(fileId: Int) { + completedDownloads += fileId + } + + override fun notifyUploadComplete(fileId: Int) = Unit + } + + private companion object { + const val BURST_SIZE = 256 + const val CHAT_ID = 100L + const val MESSAGE_ID_OFFSET = 1_000 + } +} diff --git a/data/src/test/java/org/monogram/data/infra/LatestByKeyBatcherTest.kt b/data/src/test/java/org/monogram/data/infra/LatestByKeyBatcherTest.kt new file mode 100644 index 000000000..35d7629d2 --- /dev/null +++ b/data/src/test/java/org/monogram/data/infra/LatestByKeyBatcherTest.kt @@ -0,0 +1,32 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +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 LatestByKeyBatcherTest { + + @Test + fun `keeps only the latest value for each key in a burst`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val batches = mutableListOf>() + val batcher = LatestByKeyBatcher(scope, debounceMs = 0L) { batch -> + batches += batch + } + + assertTrue(batcher.offer(1L, "offline")) + assertTrue(batcher.offer(1L, "online")) + assertTrue(batcher.offer(2L, "recently")) + advanceUntilIdle() + + assertEquals(listOf(mapOf(1L to "online", 2L to "recently")), batches) + scope.cancel() + } +} diff --git a/data/src/test/java/org/monogram/data/infra/OutboxLifecycleMetricsTest.kt b/data/src/test/java/org/monogram/data/infra/OutboxLifecycleMetricsTest.kt new file mode 100644 index 000000000..2b5a7cb72 --- /dev/null +++ b/data/src/test/java/org/monogram/data/infra/OutboxLifecycleMetricsTest.kt @@ -0,0 +1,26 @@ +package org.monogram.data.infra + +import org.junit.Assert.assertEquals +import org.junit.Test + +class OutboxLifecycleMetricsTest { + + @Test + fun `publishes lifecycle percentiles after terminal updates`() { + var now = 0L + val metrics = OutboxLifecycleMetrics { now } + metrics.recordPending(chatId = 1L, temporaryMessageId = -1L) + now = 100L + metrics.recordTerminal(chatId = 1L, temporaryMessageId = -1L) + metrics.recordPending(chatId = 1L, temporaryMessageId = -2L) + now = 300L + metrics.recordTerminal(chatId = 1L, temporaryMessageId = -2L) + + val snapshot = metrics.snapshot.value + assertEquals(0, snapshot.active) + assertEquals(2L, snapshot.completed) + assertEquals(100L, snapshot.p50Ms) + assertEquals(200L, snapshot.p95Ms) + assertEquals(200L, snapshot.p99Ms) + } +} diff --git a/data/src/test/java/org/monogram/data/notifications/NotificationRenderBatcherTest.kt b/data/src/test/java/org/monogram/data/notifications/NotificationRenderBatcherTest.kt new file mode 100644 index 000000000..fa903afa9 --- /dev/null +++ b/data/src/test/java/org/monogram/data/notifications/NotificationRenderBatcherTest.kt @@ -0,0 +1,30 @@ +package org.monogram.data.notifications + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NotificationRenderBatcherTest { + + @Test + fun `related updates render each chat once in a batch`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val renders = mutableListOf>() + val batcher = NotificationRenderBatcher(scope, batchWindowMs = 16L) { renders += it } + runCurrent() + + batcher.enqueue(setOf(10L, 20L)) + batcher.enqueue(setOf(20L, 30L)) + advanceUntilIdle() + + assertEquals(listOf(setOf(10L, 20L, 30L)), renders) + scope.cancel() + } +} diff --git a/data/src/test/java/org/monogram/data/push/ConflatedSyncRequestQueueTest.kt b/data/src/test/java/org/monogram/data/push/ConflatedSyncRequestQueueTest.kt new file mode 100644 index 000000000..e075fc2f5 --- /dev/null +++ b/data/src/test/java/org/monogram/data/push/ConflatedSyncRequestQueueTest.kt @@ -0,0 +1,47 @@ +package org.monogram.data.push + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class ConflatedSyncRequestQueueTest { + + @Test + fun `keeps the latest request received while a sync is running`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val processed = mutableListOf() + val queue = ConflatedSyncRequestQueue( + scope = scope, + minIntervalMs = 0L + ) { reason -> + processed += reason + if (reason == "first") { + firstStarted.complete(Unit) + releaseFirst.await() + } + } + + assertTrue(queue.request("first")) + runCurrent() + firstStarted.await() + + assertTrue(queue.request("second")) + assertTrue(queue.request("third")) + releaseFirst.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf("first", "third"), processed) + scope.cancel() + } +} diff --git a/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt index d0d3ef4df..13d96371a 100644 --- a/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt +++ b/data/src/test/java/org/monogram/data/repository/ContactEditRepositoryImplTest.kt @@ -157,9 +157,6 @@ private class FakeUserRemoteDataSource( override suspend fun getPremiumFeatures(source: TdApi.PremiumSource): TdApi.PremiumFeatures? = null - override suspend fun getPremiumLimit(limitType: TdApi.PremiumLimitType): TdApi.PremiumLimit? = - null - override suspend fun setSponsoredMessagesEnabled(enabled: Boolean) = Unit override suspend fun getBotFullInfo(userId: Long): TdApi.UserFullInfo? = null override suspend fun getChatStatistics(chatId: Long, isDark: Boolean): TdApi.ChatStatistics? = diff --git a/domain/src/main/java/org/monogram/domain/models/MessageSendFailedEvent.kt b/domain/src/main/java/org/monogram/domain/models/MessageSendFailedEvent.kt new file mode 100644 index 000000000..cafb18f10 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/MessageSendFailedEvent.kt @@ -0,0 +1,8 @@ +package org.monogram.domain.models + +data class MessageSendFailedEvent( + val chatId: Long, + val temporaryMessageId: Long, + val message: MessageModel, + val errorCode: Int +) diff --git a/domain/src/main/java/org/monogram/domain/models/PremiumStateModel.kt b/domain/src/main/java/org/monogram/domain/models/PremiumStateModel.kt index 4d3a1a389..e6f96f42f 100644 --- a/domain/src/main/java/org/monogram/domain/models/PremiumStateModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/PremiumStateModel.kt @@ -3,11 +3,32 @@ package org.monogram.domain.models data class PremiumStateModel( val state: String, val animations: List = emptyList(), - val paymentOptions: List = emptyList() + val paymentOptions: List = emptyList(), + val businessAnimations: List = emptyList() +) + +data class PremiumFeaturesModel( + val features: List = emptyList(), + val limits: List = emptyList(), + val paymentLink: String? = null +) + +data class PremiumFeatureModel( + val type: PremiumFeatureType, + val apiName: String +) + +data class PremiumLimitModel( + val type: PremiumLimitType, + val defaultValue: Int, + val premiumValue: Int, + val apiName: String ) enum class PremiumFeatureType { DOUBLE_LIMITS, + INCREASED_UPLOAD_FILE_SIZE, + UNIQUE_STICKERS, VOICE_TO_TEXT, FASTER_DOWNLOAD, TRANSLATION, @@ -18,6 +39,22 @@ enum class PremiumFeatureType { BADGE, PROFILE_BADGE, APP_ICONS, + ANIMATED_PROFILE_PHOTO, + FORUM_TOPIC_ICON, + UPGRADED_STORIES, + CHAT_BOOST, + ACCENT_COLOR, + BACKGROUND_FOR_BOTH, + SAVED_MESSAGES_TAGS, + MESSAGE_PRIVACY, + LAST_SEEN_TIMES, + BUSINESS, + MESSAGE_EFFECTS, + CHECKLISTS, + PAID_MESSAGES, + PROTECT_PRIVATE_CHAT_CONTENT, + TEXT_COMPOSITION, + RICH_MESSAGES, UNKNOWN } @@ -31,20 +68,38 @@ enum class PremiumSource { enum class PremiumLimitType { SUPERGROUP_COUNT, - CHAT_FOLDER_COUNT, PINNED_CHAT_COUNT, CREATED_PUBLIC_CHAT_COUNT, + SAVED_ANIMATION_COUNT, + FAVORITE_STICKER_COUNT, + CHAT_FOLDER_COUNT, + CHAT_FOLDER_CHOSEN_CHAT_COUNT, + PINNED_ARCHIVED_CHAT_COUNT, + PINNED_SAVED_MESSAGES_TOPIC_COUNT, + MESSAGE_TEXT_LENGTH, + CAPTION_LENGTH, + BIO_LENGTH, CHAT_FOLDER_INVITE_LINK_COUNT, SHAREABLE_CHAT_FOLDER_COUNT, ACTIVE_STORY_COUNT, - WEEKLY_SENT_MESSAGE_COUNT, - MONTHLY_SENT_MESSAGE_COUNT + WEEKLY_POSTED_STORY_COUNT, + MONTHLY_POSTED_STORY_COUNT, + STORY_CAPTION_LENGTH, + STORY_SUGGESTED_REACTION_AREA_COUNT, + SIMILAR_CHAT_COUNT, + OWNED_BOT_COUNT, + CUSTOM_TEXT_COMPOSITION_STYLE_COUNT, + UNKNOWN } data class PremiumPaymentOptionModel( val currency: String, val amount: Long, + val discountPercentage: Int, val monthCount: Int, val storeProductId: String, - val paymentLink: String? + val paymentLink: String?, + val isCurrent: Boolean = false, + val isUpgrade: Boolean = false, + val lastTransactionId: String? = null ) 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 effcdc27c..92e0a29e6 100644 --- a/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt +++ b/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt @@ -57,3 +57,8 @@ data class MessageIdUpdatedEvent( val oldMessageId: Long, val message: MessageModel ) + +data class MessageSendAcknowledgedEvent( + val chatId: Long, + val temporaryMessageId: Long +) 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 66856c011..2d4cd06f5 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -1,6 +1,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.DraftLinkPreview import org.monogram.domain.models.DraftLinkPreviewRequest @@ -9,6 +10,8 @@ import org.monogram.domain.models.MessageDeletedEvent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageIdUpdatedEvent import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendAcknowledgedEvent +import org.monogram.domain.models.MessageSendFailedEvent import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel @@ -95,6 +98,10 @@ interface MessageRepository : val messageDeletedFlow: Flow val messageEditedFlow: Flow val messageIdUpdateFlow: Flow + val messageAcknowledgedFlow: Flow + get() = emptyFlow() + val messageSendFailedFlow: Flow + get() = emptyFlow() val pinnedMessageFlow: Flow val mediaUpdateFlow: Flow suspend fun getHighResFileId(chatId: Long, messageId: Long): Int? @@ -176,6 +183,9 @@ interface MessageRepository : sendOptions: MessageSendOptions = MessageSendOptions() ) + /** Retries a TDLib-owned failed outgoing operation without creating another local bubble. */ + suspend fun retryFailedMessage(chatId: Long, temporaryMessageId: Long) = Unit + suspend fun sendRichMessage( chatId: Long, markdown: String, diff --git a/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt index b8de6ecec..8f6c9d3a7 100644 --- a/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/PremiumRepository.kt @@ -1,13 +1,11 @@ package org.monogram.domain.repository -import org.monogram.domain.models.PremiumFeatureType -import org.monogram.domain.models.PremiumLimitType +import org.monogram.domain.models.PremiumFeaturesModel import org.monogram.domain.models.PremiumSource import org.monogram.domain.models.PremiumStateModel interface PremiumRepository { suspend fun getPremiumState(): PremiumStateModel? - suspend fun getPremiumFeatures(source: PremiumSource): List - suspend fun getPremiumLimit(limitType: PremiumLimitType): Int + suspend fun getPremiumFeatures(source: PremiumSource): PremiumFeaturesModel? suspend fun setSponsoredMessagesEnabled(enabled: Boolean) } diff --git a/gradle.properties b/gradle.properties index ff9e0897d..da6e9abab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,10 +8,12 @@ # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8 --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.ref=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.locks=ALL-UNNAMED org.gradle.configuration-cache=true -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. For more details, visit -# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects -# org.gradle.parallel=true +org.gradle.caching=true +org.gradle.parallel=true +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true +verifyBeforeAssemble=false +kotlin.incremental=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d4f6bf4c5..73e561fe4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] # Plugins -agp = "9.3.0" +agp = "9.3.1" exifinterface = "1.4.2" -kotlin = "2.3.21" +kotlin = "2.4.10" google-services = "4.5.0" ossLicensesPlugin = "0.13.0" -ksp = "2.3.6" -baselineprofile = "1.5.0-alpha07" +ksp = "2.3.9" +baselineprofile = "1.5.0-beta01" javaToolchain = "21" @@ -17,7 +17,7 @@ androidx-camera = "1.6.1" androidx-compose-bom = "2026.06.01" androidx-compose-runtime = "1.11.4" androidx-compose-ui = "1.11.4" -androidx-material3 = "1.5.0-alpha24" +androidx-material3 = "1.5.0-alpha25" androidx-adaptive = "1.2.0" androidx-media3 = "1.10.1" androidx-securityCrypto = "1.1.0" @@ -28,6 +28,7 @@ androidx-test-ext-junit = "1.3.0" androidx-window = "1.5.1" androidx-core-splashscreen = "1.2.0" androidx-lifecycle = "2.11.0" +androidx-work = "2.11.2" # KotlinX kotlinx-coroutines = "1.11.0" @@ -44,15 +45,15 @@ coil = "3.5.0" maplibre = "1.7.0" # Google & Firebase -firebase-bom = "34.16.0" +firebase-bom = "34.17.0" playServices-ossLicenses = "17.5.1" # Others zxing = "3.5.4" junit = "4.13.2" -libphonenumber = "9.0.35" +libphonenumber = "9.0.36" unifiedpush-connector = "3.3.3" -json = "20260522" +json = "20260719" [libraries] # AndroidX Activity @@ -61,6 +62,7 @@ androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", vers androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } # AndroidX Biometric androidx-biometric = { module = "androidx.biometric:biometric-compose", version.ref = "androidx-biometric" } diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 0656947c8..363b56aea 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -31,10 +31,21 @@ val buildBranch = ?: runCommandAndCaptureOutput("git", "branch", "--show-current")?.takeIf { it.isNotBlank() } ?: "detached" +val requestsReleaseVariant = gradle.startParameter.taskNames.any { + it.contains("Release", ignoreCase = true) +} + val buildTimeMillis = resolveBuildInfoProperty("buildTimeMillis", "BUILD_TIME_MILLIS") ?.toLongOrNull() - ?: System.currentTimeMillis() + ?: if (requestsReleaseVariant) { + System.currentTimeMillis() + } else { + runCommandAndCaptureOutput("git", "show", "-s", "--format=%ct", "HEAD") + ?.toLongOrNull() + ?.times(1000) + ?: 0L + } android { namespace = "org.monogram.presentation" diff --git a/presentation/src/main/java/org/monogram/presentation/core/media/AvatarPlayer.kt b/presentation/src/main/java/org/monogram/presentation/core/media/AvatarPlayer.kt index 48d970b58..d738a2236 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/media/AvatarPlayer.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/media/AvatarPlayer.kt @@ -8,6 +8,7 @@ import org.monogram.presentation.features.chats.conversation.ui.InlineVideoPlaye @Composable fun AvatarPlayer( path: String, + animate: Boolean = true, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop ) { @@ -15,6 +16,6 @@ fun AvatarPlayer( path = path, modifier = modifier, contentScale = contentScale, - placeholderData = path + animate = animate ) } diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt index 62cab58f4..80a110208 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/Avatar.kt @@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -21,9 +23,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest +import org.koin.compose.koinInject import org.monogram.presentation.R -import org.monogram.presentation.core.util.generateColorFromHash import org.monogram.presentation.core.media.AvatarPlayer +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.generateColorFromHash import java.io.File @Composable @@ -39,6 +43,7 @@ fun Avatar( onClick: () -> Unit = {} ) { val context = LocalContext.current + val shouldAnimateVideoAvatars = rememberAnimatedAvatarPlaybackEnabled() val combinedModifier = modifier .size(size) .clip(CircleShape) @@ -71,6 +76,7 @@ fun Avatar( key(imageSource?.cacheKey ?: resolvedPath) { AvatarPlayer( path = resolvedPath, + animate = shouldAnimateVideoAvatars, modifier = combinedModifier, contentScale = ContentScale.Crop ) @@ -126,6 +132,7 @@ fun AvatarForChat( isLocal: Boolean = false ) { val context = LocalContext.current + val shouldAnimateVideoAvatars = rememberAnimatedAvatarPlaybackEnabled() val combinedModifier = modifier .size(size) .clip(CircleShape) @@ -157,6 +164,7 @@ fun AvatarForChat( key(imageSource?.cacheKey ?: resolvedPath) { AvatarPlayer( path = resolvedPath, + animate = shouldAnimateVideoAvatars, modifier = combinedModifier, contentScale = ContentScale.Crop ) @@ -205,6 +213,16 @@ private data class AvatarImageSource( val cacheKey: String? ) +@Composable +internal fun rememberAnimatedAvatarPlaybackEnabled(): Boolean { + val appPreferences: AppPreferences = koinInject() + val autoplayGifs by appPreferences.autoplayGifs.collectAsState() + val chatAnimationsEnabled by appPreferences.isChatAnimationsEnabled.collectAsState() + val powerSavingEnabled by appPreferences.isPowerSavingMode.collectAsState() + + return autoplayGifs && chatAnimationsEnabled && !powerSavingEnabled +} + private fun resolveAvatarImageSource(path: String): AvatarImageSource { return if (path.startsWith("http") || path.startsWith("content:") || path.startsWith("file:")) { AvatarImageSource(model = path, cacheKey = path) diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt index cf58a8833..7b7eb7907 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/AvatarHeader.kt @@ -26,8 +26,8 @@ import coil3.request.ImageRequest import coil3.request.crossfade import coil3.size.Precision import coil3.size.Size -import org.monogram.presentation.core.util.generateColorFromHash import org.monogram.presentation.core.media.AvatarPlayer +import org.monogram.presentation.core.util.generateColorFromHash import java.io.File @Composable @@ -43,6 +43,7 @@ fun AvatarHeader( avatarCornerPercent: Int = 0 ) { val context = LocalContext.current + val shouldAnimateVideoAvatars = rememberAnimatedAvatarPlaybackEnabled() val combinedModifier = modifier .size(size) .clip(RoundedCornerShape(percent = avatarCornerPercent)) @@ -74,6 +75,7 @@ fun AvatarHeader( isVideo -> { AvatarPlayer( path = resolvedPath, + animate = shouldAnimateVideoAvatars, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop ) 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 24d26d16c..2514e652d 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 @@ -299,6 +299,7 @@ interface ChatComponent { val userStatus: String? = null, val typingAction: String? = null, val messages: List = emptyList(), + val outgoingMessageStates: Map = emptyMap(), val isLoading: Boolean = false, val isLoadingOlder: Boolean = false, val isLoadingNewer: Boolean = false, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/OutgoingMessageReducer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/OutgoingMessageReducer.kt new file mode 100644 index 000000000..364da70dc --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/OutgoingMessageReducer.kt @@ -0,0 +1,73 @@ +package org.monogram.presentation.features.chats.conversation + +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendingState + +/** + * Presentation projection of TDLib's outgoing-message lifecycle. The temporary id is + * intentionally retained as the key after a success so duplicate terminal updates are no-ops. + */ +object OutgoingMessageReducer { + data class Key(val chatId: Long, val temporaryMessageId: Long) + + sealed interface State { + data object PendingLocal : State + data object Acknowledged : State + data class Succeeded(val finalMessageId: Long) : State + data class Failed(val errorCode: Int, val retryable: Boolean) : State + } + + fun recover(messages: List): Map = messages + .asSequence() + .filter(MessageModel::isOutgoing) + .mapNotNull { message -> + val state = when (val sendingState = message.sendingState) { + MessageSendingState.Pending -> State.PendingLocal + is MessageSendingState.Failed -> State.Failed( + errorCode = sendingState.errorCode, + retryable = isRetryable(sendingState.errorCode) + ) + + null -> null + } ?: return@mapNotNull null + Key(message.chatId, message.id) to state + } + .toMap() + + fun pending(current: Map, message: MessageModel): Map { + if (!message.isOutgoing || message.sendingState !is MessageSendingState.Pending) return current + val key = Key(message.chatId, message.id) + return if (current[key] == State.PendingLocal) current else current + (key to State.PendingLocal) + } + + fun acknowledged(current: Map, key: Key): Map = when (current[key]) { + State.PendingLocal -> current + (key to State.Acknowledged) + null -> current + (key to State.Acknowledged) + else -> current + } + + fun succeeded( + current: Map, + key: Key, + finalMessageId: Long + ): Map = when (current[key]) { + is State.Succeeded, + is State.Failed -> current + + else -> current + (key to State.Succeeded(finalMessageId)) + } + + fun failed( + current: Map, + key: Key, + errorCode: Int + ): Map = when (current[key]) { + is State.Succeeded, + is State.Failed -> current + + else -> current + (key to State.Failed(errorCode, isRetryable(errorCode))) + } + + private fun isRetryable(errorCode: Int): Boolean = + errorCode == 408 || errorCode == 429 || errorCode >= 500 +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt index de0a17e6e..ac19015cf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/message-actions/MessageActions.kt @@ -22,6 +22,7 @@ import org.monogram.domain.models.PollDraft import org.monogram.domain.repository.RichTextParseMode import org.monogram.presentation.features.chats.common.ChatActionType import org.monogram.presentation.features.chats.conversation.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.OutgoingMessageReducer import org.monogram.presentation.features.chats.conversation.editor.video.VideoQuality import org.monogram.presentation.features.chats.conversation.editor.video.VideoTrimRange import org.monogram.presentation.features.chats.conversation.editor.video.processVideo @@ -188,7 +189,7 @@ internal fun DefaultChatComponent.handleSendMessage( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -258,7 +259,7 @@ internal fun DefaultChatComponent.handleSendPhoto( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -322,7 +323,7 @@ internal fun DefaultChatComponent.handleSendVideo( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -369,7 +370,7 @@ internal fun DefaultChatComponent.handleSendGif( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -410,7 +411,7 @@ internal fun DefaultChatComponent.handleSendDocument( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -446,7 +447,7 @@ internal fun DefaultChatComponent.handleSendPoll( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -487,7 +488,7 @@ internal fun DefaultChatComponent.handleSendGifFile( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -566,7 +567,7 @@ internal fun DefaultChatComponent.handleSendAlbum( if (sendOptions.scheduleDate == null) { clearDraftLinkPreviewAfterSend() } - if (shouldAutoScrollAfterSend(currentState.isAtBottom)) { + if (sendOptions.scheduleDate == null && shouldAutoScrollAfterSend(currentState.isAtBottom)) { onScrollToBottom() } if (sendOptions.scheduleDate != null) { @@ -899,6 +900,12 @@ internal fun DefaultChatComponent.handleCopyLink(localClipboard: Clipboard) { internal fun DefaultChatComponent.handleRepeatMessage(message: MessageModel) { scope.launch { - repositoryMessage.forwardMessage(chatId, chatId, message.id, sendCopy = true) + val key = OutgoingMessageReducer.Key(message.chatId, message.id) + val outgoingState = _state.value.outgoingMessageStates[key] + if (outgoingState is OutgoingMessageReducer.State.Failed && outgoingState.retryable) { + repositoryMessage.retryFailedMessage(message.chatId, message.id) + } else { + repositoryMessage.forwardMessage(chatId, chatId, message.id, sendCopy = true) + } } } 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 9266ed12c..336678fb5 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 @@ -27,6 +27,7 @@ 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.DefaultChatComponent +import org.monogram.presentation.features.chats.conversation.OutgoingMessageReducer import org.monogram.presentation.features.chats.conversation.ScrollAlign import java.io.File import kotlin.math.abs @@ -234,7 +235,12 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( pruneDeliveredPendingDuplicates(sortedMessages) } - if (mergedMessages == state.messages) state else state.copy(messages = mergedMessages) + if (mergedMessages == state.messages) state else state.copy( + messages = mergedMessages, + outgoingMessageStates = state.outgoingMessageStates + OutgoingMessageReducer.recover( + mergedMessages + ) + ) } } @@ -1349,6 +1355,11 @@ internal fun DefaultChatComponent.setupMessageCollectors() { } state.copy( messages = sortedMessages, + outgoingMessageStates = OutgoingMessageReducer.succeeded( + current = state.outgoingMessageStates, + key = OutgoingMessageReducer.Key(cId, oldId), + finalMessageId = newMessage.id + ), isLatestLoaded = if (newMessage.isOutgoing || state.isAtBottom) true else state.isLatestLoaded ) } @@ -1362,6 +1373,35 @@ internal fun DefaultChatComponent.setupMessageCollectors() { } .launchIn(scope) + repositoryMessage.messageAcknowledgedFlow + .onEach { event -> + if (event.chatId != chatId && event.chatId != activeThreadChatId()) return@onEach + _state.update { state -> + state.copy( + outgoingMessageStates = OutgoingMessageReducer.acknowledged( + current = state.outgoingMessageStates, + key = OutgoingMessageReducer.Key(event.chatId, event.temporaryMessageId) + ) + ) + } + } + .launchIn(scope) + + repositoryMessage.messageSendFailedFlow + .onEach { event -> + if (event.chatId != chatId && event.chatId != activeThreadChatId()) return@onEach + _state.update { state -> + state.copy( + outgoingMessageStates = OutgoingMessageReducer.failed( + current = state.outgoingMessageStates, + key = OutgoingMessageReducer.Key(event.chatId, event.temporaryMessageId), + errorCode = event.errorCode + ) + ) + } + } + .launchIn(scope) + repositoryMessage.messageUploadProgressFlow .onEach { event -> if (event.chatId != chatId) return@onEach 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 6ed679dd1..bb9d1f71d 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -121,6 +122,7 @@ internal fun MessageBubbleContainer( } val topSpacing = if (!senderGrouping.isSameSenderAbove) 8.dp else 2.dp + val keepsGroupAvatar = behavior.isGroup && !isOutgoing && !senderGrouping.isSameSenderBelow val dragOffsetX = remember { Animatable(0f) } val coroutineScope = rememberCoroutineScope() val layoutTracker = remember { MessageBubbleLayoutTracker() } @@ -234,7 +236,11 @@ internal fun MessageBubbleContainer( Modifier.fillMaxWidth() } else { Modifier - }).widthIn(max = maxWidth), + }) + .widthIn(max = maxWidth) + .then( + if (keepsGroupAvatar) Modifier.heightIn(min = 40.dp) else Modifier + ), msg = msg, newerMsg = newerMsg, isOutgoing = isOutgoing, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/SenderGrouping.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/SenderGrouping.kt index 9b0ff2a8b..0f4e73bd6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/SenderGrouping.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/SenderGrouping.kt @@ -10,10 +10,13 @@ internal fun shouldGroupSenderBlock( dateBreak: Boolean ): Boolean { if (neighbor == null) return false - if (current.senderId <= 0L || neighbor.senderId <= 0L) return false - if (current.senderId != neighbor.senderId) return false - if (current.senderName != neighbor.senderName) return false - if (current.senderCustomTitle != neighbor.senderCustomTitle) return false + val sameSender = when { + current.senderId > 0L && neighbor.senderId > 0L -> current.senderId == neighbor.senderId + current.isOutgoing && neighbor.isOutgoing -> true + current.senderName.isNotBlank() && current.senderName == neighbor.senderName -> true + else -> false + } + if (!sameSender) return false return !dateBreak } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredMessageRow.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredMessageRow.kt index dc4a002ba..715cc6541 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredMessageRow.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChannelSponsoredMessageRow.kt @@ -80,7 +80,7 @@ internal fun ChannelSponsoredMessageRow( Column( modifier = Modifier .fillMaxWidth() - .padding(top = 12.dp) + .padding(top = 8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -89,7 +89,7 @@ internal fun ChannelSponsoredMessageRow( ) { Box( modifier = Modifier - .padding(horizontal = 8.dp) + .padding(horizontal = 6.dp) .widthIn(max = maxWidth) .fillMaxWidth() ) { @@ -99,16 +99,16 @@ internal fun ChannelSponsoredMessageRow( .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f), - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(18.dp) ), color = bubbleColor, - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(18.dp), tonalElevation = 0.dp, onClick = onCardClick ) { Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { Row( verticalAlignment = Alignment.Top, @@ -172,7 +172,7 @@ internal fun ChannelSponsoredMessageRow( text = info, style = MaterialTheme.typography.bodySmall, color = secondaryTextColor, - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -192,7 +192,9 @@ internal fun ChannelSponsoredMessageRow( Text( text = text, style = MaterialTheme.typography.bodyMedium, - color = titleColor + color = titleColor, + maxLines = 4, + overflow = TextOverflow.Ellipsis ) } @@ -201,7 +203,7 @@ internal fun ChannelSponsoredMessageRow( text = info, style = MaterialTheme.typography.bodySmall, color = secondaryTextColor, - maxLines = 3, + maxLines = 2, overflow = TextOverflow.Ellipsis ) } @@ -219,7 +221,7 @@ internal fun ChannelSponsoredMessageRow( containerColor = buttonColor, contentColor = titleColor ), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp) + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) ) { Text( text = buttonText, @@ -265,6 +267,7 @@ private fun SponsoredMedia( 1f } val mediaHeight = (maxWidth / mediaRatio).coerceIn(160.dp, 320.dp) + .coerceAtMost(240.dp) Box( modifier = Modifier diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt index 61f0871bf..3722d5b0b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/content/ChatContentDerivedState.kt @@ -6,10 +6,12 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendingState import org.monogram.domain.models.UserModel import org.monogram.presentation.features.chats.conversation.ChatComponent import org.monogram.presentation.features.chats.conversation.ChatRenderMode import org.monogram.presentation.features.chats.conversation.ChatViewportPhase +import org.monogram.presentation.features.chats.conversation.OutgoingMessageReducer @Immutable internal data class ChatContentPermissionState( @@ -113,7 +115,30 @@ internal fun rememberChatContentPreviewState( val previewRootMessage = if (renderMode == ChatRenderMode.ForumTopicSwipePreview) null else state.rootMessage val previewMessages = - if (renderMode == ChatRenderMode.ForumTopicSwipePreview) emptyList() else state.messages + if (renderMode == ChatRenderMode.ForumTopicSwipePreview) { + emptyList() + } else { + state.messages.map { message -> + val outgoingState = state.outgoingMessageStates[ + OutgoingMessageReducer.Key(message.chatId, message.id) + ] + when (outgoingState) { + is OutgoingMessageReducer.State.Failed -> message.copy( + sendingState = MessageSendingState.Failed( + outgoingState.errorCode, + "TDLib send failed" + ) + ) + + is OutgoingMessageReducer.State.PendingLocal, + is OutgoingMessageReducer.State.Acknowledged -> message.copy( + sendingState = message.sendingState ?: MessageSendingState.Pending + ) + + else -> message + } + } + } return remember( previewCurrentTopicId, 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 705a20fbb..614fc9e6c 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 @@ -29,6 +29,7 @@ internal fun shouldAutoFollowLatestAfterContentChange( previousLastGroupedMessageId: Long?, currentLastGroupedMessageId: Long?, followLatestArmed: Boolean, + stateIsAtBottom: Boolean = false, viewportPhase: ChatViewportPhase, pendingScrollCommand: ChatScrollCommand?, isLoading: Boolean, @@ -38,12 +39,12 @@ internal fun shouldAutoFollowLatestAfterContentChange( showInitialLoading: Boolean, isLatestLoaded: Boolean ): Boolean { - if (!followLatestArmed) return false + if (!followLatestArmed && !stateIsAtBottom) return false if (viewportPhase != ChatViewportPhase.Settled) return false if (previousLastGroupedMessageId == null || currentLastGroupedMessageId == null) return false if (previousLastGroupedMessageId == currentLastGroupedMessageId) return false if (pendingScrollCommand != null) return false - if (isLoading || isLoadingOlder || isLoadingNewer || isScrollInProgress || showInitialLoading) return false + if (isLoadingOlder || isLoadingNewer || isScrollInProgress || showInitialLoading) return false if (!isLatestLoaded) return false return true } @@ -136,6 +137,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 conversationItems = remember( groupedMessages, state.channelSponsoredMessages, @@ -156,7 +162,7 @@ internal fun ChatContentEffects( } } var followLatestArmed by remember { mutableStateOf(false) } - var previousLastGroupedMessageId by remember { mutableStateOf(null) } + var previousLatestGroupedMessageId by remember { mutableStateOf(null) } suspend fun consumeScrollCommandAndSettle() { ChatConversationLog.logState( stream = ChatConversationLog.STREAM_VIEWPORT, @@ -285,19 +291,22 @@ internal fun ChatContentEffects( isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, isAtBottom = state.isAtBottom, + isNearBottom = scrollState.isNearBottom(isComments = isComments), hasMessages = groupedMessages.isNotEmpty() ) val groupedLazyIndexByFirstMessageId = buildGroupedLazyIndexByFirstMessageId( conversationItems = conversationItems, leadingItemsCount = leadingItems ) + val bottomMessageLazyIndex = if (isComments) null else leadingItems when (command) { is ChatScrollCommand.RestoreViewport -> { if (command.atBottom || command.anchorMessageId == null) { scrollState.scrollToChatBottomStaged( isComments = isComments, - animated = false + animated = false, + bottomTargetIndex = bottomMessageLazyIndex ) } else { val anchorCandidateIds = @@ -325,7 +334,8 @@ internal fun ChatContentEffects( } else { scrollState.scrollToChatBottomStaged( isComments = isComments, - animated = false + animated = false, + bottomTargetIndex = bottomMessageLazyIndex ) } } @@ -359,7 +369,8 @@ internal fun ChatContentEffects( is ChatScrollCommand.ScrollToBottom -> { scrollState.scrollToChatBottomStaged( isComments = isComments, - animated = command.animated && state.isChatAnimationsEnabled + animated = command.animated && state.isChatAnimationsEnabled, + bottomTargetIndex = bottomMessageLazyIndex ) consumeScrollCommandAndSettle() } @@ -448,36 +459,52 @@ internal fun ChatContentEffects( LaunchedEffect( effectsEnabled, state.viewportPhase, - lastGroupedMessageId, + latestGroupedMessageId, state.pendingScrollCommand, state.isLoading, state.isLoadingOlder, state.isLoadingNewer, state.isLatestLoaded, + state.isAtBottom, showInitialLoading, isDragged ) { if (!effectsEnabled) return@LaunchedEffect val shouldAutoFollow = shouldAutoFollowLatestAfterContentChange( - previousLastGroupedMessageId = previousLastGroupedMessageId, - currentLastGroupedMessageId = lastGroupedMessageId, + previousLastGroupedMessageId = previousLatestGroupedMessageId, + currentLastGroupedMessageId = latestGroupedMessageId, followLatestArmed = followLatestArmed, + stateIsAtBottom = state.isAtBottom, viewportPhase = state.viewportPhase, pendingScrollCommand = state.pendingScrollCommand, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, - isScrollInProgress = isDragged || scrollState.isScrollInProgress, + isScrollInProgress = isDragged, showInitialLoading = showInitialLoading, isLatestLoaded = state.isLatestLoaded ) if (shouldAutoFollow) { + val bottomMessageLazyIndex = if (isComments) { + null + } else { + 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 = state.isChatAnimationsEnabled + animated = false, + bottomTargetIndex = bottomMessageLazyIndex ) } - previousLastGroupedMessageId = lastGroupedMessageId + previousLatestGroupedMessageId = latestGroupedMessageId } LaunchedEffect( @@ -571,6 +598,9 @@ internal fun ChatContentEffects( isLoadingOlder = currentState.isLoadingOlder, isLoadingNewer = currentState.isLoadingNewer, isAtBottom = currentState.isAtBottom, + isNearBottom = scrollState.isNearBottom( + isComments = currentState.rootMessage != null + ), hasMessages = groupedMessages.isNotEmpty() ) val visibleIds = LinkedHashSet() @@ -659,13 +689,77 @@ internal fun ChatContentEffects( 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 = state.isChatAnimationsEnabled + 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( + isComments = false, + animated = false, + bottomTargetIndex = 0 + ) + } + } + } + LaunchedEffect(isDragged) { if (isDragged) { onHideKeyboardAndClearFocus(false) 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 f2bcba103..8367f771f 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 @@ -5,9 +5,7 @@ import android.util.Log import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -180,6 +178,9 @@ private enum class PagingIndicatorPlacement { Center } +private const val MIN_EDGE_PRELOAD_ITEMS = 12 +private const val EDGE_PRELOAD_VIEWPORT_MULTIPLIER = 2 + internal fun shouldShowUnreadSeparator( isComments: Boolean, unreadBoundaryIndex: Int?, @@ -385,6 +386,7 @@ internal fun ChatContentList( isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, isAtBottom = state.isAtBottom, + isNearBottom = scrollState.isNearBottom(isComments = isComments), hasMessages = groupedMessages.isNotEmpty() ) scrollState.layoutInfo.visibleItemsInfo.mapNotNull { visibleItem -> @@ -426,6 +428,7 @@ internal fun ChatContentList( isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, isAtBottom = state.isAtBottom, + isNearBottom = scrollState.isNearBottom(isComments = isComments), hasMessages = groupedMessages.isNotEmpty() ) val viewportTopOffset = with(density) { @@ -500,12 +503,12 @@ internal fun ChatContentList( snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } .filter { it.isNotEmpty() && groupedMessages.isNotEmpty() } .map { visibleItems -> - val firstIndex = visibleItems.first().index - val lastIndex = visibleItems.last().index - firstIndex to lastIndex + val firstIndex = visibleItems.minOf { it.index } + val lastIndex = visibleItems.maxOf { it.index } + Triple(firstIndex, lastIndex, visibleItems.size) } .distinctUntilChanged() - .collect { (firstVisibleIndex, lastVisibleIndex) -> + .collect { (firstVisibleIndex, lastVisibleIndex, visibleItemsCount) -> val currentState = latestState if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return@collect @@ -518,6 +521,7 @@ internal fun ChatContentList( isLoadingOlder = currentState.isLoadingOlder, isLoadingNewer = currentState.isLoadingNewer, isAtBottom = currentState.isAtBottom, + isNearBottom = scrollState.isNearBottom(isComments = isComments), hasMessages = groupedMessages.isNotEmpty() ) val lastConversationIndex = @@ -528,11 +532,14 @@ internal fun ChatContentList( -1 } } - val olderPreloadThreshold = 2 - val newerPreloadThreshold = 8 + val preloadThreshold = (visibleItemsCount * EDGE_PRELOAD_VIEWPORT_MULTIPLIER) + .coerceAtLeast(MIN_EDGE_PRELOAD_ITEMS) + val olderPreloadThreshold = preloadThreshold + val newerPreloadThreshold = preloadThreshold val nearStartForOlder = firstVisibleIndex <= olderPreloadThreshold val nearStartForNewer = firstVisibleIndex <= newerPreloadThreshold - val nearEnd = lastVisibleIndex >= (groupedLastLazyIndex - 2).coerceAtLeast(0) + val nearEnd = lastVisibleIndex >= + (groupedLastLazyIndex - olderPreloadThreshold).coerceAtLeast(0) val nearEndForNewer = lastVisibleIndex >= (groupedLastLazyIndex - newerPreloadThreshold).coerceAtLeast( 0 @@ -612,6 +619,7 @@ internal fun ChatContentList( isLoadingOlder = state.isLoadingOlder, isLoadingNewer = state.isLoadingNewer, isAtBottom = state.isAtBottom, + isNearBottom = scrollState.isNearBottom(isComments = isComments), hasMessages = groupedMessages.isNotEmpty() ) val visibleGroupedIndices = visibleLazyIndices.mapNotNull { lazyIndex -> @@ -663,7 +671,12 @@ internal fun ChatContentList( } } - if (!isComments && state.isLoadingNewer && !state.isAtBottom && groupedMessages.isNotEmpty()) { + if (!isComments && + state.isLoadingNewer && + !state.isAtBottom && + !scrollState.isNearBottom(isComments = false) && + groupedMessages.isNotEmpty() + ) { item(key = "loading_newer_bottom") { PagingLoadingIndicator(placement = PagingIndicatorPlacement.Top) } @@ -742,7 +755,13 @@ internal fun ChatContentList( isTargetVisibleInViewport = item.firstMessageId in visibleGroupedMessageIds, canReportVisibleMessagesAsRead = canReportVisibleMessagesAsRead, rootMessageId = state.rootMessage?.id, - isEntryAnimationPending = pendingEntryAnimationIds.containsKey(item.firstMessageId) + isEntryAnimationPending = pendingEntryAnimationIds.containsKey( + item.firstMessageId + ) || ( + hasSeededEntryAnimations && + !state.suppressEntryAnimations && + !seenMessageIds.containsKey(item.firstMessageId) + ) ) } @@ -865,7 +884,11 @@ internal fun ChatContentList( rootMessageId = state.rootMessage?.id, isEntryAnimationPending = pendingEntryAnimationIds.containsKey( groupedItem.firstMessageId - ) + ) || ( + hasSeededEntryAnimations && + !state.suppressEntryAnimations && + !seenMessageIds.containsKey(groupedItem.firstMessageId) + ) ) } @@ -1061,19 +1084,18 @@ private fun MessageRowItem( val shouldAnimateEntry = isChatAnimationsEnabled && isEntryAnimationPending && !isScrolling - val scale = remember(mainMsg.id) { - Animatable( - if (shouldAnimateEntry) 0.98f else 1f - ) - } val itemAlpha = remember(mainMsg.id) { Animatable( - if (shouldAnimateEntry) 0f else 1f + if (shouldAnimateEntry) 0.7f else 1f ) } val offsetY = remember(mainMsg.id) { Animatable( - if (shouldAnimateEntry) 10f else 0f + if (shouldAnimateEntry) { + if (mainMsg.isOutgoing) 6f else 10f + } else { + 0f + } ) } val rowShape = remember { RoundedCornerShape(18.dp) } @@ -1090,14 +1112,13 @@ private fun MessageRowItem( } LaunchedEffect(mainMsg.id, shouldAnimateEntry) { - if (shouldAnimateEntry && scale.value < 1f) { - val stiffness = Spring.StiffnessMediumLow - launch { scale.animateTo(1f, spring(Spring.DampingRatioLowBouncy, stiffness)) } - launch { itemAlpha.animateTo(1f, spring(stiffness = stiffness)) } - launch { offsetY.animateTo(0f, spring(stiffness = Spring.StiffnessLow)) } + if (shouldAnimateEntry && (itemAlpha.value < 1f || offsetY.value != 0f)) { + val alphaJob = launch { itemAlpha.animateTo(1f, tween(durationMillis = 120)) } + val offsetJob = launch { offsetY.animateTo(0f, tween(durationMillis = 180)) } + alphaJob.join() + offsetJob.join() onEntryAnimationConsumed(mainMsg.id) } else { - scale.snapTo(1f) itemAlpha.snapTo(1f) offsetY.snapTo(0f) if (isEntryAnimationPending) { @@ -1176,8 +1197,8 @@ private fun MessageRowItem( modifier = Modifier .fillMaxWidth() .graphicsLayer { - scaleX = scale.value * highlightScale.value - scaleY = scale.value * highlightScale.value + scaleX = highlightScale.value + scaleY = highlightScale.value alpha = itemAlpha.value translationY = offsetY.value } @@ -1831,6 +1852,7 @@ internal fun chatContentLeadingItemsCount( isLoadingOlder: Boolean, isLoadingNewer: Boolean, isAtBottom: Boolean, + isNearBottom: Boolean = false, hasMessages: Boolean ): Int { return if (isComments) { @@ -1838,7 +1860,13 @@ internal fun chatContentLeadingItemsCount( loadingOlderTop + 1 // root header } else { val navPadding = if (showNavPadding) 1 else 0 - val loadingNewerBottom = if (isLoadingNewer && !isAtBottom && hasMessages) 1 else 0 + val loadingNewerBottom = if ( + isLoadingNewer && !isAtBottom && !isNearBottom && hasMessages + ) { + 1 + } else { + 0 + } navPadding + loadingNewerBottom } } 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 c2567fecb..971fab361 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 @@ -224,12 +224,14 @@ private suspend fun LazyListState.alignVisibleMessage( internal suspend fun LazyListState.scrollToChatBottomStaged( isComments: Boolean, - animated: Boolean + animated: Boolean, + bottomTargetIndex: Int? = null ) { val total = layoutInfo.totalItemsCount if (total <= 0) return - val targetIndex = if (isComments) total - 1 else 0 + val targetIndex = (bottomTargetIndex ?: if (isComments) total - 1 else 0) + .coerceIn(0, total - 1) val visibleTargetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == targetIndex } if (visibleTargetInfo != null) { val visibleDelta = calculateBottomAlignmentDelta( @@ -252,9 +254,7 @@ internal suspend fun LazyListState.scrollToChatBottomStaged( scrollBy(visibleDelta) } val remainingDelta = bottomAlignmentDelta(isComments = isComments) - if (remainingDelta == null || !needsBottomAlignmentCorrection(remainingDelta)) { - return - } + if (remainingDelta == null || !needsBottomAlignmentCorrection(remainingDelta)) return } } @@ -286,9 +286,7 @@ internal suspend fun LazyListState.scrollToChatBottomStaged( scrollBy(delta) } val remainingDelta = bottomAlignmentDelta(isComments = isComments) - if (remainingDelta == null || !needsBottomAlignmentCorrection(remainingDelta)) { - return - } + if (remainingDelta == null || !needsBottomAlignmentCorrection(remainingDelta)) return } scrollToItem(targetIndex) @@ -461,6 +459,7 @@ internal fun buildViewportSnapshot( isLoadingOlder = isLoadingOlder, isLoadingNewer = isLoadingNewer, isAtBottom = isAtBottom, + isNearBottom = nearBottomNow, hasMessages = groupedMessages.isNotEmpty() ) val info = scrollState.layoutInfo diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt index 1cf864b83..5aa20007a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/ChatListContent.kt @@ -421,6 +421,7 @@ fun ChatListContent( ) val showCreateStoryFab = shouldShowCreateStoryFab( selectedFolderId = effectiveFoldersState.selectedFolderId, + showStoriesBlock = showStoriesBlock, areMainStoriesLoaded = storiesState.isMainStoriesLoaded, hasVisibleStories = hasMainStoryItems ) @@ -3134,10 +3135,11 @@ internal fun shouldShowProjectChannelPromo( internal fun shouldShowCreateStoryFab( selectedFolderId: Int, + showStoriesBlock: Boolean, areMainStoriesLoaded: Boolean, hasVisibleStories: Boolean ): Boolean { - return selectedFolderId != -2 && areMainStoriesLoaded && !hasVisibleStories + return showStoriesBlock && selectedFolderId != -2 && areMainStoriesLoaded && !hasVisibleStories } internal fun shouldShowCreateStoryStripButton( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/Avatar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/Avatar.kt index 621ca6943..f2489819a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/Avatar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/list/components/Avatar.kt @@ -21,8 +21,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest -import org.monogram.presentation.core.util.generateColorFromHash import org.monogram.presentation.core.media.AvatarPlayer +import org.monogram.presentation.core.ui.rememberAnimatedAvatarPlaybackEnabled +import org.monogram.presentation.core.util.generateColorFromHash import java.io.File @Composable @@ -36,6 +37,7 @@ fun AvatarTopAppBar( isOnline: Boolean = false ) { val context = LocalContext.current + val shouldAnimateVideoAvatars = rememberAnimatedAvatarPlaybackEnabled() val combinedModifier = modifier .size(size) .clip(CircleShape) @@ -49,6 +51,7 @@ fun AvatarTopAppBar( key(avatarVersion) { AvatarPlayer( path = resolvedPath, + animate = shouldAnimateVideoAvatars, modifier = combinedModifier, contentScale = ContentScale.Crop ) diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt index f2c230936..673650643 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -800,8 +800,8 @@ class DefaultRootComponent( is Config.ChatSettings -> RootComponent.Child.ChatSettingsChild( DefaultChatSettingsComponent( context = context, - onBack = { navigation.pop() }, - onAdBlock = { navigation.bringToFront(Config.AdBlock) }) + onBack = { navigation.pop() } + ) ) is Config.DataStorage -> RootComponent.Child.DataStorageChild( DefaultDataStorageComponent( @@ -1000,7 +1000,8 @@ class DefaultRootComponent( is Config.Debug -> RootComponent.Child.DebugChild( DefaultDebugComponent( context = context, - onBack = { navigation.pop() } + onBack = { navigation.pop() }, + onAdBlock = { navigation.bringToFront(Config.AdBlock) } ) ) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt index 78c4e4c01..10593f50e 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt @@ -86,7 +86,6 @@ interface ChatSettingsComponent { fun onDragToBackChanged(enabled: Boolean) fun onInAppBrowserEnabledChanged(enabled: Boolean) fun onTabletInterfaceEnabledChanged(enabled: Boolean) - fun onAdBlockClick() fun onEmojiStyleChanged(style: EmojiStyle) fun onEmojiStyleLongClick(style: EmojiStyle) fun onConfirmRemoveEmojiPack() @@ -174,8 +173,7 @@ interface ChatSettingsComponent { class DefaultChatSettingsComponent( context: AppComponentContext, - private val onBack: () -> Unit, - private val onAdBlock: () -> Unit + private val onBack: () -> Unit ) : ChatSettingsComponent, AppComponentContext by context { private val appPreferences: AppPreferences = container.preferences.appPreferences @@ -1058,10 +1056,6 @@ class DefaultChatSettingsComponent( appPreferences.setTabletInterfaceEnabled(enabled) } - override fun onAdBlockClick() { - onAdBlock() - } - override fun onEmojiStyleChanged(style: EmojiStyle) { val isDownloaded = when (style) { EmojiStyle.APPLE -> state.value.isAppleEmojiDownloaded diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt index 19872b562..ab84f179b 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt @@ -54,7 +54,6 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.StickyNote2 import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Archive -import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Brightness4 import androidx.compose.material.icons.rounded.BrightnessAuto import androidx.compose.material.icons.rounded.BrightnessLow @@ -1043,20 +1042,6 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { ) } - if (!state.isInstalledFromGooglePlay) { - item { - SectionHeader(stringResource(R.string.experimental_header)) - SettingsTile( - icon = Icons.Rounded.Block, - title = stringResource(R.string.adblock_channels_title), - subtitle = stringResource(R.string.adblock_channels_subtitle), - iconColor = redColor, - position = ItemPosition.STANDALONE, - onClick = component::onAdBlockClick - ) - } - } - item { SectionHeader(stringResource(R.string.recent_media_header)) SettingsTile( diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt index 578502c5f..c032d696e 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatThemeEditorScreen.kt @@ -1,6 +1,8 @@ package org.monogram.presentation.settings.chatSettings -import android.graphics.Color.* +import android.graphics.Color.HSVToColor +import android.graphics.Color.colorToHSV +import android.graphics.Color.parseColor import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -8,7 +10,18 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -20,9 +33,33 @@ import androidx.compose.material.icons.outlined.Colorize import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Upload -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedToggleButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -34,10 +71,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsTextField import org.monogram.presentation.core.util.NightMode import org.monogram.presentation.core.util.coRunCatching -import org.monogram.presentation.core.ui.SettingsTextField -import java.util.* +import java.util.Calendar private enum class PaletteMode { LIGHT, DARK } @@ -278,16 +315,14 @@ fun ChatThemeEditorScreen( OutlinedToggleButton( checked = !isDark, onCheckedChange = { if (it) mode = PaletteMode.LIGHT }, - modifier = Modifier.weight(1f), - shapes = ToggleButtonDefaults.shapes(shape = ShapeDefaults.LargeIncreased) + modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.chat_theme_editor_light)) } OutlinedToggleButton( checked = isDark, onCheckedChange = { if (it) mode = PaletteMode.DARK }, - modifier = Modifier.weight(1f), - shapes = ToggleButtonDefaults.shapes(shape = ShapeDefaults.LargeIncreased) + modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.chat_theme_editor_dark)) } 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 996dd3568..f5f032c79 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 @@ -11,6 +11,7 @@ interface DebugComponent { fun onCrashClicked() fun onForceSponsorSyncClicked() fun onTestPushClicked() + fun onAdBlockClicked() fun onDropDatabasesClicked() fun onDropCachePrefsClicked() fun onDropPrefsClicked() @@ -35,6 +36,7 @@ interface DebugComponent { val isGmsAvailable: Boolean = false, val isFcmAvailable: Boolean = false, val isUnifiedPushDistributorAvailable: Boolean = false, + val isInstalledFromGooglePlay: Boolean = true, val supportersCount: Int = 0, val isSponsorsLoading: Boolean = true, val sponsorLastSyncAt: Long = 0L 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 7c2390a95..785e50fab 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 @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.BatteryAlert import androidx.compose.material.icons.rounded.BatterySaver +import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Cloud @@ -89,6 +90,20 @@ fun DebugContent(component: DebugComponent) { contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + if (!state.isInstalledFromGooglePlay) { + item { + SectionHeader(stringResource(R.string.experimental_header)) + SettingsItem( + icon = Icons.Rounded.Block, + title = stringResource(R.string.adblock_channels_title), + subtitle = stringResource(R.string.adblock_channels_subtitle), + iconBackgroundColor = Color(0xFFD32F2F), + position = ItemPosition.STANDALONE, + onClick = component::onAdBlockClicked + ) + } + } + item { SectionHeader(stringResource(R.string.debug_section_push_diagnostics)) SettingsItem( 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 d016afa61..978e7ba44 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 @@ -13,7 +13,8 @@ import java.io.File class DefaultDebugComponent( private val context: AppComponentContext, - private val onBack: () -> Unit + private val onBack: () -> Unit, + private val onAdBlock: () -> Unit ) : DebugComponent, AppComponentContext by context { private val messageDisplayer = container.utils.messageDisplayer() @@ -27,7 +28,8 @@ class DefaultDebugComponent( DebugComponent.State( isGmsAvailable = distrManager.isGmsAvailable(), isFcmAvailable = distrManager.isFcmAvailable(), - isUnifiedPushDistributorAvailable = distrManager.isUnifiedPushDistributorAvailable() + isUnifiedPushDistributorAvailable = distrManager.isUnifiedPushDistributorAvailable(), + isInstalledFromGooglePlay = distrManager.isInstalledFromGooglePlay() ) ) override val state: Value = _state @@ -86,6 +88,10 @@ class DefaultDebugComponent( messageDisplayer.show("Debug push dispatched") } + override fun onAdBlockClicked() { + onAdBlock() + } + override fun onDropDatabasesClicked() { messageDisplayer.show("Dropping databases and restarting...") assetsManager.getDatabasePath("monogram_db").delete() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt index 376103717..9664b276d 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumComponent.kt @@ -6,8 +6,12 @@ import com.arkivanov.decompose.value.update import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import org.monogram.domain.models.PremiumFeatureModel import org.monogram.domain.models.PremiumFeatureType +import org.monogram.domain.models.PremiumFeaturesModel +import org.monogram.domain.models.PremiumLimitModel import org.monogram.domain.models.PremiumLimitType +import org.monogram.domain.models.PremiumPaymentOptionModel import org.monogram.domain.models.PremiumSource import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.UserRepository @@ -23,6 +27,9 @@ interface PremiumComponent { data class State( val features: List = emptyList(), + val limits: List = emptyList(), + val paymentOptions: List = emptyList(), + val paymentLink: String? = null, val isLoading: Boolean = false, val isPremium: Boolean = false, val statusText: String? = null, @@ -35,6 +42,13 @@ interface PremiumComponent { val description: String, val color: Long ) + + data class PremiumLimit( + val title: String, + val subtitle: String? = null, + val defaultValue: Int, + val premiumValue: Int + ) } class DefaultPremiumComponent( @@ -71,116 +85,307 @@ class DefaultPremiumComponent( scope.launch { _state.update { it.copy(isLoading = true) } - val premiumState = premiumRepository.getPremiumState() - val features = premiumRepository.getPremiumFeatures(PremiumSource.SETTINGS) + val premiumState = runCatching { premiumRepository.getPremiumState() }.getOrNull() + val premiumFeatures = runCatching { + premiumRepository.getPremiumFeatures(PremiumSource.SETTINGS) + }.getOrNull() ?: PremiumFeaturesModel() - val mappedFeatures = features.mapNotNull { featureType -> - mapToPremiumFeature(featureType) + val mappedFeatures = premiumFeatures.features.map { feature -> + mapToPremiumFeature(feature, premiumFeatures.limits) } + val mappedLimits = premiumFeatures.limits.map(::mapToPremiumLimit) _state.update { it.copy( features = mappedFeatures, - statusText = premiumState?.state, + limits = mappedLimits, + paymentOptions = premiumState?.paymentOptions.orEmpty(), + paymentLink = premiumFeatures.paymentLink, + statusText = premiumState?.state?.takeIf(String::isNotBlank), isLoading = false ) } } } - private suspend fun mapToPremiumFeature(featureType: PremiumFeatureType): PremiumComponent.PremiumFeature? { - return when (featureType) { - PremiumFeatureType.DOUBLE_LIMITS -> { - val channels = premiumRepository.getPremiumLimit(PremiumLimitType.SUPERGROUP_COUNT) - val folders = premiumRepository.getPremiumLimit(PremiumLimitType.CHAT_FOLDER_COUNT) - val pins = premiumRepository.getPremiumLimit(PremiumLimitType.PINNED_CHAT_COUNT) - val publicLinks = premiumRepository.getPremiumLimit(PremiumLimitType.CREATED_PUBLIC_CHAT_COUNT) - PremiumComponent.PremiumFeature( - icon = "star", - title = stringProvider.getString("premium_feature_doubled_limits_title"), - description = stringProvider.getString( - "premium_feature_doubled_limits_description", - channels, - folders, - pins, - publicLinks - ), - color = 0xFFAF52DE - ) + private fun mapToPremiumFeature( + feature: PremiumFeatureModel, + limits: List + ): PremiumComponent.PremiumFeature { + if (feature.type == PremiumFeatureType.DOUBLE_LIMITS) { + val channels = limits.valueFor(PremiumLimitType.SUPERGROUP_COUNT) + val folders = limits.valueFor(PremiumLimitType.CHAT_FOLDER_COUNT) + val pins = limits.valueFor(PremiumLimitType.PINNED_CHAT_COUNT) + val publicLinks = limits.valueFor(PremiumLimitType.CREATED_PUBLIC_CHAT_COUNT) + val descriptionKey = if (channels > 0 || folders > 0 || pins > 0 || publicLinks > 0) { + "premium_feature_doubled_limits_description" + } else { + "premium_feature_doubled_limits_description_generic" } + return PremiumComponent.PremiumFeature( + icon = "star", + title = stringProvider.getString("premium_feature_doubled_limits_title"), + description = stringProvider.getString( + descriptionKey, + channels, + folders, + pins, + publicLinks + ), + color = 0xFFAF52DE + ) + } + + val presentation = when (feature.type) { + PremiumFeatureType.INCREASED_UPLOAD_FILE_SIZE -> FeaturePresentation( + "premium_feature_increased_upload_file_size_title", + "premium_feature_increased_upload_file_size_description", + "download", + 0xFF34A853 + ) + + PremiumFeatureType.VOICE_TO_TEXT -> FeaturePresentation( + "premium_feature_voice_to_text_title", + "premium_feature_voice_to_text_description", + "mic", + 0xFF4285F4 + ) + + PremiumFeatureType.FASTER_DOWNLOAD -> FeaturePresentation( + "premium_feature_faster_download_title", + "premium_feature_faster_download_description", + "download", + 0xFF34A853 + ) + + PremiumFeatureType.TRANSLATION -> FeaturePresentation( + "premium_feature_translation_title", + "premium_feature_translation_description", + "translate", + 0xFFF9AB00 + ) + + PremiumFeatureType.ANIMATED_EMOJI -> FeaturePresentation( + "premium_feature_animated_emoji_title", + "premium_feature_animated_emoji_description", + "face", + 0xFFFF6D66 + ) + + PremiumFeatureType.ADVANCED_CHAT_MANAGEMENT -> FeaturePresentation( + "premium_feature_chat_management_title", + "premium_feature_chat_management_description", + "folder", + 0xFF536DFE + ) + + PremiumFeatureType.NO_ADS -> FeaturePresentation( + "premium_feature_no_ads_title", + "premium_feature_no_ads_description", + "block", + 0xFF00BFA5 + ) + + PremiumFeatureType.INFINITE_REACTIONS -> FeaturePresentation( + "premium_feature_infinite_reactions_title", + "premium_feature_infinite_reactions_description", + "heart", + 0xFFFF6D66 + ) + + PremiumFeatureType.UNIQUE_STICKERS -> FeaturePresentation( + "premium_feature_unique_stickers_title", + "premium_feature_unique_stickers_description", + "favorite", + 0xFFFF8A65 + ) + + PremiumFeatureType.BADGE -> FeaturePresentation( + "premium_feature_badge_title", + "premium_feature_badge_description", + "verified", + 0xFF24A1DE + ) - PremiumFeatureType.VOICE_TO_TEXT -> PremiumComponent.PremiumFeature( - icon = "mic", - title = stringProvider.getString("premium_feature_voice_to_text_title"), - description = stringProvider.getString("premium_feature_voice_to_text_description"), - color = 0xFF4285F4 + PremiumFeatureType.PROFILE_BADGE -> FeaturePresentation( + "premium_feature_emoji_status_title", + "premium_feature_emoji_status_description", + "face", + 0xFFF9AB00 ) - PremiumFeatureType.FASTER_DOWNLOAD -> PremiumComponent.PremiumFeature( - icon = "download", - title = stringProvider.getString("premium_feature_faster_download_title"), - description = stringProvider.getString("premium_feature_faster_download_description"), - color = 0xFF34A853 + PremiumFeatureType.ANIMATED_PROFILE_PHOTO -> FeaturePresentation( + "premium_feature_animated_profile_photo_title", + "premium_feature_animated_profile_photo_description", + "face", + 0xFFAB47BC ) - PremiumFeatureType.TRANSLATION -> PremiumComponent.PremiumFeature( - icon = "translate", - title = stringProvider.getString("premium_feature_translation_title"), - description = stringProvider.getString("premium_feature_translation_description"), - color = 0xFFF9AB00 + PremiumFeatureType.FORUM_TOPIC_ICON -> FeaturePresentation( + "premium_feature_forum_topic_icon_title", + "premium_feature_forum_topic_icon_description", + "folder", + 0xFF5C6BC0 ) - PremiumFeatureType.ANIMATED_EMOJI -> PremiumComponent.PremiumFeature( - icon = "face", - title = stringProvider.getString("premium_feature_animated_emoji_title"), - description = stringProvider.getString("premium_feature_animated_emoji_description"), - color = 0xFFFF6D66 + PremiumFeatureType.APP_ICONS -> FeaturePresentation( + "premium_feature_app_icons_title", + "premium_feature_app_icons_description", + "settings", + 0xFF673AB7 ) - PremiumFeatureType.ADVANCED_CHAT_MANAGEMENT -> PremiumComponent.PremiumFeature( - icon = "folder", - title = stringProvider.getString("premium_feature_chat_management_title"), - description = stringProvider.getString("premium_feature_chat_management_description"), - color = 0xFF536DFE + PremiumFeatureType.UPGRADED_STORIES -> FeaturePresentation( + "premium_feature_upgraded_stories_title", + "premium_feature_upgraded_stories_description", + "star", + 0xFFFFB300 ) - PremiumFeatureType.NO_ADS -> PremiumComponent.PremiumFeature( - icon = "block", - title = stringProvider.getString("premium_feature_no_ads_title"), - description = stringProvider.getString("premium_feature_no_ads_description"), - color = 0xFF00BFA5 + PremiumFeatureType.CHAT_BOOST -> FeaturePresentation( + "premium_feature_chat_boost_title", + "premium_feature_chat_boost_description", + "heart", + 0xFFEC407A ) - PremiumFeatureType.INFINITE_REACTIONS -> PremiumComponent.PremiumFeature( - icon = "heart", - title = stringProvider.getString("premium_feature_infinite_reactions_title"), - description = stringProvider.getString("premium_feature_infinite_reactions_description"), - color = 0xFFFF6D66 + PremiumFeatureType.ACCENT_COLOR -> FeaturePresentation( + "premium_feature_accent_color_title", + "premium_feature_accent_color_description", + "settings", + 0xFF26A69A ) - PremiumFeatureType.BADGE -> PremiumComponent.PremiumFeature( - icon = "verified", - title = stringProvider.getString("premium_feature_badge_title"), - description = stringProvider.getString("premium_feature_badge_description"), - color = 0xFF24A1DE + PremiumFeatureType.BACKGROUND_FOR_BOTH -> FeaturePresentation( + "premium_feature_background_for_both_title", + "premium_feature_background_for_both_description", + "settings", + 0xFF42A5F5 ) - PremiumFeatureType.PROFILE_BADGE -> PremiumComponent.PremiumFeature( - icon = "face", - title = stringProvider.getString("premium_feature_emoji_status_title"), - description = stringProvider.getString("premium_feature_emoji_status_description"), - color = 0xFFF9AB00 + PremiumFeatureType.SAVED_MESSAGES_TAGS -> FeaturePresentation( + "premium_feature_saved_messages_tags_title", + "premium_feature_saved_messages_tags_description", + "folder", + 0xFF7E57C2 ) - PremiumFeatureType.APP_ICONS -> PremiumComponent.PremiumFeature( - icon = "settings", - title = stringProvider.getString("premium_feature_app_icons_title"), - description = stringProvider.getString("premium_feature_app_icons_description"), - color = 0xFF673AB7 + PremiumFeatureType.MESSAGE_PRIVACY -> FeaturePresentation( + "premium_feature_message_privacy_title", + "premium_feature_message_privacy_description", + "verified", + 0xFF26A69A ) + PremiumFeatureType.LAST_SEEN_TIMES -> FeaturePresentation( + "premium_feature_last_seen_times_title", + "premium_feature_last_seen_times_description", + "verified", + 0xFF42A5F5 + ) + + PremiumFeatureType.BUSINESS -> FeaturePresentation( + "premium_feature_business_title", + "premium_feature_business_description", + "settings", + 0xFF5C6BC0 + ) + + PremiumFeatureType.MESSAGE_EFFECTS -> FeaturePresentation( + "premium_feature_message_effects_title", + "premium_feature_message_effects_description", + "favorite", + 0xFFFF7043 + ) + + PremiumFeatureType.CHECKLISTS -> FeaturePresentation( + "premium_feature_checklists_title", + "premium_feature_checklists_description", + "settings", + 0xFF26A69A + ) + + PremiumFeatureType.PAID_MESSAGES -> FeaturePresentation( + "premium_feature_paid_messages_title", + "premium_feature_paid_messages_description", + "star", + 0xFFFFB300 + ) + + PremiumFeatureType.PROTECT_PRIVATE_CHAT_CONTENT -> FeaturePresentation( + "premium_feature_protect_private_chat_content_title", + "premium_feature_protect_private_chat_content_description", + "block", + 0xFFEF5350 + ) + + PremiumFeatureType.TEXT_COMPOSITION -> FeaturePresentation( + "premium_feature_text_composition_title", + "premium_feature_text_composition_description", + "translate", + 0xFF7E57C2 + ) + + PremiumFeatureType.RICH_MESSAGES -> FeaturePresentation( + "premium_feature_rich_messages_title", + "premium_feature_rich_messages_description", + "settings", + 0xFF42A5F5 + ) PremiumFeatureType.UNKNOWN -> null + PremiumFeatureType.DOUBLE_LIMITS -> null + } + + return presentation?.let { + PremiumComponent.PremiumFeature( + icon = it.icon, + title = stringProvider.getString(it.titleKey), + description = stringProvider.getString(it.descriptionKey), + color = it.color + ) + } ?: PremiumComponent.PremiumFeature( + icon = "star", + title = stringProvider.getString("premium_feature_unknown_title"), + description = stringProvider.getString( + "premium_feature_unknown_description", + feature.apiName + ), + color = 0xFFAF52DE + ) + } + + private fun mapToPremiumLimit(limit: PremiumLimitModel): PremiumComponent.PremiumLimit { + val titleKey = when (limit.type) { + PremiumLimitType.SUPERGROUP_COUNT -> "premium_limit_supergroup_count" + PremiumLimitType.PINNED_CHAT_COUNT -> "premium_limit_pinned_chat_count" + PremiumLimitType.CREATED_PUBLIC_CHAT_COUNT -> "premium_limit_created_public_chat_count" + PremiumLimitType.SAVED_ANIMATION_COUNT -> "premium_limit_saved_animation_count" + PremiumLimitType.FAVORITE_STICKER_COUNT -> "premium_limit_favorite_sticker_count" + PremiumLimitType.CHAT_FOLDER_COUNT -> "premium_limit_chat_folder_count" + PremiumLimitType.CHAT_FOLDER_CHOSEN_CHAT_COUNT -> "premium_limit_chat_folder_chosen_chat_count" + PremiumLimitType.PINNED_ARCHIVED_CHAT_COUNT -> "premium_limit_pinned_archived_chat_count" + PremiumLimitType.PINNED_SAVED_MESSAGES_TOPIC_COUNT -> "premium_limit_pinned_saved_messages_topic_count" + PremiumLimitType.MESSAGE_TEXT_LENGTH -> "premium_limit_message_text_length" + PremiumLimitType.CAPTION_LENGTH -> "premium_limit_caption_length" + PremiumLimitType.BIO_LENGTH -> "premium_limit_bio_length" + PremiumLimitType.CHAT_FOLDER_INVITE_LINK_COUNT -> "premium_limit_chat_folder_invite_link_count" + PremiumLimitType.SHAREABLE_CHAT_FOLDER_COUNT -> "premium_limit_shareable_chat_folder_count" + PremiumLimitType.ACTIVE_STORY_COUNT -> "premium_limit_active_story_count" + PremiumLimitType.WEEKLY_POSTED_STORY_COUNT -> "premium_limit_weekly_posted_story_count" + PremiumLimitType.MONTHLY_POSTED_STORY_COUNT -> "premium_limit_monthly_posted_story_count" + PremiumLimitType.STORY_CAPTION_LENGTH -> "premium_limit_story_caption_length" + PremiumLimitType.STORY_SUGGESTED_REACTION_AREA_COUNT -> "premium_limit_story_suggested_reaction_area_count" + PremiumLimitType.SIMILAR_CHAT_COUNT -> "premium_limit_similar_chat_count" + PremiumLimitType.OWNED_BOT_COUNT -> "premium_limit_owned_bot_count" + PremiumLimitType.CUSTOM_TEXT_COMPOSITION_STYLE_COUNT -> "premium_limit_custom_text_composition_style_count" + PremiumLimitType.UNKNOWN -> "premium_limit_unknown_title" } + return PremiumComponent.PremiumLimit( + title = stringProvider.getString(titleKey), + subtitle = limit.apiName.takeIf { limit.type == PremiumLimitType.UNKNOWN }, + defaultValue = limit.defaultValue, + premiumValue = limit.premiumValue + ) } override fun onBackClicked() { @@ -198,4 +403,14 @@ class DefaultPremiumComponent( } } } + + private data class FeaturePresentation( + val titleKey: String, + val descriptionKey: String, + val icon: String, + val color: Long + ) } + +private fun List.valueFor(type: PremiumLimitType): Int = + firstOrNull { it.type == type }?.premiumValue ?: 0 diff --git a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt index 6e6057641..2c3a9e2ac 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/premium/PremiumContent.kt @@ -16,8 +16,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack @@ -36,8 +38,10 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -51,12 +55,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -67,15 +68,15 @@ import androidx.compose.ui.unit.sp import com.arkivanov.decompose.extensions.compose.subscribeAsState import kotlinx.coroutines.launch import org.koin.compose.koinInject +import org.monogram.domain.models.PremiumPaymentOptionModel import org.monogram.domain.repository.TelegramLinkRepository import org.monogram.presentation.R -import org.monogram.presentation.core.ui.CollapsingToolbarScaffold import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile -import org.monogram.presentation.core.ui.TelegramStarInteractive -import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState -import org.monogram.presentation.core.util.ScrollStrategy +import java.math.BigDecimal +import java.text.NumberFormat +import java.util.Currency @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -84,15 +85,21 @@ fun PremiumContent(component: PremiumComponent) { val context = LocalContext.current val telegramLinkRepository: TelegramLinkRepository = koinInject() val scope = rememberCoroutineScope() - val states = rememberCollapsingToolbarScaffoldState() - val currentRadius = 28.dp * states.toolbarState.progress - val collapsedColor = MaterialTheme.colorScheme.surface - val expandedColor = MaterialTheme.colorScheme.primaryContainer - val dynamicContainerColor = lerp( - start = collapsedColor, - stop = expandedColor, - fraction = states.toolbarState.progress - ) + + fun openPremiumBot() { + scope.launch { + runCatching { + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse(telegramLinkRepository.buildUrl("PremiumBot")) + ) + context.startActivity(intent) + component.onSubscribeClicked() + } + } + } + + val firstPaymentOption = state.paymentOptions.firstOrNull() Scaffold( modifier = Modifier.semantics { contentDescription = "PremiumContent" }, @@ -100,9 +107,9 @@ fun PremiumContent(component: PremiumComponent) { TopAppBar( title = { Text( - stringResource(R.string.premium_title), + text = stringResource(R.string.premium_title), fontWeight = FontWeight.Bold, - fontSize = 20.sp + maxLines = 1 ) }, navigationIcon = { @@ -111,138 +118,134 @@ fun PremiumContent(component: PremiumComponent) { shapes = ExpressiveDefaults.iconButtonShapes() ) { Icon( - Icons.AutoMirrored.Rounded.ArrowBack, + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.premium_back) ) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = dynamicContainerColor, - scrolledContainerColor = dynamicContainerColor + containerColor = MaterialTheme.colorScheme.surface ) ) }, bottomBar = { if (!state.isPremium) { Surface( - color = MaterialTheme.colorScheme.background, - tonalElevation = 8.dp, - shadowElevation = 8.dp + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + shadowElevation = 6.dp ) { - Column(modifier = Modifier.navigationBarsPadding()) { - Button( - onClick = { - scope.launch { - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse(telegramLinkRepository.buildUrl("PremiumBot")) - ) - context.startActivity(intent) - component.onSubscribeClicked() - } - }, - shapes = ExpressiveDefaults.largeButtonShapes(), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .height(ButtonDefaults.MediumContainerHeight), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFAF52DE) - ) - ) { - Text( - stringResource(R.string.premium_subscribe_button, "$4.99"), - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - } + Button( + onClick = ::openPremiumBot, + shapes = ExpressiveDefaults.largeButtonShapes(), + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp) + .height(ButtonDefaults.MediumContainerHeight), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFAF52DE) + ) + ) { + Text( + text = firstPaymentOption?.let { option -> + stringResource( + R.string.premium_subscribe_button_format, + formatPremiumAmount(option) + ) + } ?: stringResource(R.string.premium_subscribe_button_fallback), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) } } } } ) { padding -> - CollapsingToolbarScaffold( + Box( modifier = Modifier .fillMaxSize() - .background(dynamicContainerColor) - .padding(top = padding.calculateTopPadding()), - state = states, - scrollStrategy = ScrollStrategy.ExitUntilCollapsed, - toolbar = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(0.dp) - .pin() - .background(dynamicContainerColor) + .padding(top = padding.calculateTopPadding()) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 760.dp) + .align(Alignment.TopCenter), + contentPadding = PaddingValues( + bottom = padding.calculateBottomPadding() + 24.dp ) + ) { + item { + PremiumHero( + isPremium = state.isPremium, + statusText = state.statusText + ) + } - Column( - modifier = Modifier - .fillMaxWidth() - .road(Alignment.Center, Alignment.BottomCenter) - .padding(top = 16.dp, bottom = 48.dp) - .alpha(states.toolbarState.progress), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box(modifier = Modifier - .fillMaxWidth() - .height(270.dp)) { - TelegramStarInteractive( - resId = R.raw.star, - alpha = states.toolbarState.progress - ) + if (state.isLoading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Color(0xFFAF52DE)) + } } } - } - ) { - Card( - modifier = Modifier.fillMaxSize(), - shape = RoundedCornerShape(topStart = currentRadius, topEnd = currentRadius), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.background - ) - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 24.dp, bottom = padding.calculateBottomPadding()) - ) { + + if (!state.isPremium && state.paymentOptions.isNotEmpty()) { + item { + PremiumSectionTitle(R.string.premium_plans_title) + } + itemsIndexed( + items = state.paymentOptions, + key = { index, option -> "${option.storeProductId}-$index" } + ) { _, option -> + PremiumPlanItem(option) + } + item { Spacer(modifier = Modifier.height(12.dp)) } + } + + if (state.features.isNotEmpty()) { item { - PremiumStatusCard( - isPremium = state.isPremium, - statusText = state.statusText, - onClick = { - scope.launch { - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse(telegramLinkRepository.buildUrl("PremiumBot")) - ) - context.startActivity(intent) - } - } + PremiumSectionTitle(R.string.premium_features_section) + } + itemsIndexed( + items = state.features, + key = { index, feature -> "${feature.title}-$index" } + ) { index, feature -> + PremiumFeatureItem( + feature = feature, + showDivider = index < state.features.lastIndex ) - Spacer(modifier = Modifier.height(24.dp)) } + } - items(state.features) { feature -> - PremiumFeatureItem(feature) + if (state.limits.isNotEmpty()) { + item { + PremiumSectionTitle(R.string.premium_limits_section) } + item { + PremiumLimitsCard(state.limits) + } + } - if (state.isPremium) { - item { - Spacer(modifier = Modifier.height(12.dp)) - Box(modifier = Modifier.padding(horizontal = 16.dp)) { - SettingsSwitchTile( - icon = Icons.Rounded.Campaign, - title = stringResource(R.string.premium_show_sponsored_messages_title), - subtitle = stringResource(R.string.premium_show_sponsored_messages_subtitle), - checked = state.showSponsoredMessagesForPremium, - iconColor = Color(0xFF00BFA5), - position = ItemPosition.STANDALONE, - onCheckedChange = component::onShowSponsoredMessagesChanged - ) - } - Spacer(modifier = Modifier.height(24.dp)) + if (state.isPremium) { + item { + Spacer(modifier = Modifier.height(20.dp)) + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + SettingsSwitchTile( + icon = Icons.Rounded.Campaign, + title = stringResource(R.string.premium_show_sponsored_messages_title), + subtitle = stringResource(R.string.premium_show_sponsored_messages_subtitle), + checked = state.showSponsoredMessagesForPremium, + iconColor = Color(0xFF00BFA5), + position = ItemPosition.STANDALONE, + onCheckedChange = component::onShowSponsoredMessagesChanged + ) } } } @@ -252,20 +255,78 @@ fun PremiumContent(component: PremiumComponent) { } @Composable -fun PremiumStatusCard( +private fun PremiumHero( isPremium: Boolean, - statusText: String?, - onClick: () -> Unit + statusText: String? ) { + val subtitle = if (isPremium) { + statusText ?: stringResource(R.string.premium_status_active) + } else { + stringResource(R.string.premium_unlock_features) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(112.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = null, + tint = Color(0xFFFFC107), + modifier = Modifier.size(76.dp) + ) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = stringResource(R.string.premium_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = subtitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@Composable +private fun PremiumSectionTitle(resourceId: Int) { + Text( + text = stringResource(resourceId), + modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 24.dp, bottom = 8.dp), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) +} + +@Composable +private fun PremiumPlanItem(option: PremiumPaymentOptionModel) { Card( - shape = RoundedCornerShape(24.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - onClick = onClick, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp, vertical = 4.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) ) { Row( modifier = Modifier @@ -273,86 +334,198 @@ fun PremiumStatusCard( .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - Column( + Box( modifier = Modifier - .weight(1f) - .padding(start = 8.dp), - verticalArrangement = Arrangement.Center + .size(40.dp) + .background( + color = Color(0xFFAF52DE).copy(alpha = 0.14f), + shape = CircleShape + ), + contentAlignment = Alignment.Center ) { - if (isPremium && statusText != null) { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = null, + tint = Color(0xFFAF52DE), + modifier = Modifier.size(22.dp) + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = formatPremiumAmount(option), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = pluralStringResource( + R.plurals.premium_plan_months, + option.monthCount.coerceAtLeast(1), + option.monthCount.coerceAtLeast(1) + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (option.discountPercentage > 0) { + Text( + text = stringResource( + R.string.premium_plan_discount, + option.discountPercentage + ), + color = Color(0xFF168A4A), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + if (option.isCurrent) { Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - lineHeight = 20.sp + text = stringResource(R.string.premium_plan_current), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium ) - } else if (!isPremium) { + } else if (option.isUpgrade) { Text( - text = stringResource(R.string.premium_unlock_features), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = stringResource(R.string.premium_plan_upgrade), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium ) } } - } } } @Composable -fun PremiumFeatureItem(feature: PremiumComponent.PremiumFeature) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.Top - ) { - Box( +private fun PremiumFeatureItem( + feature: PremiumComponent.PremiumFeature, + showDivider: Boolean +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Color(feature.color).copy(alpha = 0.1f)), - contentAlignment = Alignment.Center + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.Top ) { - Icon( - imageVector = getIconForName(feature.icon), - contentDescription = null, - tint = Color(feature.color), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .size(40.dp) + .background( + color = Color(feature.color).copy(alpha = 0.12f), + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = getIconForName(feature.icon), + contentDescription = null, + tint = Color(feature.color), + modifier = Modifier.size(22.dp) + ) + } - Column { - Text( - text = feature.title, - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - Text( - text = feature.description, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text( + text = feature.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = feature.description, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } + } + if (showDivider) { + HorizontalDivider(modifier = Modifier.padding(start = 76.dp)) } } } -fun getIconForName(name: String): ImageVector { - return when (name) { - "star" -> Icons.Rounded.Star - "mic" -> Icons.Rounded.Mic - "download" -> Icons.Rounded.Download - "translate" -> Icons.Rounded.Translate - "face" -> Icons.Rounded.Face - "folder" -> Icons.Rounded.Folder - "block" -> Icons.Rounded.Block - "heart" -> Icons.Rounded.Favorite - "verified" -> Icons.Rounded.Verified - "settings" -> Icons.Rounded.Settings - else -> Icons.Rounded.Star +@Composable +private fun PremiumLimitsCard(limits: List) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + limits.forEachIndexed { index, limit -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 13.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = limit.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + limit.subtitle?.let { subtitle -> + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource( + R.string.premium_limit_value_format, + limit.defaultValue, + limit.premiumValue + ), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } + if (index < limits.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + } + } } } +fun getIconForName(name: String) = when (name) { + "mic" -> Icons.Rounded.Mic + "download" -> Icons.Rounded.Download + "translate" -> Icons.Rounded.Translate + "face" -> Icons.Rounded.Face + "folder" -> Icons.Rounded.Folder + "block" -> Icons.Rounded.Block + "heart", "favorite" -> Icons.Rounded.Favorite + "verified" -> Icons.Rounded.Verified + "settings" -> Icons.Rounded.Settings + else -> Icons.Rounded.Star +} + +private fun formatPremiumAmount(option: PremiumPaymentOptionModel): String { + val currency = runCatching { Currency.getInstance(option.currency) }.getOrNull() + ?: return "${option.amount} ${option.currency}" + val fractionDigits = currency.defaultFractionDigits.coerceAtLeast(0) + val value = BigDecimal.valueOf(option.amount).movePointLeft(fractionDigits) + return NumberFormat.getCurrencyInstance().apply { + this.currency = currency + }.format(value) +} diff --git a/presentation/src/main/res/raw/star.gltf b/presentation/src/main/res/raw/star.gltf deleted file mode 100644 index d1d236e36..000000000 --- a/presentation/src/main/res/raw/star.gltf +++ /dev/null @@ -1 +0,0 @@ -{"accessors" : [{"bufferView" : 0, "byteOffset" : 0, "componentType" : 5123, "count" : 4338, "type" : "SCALAR", "max" : [1077], "min" : [0]}, {"bufferView" : 1, "byteOffset" : 0, "componentType" : 5126, "count" : 1078, "type" : "VEC3", "max" : [32.547000885009766, 31.11210060119629, 6.589240074157715], "min" : [-32.547000885009766, -31.11210060119629, -6.589240074157715], "name" : "POSITION"}, {"bufferView" : 1, "byteOffset" : 12, "componentType" : 5126, "count" : 1078, "type" : "VEC3", "max" : [0, 1, 0], "min" : [0, 1, 0], "name" : "NORMAL"}, {"bufferView" : 1, "byteOffset" : 24, "componentType" : 5126, "count" : 1078, "type" : "VEC2", "max" : [0, 1], "min" : [0, 1], "name" : "TEXCOORD_0"}], "asset" : {"generator" : "Aspose.3D 25.11.0", "version" : "2.0"}, "buffers" : [{"uri" : "data:application/octet-stream;base64,AAABAAIAAwAEAAUABgAHAAgACQAKAAsACQALAAwACwANAA4ACwAOAA8ADAALAA8ADAAPABAACQAMABEADAAQABEADgANABIADgASABMADwAOABMADwATABQAEAAPABQAEAAUABUAEQAQABUAEQAVABYAEwASABcAEwAXABgAFAATABgAFAAYABkAFQAUABkAFQAZABoAFgAVABoAFgAaABsAHAAdAB4AHAAeAB8AFwAgACEAFwAhACIAGAAXACIAGAAiACMAGQAYACMAGQAjACQAGgAZACQAGgAkACUAGwAaACUAGwAlACYAJwAbACYAJwAmACgAKQAcAB8AKQAfACoAKgAfACsAHwAeACsALAApACoALAAqAC0ALgAsAC0ALgAtAC8AIQAuAC8AIQAvADAAIgAhADAAIgAwADEAIwAiADEAIwAxADIAJAAjADIAJAAyADMAJQAkADMAJQAzADQANAAzADUANAA1ADYAJgAlADQAJgA0ADcANwA0ADYANwA2ADgAKAAmADcAKAA3ADkAOQA3ADgAOQA4ADoAOwAoADkAOwA5ADwAPAA5ADoAPAA6AD0APgA7ADwAPgA8AD8APwA8AD0APwA9AEAAQQA+AD8AQQA/AEIAQwBEAEUAQwBFAEYARABBAEIARABCAEUAQgA/AEAAQgBAAEcARgBFAEgARQBCAEcARQBHAEgALQAqACsALQArAEkASQArAEoALwAtAEkALwBJAEsAMAAvAEsAMABLAEwAMQAwAEwAMQBMAE0AMgAxAE0AMgBNAE4ASwBJAEoASwBKAE8ATABLAE8ATABPAFAATQBMAFAATQBQAFEAUABPAFIAUABSAFMAUQBQAFMAUQBTAFQAVQBRAFQAVQBUAFYAUwBSAFcAUwBXAFgAVABTAFgAVABYAFkAVgBUAFkAVgBZAFoAWwBWAFoAWwBaAFwAXQBbAFwAXQBcAF4AXQBeAF8AXQBfAGAAWABXAGEAWABhAGIAWQBYAGIAWQBiAGMAWgBZAGMAWgBjAGQAXABaAGQAXABkAGUAXgBcAGUAXgBlAGYAXwBeAGYAXwBmAGcAYABfAGcAYABnAGgAYgBhAGkAYgBpAGoAYwBiAGoAYwBqAGsAZQBkAGwAZQBsAG0AZABjAGsAZABrAGwAbABrAG4AbABuAG8AZgBlAG0AZgBtAHAAZwBmAHAAZwBwAHEAcQBwAHIAcQByAHMAaABnAHEAaABxAHQAdQBoAHQAdQB0AHYAaQB3AHgAaQB4AHkAawBqAHoAawB6AG4AagBpAHkAagB5AHoAeQB4AHsAeQB7AHwAbwBuAH0AbgB6AH0AegB5AHwAegB8AH0AdABxAHMAdABzAH4AfgBzAH8AcwByAH8AdgB0AH4AdgB+AIAAgQB2AIAAgQCAAIIAgAB+AH8AgAB/AIMAggCAAIMAggCDAIQAcgBwAIUAhACDAIYAgwB/AIYAfwByAIUAfwCFAIYAhgCHAIgAhgCIAIQAhACIAIkAhACJAIoAhACKAIIAgQCCAIoAdgCBAIsAdgCLAHUAgQCKAIsAdQCLAIwAdQCMAGAAdQBgAGgAVgBbAI0AVgCNAFUAWwBdAI0AYACMAI0AYACNAF0AjQCOAFEAjQBRAFUAUQCOAI8AUQCPAE0AMgBOAJAATQCPAJAATQCQAE4ANgA1AJEANQAzAJEAMwAyAJAAMwCQAJEAPQA6AJIAOgA4AJIAOAA2AJEAOACRAJIARwBAAJMAPQCSAJMAPQCTAEAASABHAJMASACTAJQASACUAJUASACVAEYARgCVAJYARgCWAEMAlgCXAJgAlgCYAEMAQQBEAJgARABDAJgAOwA+AJkAPgBBAJgAPgCYAJkAOwCZACcAOwAnACgAJwCZAJoAJwCaAJsAEQAWAJsAJwCbABYAJwAWABsACgAJAJwACQARAJwAEQCbAJwAnACdAJ4AnACeAAoACwAKAJ8ACwCfAA0ACgCeAJ8AEgANAJ8AEgCfAKAAEgCgAKEAEgChABcALgAhACAALgAgAKIAFwChAKIAFwCiACAALAAuAKIAHAApAKMALACiAKMALACjACkAHACjAKQAHACkAB0AHQCkAKUAHgAdAKUAHgClAKYAUgBPAEoAUgBKAKcASgArAKcAKwAeAKYAKwCmAKcAUgCnAKgAaQBhAKkAaQCpAHcAYQBXAKkAVwBSAKgAVwCoAKkAewB4AKoAeAB3AKoAdwCpAKoAqgCrAKwAqgCsAHsAfQB8AK0AfAB7AKwAfACsAK0AbQBsAG8AbQBvAK4AfQCtAK4AfQCuAG8AcABtAK8AcACvAIUAbQCuAK8AsACxALIAsACyALMAswCyALQAswC0ALUAtgC3ALgAtgC4ALkAugC7ALwAugC8AL0AvQC8AL4AvQC+AL8AvwC+AMAAvwDAAMEAwgDDAMQAwgDEAMUAxgDHAMgAxgDIAMkAyQDIAMoAyQDKAMsAzADNAM4AzADOAM8A0ADRANIA0ADSANMA0wDSANQA0wDUANUA1QDUANYA1QDWANcA2ADZANoA2ADaANsA3ADdAN4A3ADeAN8A3wDeAOAA3wDgAOEA4gDjAOQA4gDkAOUA5gDnAOgA5gDoAOkA6QDoAOoA6QDqAOsA6wDqAOwA6wDsAO0A7gDvAPAA7gDwAPEA8gDzAPQA8gD0APUA9QD0APYA9QD2APcA+AD5APoA+AD6APsA/AD9AP4A/AD+AP8A/wD+AAAB/wAAAQEBAQEAAQIBAQECAQMBBAEFAQYBBAEGAQcBCAEJAQoBCAEKAQsBCwEKAQwBCwEMAQ0BDgEPARABDgEQAREBEgETARQBEgEUARUBFQEUARYBFQEWARcBFwEWARgBFwEYARkBGQEYARoBGQEaARsBHAEdAR4BHAEeAR8BIAEhASIBIAEiASMBIwEiASQBIwEkASUBJgEnASgBJgEoASkBKgErASwBKgEsAS0BLQEsAS4BLQEuAS8BLwEuATABLwEwATEBMgEzATQBMgE0ATUBNgE3ATgBNgE4ATkBOgE7ATwBOgE8AT0BOwE+AT8BOwE/ATwBPgFAAUEBPgFBAT8BQgFDAUQBQgFEAUUBRgFHAUgBRgFIAUkBRwFKAUsBRwFLAUgBTAFNAU4BTAFOAU8BUAFRAVIBUAFSAVMBUQFUAVUBUQFVAVIBVAFWAVcBVAFXAVUBVgFYAVkBVgFZAVcBWgFbAVwBWgFcAV0BXgFfAWABXgFgAWEBXwFiAWMBXwFjAWABZAFlAWYBZAFmAWcBaAFpAWoBaAFqAWsBaQFsAW0BaQFtAWoBbAFuAW8BbAFvAW0BcAFxAXIBcAFyAXMBdAF1AXYBdAF2AXcBdQF4AXkBdQF5AXYBegF7AXwBegF8AX0BfgF/AYABfgGAAYEBfwGCAYMBfwGDAYABggGEAYUBggGFAYMBhgGHAYgBhgGIAYkBigGLAYwBigGMAY0BiwGOAY8BiwGPAYwBkAGRAZIBkAGSAZMBlAGVAZYBlAGWAZcBlQGYAZkBlQGZAZYBmAGaAZsBmAGbAZkBnAGdAZ4BnAGeAZ8BoAGhAaIBoAGiAaMBoQGkAaUBoQGlAaIBpgGnAagBpgGoAakBqgGrAawBqgGsAa0BqwGuAa8BqwGvAawBrgGwAbEBrgGxAa8BsgGzAbQBsgG0AbUBtgG3AbgBtgG4AbkBtwG6AbsBtwG7AbgBvAG9ATcBvAE3ATYBvgG/ATsBvgE7AToBvwHAAT4BvwE+ATsBwAHBAUABwAFAAT4BwgHDAUMBwgFDAUIBxAHFAUcBxAFHAUYBxQHGAUoBxQFKAUcBxwHIAU0BxwFNAUwByQHKAVEByQFRAVABygHLAVQBygFUAVEBywHMAVYBywFWAVQBzAHNAVgBzAFYAVYBzgHPAVsBzgFbAVoB0AHRAV8B0AFfAV4B0QHSAWIB0QFiAV8B0wHUAWUB0wFlAWQB1QHWAWkB1QFpAWgB1gHXAWwB1gFsAWkB1wHYAW4B1wFuAWwB2QHaAXEB2QFxAXAB2wHcAXUB2wF1AXQB3AHdAXgB3AF4AXUB3gHfAXsB3gF7AXoB4AHhAX8B4AF/AX4B4QHiAYIB4QGCAX8B4gHjAYQB4gGEAYIB5AHlAYcB5AGHAYYB5gHnAYsB5gGLAYoB5wHoAY4B5wGOAYsB6QHqAZEB6QGRAZAB6wHsAZUB6wGVAZQB7AHtAZgB7AGYAZUB7QHuAZoB7QGaAZgB7wHwAZ0B7wGdAZwB8QHyAaEB8QGhAaAB8gHzAaQB8gGkAaEB9AH1AacB9AGnAaYB9gH3AasB9gGrAaoB9wH4Aa4B9wGuAasB+AH5AbAB+AGwAa4B+gH7AbMB+gGzAbIB/AH9AbcB/AG3AbYB/QH+AboB/QG6AbcB/wEAAr0B/wG9AbwBAQICAr8BAQK/Ab4BAgIDAsABAgLAAb8BAwIEAsEBAwLBAcABBQIGAsMBBQLDAcIBBwIIAsUBBwLFAcQBCAIJAsYBCALGAcUBCgILAsgBCgLIAccBDAINAsoBDALKAckBDQIOAssBDQLLAcoBDgIPAswBDgLMAcsBDwIQAs0BDwLNAcwBEQISAs8BEQLPAc4BEwIUAtEBEwLRAdABFAIVAtIBFALSAdEBFgIXAtQBFgLUAdMBGAIZAtYBGALWAdUBGQIaAtcBGQLXAdYBGgIbAtgBGgLYAdcBHAIdAtoBHALaAdkBHgIfAtwBHgLcAdsBHwIgAt0BHwLdAdwBIQIiAt8BIQLfAd4BIwIkAuEBIwLhAeABJAIlAuIBJALiAeEBJQImAuMBJQLjAeIBJwIoAuUBJwLlAeQBKQIqAucBKQLnAeYBKgIrAugBKgLoAecBLAItAuoBLALqAekBLgIvAuwBLgLsAesBLwIwAu0BLwLtAewBMAIxAu4BMALuAe0BMgIzAvABMgLwAe8BNAI1AvIBNALyAfEBNQI2AvMBNQLzAfIBNwI4AvUBNwL1AfQBOQI6AvcBOQL3AfYBOgI7AvgBOgL4AfcBOwI8AvkBOwL5AfgBPQI+AvsBPQL7AfoBPwJAAv0BPwL9AfwBQAJBAv4BQAL+Af0BQgJDAgACQgIAAv8BRAJFAgICRAICAgECRQJGAgMCRQIDAgICRgJHAgQCRgIEAgMCSAJJAgYCSAIGAgUCSgJLAggCSgIIAgcCSwJMAgkCSwIJAggCTQJOAgsCTQILAgoCTwJQAg0CTwINAgwCUAJRAg4CUAIOAg0CUQJSAg8CUQIPAg4CUgJTAhACUgIQAg8CVAJVAhICVAISAhECVgJXAhQCVgIUAhMCVwJYAhUCVwIVAhQCWQJaAhcCWQIXAhYCWwJcAhkCWwIZAhgCXAJdAhoCXAIaAhkCXQJeAhsCXQIbAhoCXwJgAh0CXwIdAhwCYQJiAh8CYQIfAh4CYgJjAiACYgIgAh8CZAJlAiICZAIiAiECZgJnAiQCZgIkAiMCZwJoAiUCZwIlAiQCaAJpAiYCaAImAiUCagJrAigCagIoAicCbAJtAioCbAIqAikCbQJuAisCbQIrAioCbwJwAi0CbwItAiwCcQJyAi8CcQIvAi4CcgJzAjACcgIwAi8CcwJ0AjECcwIxAjACdQJ2AjMCdQIzAjICdwJ4AjUCdwI1AjQCeAJ5AjYCeAI2AjUCegJ7AjgCegI4AjcCfAJ9AjoCfAI6AjkCfQJ+AjsCfQI7AjoCfgJ/AjwCfgI8AjsCgAKBAj4CgAI+Aj0CggKDAkACggJAAj8CgwKEAkECgwJBAkAChQKGAkMChQJDAkIChwKIAkUChwJFAkQCiAKJAkYCiAJGAkUCiQKKAkcCiQJHAkYCiwKMAkkCiwJJAkgCjQKOAksCjQJLAkoCjgKPAkwCjgJMAksCkAKRAk4CkAJOAk0CkgKTAlACkgJQAk8CkwKUAlECkwJRAlAClAKVAlIClAJSAlEClQKWAlMClQJTAlIClwKYAlUClwJVAlQCmQKaAlcCmQJXAlYCmgKbAlgCmgJYAlcCnAKdAloCnAJaAlkCngKfAlwCngJcAlsCnwKgAl0CnwJdAlwCoAKhAl4CoAJeAl0CogKjAmACogJgAl8CpAKlAmICpAJiAmECpQKmAmMCpQJjAmICpwKoAmUCpwJlAmQCqQKqAmcCqQJnAmYCqgKrAmgCqgJoAmcCqwKsAmkCqwJpAmgCrQKuAmsCrQJrAmoCrwKwAm0CrwJtAmwCsAKxAm4CsAJuAm0CsgKzAnACsgJwAm8CtAK1AnICtAJyAnECtQK2AnMCtQJzAnICtgK3AnQCtgJ0AnMCuAK5AnYCuAJ2AnUCugK7AngCugJ4AncCuwK8AnkCuwJ5AngCvQK+AnsCvQJ7AnoCvwLAAn0CvwJ9AnwCwALBAn4CwAJ+An0CwQLCAn8CwQJ/An4CwwLEAoECwwKBAoACxQLGAoMCxQKDAoICxgLHAoQCxgKEAoMCyALJAsoCyALKAssCzALNAskCzALJAs4CyALOAskCzALPAs0C0ALRAs0C0ALNAtICzwLSAs0C0gLTAtAC0gLUAtUC0gLVAtMC1gLXAtUC1ALWAtUC2ALZAtcC2gLYAtcC2gLXAtYC2wLcAt0C2wLdAtkC2ALeAtsC2ALbAtkC3wLgAt0C3wLdAuEC4gLhAt0C4gLdAuMC3ALjAt0C5ALgAt8C5ALfAuUC5gLkAuUC5gLlAucC5wLoAuYC6QLqAusC7ALtAugC7ALoAu4C6gLuAugC6wLqAugC7wLwAu0C7ALvAu0C8ALxAvIC8ALyAu0C8wL0AvIC8wLyAvUC8QL1AvIC8wL2AvcC8wL3AvQC+AL3AvYC+AL2AvkC+gL7AvgC+gL4AvkC/AL9AvoC/AL6Av4C/wL+AvoC+QL/AvoCBwAGAP0CAAMBAwcAAAMHAP0C/AIAA/0CCAACAwYAAwMEAwIDBQMDAwIDCAAFAwIDBAAGAwQDBAAEAwUABwMFAAQDAwMHAwQDCAMDAAUACAMAAAMAAgAJAwAACgMBAAAACgMAAAgDAgALAwwDAgAMAwkDDQMOAw8DDQMPAwwDEAMNAwwDCwMRAxADCwMQAwwDEgMTAxQDEgMUAw8DDgMSAw8DEwMVAxQDFgMXAxUDFgMVAxMDGAMZAxcDGAMXAxYDGAMaAxkDGwMcAxkDGgMdAxsDGgMbAxkDGwMeAx8DGwMfAxwDIAMhAyIDIwMkAx8DIwMfAyUDHgMlAx8DJgMnAygDJgMoAykDKgMmAykDJAMqAykDJwMrAygDLAMtAysDLAMrAycDLgMvAy0DLgMtAywDywIvAy4DywIuAzADywLKAi8DMQMyAwUAMQMFAAcDAwMxAwcDMgMKAwgDMgMIAwUAMwM0AzIDMwMyAzEDAwMzAzEDNAM1AwoDNAMKAzIDNQMBAAoDNgM3AzQDNgM0AzMDBQM2AzMDBQMzAwMDNwM4AzUDNwM1AzQDOAMBADUDCAA5AzYDCAA2AwUDOQM6AzcDOQM3AzYDOgM7AzgDOgM4AzcDOwMCAAEAOwMBADgDPAM9AzkDPAM5AwgAAQM8AwgAAQMIAAcAPQM+AzoDPQM6AzkDPgM/AzsDPgM7AzoDPwNAAwIAPwMCADsDQAMRAwsDQAMLAwIAQQNCAzwDQQM8AwEDQwNBAwEDQwMBAwADQgNEAz0DQgM9AzwDRAPpAj4DRAM+Az0D6QJFAz8D6QI/Az4DRQNGA0ADRQNAAz8DRgNHAxEDRgMRA0ADRwNIAxADRwMQAxED9gJJA/8C9gL/AvkCSQNKA/4CSQP+Av8C8wJJA/YC8wL1AkoD8wJKA0kDSgNLA/wCSgP8Av4C9QLxAksD9QJLA0oDSwNDAwADSwMAA/wC8QLwAkMD8QJDA0sD8ALvAkED8AJBA0MD7wLsAkID7wJCA0ED7ALuAkQD7AJEA0ID7gLqAukC7gLpAkQD5wLlAkYD5wJGA0UD5QJMA0cD5QJHA0YDTANNA0gDTANIA0cDSANOAw0DSAMNAxADTQNPA04DTQNOA0gDTgNQAw4DTgMOAw0DTwMYA1ADTwNQA04DUANRAxIDUAMSAw4DGANRA1ADUQMWAxMDUQMTAxIDGAMWA1ED3wJSA0wD3wJMA+UCUgMdA00DUgNNA0wDHQMaA08DHQNPA00DGgMYA08D4gJTA98C4gLfAuECUwNUA1IDUwNSA98CVAMbAx0DVAMdA1IDVQNWA1MDVQNTA+ICVwNVA+ICVwPiAuMCVgNYA1QDVgNUA1MDWAMeAxsDWAMbA1QDWQNXA+MCWQPjAtwCWgNZA9wC2wJaA9wCWwNcA1cDWwNXA1kDXQNbA1kDXQNZA1oDXANeA1UDXANVA1cDXgNfA1YDXgNWA1UDXwNgA1gDXwNYA1YDYAMlAx4DYAMeA1gD3gJdA1oD3gJaA9sCYQNiA10DYQNdA94C2gJhA94C2gLeAtgCYgPIAlsDYgNbA10DyALLAlwDyAJcA1sDywIwA14DywJeA1wDMANjA18DMANfA14DYwNkA2ADYwNgA18DZAMjAyUDZAMlA2ADZQNmA2EDZQNhA9oC1AJlA9oC1ALaAtYCZgNnA2IDZgNiA2EDZwPOAsgCZwPIAmIDLgNoA2MDLgNjAzADaANpA2QDaANkA2MDaQNqAyMDaQMjA2QDagMqAyQDagMkAyMD0gLPAmUD0gJlA9QCzwLMAmYDzwJmA2UDzAJnA2YDzALOAmcDawNoAy4DLANrAy4DawNpA2gDawNsA2oDawNqA2kDbAMmAyoDbAMqA2oDLANsA2sDLAMnAyYDLAMmA2wDOAFtA24DOAFuAzkBPAFvA3ADPAFwAz0BPwFxA28DPwFvAzwBQQFyA3EDQQFxAz8BRAFzA3QDRAF0A0UBSAF1A3YDSAF2A0kBSwF3A3UDSwF1A0gBTgF4A3kDTgF5A08BUgF6A3sDUgF7A1MBVQF8A3oDVQF6A1IBVwF9A3wDVwF8A1UBWQF+A30DWQF9A1cBXAF/A4ADXAGAA10BYAGBA4IDYAGCA2EBYwGDA4EDYwGBA2ABZgGEA4UDZgGFA2cBagGGA4cDagGHA2sBbQGIA4YDbQGGA2oBbwGJA4gDbwGIA20BcgGKA4sDcgGLA3MBdgGMA40DdgGNA3cBeQGOA4wDeQGMA3YBfAGPA5ADfAGQA30BgAGRA5IDgAGSA4EBgwGTA5EDgwGRA4ABhQGUA5MDhQGTA4MBiAGVA5YDiAGWA4kBjAGXA5gDjAGYA40BjwGZA5cDjwGXA4wBkgGaA5sDkgGbA5MBlgGcA50DlgGdA5cBmQGeA5wDmQGcA5YBmwGfA54DmwGeA5kBngGgA6EDngGhA58BogGiA6MDogGjA6MBpQGkA6IDpQGiA6IBqAGlA6YDqAGmA6kBrAGnA6gDrAGoA60BrwGpA6cDrwGnA6wBsQGqA6kDsQGpA68BtAGrA6wDtAGsA7UBuAGtA64DuAGuA7kBuwGvA60DuwGtA7gBbQOwA7EDbQOxA24DbwOyA7MDbwOzA3ADcQO0A7IDcQOyA28DcgO1A7QDcgO0A3EDcwO2A7cDcwO3A3QDdQO4A7kDdQO5A3YDdwO6A7gDdwO4A3UDeAO7A7wDeAO8A3kDegO9A74DegO+A3sDfAO/A70DfAO9A3oDfQPAA78DfQO/A3wDfgPBA8ADfgPAA30DfwPCA8MDfwPDA4ADgQPEA8UDgQPFA4IDgwPGA8QDgwPEA4EDhAPHA8gDhAPIA4UDhgPJA8oDhgPKA4cDiAPLA8kDiAPJA4YDiQPMA8sDiQPLA4gDigPNA84DigPOA4sDjAPPA9ADjAPQA40DjgPRA88DjgPPA4wDjwPSA9MDjwPTA5ADkQPUA9UDkQPVA5IDkwPWA9QDkwPUA5EDlAPXA9YDlAPWA5MDlQPYA9kDlQPZA5YDlwPaA9sDlwPbA5gDmQPcA9oDmQPaA5cDmgPdA94DmgPeA5sDnAPfA+ADnAPgA50DngPhA98DngPfA5wDnwPiA+EDnwPhA54DoAPjA+QDoAPkA6EDogPlA+YDogPmA6MDpAPnA+UDpAPlA6IDpQPoA+kDpQPpA6YDpwPqA+sDpwPrA6gDqQPsA+oDqQPqA6cDqgPtA+wDqgPsA6kDqwPuA+8DqwPvA6wDrQPwA/EDrQPxA64DrwPyA/ADrwPwA60DsAPzA/QDsAP0A7EDsgP1A/YDsgP2A7MDtAP3A/UDtAP1A7IDtQP4A/cDtQP3A7QDtgP5A/oDtgP6A7cDuAP7A/wDuAP8A7kDugP9A/sDugP7A7gDuwP+A/8DuwP/A7wDvQMABAEEvQMBBL4DvwMCBAAEvwMABL0DwAMDBAIEwAMCBL8DwQMEBAMEwQMDBMADwgMFBAYEwgMGBMMDxAMHBAgExAMIBMUDxgMJBAcExgMHBMQDxwMKBAsExwMLBMgDyQMMBA0EyQMNBMoDywMOBAwEywMMBMkDzAMPBA4EzAMOBMsDzQMQBBEEzQMRBM4DzwMSBBMEzwMTBNAD0QMUBBIE0QMSBM8D0gMVBBYE0gMWBNMD1AMXBBgE1AMYBNUD1gMZBBcE1gMXBNQD1wMaBBkE1wMZBNYD2AMbBBwE2AMcBNkD2gMdBB4E2gMeBNsD3AMfBB0E3AMdBNoD3QMgBCEE3QMhBN4D3wMiBCME3wMjBOAD4QMkBCIE4QMiBN8D4gMlBCQE4gMkBOED4wMmBCcE4wMnBOQD5QMoBCkE5QMpBOYD5wMqBCgE5wMoBOUD6AMrBCwE6AMsBOkD6gMtBC4E6gMuBOsD7AMvBC0E7AMtBOoD7QMwBC8E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", "byteLength" : 43180}], "bufferViews" : [{"buffer" : 0, "byteOffset" : 0, "byteLength" : 8676, "target" : 34963}, {"buffer" : 0, "byteOffset" : 8680, "byteLength" : 34496, "byteStride" : 32, "target" : 34962}], "images" : [{"uri" : "../Downloads/shine.png"}, {"uri" : "../Downloads/texture.jpg"}], "materials" : [{"name" : "Mat", "pbrMetallicRoughness" : {"baseColorFactor" : [1, 1, 1, 1], "baseColorTexture" : {"index" : 1, "texCoord" : 0}, "metallicFactor" : 1, "roughnessFactor" : 1}, "emissiveTexture" : {"index" : 0, "texCoord" : 0}, "emissiveFactor" : [0, 0, 0], "alphaMode" : "OPAQUE", "doubleSided" : false}], "meshes" : [{"primitives" : [{"attributes" : {"POSITION" : 1, "NORMAL" : 2, "TEXCOORD_0" : 3}, "indices" : 0, "material" : 0, "mode" : 4}], "name" : ""}], "nodes" : [{"matrix" : [0.1, 0, 0, 0, 0, 0.1, 0, 0, 0, 0, 0.1, 0, 0, 0, 0, 1], "mesh" : 0, "name" : "star"}], "samplers" : [{"magFilter" : 9729, "minFilter" : 9985, "wrapS" : 33071, "wrapT" : 33071}, {"magFilter" : 9729, "minFilter" : 9729, "wrapS" : 33648, "wrapT" : 33648}], "scene" : 0, "scenes" : [{"nodes" : [0]}], "textures" : [{"sampler" : 0, "source" : 0, "name" : ""}, {"sampler" : 1, "source" : 1, "name" : ""}]} \ No newline at end of file diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 971f8eb85..fcb3e8f70 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -1701,27 +1701,103 @@ Desbloquear características exclusivas Límites duplicados - Hasta %1$d canales, %2$d carpetas de chat, %3$d fijaciones, %4$d enlaces públicos y más. + Hasta %1$d canales, %2$d carpetas de chat, %3$d fijaciones, %4$d enlaces públicos y más Conversión de voz a texto - Lee la transcripción de cualquier mensaje de voz tocando el botón junto a él. + Lee la transcripción de cualquier mensaje de voz tocando el botón junto a él Velocidad de descarga más rápida - Sin más límites en la velocidad de descarga de multimedia y documentos. + Sin más límites en la velocidad de descarga de multimedia y documentos Traducción en tiempo real - Traduce chats completos en tiempo real con un solo toque. + Traduce chats completos en tiempo real con un solo toque Emojis animados - Incluye emojis animados de cientos de paquetes en tus mensajes. + Incluye emojis animados de cientos de paquetes en tus mensajes Gestión avanzada de chats - Herramientas para establecer carpeta predeterminada, archivar automáticamente y ocultar chats nuevos de no contactos. + Herramientas para establecer carpeta predeterminada, archivar automáticamente y ocultar chats nuevos de no contactos Sin anuncios - Los canales públicos a veces muestran anuncios, pero ya no aparecerán para ti. + Los canales públicos a veces muestran anuncios, pero ya no aparecerán para ti Reacciones ilimitadas - Reacciona con miles de emojis — usando hasta 3 por mensaje. + Reacciona con miles de emojis — usando hasta 3 por mensaje Insignia Premium - Una insignia especial junto a tu nombre mostrando que te suscribes a Telegram Premium. + Una insignia especial junto a tu nombre mostrando que te suscribes a Telegram Premium Estados de Emoji - Elige de miles de emojis para mostrar junto a tu nombre. + Elige de miles de emojis para mostrar junto a tu nombre Iconos de aplicación Premium - Elige de una selección de iconos de aplicación de Telegram para tu pantalla de inicio. + Elige de una selección de iconos de aplicación de Telegram para tu pantalla de inicio + Límites más altos para chats, carpetas, fijaciones y enlaces públicos + Archivos más grandes + Envía archivos más grandes que el límite de carga estándar + Efectos únicos para stickers + Usa stickers Premium con efectos únicos en tus mensajes + Fotos de perfil animadas + Muestra fotos de perfil animadas en las pantallas de chats y mensajes + Iconos personalizados para temas + Establece un emoji personalizado como icono de un tema del foro + Historias mejoradas + Usa herramientas y opciones adicionales al crear historias + Impulsos de chats + Impulsa chats y ayuda a desbloquear funciones adicionales para ellos + Colores de acento + Elige un color de acento para las respuestas y tu perfil + Fondos compartidos para chats + Establece un fondo de chat privado para ambos participantes + Etiquetas en Mensajes guardados + Organiza Mensajes guardados con etiquetas personalizadas + Privacidad de mensajes + Controla los mensajes y notas de voz o vídeo entrantes de usuarios que no son contactos + Horas de última conexión + Ve las horas de última conexión y lectura aunque las tuyas estén ocultas + Funciones empresariales + Usa las herramientas de Telegram Business en tu cuenta + Efectos de mensajes + Usa todos los efectos de mensajes disponibles + Listas de tareas + Crea y usa mensajes con listas de tareas + Mensajes de pago + Solicita un pago por los mensajes entrantes en chats nuevos + Protección de chats privados + Protege el contenido de los chats privados + Composición de texto con IA + Redacta textos con herramientas de IA + Mensajes enriquecidos + Envía mensajes con formato adicional + Más funciones Premium + Función devuelta por Telegram: %s + Tu suscripción Premium está activa + Opciones de suscripción + Funciones incluidas + Límites superiores + Suscribirse por %s + Abrir Premium + Ahorra un %1$d%% + Actual + Ampliar + + %d mes + %d meses + + %1$d -> %2$d + Canales y supergrupos + Chats fijados + Enlaces públicos creados + GIF guardados + Stickers favoritos + Carpetas de chats + Chats en una carpeta + Chats archivados fijados + Temas fijados de Mensajes guardados + Longitud del mensaje + Longitud del texto de los medios + Longitud de la biografía + Enlaces de invitación a carpetas + Carpetas de chats compartibles + Historias activas + Historias por semana + Historias por mes + Longitud del texto de la historia + Áreas de reacción de historias + Chats similares + Bots propios + Estilos de texto personalizados + Más límites Premium Recargar Copiar enlace diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index c9a769ae9..48f1d3ff2 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -2355,6 +2355,82 @@ Այս կոնտակտի համար պետք է կիսվել ձեր համարով։ Ցույց տալ գովազդը Կրկին ցուցադրել հովանավորվող հաղորդագրությունները ալիքների ժապավենում + Ավելի բարձր սահմանաչափեր չատերի, թղթապանակների, ամրացումների և հանրային հղումների համար + Ավելի մեծ ֆայլեր + Ուղարկեք սովորական սահմանաչափից մեծ ֆայլեր + Սթիքերների եզակի էֆեկտներ + Օգտագործեք եզակի էֆեկտներով Premium սթիքերներ ձեր հաղորդագրություններում + Անիմացիոն պրոֆիլի լուսանկարներ + Ցուցադրեք անիմացիոն պրոֆիլի լուսանկարներ չատերի և հաղորդագրությունների էկրաններում + Թեմաների հատուկ պատկերակներ + Սահմանեք հատուկ էմոջի որպես ֆորումի թեմայի պատկերակ + Ընդլայնված պատմություններ + Պատմություններ ստեղծելիս օգտագործեք լրացուցիչ գործիքներ և ընտրանքներ + Չատերի boost-եր + Boost արեք չատերը և օգնեք բացել դրանց լրացուցիչ հնարավորությունները + Շեշտադրման գույներ + Ընտրեք պատասխանների և պրոֆիլի շեշտադրման գույնը + Չատի ընդհանուր ֆոն + Սահմանեք անձնական չատի ֆոն երկու մասնակիցների համար + Պիտակներ «Պահված հաղորդագրություններում» + Կազմակերպեք «Պահված հաղորդագրությունները» հատուկ պիտակներով + Հաղորդագրությունների գաղտնիություն + Կառավարեք անծանոթ օգտատերերից ստացվող հաղորդագրությունները և ձայնային կամ տեսանյութային նշումները + Վերջին այցելության ժամերը + Տեսեք վերջին այցելության և կարդալու ժամերը, նույնիսկ եթե ձերը թաքցված է + Բիզնես հնարավորություններ + Օգտագործեք Telegram Business-ի գործիքները ձեր հաշվում + Հաղորդագրությունների էֆեկտներ + Օգտագործեք հաղորդագրությունների բոլոր հասանելի էֆեկտները + Ստուգաթերթեր + Ստեղծեք և օգտագործեք ստուգաթերթերով հաղորդագրություններ + Վճարովի հաղորդագրություններ + Նոր չատերում պահանջեք վճար մուտքային հաղորդագրությունների համար + Անձնական չատի պաշտպանություն + Պաշտպանեք անձնական չատերի բովանդակությունը + Տեքստի ստեղծում AI-ով + Ստեղծեք տեքստ AI գործիքներով + Ընդլայնված հաղորդագրություններ + Ուղարկեք լրացուցիչ ձևաչափմամբ հաղորդագրություններ + Premium-ի այլ հնարավորություններ + Telegram-ի կողմից վերադարձված գործառույթ՝ %s + Ձեր Premium բաժանորդագրությունն ակտիվ է + Բաժանորդագրության տարբերակներ + Ներառված հնարավորություններ + Ավելի բարձր սահմանաչափեր + Բաժանորդագրվել %s-ով + Բացել Premium-ը + Խնայողություն՝ %1$d%% + Ընթացիկ + Թարմացնել + + %d ամիս + %d ամիս + + %1$d -> %2$d + Ալիքներ և սուպերխմբեր + Ամրացված չատեր + Ստեղծված հանրային հղումներ + Պահված GIF-եր + Սիրված սթիքերներ + Չատերի թղթապանակներ + Չատեր թղթապանակում + Ամրացված արխիվացված չատեր + Ամրացված թեմաներ «Պահված հաղորդագրություններում» + Հաղորդագրության երկարություն + Մեդիայի նկարագրության երկարություն + Կենսագրության երկարություն + Թղթապանակների հրավերի հղումներ + Համօգտագործվող չատերի թղթապանակներ + Ակտիվ պատմություններ + Պատմություններ շաբաթական + Պատմություններ ամսական + Պատմության նկարագրության երկարություն + Պատմության արձագանքի տարածքներ + Նման չատեր + Սեփական բոտեր + Հատուկ տեքստային ոճեր + Premium-ի այլ սահմանաչափեր Հովանավորվող Առաջարկվող Monogram-ի թարմացումներ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index ed932b545..feb52061a 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -424,7 +424,7 @@ Endereço comercial Localização geográfica Horário de funcionamento - Como usuário Premium, você pode adicionar seu link a um canal e incluir detalhes comerciais no perfil. + Como usuário Premium, você pode adicionar seu link a um canal e incluir detalhes comerciais no perfil Não definido (%1$d dias) @@ -1734,27 +1734,103 @@ Desbloquear recursos exclusivos Limites dobrados - Até %1$d canais, %2$d pastas de chat, %3$d fixações, %4$d links públicos e mais. + Até %1$d canais, %2$d pastas de chat, %3$d fixações, %4$d links públicos e mais Conversão de voz para texto - Leia a transcrição de qualquer mensagem de voz tocando no botão ao lado. + Leia a transcrição de qualquer mensagem de voz tocando no botão ao lado Velocidade de download maior - Sem limites na velocidade de download de mídias e documentos. + Sem limites na velocidade de download de mídias e documentos Tradução em tempo real - Traduza chats inteiros em tempo real com um único toque. + Traduza chats inteiros em tempo real com um único toque Emojis animados - Inclua emojis animados de centenas de pacotes nas suas mensagens. + Inclua emojis animados de centenas de pacotes nas suas mensagens Gerenciamento avançado de chats - Ferramentas para definir pasta padrão, arquivar automaticamente e ocultar novos chats de não contatos. + Ferramentas para definir pasta padrão, arquivar automaticamente e ocultar novos chats de não contatos Sem anúncios - Canais públicos às vezes mostram anúncios, mas eles não aparecerão mais para você. + Canais públicos às vezes mostram anúncios, mas eles não aparecerão mais para você Reações infinitas - Reaja com milhares de emojis — usando até 3 por mensagem. + Reaja com milhares de emojis — usando até 3 por mensagem Distintivo Premium - Um distintivo especial ao lado do seu nome mostrando que você assina o Telegram Premium. + Um distintivo especial ao lado do seu nome mostrando que você assina o Telegram Premium Status com emoji - Escolha entre milhares de emojis para mostrar ao lado do seu nome. + Escolha entre milhares de emojis para mostrar ao lado do seu nome Ícones Premium - Escolha entre uma seleção de ícones do Telegram para a tela inicial. + Escolha entre uma seleção de ícones do Telegram para a tela inicial + Limites maiores para chats, pastas, fixações e links públicos + Uploads maiores + Envie arquivos maiores que o limite padrão de upload + Efeitos exclusivos de figurinhas + Use figurinhas Premium com efeitos exclusivos nas suas mensagens + Fotos de perfil animadas + Mostre fotos de perfil animadas nas telas de chats e mensagens + Ícones personalizados de tópicos + Defina um emoji personalizado como ícone de um tópico do fórum + Stories aprimorados + Use ferramentas e opções adicionais ao criar stories + Boosts de chats + Dê boost em chats e ajude a desbloquear recursos extras para eles + Cores de destaque + Escolha uma cor de destaque para respostas e para o seu perfil + Planos de fundo compartilhados + Defina um plano de fundo de chat privado para os dois participantes + Tags nas Mensagens salvas + Organize as Mensagens salvas com tags personalizadas + Privacidade das mensagens + Controle mensagens e notas de voz ou vídeo recebidas de não contatos + Horários de visto por último + Veja os horários de visto por último e de leitura mesmo quando os seus estiverem ocultos + Recursos para negócios + Use as ferramentas do Telegram Business na sua conta + Efeitos de mensagens + Use todos os efeitos de mensagens disponíveis + Checklists + Crie e use mensagens com checklists + Mensagens pagas + Exija pagamento por mensagens recebidas em novos chats + Proteção de chats privados + Proteja o conteúdo de chats privados + Composição de texto com IA + Crie textos com ferramentas de IA + Mensagens avançadas + Envie mensagens com formatação adicional + Mais recursos Premium + Recurso retornado pelo Telegram: %s + Sua assinatura Premium está ativa + Opções de assinatura + Recursos incluídos + Limites maiores + Assinar por %s + Abrir Premium + Economize %1$d%% + Atual + Fazer upgrade + + %d mês + %d meses + + %1$d -> %2$d + Canais e supergrupos + Chats fixados + Links públicos criados + GIFs salvos + Figurinhas favoritas + Pastas de chats + Chats em uma pasta + Chats arquivados fixados + Tópicos fixados das Mensagens salvas + Tamanho da mensagem + Tamanho da legenda da mídia + Tamanho da bio + Links de convite para pastas + Pastas de chats compartilháveis + Stories ativos + Stories por semana + Stories por mês + Tamanho da legenda do story + Áreas de reação em stories + Chats semelhantes + Bots próprios + Estilos de texto personalizados + Mais limites Premium Recarregar Copiar link diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index fded7fc3b..d9f5654b3 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -465,7 +465,7 @@ Адрес бизнеса Геопозиция Часы работы - С подпиской Premium Вы можете привязать канал и добавить сведения о бизнесе в свой профиль. + С подпиской Premium Вы можете привязать канал и добавить сведения о бизнесе в свой профиль Не указано (%1$d дн.) Пн @@ -1772,27 +1772,105 @@ Разблокируйте эксклюзивные функции Удвоенные лимиты - До %1$d каналов, %2$d папок, %3$d закрепов, %4$d публичных ссылок и не только. + До %1$d каналов, %2$d папок, %3$d закрепов, %4$d публичных ссылок и не только Расшифровка голоса - Читайте текстовую версию любого голосового сообщения одним нажатием. + Читайте текстовую версию любого голосового сообщения одним нажатием Ускоренная загрузка - Снятие ограничений на скорость загрузки фото, видео и документов. + Снятие ограничений на скорость загрузки фото, видео и документов Перевод в реальном времени - Мгновенный перевод целых чатов одним касанием. + Мгновенный перевод целых чатов одним касанием Анимированные эмодзи - Использование авторских анимированных эмодзи в сообщениях. + Использование авторских анимированных эмодзи в сообщениях Управление чатами - Выбор папки по умолчанию, автоархивация и скрытие новых чатов. + Выбор папки по умолчанию, автоархивация и скрытие новых чатов Отключение рекламы - Рекламные сообщения в каналах больше не будут отображаться. + Рекламные сообщения в каналах больше не будут отображаться Активные реакции - Тысячи эмодзи для реакций и до 3 реакций на одно сообщение. + Тысячи эмодзи для реакций и до 3 реакций на одно сообщение Значок подписчика - Особый значок рядом с Вашим именем, подтверждающий подписку. + Особый значок рядом с Вашим именем, подтверждающий подписку Эмодзи-статусы - Установка любого эмодзи в качестве статуса рядом с именем. + Установка любого эмодзи в качестве статуса рядом с именем Иконки приложения - Эксклюзивные иконки для приложения Telegram на главном экране. + Эксклюзивные иконки для приложения Telegram на главном экране + Расширенные лимиты для чатов, папок, закреплений и публичных ссылок + Большие файлы + Отправляйте файлы, размер которых превышает обычный лимит + Уникальные эффекты стикеров + Используйте Premium-стикеры с уникальными эффектами в сообщениях + Анимированная аватарка + Показывайте анимированную аватарку на экранах чатов и сообщений + Иконки тем + Устанавливайте собственный эмодзи в качестве иконки темы форума + Расширенные истории + Используйте дополнительные инструменты и параметры при создании историй + Ускорение чатов + Повышайте уровень чатов и открывайте для них дополнительные возможности + Цвет оформления + Выбирайте цвет оформления ответов и профиля + Общий фон чата + Устанавливайте фон личного чата для обоих участников + Метки в «Избранном» + Организуйте «Избранное» с помощью собственных меток + Приватность сообщений + Управляйте входящими сообщениями и голосовыми или видеосообщениями от незнакомых пользователей + Время посещения + Видьте время посещения и прочтения, даже если ваше скрыто + Бизнес-возможности + Используйте инструменты Telegram Business в своём аккаунте + Эффекты сообщений + Используйте все доступные эффекты сообщений + Чек-листы + Создавайте и используйте сообщения с чек-листами + Платные сообщения + Требуйте оплату входящих сообщений в новых чатах + Защита личных чатов + Защищайте содержимое личных чатов + Создание текста с ИИ + Создавайте текст с помощью инструментов ИИ + Расширенные сообщения + Отправляйте сообщения с дополнительным форматированием + Другие возможности Premium + Функция, возвращённая Telegram: %s + Подписка Premium активна + Варианты подписки + Возможности + Расширенные лимиты + Подписаться за %s + Открыть Premium + Экономия %1$d%% + Текущий + Продлить + + %d месяц + %d месяца + %d месяцев + %d месяца + + %1$d -> %2$d + Каналы и супергруппы + Закреплённые чаты + Созданные публичные ссылки + Сохранённые GIF + Избранные стикеры + Папки с чатами + Чаты в папке + Закреплённые архивные чаты + Закреплённые темы в «Избранном» + Длина сообщения + Длина подписи к медиа + Длина описания профиля + Ссылки-приглашения в папки + Публичные папки с чатами + Активные истории + Истории в неделю + Истории в месяц + Длина подписи к истории + Области реакций в истории + Похожие чаты + Собственные боты + Собственные стили текста + Другие лимиты Premium Обновить Копировать ссылку diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index d89412d91..a70dbbc8f 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -473,7 +473,7 @@ Citlivý obsah Vypnúť filtrovanie Zobrazovať citlivé médiá vo verejných kanáloch na všetkých vašich - zariadeniach. + zariadeniach Rozšírené Odstrániť môj účet @@ -1736,43 +1736,121 @@ Zdvojené limity Až %1$d kanálov, %2$d priečinkov chatov, %3$d pripnutých - správ, %4$d verejných odkazov a viac. + správ, %4$d verejných odkazov a viac Prevod hlasu na text Prečítajte si prepis ľubovoľnej hlasovej správy klepnutím - na tlačidlo vedľa nej. + na tlačidlo vedľa nej Rýchlejšie sťahovanie Žiadne obmedzenia rýchlosti sťahovania médií a - dokumentov. + dokumentov Preklad v reálnom čase - Preložte celé chaty v reálnom čase jedným klepnutím. + Preložte celé chaty v reálnom čase jedným klepnutím Animované emoji - Vkladajte animované emoji zo stoviek balíčkov do správ. + Vkladajte animované emoji zo stoviek balíčkov do správ Pokročilá správa chatov Nástroje na nastavenie predvoleného priečinka, - automatické archivovanie a skrytie nových chatov od neznámych kontaktov. + automatické archivovanie a skrytie nových chatov od neznámych kontaktov Bez reklám Verejné kanály niekedy zobrazujú reklamy, ale vám sa už zobrazovať - nebudú. + nebudú Neobmedzené reakcie - Reagujte tisíckami emoji — až 3 na správu. + Reagujte tisíckami emoji — až 3 na správu Premium odznak Špeciálny odznak vedľa vášho mena zobrazujúci, že ste - predplatiteľom Telegram Premium. + predplatiteľom Telegram Premium Emoji statusy Vyberte si z tisícok emoji na zobrazenie vedľa svojho - mena. + mena Prémiové ikony aplikácie Vyberte si ikonu aplikácie Telegram pre plochu zo širokej - ponuky. - + ponuky + + Vyššie limity pre chaty, priečinky, pripnutia a verejné odkazy + Väčšie súbory + Posielajte súbory väčšie než štandardný limit nahrávania + Jedinečné efekty nálepiek + Používajte Premium nálepky s jedinečnými efektmi v správach + Animované profilové fotky + Zobrazujte animované profilové fotky na obrazovkách chatov a správ + Vlastné ikony tém + Nastavte vlastné emoji ako ikonu témy fóra + Vylepšené príbehy + Pri vytváraní príbehov používajte ďalšie nástroje a možnosti + Boosty chatov + Boostujte chaty a pomôžte im odomknúť ďalšie možnosti + Farby zvýraznenia + Vyberte farbu zvýraznenia pre odpovede a svoj profil + Spoločné pozadia chatu + Nastavte pozadie súkromného chatu pre oboch účastníkov + Značky v Uložených správach + Organizujte Uložené správy pomocou vlastných značiek + Súkromie správ + Ovládajte prichádzajúce správy a hlasové či video správy od neznámych používateľov + Časy poslednej aktivity + Zobrazte časy poslednej aktivity a prečítania, aj keď sú vaše skryté + Firemné funkcie + Používajte nástroje Telegram Business vo svojom účte + Efekty správ + Používajte všetky dostupné efekty správ + Kontrolné zoznamy + Vytvárajte a používajte správy s kontrolnými zoznamami + Platené správy + Vyžadujte platbu za prichádzajúce správy v nových chatoch + Ochrana súkromných chatov + Chráňte obsah súkromných chatov + Tvorba textu pomocou AI + Vytvárajte texty pomocou nástrojov AI + Rozšírené správy + Posielajte správy s ďalším formátovaním + Ďalšie funkcie Premium + Funkcia vrátená Telegramom: %s + Vaše predplatné Premium je aktívne + Možnosti predplatného + Zahrnuté funkcie + Vyššie limity + Predplatiť za %s + Otvoriť Premium + Ušetríte %1$d%% + Aktuálne + Vylepšiť + + %d mesiac + %d mesiace + %d mesiacov + %d mesiaca + + %1$d -> %2$d + Kanály a supergroupy + Pripnuté chaty + Vytvorené verejné odkazy + Uložené GIF-y + Obľúbené nálepky + Priečinky chatov + Chaty v priečinku + Pripnuté archivované chaty + Pripnuté témy Uložených správ + Dĺžka správy + Dĺžka popisu média + Dĺžka životopisu + Pozývacie odkazy priečinkov + Zdieľateľné priečinky chatov + Aktívne príbehy + Príbehy za týždeň + Príbehy za mesiac + Dĺžka popisu príbehu + Oblasti reakcií v príbehoch + Podobné chaty + Vlastné boty + Vlastné textové štýly + Ďalšie limity Premium Znovu načítať Kopírovať odkaz diff --git a/presentation/src/main/res/values-tr/string.xml b/presentation/src/main/res/values-tr/string.xml index bf3a1ce35..5e165281f 100644 --- a/presentation/src/main/res/values-tr/string.xml +++ b/presentation/src/main/res/values-tr/string.xml @@ -423,7 +423,7 @@ İşletme Adresi Coğrafi Konum Çalışma Saatleri - Premium kullanıcı olarak bir kanala bağlantı ekleyebilir ve profilinize işletme detaylarını set edebilirsiniz. + Premium kullanıcı olarak bir kanala bağlantı ekleyebilir ve profilinize işletme detaylarını set edebilirsiniz Ayarlanmadı (%1$d gün) Pzt @@ -1713,27 +1713,103 @@ Özel özelliklerin kilidini aç İki Kat Limitler - %1$d kanala kadar, %2$d sohbet klasörü, %3$d sabitleme, %4$d genel bağlantı ve daha fazlası. + %1$d kanala kadar, %2$d sohbet klasörü, %3$d sabitleme, %4$d genel bağlantı ve daha fazlası Sesi Metne Dönüştürme - Yanındaki düğmeye dokunarak herhangi bir sesli mesajın metin dökümünü okuyun. + Yanındaki düğmeye dokunarak herhangi bir sesli mesajın metin dökümünü okuyun Daha Hızlı İndirme - Medya ve dosyaların indirilme hızındaki tüm limitler kalkıyor. + Medya ve dosyaların indirilme hızındaki tüm limitler kalkıyor Gerçek Zamanlı Çeviri - Tüm sohbetleri tek bir dokunuşla gerçek zamanlı olarak çevirin. + Tüm sohbetleri tek bir dokunuşla gerçek zamanlı olarak çevirin Hareketli Emojiler - Yüzlerce paketteki hareketli emojileri mesajlarınıza dahil edin. + Yüzlerce paketteki hareketli emojileri mesajlarınıza dahil edin Gelişmiş Sohbet Yönetimi - Varsayılan klasörü ayarlama, otomatik arşivleme ve yeni sohbetleri gizleme araçları. + Varsayılan klasörü ayarlama, otomatik arşivleme ve yeni sohbetleri gizleme araçları Reklam Yok - Genel kanallarda bazen gösterilen reklamlar artık karşınıza çıkmayacak. + Genel kanallarda bazen gösterilen reklamlar artık karşınıza çıkmayacak Sonsuz Tepki - Binlerce emoji ile tepki verin; her mesajda 3 taneye kadar kullanın. + Binlerce emoji ile tepki verin; her mesajda 3 taneye kadar kullanın Premium Rozeti - Adınızın yanında Telegram Premium abonesi olduğunuzu gösteren özel bir rozet. + Adınızın yanında Telegram Premium abonesi olduğunuzu gösteren özel bir rozet Emoji Durumları - Adınızın yanında görünecek binlerce emoji arasından seçim yapın. + Adınızın yanında görünecek binlerce emoji arasından seçim yapın Premium Uygulama Simgeleri - Ana ekranınız için çeşitli Telegram uygulama simgeleri arasından seçim yapın. + Ana ekranınız için çeşitli Telegram uygulama simgeleri arasından seçim yapın + Sohbetler, klasörler, sabitlemeler ve genel bağlantılar için daha yüksek limitler + Daha büyük dosyalar + Standart yükleme limitinden daha büyük dosyalar gönderin + Benzersiz çıkartma efektleri + Mesajlarınızda benzersiz efektlere sahip Premium çıkartmaları kullanın + Hareketli profil fotoğrafları + Sohbet ve mesaj ekranlarında hareketli profil fotoğrafları gösterin + Özel konu simgeleri + Bir forum konusu simgesi olarak özel emoji belirleyin + Geliştirilmiş Hikayeler + Hikaye oluştururken ek araçları ve seçenekleri kullanın + Sohbet destekleri + Sohbetleri destekleyin ve ek özelliklerin kilidini açmaya yardımcı olun + Vurgu renkleri + Yanıtlar ve profiliniz için bir vurgu rengi seçin + Ortak sohbet arka planları + Özel bir sohbet arka planını her iki katılımcı için belirleyin + Kayıtlı Mesajlar etiketleri + Kayıtlı Mesajları özel etiketlerle düzenleyin + Mesaj gizliliği + Kişileriniz olmayan kullanıcılardan gelen mesajları ve sesli veya görüntülü notları yönetin + Son görülme zamanları + Sizinkiler gizli olsa bile son görülme ve okuma zamanlarını görün + İşletme özellikleri + Telegram Business araçlarını hesabınızda kullanın + Mesaj efektleri + Kullanılabilen tüm mesaj efektlerini kullanın + Kontrol listeleri + Kontrol listesi mesajları oluşturun ve kullanın + Ücretli mesajlar + Yeni sohbetlerde gelen mesajlar için ödeme talep edin + Özel sohbet koruması + Özel sohbetlerdeki içeriği koruyun + Yapay zekâ ile metin oluşturma + Yapay zekâ araçlarıyla metin oluşturun + Zengin mesajlar + Ek biçimlendirme içeren zengin mesajlar gönderin + Diğer Premium özellikleri + Telegram tarafından döndürülen özellik: %s + Premium aboneliğiniz etkin + Abonelik seçenekleri + Dahil olan özellikler + Daha yüksek limitler + %s karşılığında abone ol + Premium’u aç + %1$d%% tasarruf + Mevcut + Yükselt + + %d ay + %d ay + + %1$d -> %2$d + Kanallar ve süper gruplar + Sabitlenmiş sohbetler + Oluşturulan genel bağlantılar + Kayıtlı GIF’ler + Favori çıkartmalar + Sohbet klasörleri + Klasördeki sohbetler + Sabitlenmiş arşiv sohbetleri + Sabitlenmiş Kayıtlı Mesajlar konuları + Mesaj uzunluğu + Medya açıklaması uzunluğu + Biyografi uzunluğu + Klasör davet bağlantıları + Paylaşılabilir sohbet klasörleri + Etkin Hikayeler + Haftalık Hikayeler + Aylık Hikayeler + Hikaye açıklaması uzunluğu + Hikaye tepki alanları + Benzer sohbetler + Sahip olunan botlar + Özel metin stilleri + Diğer Premium limitleri Yenile Bağlantıyı kopyala diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index d583f5c05..fe005b32f 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -420,7 +420,7 @@ Адреса бізнесу Геопозиція Години роботи - З підпискою Premium Ви можете приєднати канал та додати відомості про бізнес у свій профіль. + З підпискою Premium Ви можете приєднати канал та додати відомості про бізнес у свій профіль Не вказано (%1$d дн.) Пн @@ -1668,27 +1668,105 @@ Розблокуйте ексклюзивні функції Подвоєні ліміти - До %1$d каналів, %2$d папок, %3$d закріплень, %4$d публічних посилань та інше. + До %1$d каналів, %2$d папок, %3$d закріплень, %4$d публічних посилань та інше Розшифровка голосу - Читайте текстову версію будь-якого голосового повідомлення одним натисканням. + Читайте текстову версію будь-якого голосового повідомлення одним натисканням Швидке завантаження - Більше жодних обмежень на швидкість завантаження медіа та документів. + Більше жодних обмежень на швидкість завантаження медіа та документів Переклад у реальному часі - Миттєвий переклад цілих чатів одним дотиком. + Миттєвий переклад цілих чатів одним дотиком Анімовані емодзі - Використовуйте анімовані емодзі з сотень наборів у своїх повідомленнях. + Використовуйте анімовані емодзі з сотень наборів у своїх повідомленнях Керування чатами - Інструменти для вибору папки за замовчуванням, автоархівування та приховування нових чатів. + Інструменти для вибору папки за замовчуванням, автоархівування та приховування нових чатів Без реклами - У публічних каналах іноді показується реклама, але вона більше не з’явиться для вас. + У публічних каналах іноді показується реклама, але вона більше не з’явиться для вас Активні реакції - Тисячі емодзі для реакцій — до 3 на одне повідомлення. + Тисячі емодзі для реакцій — до 3 на одне повідомлення Значок підписника - Спеціальний значок поруч із вашим ім’ям, що підтверджує підписку. + Спеціальний значок поруч із вашим ім’ям, що підтверджує підписку Емодзі-статуси - Вибирайте з тисяч емодзі, щоб відобразити їх поруч із вашим ім’ям. + Вибирайте з тисяч емодзі, щоб відобразити їх поруч із вашим ім’ям Іконки додатка - Виберіть одну з ексклюзивних іконок Telegram для головного екрана. + Виберіть одну з ексклюзивних іконок Telegram для головного екрана + Вищі ліміти для чатів, папок, закріплень і публічних посилань + Більші файли + Надсилайте файли, більші за стандартний ліміт завантаження + Унікальні ефекти стікерів + Використовуйте Premium-стікери з унікальними ефектами у своїх повідомленнях + Анімовані фото профілю + Показуйте анімовані фото профілю на екранах чатів і повідомлень + Власні іконки тем + Встановлюйте власний емодзі як іконку теми форуму + Розширені історії + Використовуйте додаткові інструменти та параметри під час створення історій + Прискорення чатів + Підвищуйте рівень чатів і допомагайте відкривати для них додаткові можливості + Акцентні кольори + Вибирайте акцентний колір для відповідей і свого профілю + Спільний фон чату + Встановлюйте фон приватного чату для обох учасників + Мітки в «Збережених повідомленнях» + Організовуйте «Збережені повідомлення» за допомогою власних міток + Приватність повідомлень + Керуйте вхідними повідомленнями та голосовими або відеоповідомленнями від незнайомих користувачів + Час останньої активності + Переглядайте час останньої активності та прочитання, навіть якщо ваш час приховано + Бізнес-можливості + Використовуйте інструменти Telegram Business у своєму обліковому записі + Ефекти повідомлень + Використовуйте всі доступні ефекти повідомлень + Чеклісти + Створюйте та використовуйте повідомлення з чеклістами + Платні повідомлення + Вимагайте оплату за вхідні повідомлення в нових чатах + Захист приватних чатів + Захищайте вміст приватних чатів + Створення тексту за допомогою ШІ + Створюйте тексти за допомогою інструментів ШІ + Розширені повідомлення + Надсилайте повідомлення з додатковим форматуванням + Інші можливості Premium + Функція, повернута Telegram: %s + Ваша підписка Premium активна + Варіанти підписки + Доступні можливості + Розширені ліміти + Підписатися за %s + Відкрити Premium + Економія %1$d%% + Поточний + Покращити + + %d місяць + %d місяці + %d місяців + %d місяця + + %1$d -> %2$d + Канали та супергрупи + Закріплені чати + Створені публічні посилання + Збережені GIF + Улюблені стікери + Папки чатів + Чати в папці + Закріплені архівні чати + Закріплені теми «Збережених повідомлень» + Довжина повідомлення + Довжина підпису до медіа + Довжина опису профілю + Посилання-запрошення до папок + Спільні папки чатів + Активні історії + Історії на тиждень + Історії на місяць + Довжина підпису до історії + Області реакцій в історії + Схожі чати + Власні боти + Власні стилі тексту + Інші ліміти Premium Оновити Копіювати посилання diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 1367730ce..eda640aa6 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -1649,27 +1649,102 @@ 解锁专属功能 限制翻倍 - 最多可加入 %1$d 个频道,创建 %2$d 个聊天分组,置顶 %3$d 个聊天,%4$d 条公开链接等。 + 最多可加入 %1$d 个频道,创建 %2$d 个聊天分组,置顶 %3$d 个聊天,%4$d 条公开链接等 语音转文字 - 点击语音消息旁的按钮即可查看文字转录。 + 点击语音消息旁的按钮即可查看文字转录 更快的下载速度 - 下载媒体和文档时不再有速度限制。 + 下载媒体和文档时不再有速度限制 实时翻译 - 只需轻轻一点,即可实时翻译整个聊天。 + 只需轻轻一点,即可实时翻译整个聊天 动态表情符号 - 可在消息中使用数百个表情包中的动态表情。 + 可在消息中使用数百个表情包中的动态表情 高级对话管理 - 可设置默认收藏夹、自动归档以及对非联系人隐藏新聊天。 + 可设置默认收藏夹、自动归档以及对非联系人隐藏新聊天 无广告 - 公共频道有时会显示广告,订阅后将不再显示。 + 公共频道有时会显示广告,订阅后将不再显示 无限回应 - 可使用数千种表情符号进行回应,每条消息最多可添加 3 个。 + 可使用数千种表情符号进行回应,每条消息最多可添加 3 个 Premium 徽章 - 名字旁边会出现专属徽章,表明您已订阅 Telegram Premium。 + 名字旁边会出现专属徽章,表明您已订阅 Telegram Premium 表情符号状态 - 从数千个表情符号中选择一个显示在您的名字旁边。 + 从数千个表情符号中选择一个显示在您的名字旁边 Premium 应用图标 - 可从精选的 Telegram 图标中选择并替换主屏幕图标。 + 可从精选的 Telegram 图标中选择并替换主屏幕图标 + 聊天、文件夹、置顶和公开链接的更高上限 + 更大的文件 + 发送超过标准上传上限的文件 + 独特的贴纸效果 + 在消息中使用带有独特效果的 Premium 贴纸 + 动态头像 + 在聊天和消息页面显示动态头像 + 自定义主题图标 + 设置自定义表情作为论坛主题图标 + 增强的故事 + 创建故事时使用更多工具和选项 + 聊天助推 + 助推聊天并帮助解锁更多功能 + 强调色 + 为回复和个人资料选择强调色 + 共享聊天背景 + 为双方设置私聊背景 + 收藏夹标签 + 使用自定义标签整理收藏夹 + 消息隐私 + 管理非联系人发来的消息以及语音或视频消息 + 最后上线时间 + 即使隐藏自己的时间,也能查看最后上线和已读时间 + 商务功能 + 在账号中使用 Telegram Business 工具 + 消息效果 + 使用所有可用的消息效果 + 清单 + 创建和使用清单消息 + 付费消息 + 要求新聊天中的收件消息付费 + 私聊内容保护 + 保护私聊中的内容 + AI 文本创作 + 使用 AI 工具创作文本 + 富文本消息 + 发送带有更多格式的消息 + 更多 Premium 功能 + Telegram 返回的功能:%s + 您的 Premium 订阅已启用 + 订阅选项 + 包含的功能 + 更高上限 + 以 %s 订阅 + 打开 Premium + 节省 %1$d%% + 当前 + 升级 + + %d 个月 + + %1$d -> %2$d + 频道和超级群组 + 置顶聊天 + 创建的公开链接 + 已保存的 GIF + 收藏的贴纸 + 聊天文件夹 + 文件夹中的聊天 + 置顶的已归档聊天 + 收藏夹中的置顶主题 + 消息长度 + 媒体说明长度 + 个人简介长度 + 文件夹邀请链接 + 可分享的聊天文件夹 + 活跃故事 + 每周故事 + 每月故事 + 故事说明长度 + 故事反应区域 + 相似聊天 + 自有机器人 + 自定义文本样式 + 更多 Premium 上限 重新加载 复制链接 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 54c06087f..febbc6a99 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -2482,27 +2482,103 @@ Doubled Limits - Up to %1$d channels, %2$d chat folders, %3$d pins, %4$d public links and more. + Up to %1$d channels, %2$d chat folders, %3$d pins, %4$d public links and more Voice-to-Text Conversion - Read the transcript of any voice message by tapping the button next to it. + Read the transcript of any voice message by tapping the button next to it Faster Download Speed - No more limits on the speed with which media and documents are downloaded. + No more limits on the speed with which media and documents are downloaded Real-Time Translation - Translate entire chats in real time with a single tap. + Translate entire chats in real time with a single tap Animated Emojis - Include animated emojis from hundreds of packs in your messages. + Include animated emojis from hundreds of packs in your messages Advanced Chat Management - Tools to set default folder, auto-archive and hide new chats from non-contacts. + Tools to set default folder, auto-archive and hide new chats from non-contacts No Ads - Public channels sometimes show ads, but they will no longer appear for you. + Public channels sometimes show ads, but they will no longer appear for you Infinite Reactions - React with thousands of emojis — using up to 3 per message. + React with thousands of emojis — using up to 3 per message Premium Badge - A special badge next to your name showing that you subscribe to Telegram Premium. + A special badge next to your name showing that you subscribe to Telegram Premium Emoji Statuses - Choose from thousands of emojis to show next to your name. + Choose from thousands of emojis to show next to your name Premium App Icons - Choose from a selection of Telegram app icons for your home screen. + Choose from a selection of Telegram app icons for your home screen + Higher limits for chats, folders, pins and public links + Larger Uploads + Send files larger than the standard upload limit + Unique Sticker Effects + Use premium stickers with unique effects in your messages + Animated Profile Photos + Show animated profile photos on message and chat screens + Custom Topic Icons + Set a custom emoji as a forum topic icon + Upgraded Stories + Use additional tools and options when creating stories + Chat Boosts + Boost chats and help unlock extra features for them + Accent Colors + Choose an accent color for replies and your profile + Shared Chat Backgrounds + Set a private chat background for both participants + Saved Messages Tags + Organize Saved Messages with custom tags + Message Privacy + Control incoming messages and voice or video notes from non-contacts + Last Seen Times + See last seen and read times even when yours are hidden + Business Features + Use Telegram Business tools in your account + Message Effects + Use all available message effects + Checklists + Create and use checklist messages + Paid Messages + Require payment for incoming messages in new chats + Private Chat Protection + Protect content in private chats + AI Text Composition + Compose text with AI tools + Rich Messages + Send rich messages with additional formatting + More Premium Features + Feature returned by Telegram: %s + Your Premium subscription is active + Subscription options + Included features + Higher limits + Subscribe for %s + Open Premium + Save %1$d%% + Current + Upgrade + + %d month + %d months + + %1$d -> %2$d + Channels and supergroups + Pinned chats + Created public links + Saved GIFs + Favorite stickers + Chat folders + Chats in a folder + Pinned archived chats + Pinned Saved Messages topics + Message length + Media caption length + Bio length + Folder invite links + Shareable chat folders + Active stories + Stories per week + Stories per month + Story caption length + Story reaction areas + Similar chats + Owned bots + Custom text styles + More Premium limits Reload diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/OutgoingMessageReducerTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/OutgoingMessageReducerTest.kt new file mode 100644 index 000000000..a058ea098 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/conversation/OutgoingMessageReducerTest.kt @@ -0,0 +1,70 @@ +package org.monogram.presentation.features.chats.conversation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendingState + +class OutgoingMessageReducerTest { + private val key = OutgoingMessageReducer.Key(chatId = 7L, temporaryMessageId = -10L) + + @Test + fun `pending acknowledgement and success form a terminal lifecycle`() { + val pending = OutgoingMessageReducer.pending(emptyMap(), pendingMessage()) + val acknowledged = OutgoingMessageReducer.acknowledged(pending, key) + val succeeded = OutgoingMessageReducer.succeeded(acknowledged, key, finalMessageId = 101L) + + assertEquals(OutgoingMessageReducer.State.PendingLocal, pending[key]) + assertEquals(OutgoingMessageReducer.State.Acknowledged, acknowledged[key]) + assertEquals(OutgoingMessageReducer.State.Succeeded(101L), succeeded[key]) + } + + @Test + fun `duplicate terminal updates do not replace the first terminal state`() { + val succeeded = OutgoingMessageReducer.succeeded(emptyMap(), key, finalMessageId = 101L) + val duplicateSuccess = + OutgoingMessageReducer.succeeded(succeeded, key, finalMessageId = 102L) + val lateFailure = OutgoingMessageReducer.failed(duplicateSuccess, key, errorCode = 500) + + assertEquals(OutgoingMessageReducer.State.Succeeded(101L), lateFailure[key]) + } + + @Test + fun `failure is retryable only for transient TDLib errors`() { + val retryable = OutgoingMessageReducer.failed(emptyMap(), key, errorCode = 429) + val permanent = OutgoingMessageReducer.failed(emptyMap(), key, errorCode = 400) + + assertEquals(OutgoingMessageReducer.State.Failed(429, true), retryable[key]) + assertEquals(OutgoingMessageReducer.State.Failed(400, false), permanent[key]) + } + + @Test + fun `recovery recreates pending and failed operations from TDLib message state`() { + val restored = OutgoingMessageReducer.recover( + listOf( + pendingMessage(), + pendingMessage(id = -11L, state = MessageSendingState.Failed(500, "temporary")) + ) + ) + + assertEquals(OutgoingMessageReducer.State.PendingLocal, restored[key]) + assertTrue( + restored[OutgoingMessageReducer.Key(7L, -11L)] is OutgoingMessageReducer.State.Failed + ) + } + + private fun pendingMessage( + id: Long = -10L, + state: MessageSendingState = MessageSendingState.Pending + ) = MessageModel( + id = id, + date = 0, + isOutgoing = true, + senderName = "", + chatId = 7L, + content = MessageContent.Text(""), + sendingState = state + ) +}