diff --git a/data/src/main/java/org/monogram/data/chats/ChatCache.kt b/data/src/main/java/org/monogram/data/chats/ChatCache.kt index 1e07ff086..9bcf908a4 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -5,6 +5,7 @@ import org.monogram.data.compat.buildTdChatPermissions import org.monogram.data.datasource.cache.ChatsCacheDataSource import org.monogram.data.datasource.cache.UserCacheDataSource import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit class ChatCache : ChatsCacheDataSource, UserCacheDataSource { // Chats and their positions in lists @@ -49,6 +50,7 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { val pendingSecretChats = ConcurrentHashMap.newKeySet() val pendingChatPermissions = ConcurrentHashMap.newKeySet() val pendingMyChatMember = ConcurrentHashMap.newKeySet() + private val missingSupergroupCooldownUntil = ConcurrentHashMap() override fun getChat(chatId: Long): TdApi.Chat? = allChats[chatId] override fun putChat(chat: TdApi.Chat) { @@ -238,14 +240,31 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { existing.hasTimestampedMedia = message.hasTimestampedMedia existing.isChannelPost = message.isChannelPost existing.containsUnreadMention = message.containsUnreadMention + existing.unreadReactions = message.unreadReactions + existing.isPinned = message.isPinned + existing.containsUnreadPollVotes = message.containsUnreadPollVotes existing.sendingState = message.sendingState existing.schedulingState = message.schedulingState + existing.factCheck = message.factCheck + existing.suggestedPostInfo = message.suggestedPostInfo } } else { chatMessages[message.id] = message } } + fun updateMessage( + chatId: Long, + messageId: Long, + action: (TdApi.Message) -> Unit + ): TdApi.Message? { + val message = messages[chatId]?.get(messageId) ?: return null + synchronized(message) { + action(message) + } + return message + } + override fun removeMessage(chatId: Long, messageId: Long) { messages[chatId]?.remove(messageId) } @@ -274,6 +293,27 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { chatMessages[newId] = message } + fun isSupergroupTemporarilyMissing(supergroupId: Long): Boolean { + if (supergroupId == 0L) return false + val cooldownUntil = missingSupergroupCooldownUntil[supergroupId] ?: return false + if (cooldownUntil > System.currentTimeMillis()) { + return true + } + missingSupergroupCooldownUntil.remove(supergroupId, cooldownUntil) + return false + } + + fun markSupergroupTemporarilyMissing(supergroupId: Long) { + if (supergroupId == 0L) return + missingSupergroupCooldownUntil[supergroupId] = + System.currentTimeMillis() + MISSING_SUPERGROUP_COOLDOWN_MS + } + + fun clearTemporarilyMissingSupergroup(supergroupId: Long) { + if (supergroupId == 0L) return + missingSupergroupCooldownUntil.remove(supergroupId) + } + override fun clearAll() { allChats.clear() activeListPositions.clear() @@ -305,6 +345,7 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { pendingSecretChats.clear() pendingChatPermissions.clear() pendingMyChatMember.clear() + missingSupergroupCooldownUntil.clear() } fun putChatFromEntity(entity: org.monogram.data.db.model.ChatEntity) { @@ -529,4 +570,8 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { return parsed.takeIf { it.isNotEmpty() }?.toTypedArray() } + + private companion object { + private val MISSING_SUPERGROUP_COOLDOWN_MS = TimeUnit.MINUTES.toMillis(30) + } } diff --git a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt index 46b1da0a4..a0480aec5 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -45,7 +45,9 @@ class ChatModelFactory( chat: TdApi.Chat, order: Long, isPinned: Boolean, - allowMediaDownloads: Boolean = true + allowAvatarDownloads: Boolean = true, + allowMediaDownloads: Boolean = true, + allowRemoteLookups: Boolean = true ): ChatModel { val cachedCounts = parseCachedCounts(chat.clientData) var smallPhoto = chat.photo?.small @@ -91,23 +93,26 @@ class ChatModelFactory( isMember = it.status !is TdApi.ChatMemberStatusLeft isAdmin = it.status is TdApi.ChatMemberStatusAdministrator || it.status is TdApi.ChatMemberStatusCreator - } ?: lazyLoad(cache.pendingBasicGroups, type.basicGroupId) { + } ?: if (allowRemoteLookups) lazyLoad(cache.pendingBasicGroups, type.basicGroupId) { if (type.basicGroupId == 0L) return@lazyLoad val result = gateway.execute(TdApi.GetBasicGroup(type.basicGroupId)) cache.basicGroups[result.id] = result scheduleUpdate(chat.id) - } + } else Unit cache.basicGroupFullInfoCache[type.basicGroupId]?.let { fullInfo -> description = fullInfo.description inviteLink = fullInfo.inviteLink?.inviteLink personalAvatarPath = null - } ?: lazyLoad(cache.pendingBasicGroupFullInfo, type.basicGroupId) { + } ?: if (allowRemoteLookups) lazyLoad( + cache.pendingBasicGroupFullInfo, + type.basicGroupId + ) { if (type.basicGroupId == 0L) return@lazyLoad val result = gateway.execute(TdApi.GetBasicGroupFullInfo(type.basicGroupId)) cache.basicGroupFullInfoCache[type.basicGroupId] = result scheduleUpdate(chat.id) - } + } else Unit } is TdApi.ChatTypeSupergroup -> { @@ -115,6 +120,7 @@ class ChatModelFactory( isChannel = type.isChannel val supergroup = cache.supergroups[type.supergroupId] supergroup?.let { + cache.clearTemporarilyMissingSupergroup(type.supergroupId) memberCount = it.memberCount isVerified = (it.verificationStatus?.isVerified ?: false) || isForcedVerifiedChat(chat.id) isScam = it.verificationStatus?.isScam ?: false @@ -137,12 +143,21 @@ class ChatModelFactory( hasAutomaticTranslation = it.hasAutomaticTranslation signMessages = it.signMessages joinToSendMessages = it.joinToSendMessages - } ?: lazyLoad(cache.pendingSupergroups, type.supergroupId) { - if (type.supergroupId == 0L) return@lazyLoad - val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId)) - cache.supergroups[result.id] = result - scheduleUpdate(chat.id) } + ?: if (allowRemoteLookups && !cache.isSupergroupTemporarilyMissing(type.supergroupId)) lazyLoad( + cache.pendingSupergroups, + type.supergroupId + ) { + if (type.supergroupId == 0L) return@lazyLoad + coRunCatching { + val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId)) + cache.supergroups[result.id] = result + cache.clearTemporarilyMissingSupergroup(type.supergroupId) + scheduleUpdate(chat.id) + }.onFailure { + cache.markSupergroupTemporarilyMissing(type.supergroupId) + } + } else Unit val canLoadSupergroupFullInfo = supergroup?.status?.let { it !is TdApi.ChatMemberStatusLeft && it !is TdApi.ChatMemberStatusBanned @@ -153,12 +168,22 @@ class ChatModelFactory( description = fullInfo.description inviteLink = fullInfo.inviteLink?.inviteLink personalAvatarPath = null - } ?: lazyLoad(cache.pendingSupergroupFullInfo, type.supergroupId) { - if (type.supergroupId == 0L) return@lazyLoad - val result = gateway.execute(TdApi.GetSupergroupFullInfo(type.supergroupId)) - cache.supergroupFullInfoCache[type.supergroupId] = result - scheduleUpdate(chat.id) } + ?: if (allowRemoteLookups && !cache.isSupergroupTemporarilyMissing(type.supergroupId)) lazyLoad( + cache.pendingSupergroupFullInfo, + type.supergroupId + ) { + if (type.supergroupId == 0L) return@lazyLoad + coRunCatching { + val result = + gateway.execute(TdApi.GetSupergroupFullInfo(type.supergroupId)) + cache.supergroupFullInfoCache[type.supergroupId] = result + cache.clearTemporarilyMissingSupergroup(type.supergroupId) + scheduleUpdate(chat.id) + }.onFailure { + cache.markSupergroupTemporarilyMissing(type.supergroupId) + } + } else Unit } } @@ -188,16 +213,21 @@ class ChatModelFactory( val hasStablePhotoIdentity = (user.profilePhoto?.small?.id ?: 0) != 0 || (user.profilePhoto?.big?.id ?: 0) != 0 - if (!hasStablePhotoIdentity) { + if (!hasStablePhotoIdentity && allowRemoteLookups) { + fetchUser(type.userId) + } + } ?: run { + if (allowRemoteLookups) { fetchUser(type.userId) } - } ?: run { fetchUser(type.userId) } + } if (user != null) { cache.userFullInfoCache[type.userId]?.let { fullInfo -> description = fullInfo.bio?.text personalAvatarPath = resolvePhotoPath(fullInfo.personalPhoto, chat.id, allowMediaDownloads) } ?: run { + if (!allowRemoteLookups) return@run if (!isUserFullInfoTemporarilyMissing(type.userId)) { lazyLoad(cache.pendingUserFullInfo, type.userId) { if (type.userId == 0L) return@lazyLoad @@ -236,7 +266,7 @@ class ChatModelFactory( else -> {} } - if (cache.chatPermissionsCache[chat.id] == null) { + if (allowRemoteLookups && cache.chatPermissionsCache[chat.id] == null) { lazyLoad(cache.pendingChatPermissions, chat.id) { val result = gateway.execute(TdApi.GetChat(chat.id)) cache.chatPermissionsCache[chat.id] = result.permissions @@ -244,13 +274,16 @@ class ChatModelFactory( } } - if (cache.myChatMemberCache[chat.id] == null) { + if (allowRemoteLookups && cache.myChatMemberCache[chat.id] == null) { val canGetMember = when (val type = chat.type) { is TdApi.ChatTypePrivate, is TdApi.ChatTypeBasicGroup -> true - is TdApi.ChatTypeSupergroup -> !type.isChannel || - cache.supergroups[type.supergroupId]?.status.let { - it is TdApi.ChatMemberStatusAdministrator || it is TdApi.ChatMemberStatusCreator - } + is TdApi.ChatTypeSupergroup -> if (!type.isChannel) { + true + } else { + cache.supergroups[type.supergroupId]?.status.let { + it is TdApi.ChatMemberStatusAdministrator || it is TdApi.ChatMemberStatusCreator + } + } else -> false } if (canGetMember) { @@ -265,7 +298,7 @@ class ChatModelFactory( } } - val finalPath = resolvePhotoPath(smallPhoto, chat.id, allowMediaDownloads) + val finalPath = resolvePhotoPath(smallPhoto, chat.id, allowAvatarDownloads) val emojiStatusId = (chat.emojiStatus?.type as? TdApi.EmojiStatusTypeCustomEmoji)?.customEmojiId ?: 0L var emojiPath: String? = null diff --git a/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt b/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt index 7439914bf..2ee093385 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatPersistenceManager.kt @@ -43,7 +43,9 @@ class ChatPersistenceManager( chat = chat, order = position?.order ?: 0L, isPinned = position?.isPinned ?: false, - allowMediaDownloads = false + allowAvatarDownloads = false, + allowMediaDownloads = false, + allowRemoteLookups = false ) var entity = chatMapper.mapToEntity(chat, model) if (position != null && (position.order != entity.order || position.isPinned != entity.isPinned)) { @@ -104,7 +106,9 @@ class ChatPersistenceManager( chat = chat, order = position.order, isPinned = position.isPinned, - allowMediaDownloads = false + allowAvatarDownloads = false, + allowMediaDownloads = false, + allowRemoteLookups = false ) } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/GitHubRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/GitHubRemoteDataSource.kt new file mode 100644 index 000000000..d25cf1237 --- /dev/null +++ b/data/src/main/java/org/monogram/data/datasource/remote/GitHubRemoteDataSource.kt @@ -0,0 +1,81 @@ +package org.monogram.data.datasource.remote + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.HttpURLConnection +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +class GitHubRemoteDataSource { + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } + + suspend fun getRecentCommits( + branchOrSha: String, + limit: Int + ): Result> = withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + val encodedBranch = URLEncoder.encode(branchOrSha, StandardCharsets.UTF_8) + val url = URI("$BASE_URL/commits?sha=$encodedBranch&per_page=$limit").toURL() + connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Accept", "application/vnd.github+json") + setRequestProperty("X-GitHub-Api-Version", GITHUB_API_VERSION) + } + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + val responseCode = connection.responseCode + Log.w(TAG, "GitHub commits request failed code=$responseCode ref=$branchOrSha") + return@withContext Result.failure(IllegalStateException("GitHub response code=$responseCode")) + } + + val responseText = connection.inputStream.bufferedReader().use { it.readText() } + Result.success(json.decodeFromString>(responseText)) + } catch (error: Exception) { + Log.w(TAG, "GitHub commits request failed ref=$branchOrSha", error) + Result.failure(error) + } finally { + connection?.disconnect() + } + } + + @Serializable + data class GitHubCommitResponse( + val sha: String, + @SerialName("html_url") + val htmlUrl: String, + val commit: GitHubCommitDetails + ) + + @Serializable + data class GitHubCommitDetails( + val message: String, + val author: GitHubCommitAuthor? = null + ) + + @Serializable + data class GitHubCommitAuthor( + val name: String? = null, + val date: String? = null + ) + + private companion object { + private const val TAG = "GitHubRemote" + private const val BASE_URL = "https://api.github.com/repos/monogram-android/monogram" + private const val USER_AGENT = "MonoGram-Android-App/1.0" + private const val GITHUB_API_VERSION = "2026-03-10" + private const val TIMEOUT_MS = 15_000 + } +} diff --git a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt index 71ab50f34..6f8ced671 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/SettingsRemoteDataSource.kt @@ -14,6 +14,7 @@ interface SettingsRemoteDataSource { scope: TdApi.NotificationSettingsScope, compareSound: Boolean ): TdApi.Chats? + suspend fun getArchiveChatListSettings(): TdApi.ArchiveChatListSettings? suspend fun setDefaultBackground( background: TdApi.InputBackground?, type: TdApi.BackgroundType?, @@ -27,6 +28,7 @@ interface SettingsRemoteDataSource { ) suspend fun setChatNotificationSettings(chatId: Long, settings: TdApi.ChatNotificationSettings) suspend fun setOption(name: String, value: TdApi.OptionValue) + suspend fun setArchiveChatListSettings(settings: TdApi.ArchiveChatListSettings) // Sessions suspend fun terminateSession(sessionId: Long): Boolean 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 c129bad1f..500137e11 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 @@ -62,6 +62,7 @@ import org.monogram.domain.repository.ReadUpdate import org.monogram.domain.repository.SearchChatMessagesResult import org.monogram.domain.repository.UserRepository import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit class TdMessageRemoteDataSource( private val gateway: TelegramGateway, @@ -81,6 +82,7 @@ class TdMessageRemoteDataSource( private val chatRequests = ConcurrentHashMap>() private val messageRequests = ConcurrentHashMap, Deferred>() private val refreshJobs = ConcurrentHashMap, Job>() + private val missingMessageCooldownUntil = ConcurrentHashMap, Long>() private val sendQueue = Channel Unit>(Channel.BUFFERED) override val newMessageFlow = MutableSharedFlow() override val messageEditedFlow = MutableSharedFlow() @@ -153,10 +155,23 @@ class TdMessageRemoteDataSource( override suspend fun getMessage(chatId: Long, messageId: Long): TdApi.Message? { cache.getMessage(chatId, messageId)?.let { return it } val key = chatId to messageId + val cooldownUntil = missingMessageCooldownUntil[key] + if (cooldownUntil != null) { + if (cooldownUntil > System.currentTimeMillis()) { + return null + } + missingMessageCooldownUntil.remove(key, cooldownUntil) + } val deferred = messageRequests.getOrPut(key) { scope.async { val result = safeExecute(TdApi.GetMessage(chatId, messageId)) - if (result != null) cache.putMessage(result) + if (result != null) { + cache.putMessage(result) + missingMessageCooldownUntil.remove(key) + } else { + missingMessageCooldownUntil[key] = + System.currentTimeMillis() + MISSING_MESSAGE_COOLDOWN_MS + } result } } @@ -1798,30 +1813,75 @@ class TdMessageRemoteDataSource( val poll = (update.newContent as TdApi.MessagePoll).poll pollRepository.mapPollIdToMessage(poll.id, update.chatId, update.messageId) } - cache.removeMessage(update.chatId, update.messageId) + cache.updateMessage(update.chatId, update.messageId) { message -> + message.content = update.newContent + } refreshMessageDebounced(update.chatId, update.messageId) } is TdApi.UpdateMessageEdited -> { - cache.removeMessage(update.chatId, update.messageId) + cache.updateMessage(update.chatId, update.messageId) { message -> + message.editDate = update.editDate + message.replyMarkup = update.replyMarkup + } refreshMessageDebounced(update.chatId, update.messageId) } is TdApi.UpdateMessageInteractionInfo -> { - cache.removeMessage(update.chatId, update.messageId) + cache.updateMessage(update.chatId, update.messageId) { message -> + message.interactionInfo = update.interactionInfo + } refreshMessageDebounced(update.chatId, update.messageId) } is TdApi.UpdateMessageReaction -> { - cache.removeMessage(update.chatId, update.messageId) - refreshMessageDebounced(update.chatId, update.messageId) + // Bots receive delta-only reaction updates, so avoid rereads here. } is TdApi.UpdateMessageReactions -> { - cache.removeMessage(update.chatId, update.messageId) - refreshMessageDebounced(update.chatId, update.messageId) + // Bots receive reaction payloads that don't justify a full message reread. } is TdApi.UpdateMessageMentionRead -> { - cache.removeMessage(update.chatId, update.messageId) + cache.updateMessage(update.chatId, update.messageId) { message -> + message.containsUnreadMention = false + } cache.updateChat(update.chatId) { it.unreadMentionCount = update.unreadMentionCount } refreshMessageDebounced(update.chatId, update.messageId) } + + is TdApi.UpdateMessageUnreadReactions -> { + cache.updateMessage(update.chatId, update.messageId) { message -> + message.unreadReactions = update.unreadReactions + } + cache.updateChat(update.chatId) { + it.unreadReactionCount = update.unreadReactionCount + } + refreshMessageDebounced(update.chatId, update.messageId) + } + + is TdApi.UpdateMessageFactCheck -> { + cache.updateMessage(update.chatId, update.messageId) { message -> + message.factCheck = update.factCheck + } + refreshMessageDebounced(update.chatId, update.messageId) + } + + is TdApi.UpdateMessageSuggestedPostInfo -> { + cache.updateMessage(update.chatId, update.messageId) { message -> + message.suggestedPostInfo = update.suggestedPostInfo + } + refreshMessageDebounced(update.chatId, update.messageId) + } + + is TdApi.UpdateMessageIsPinned -> { + cache.updateMessage(update.chatId, update.messageId) { message -> + message.isPinned = update.isPinned + } + scope.launch(dispatcherProvider.io) { pinnedMessageFlow.emit(update.chatId) } + } + + is TdApi.UpdateMessageContainsUnreadPollVotes -> { + cache.updateMessage(update.chatId, update.messageId) { message -> + message.containsUnreadPollVotes = update.containsUnreadPollVotes + } + refreshMessageDebounced(update.chatId, update.messageId) + } is TdApi.UpdateFile -> { scope.launch(dispatcherProvider.io) { handleFileUpdate(update.file) } } @@ -1859,7 +1919,6 @@ class TdMessageRemoteDataSource( } } } - is TdApi.UpdateMessageIsPinned -> { scope.launch(dispatcherProvider.io) { pinnedMessageFlow.emit(update.chatId) } } is TdApi.UpdateChatUnreadMentionCount -> { cache.updateChat(update.chatId) { it.unreadMentionCount = update.unreadMentionCount } } @@ -1888,7 +1947,7 @@ class TdMessageRemoteDataSource( private suspend fun refreshAndEmitMessage(chatId: Long, messageId: Long) { if (messageId == 0L) return - val msg = getMessage(chatId, messageId) ?: return + val msg = cache.getMessage(chatId, messageId) ?: return val model = mapMessageToModel(msg) messageEditedFlow.emit(model) } @@ -2117,7 +2176,7 @@ class TdMessageRemoteDataSource( messageUpdateJobs[key]?.cancel() val job = scope.launch { delay(150) - val msg = getMessage(chatId, messageId) ?: return@launch + val msg = cache.getMessage(chatId, messageId) ?: return@launch try { messageEditedFlow.emit(mapMessageToModel(msg)) } catch (e: CancellationException) { @@ -2135,6 +2194,7 @@ class TdMessageRemoteDataSource( refreshJobs.values.forEach { it.cancel() }; refreshJobs.clear() messageUpdateJobs.values.forEach { it.cancel() }; messageUpdateJobs.clear() lastProgressMap.clear() + missingMessageCooldownUntil.clear() fileDownloadQueue.setObserver(null) } @@ -2163,6 +2223,7 @@ class TdMessageRemoteDataSource( } private companion object { + private val MISSING_MESSAGE_COOLDOWN_MS = TimeUnit.MINUTES.toMillis(2) private const val DRAFT_LINK_PREVIEW_TAG = "DraftLinkPreview" } } diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt index c63f26e98..7b1267bce 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdSettingsRemoteDataSource.kt @@ -80,6 +80,9 @@ class TdSettingsRemoteDataSource( override suspend fun getOption(name: String): TdApi.OptionValue? = coRunCatching { gateway.execute(TdApi.GetOption(name)) }.getOrNull() + override suspend fun getArchiveChatListSettings(): TdApi.ArchiveChatListSettings? = + coRunCatching { gateway.execute(TdApi.GetArchiveChatListSettings()) }.getOrNull() + override suspend fun getChatNotificationSettingsExceptions( scope: TdApi.NotificationSettingsScope, compareSound: Boolean @@ -106,6 +109,10 @@ class TdSettingsRemoteDataSource( coRunCatching { gateway.execute(TdApi.SetOption(name, value)) } } + override suspend fun setArchiveChatListSettings(settings: TdApi.ArchiveChatListSettings) { + coRunCatching { gateway.execute(TdApi.SetArchiveChatListSettings(settings)) } + } + override suspend fun terminateSession(sessionId: Long): Boolean = coRunCatching { gateway.execute(TdApi.TerminateSession(sessionId)); true }.getOrDefault(false) 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 fea29fad4..dc21c5e35 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 @@ -3,10 +3,13 @@ package org.monogram.data.datasource.remote import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit class TdUserRemoteDataSource( private val gateway: TelegramGateway ) : UserRemoteDataSource { + private val missingSupergroupCooldownUntil = ConcurrentHashMap() override suspend fun getUser(userId: Long): TdApi.User? { if (userId == 0L) return null @@ -31,7 +34,11 @@ class TdUserRemoteDataSource( override suspend fun getSupergroup(supergroupId: Long): TdApi.Supergroup? { if (supergroupId == 0L) return null - return coRunCatching { gateway.execute(TdApi.GetSupergroup(supergroupId)) }.getOrNull() + if (isSupergroupTemporarilyMissing(supergroupId)) return null + return coRunCatching { gateway.execute(TdApi.GetSupergroup(supergroupId)) } + .onSuccess { clearTemporarilyMissingSupergroup(supergroupId) } + .onFailure { markSupergroupTemporarilyMissing(supergroupId) } + .getOrNull() } override suspend fun getChat(chatId: Long): TdApi.Chat? { @@ -81,8 +88,9 @@ class TdUserRemoteDataSource( override suspend fun searchPublicChat(username: String): TdApi.Chat? = coRunCatching { gateway.execute(TdApi.SearchPublicChat(username)) }.getOrNull() - override suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? = - coRunCatching { + override suspend fun getChatMember(chatId: Long, userId: Long): TdApi.ChatMember? { + var requestedSupergroupId = 0L + return coRunCatching { val chat = gateway.execute(TdApi.GetChat(chatId)) val me = gateway.execute(TdApi.GetMe()) val requestingOtherUser = userId != me.id @@ -96,11 +104,18 @@ class TdUserRemoteDataSource( } if (type is TdApi.ChatTypeSupergroup) { if (type.supergroupId == 0L) return@coRunCatching null + requestedSupergroupId = type.supergroupId + if (isSupergroupTemporarilyMissing(type.supergroupId)) return@coRunCatching null val supergroup = gateway.execute(TdApi.GetSupergroup(type.supergroupId)) + clearTemporarilyMissingSupergroup(type.supergroupId) val isMember = supergroup.status !is TdApi.ChatMemberStatusLeft && supergroup.status !is TdApi.ChatMemberStatusBanned if (!isMember) return@coRunCatching null + if (!requestingOtherUser && type.isChannel) { + return@coRunCatching null + } + if (!requestingOtherUser) { return@coRunCatching gateway.execute(TdApi.GetChatMember(chatId, TdApi.MessageSenderUser(userId))) } @@ -111,10 +126,14 @@ class TdUserRemoteDataSource( if (!canGetOthers) return@coRunCatching null } gateway.execute(TdApi.GetChatMember(chatId, TdApi.MessageSenderUser(userId))) - }.getOrElse { e -> + }.getOrElse { // Handle 400 CHANNEL_PRIVATE and other errors gracefully + if (requestedSupergroupId != 0L) { + markSupergroupTemporarilyMissing(requestedSupergroupId) + } null } + } override suspend fun getSupergroupMembers( supergroupId: Long, @@ -229,4 +248,26 @@ class TdUserRemoteDataSource( ) { gateway.execute(TdApi.SetChatMemberStatus(chatId, TdApi.MessageSenderUser(userId), status)) } -} \ No newline at end of file + + private fun isSupergroupTemporarilyMissing(supergroupId: Long): Boolean { + val cooldownUntil = missingSupergroupCooldownUntil[supergroupId] ?: return false + if (cooldownUntil > System.currentTimeMillis()) { + return true + } + missingSupergroupCooldownUntil.remove(supergroupId, cooldownUntil) + return false + } + + private fun markSupergroupTemporarilyMissing(supergroupId: Long) { + missingSupergroupCooldownUntil[supergroupId] = + System.currentTimeMillis() + MISSING_SUPERGROUP_COOLDOWN_MS + } + + private fun clearTemporarilyMissingSupergroup(supergroupId: Long) { + missingSupergroupCooldownUntil.remove(supergroupId) + } + + private companion object { + private val MISSING_SUPERGROUP_COOLDOWN_MS = TimeUnit.MINUTES.toMillis(30) + } +} 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 a1230af1f..9e1d3921e 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -1212,8 +1212,9 @@ class TdNotificationManager( } private fun preloadNotificationAssets(senderId: TdApi.MessageSender?, chat: TdApi.Chat) { - resolveSender(senderId, chat, false) { _, _ -> } - downloadFile(chat.photo?.small, false) { _ -> } + // Avoid network-triggered preloads for notifications; use local cache only. + resolveSender(senderId, chat, true) { _, _ -> } + downloadFile(chat.photo?.small, true) { _ -> } } private fun downloadFile(file: TdApi.File?, onlyIfLocal: Boolean = false, callback: (Bitmap?) -> Unit) { 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 de054f91d..14eb63391 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -30,6 +30,7 @@ import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.datasource.remote.EmojiRemoteSource import org.monogram.data.datasource.remote.FxEmbedRemoteDataSource import org.monogram.data.datasource.remote.GifRemoteSource +import org.monogram.data.datasource.remote.GitHubRemoteDataSource import org.monogram.data.datasource.remote.LinkRemoteDataSource import org.monogram.data.datasource.remote.MessageFileApi import org.monogram.data.datasource.remote.MessageFileCoordinator @@ -94,10 +95,12 @@ import org.monogram.data.repository.BotRepositoryImpl import org.monogram.data.repository.ChatInfoRepositoryImpl import org.monogram.data.repository.ChatStatisticsRepositoryImpl import org.monogram.data.repository.ChatsListRepositoryImpl +import org.monogram.data.repository.ClientOptionsRepositoryImpl import org.monogram.data.repository.ContactEditRepositoryImpl import org.monogram.data.repository.DraftLinkPreviewResolver import org.monogram.data.repository.EmojiRepositoryImpl import org.monogram.data.repository.GifRepositoryImpl +import org.monogram.data.repository.GitHubCommitRepositoryImpl import org.monogram.data.repository.LinkHandlerRepositoryImpl import org.monogram.data.repository.LinkParser import org.monogram.data.repository.LocationRepositoryImpl @@ -136,11 +139,13 @@ import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSearchRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.ClientOptionsRepository import org.monogram.domain.repository.ContactEditRepository import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.ForumTopicsRepository import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.GitHubCommitRepository import org.monogram.domain.repository.InlineBotRepository import org.monogram.domain.repository.LinkHandlerRepository import org.monogram.domain.repository.LocationRepository @@ -189,6 +194,7 @@ val dataModule = module { gateway = get(), chatDao = get(), messageDao = get(), + keyValueDao = get(), userDao = get(), userFullInfoDao = get(), chatFullInfoDao = get(), @@ -237,6 +243,10 @@ val dataModule = module { FxEmbedRemoteDataSource() } + single { + GitHubRemoteDataSource() + } + factory { PlayerDataSourceFactoryImpl( fileDataSource = get() @@ -584,6 +594,12 @@ val dataModule = module { InMemorySettingsCacheDataSource() } + single { + ClientOptionsRepositoryImpl( + remote = get() + ) + } + single { NotificationSettingsRepositoryImpl( remote = get(), @@ -836,6 +852,12 @@ val dataModule = module { ) } + single { + GitHubCommitRepositoryImpl( + remoteDataSource = get() + ) + } + single { LinkParser() } diff --git a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt index 19a38debb..f0f8efe6b 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -3,7 +3,6 @@ package org.monogram.data.infra import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider @@ -12,10 +11,12 @@ import org.monogram.data.compat.toDomainSupportsGuestQueries import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.ChatDao import org.monogram.data.db.dao.ChatFullInfoDao +import org.monogram.data.db.dao.KeyValueDao import org.monogram.data.db.dao.MessageDao import org.monogram.data.db.dao.UserDao import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.db.model.ChatEntity +import org.monogram.data.db.model.KeyValueEntity import org.monogram.data.db.model.UserEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.MessageMapper @@ -24,6 +25,7 @@ import org.monogram.data.mapper.user.toTdApi import org.monogram.domain.models.stories.StoryListType import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.StoryRepository +import java.util.concurrent.TimeUnit private const val TAG = "OfflineWarmup" @@ -33,6 +35,7 @@ class OfflineWarmup( private val gateway: TelegramGateway, private val chatDao: ChatDao, private val messageDao: MessageDao, + private val keyValueDao: KeyValueDao, private val userDao: UserDao, private val userFullInfoDao: UserFullInfoDao, private val chatFullInfoDao: ChatFullInfoDao, @@ -212,14 +215,29 @@ class OfflineWarmup( when { chat.supergroupId != 0L -> { - val supergroupInfo = gateway.execute(TdApi.GetSupergroupFullInfo(chat.supergroupId)) as? TdApi.SupergroupFullInfo + if (shouldSkipMissingSupergroupWarmup(chat.supergroupId, now)) { + delay(30) + continue + } + val supergroupInfo = coRunCatching { + gateway.execute(TdApi.GetSupergroupFullInfo(chat.supergroupId)) as? TdApi.SupergroupFullInfo + }.getOrNull() if (supergroupInfo != null) { chatFullInfoDao.insertChatFullInfo(supergroupInfo.toEntity(chat.id)) - val supergroup = gateway.execute(TdApi.GetSupergroup(chat.supergroupId)) as? TdApi.Supergroup + val supergroup = coRunCatching { + gateway.execute(TdApi.GetSupergroup(chat.supergroupId)) as? TdApi.Supergroup + }.onFailure { + markMissingSupergroupWarmup(chat.supergroupId) + chatCache.markSupergroupTemporarilyMissing(chat.supergroupId) + }.getOrNull() if (supergroup != null) { + clearMissingSupergroupWarmup(chat.supergroupId) chatCache.putSupergroup(supergroup) chatCache.putSupergroupFullInfo(chat.supergroupId, supergroupInfo) } + } else { + markMissingSupergroupWarmup(chat.supergroupId) + chatCache.markSupergroupTemporarilyMissing(chat.supergroupId) } } @@ -383,6 +401,30 @@ class OfflineWarmup( } } + private suspend fun shouldSkipMissingSupergroupWarmup(supergroupId: Long, now: Long): Boolean { + val value = + keyValueDao.getValue("$MISSING_SUPERGROUP_WARMUP_KEY_PREFIX$supergroupId")?.value + ?.toLongOrNull() ?: return false + if (value > now) { + return true + } + clearMissingSupergroupWarmup(supergroupId) + return false + } + + private suspend fun markMissingSupergroupWarmup(supergroupId: Long) { + keyValueDao.insertValue( + KeyValueEntity( + key = "$MISSING_SUPERGROUP_WARMUP_KEY_PREFIX$supergroupId", + value = (System.currentTimeMillis() + MISSING_SUPERGROUP_WARMUP_TTL_MS).toString() + ) + ) + } + + private suspend fun clearMissingSupergroupWarmup(supergroupId: Long) { + keyValueDao.deleteValue("$MISSING_SUPERGROUP_WARMUP_KEY_PREFIX$supergroupId") + } + private companion object { private const val USER_WARMUP_LIMIT = 15 private const val USER_WARMUP_DELAY_MS = 150L @@ -392,5 +434,7 @@ class OfflineWarmup( private const val STORY_CHAT_WARMUP_DELAY_MS = 180L private const val ONE_DAY_MS = 24L * 60 * 60 * 1000 private const val SEVEN_DAYS_MS = 7L * ONE_DAY_MS + private val MISSING_SUPERGROUP_WARMUP_TTL_MS = TimeUnit.DAYS.toMillis(7) + private const val MISSING_SUPERGROUP_WARMUP_KEY_PREFIX = "missing_supergroup_warmup_" } } diff --git a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt index d5033313b..6ee32f883 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -115,7 +115,9 @@ class MessageMapper internal constructor( suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int { val chat = cache.getChat(chatId) - if (chat?.type !is TdApi.ChatTypePrivate) { + val privateChat = chat?.type as? TdApi.ChatTypePrivate ?: return 0 + val privateUser = cache.usersCache[privateChat.userId] ?: return 0 + if (privateUser?.type is TdApi.UserTypeBot) { return 0 } @@ -280,7 +282,7 @@ class MessageMapper internal constructor( if (replyTo !is TdApi.MessageReplyToMessage) return null to null val replyToMsgId = replyTo.messageId - if (!options.resolveReplyPreviewFromNetwork) { + if (!options.resolveReplyPreviewFromNetwork || !isChatOpen) { val cachedReply = cache.getMessage(msg.chatId, replyToMsgId) val cachedReplyModel = cachedReply?.let { mapMessageToModel( diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index b75a88ee7..ec663b3d5 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -376,7 +376,14 @@ class ChatsListRepositoryImpl( ) { cached } else { - modelFactory.mapChatToModel(chat, order, isPinned).also { mapped -> + modelFactory.mapChatToModel( + chat = chat, + order = order, + isPinned = isPinned, + allowAvatarDownloads = true, + allowMediaDownloads = false, + allowRemoteLookups = false + ).also { mapped -> modelCache[chat.id] = mapped invalidatedModels.remove(chat.id) } @@ -403,7 +410,14 @@ class ChatsListRepositoryImpl( if (oldModel.order != position.order || oldModel.isPinned != position.isPinned) { return null } - updated[index] = modelFactory.mapChatToModel(chat, position.order, position.isPinned).also { mapped -> + updated[index] = modelFactory.mapChatToModel( + chat = chat, + order = position.order, + isPinned = position.isPinned, + allowAvatarDownloads = true, + allowMediaDownloads = false, + allowRemoteLookups = false + ).also { mapped -> modelCache[chatId] = mapped } invalidatedModels.remove(chatId) @@ -752,7 +766,13 @@ class ChatsListRepositoryImpl( val position = chatObj.positions.find { listManager.isSameChatList(it.list, activeChatList) } return coRunCatching { - modelFactory.mapChatToModel(chatObj, position?.order ?: 0L, position?.isPinned ?: false) + modelFactory.mapChatToModel( + chat = chatObj, + order = position?.order ?: 0L, + isPinned = position?.isPinned ?: false, + allowAvatarDownloads = true, + allowMediaDownloads = false + ) }.getOrNull() } diff --git a/data/src/main/java/org/monogram/data/repository/ClientOptionsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ClientOptionsRepositoryImpl.kt new file mode 100644 index 000000000..f9a0f124f --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/ClientOptionsRepositoryImpl.kt @@ -0,0 +1,61 @@ +package org.monogram.data.repository + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.domain.repository.ClientOptionsRepository + +class ClientOptionsRepositoryImpl( + private val remote: SettingsRemoteDataSource +) : ClientOptionsRepository { + override suspend fun getContactJoinedNotificationsEnabled(): Boolean = + getInverseBooleanOption("disable_contact_registered_notifications") + + override suspend fun setContactJoinedNotificationsEnabled(enabled: Boolean) { + setInverseBooleanOption("disable_contact_registered_notifications", enabled) + } + + override suspend fun getSentScheduledMessageNotificationsEnabled(): Boolean = + getInverseBooleanOption("disable_sent_scheduled_message_notifications") + + override suspend fun setSentScheduledMessageNotificationsEnabled(enabled: Boolean) { + setInverseBooleanOption("disable_sent_scheduled_message_notifications", enabled) + } + + override suspend fun getAnimatedEmojiEnabled(): Boolean = + getInverseBooleanOption("disable_animated_emoji") + + override suspend fun setAnimatedEmojiEnabled(enabled: Boolean) { + setInverseBooleanOption("disable_animated_emoji", enabled) + } + + override suspend fun canArchiveAndMuteNewChatsFromUnknownUsers(): Boolean { + val result = remote.getOption("can_archive_and_mute_new_chats_from_unknown_users") + return (result as? TdApi.OptionValueBoolean)?.value ?: false + } + + override suspend fun getArchiveAndMuteNewChatsFromUnknownUsersEnabled(): Boolean { + return remote.getArchiveChatListSettings()?.archiveAndMuteNewChatsFromUnknownUsers ?: false + } + + override suspend fun setArchiveAndMuteNewChatsFromUnknownUsersEnabled(enabled: Boolean) { + val current = remote.getArchiveChatListSettings() ?: TdApi.ArchiveChatListSettings() + remote.setArchiveChatListSettings( + TdApi.ArchiveChatListSettings( + enabled, + current.keepUnmutedChatsArchived, + current.keepChatsFromFoldersArchived + ) + ) + } + + private suspend fun getInverseBooleanOption(name: String): Boolean { + return when (val result = remote.getOption(name)) { + is TdApi.OptionValueBoolean -> !result.value + else -> true + } + } + + private suspend fun setInverseBooleanOption(name: String, enabled: Boolean) { + remote.setOption(name, TdApi.OptionValueBoolean(!enabled)) + } +} diff --git a/data/src/main/java/org/monogram/data/repository/GitHubCommitRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/GitHubCommitRepositoryImpl.kt new file mode 100644 index 000000000..02f5255cd --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/GitHubCommitRepositoryImpl.kt @@ -0,0 +1,36 @@ +package org.monogram.data.repository + +import org.monogram.data.datasource.remote.GitHubRemoteDataSource +import org.monogram.domain.models.GitHubCommitModel +import org.monogram.domain.repository.GitHubCommitRepository + +class GitHubCommitRepositoryImpl( + private val remoteDataSource: GitHubRemoteDataSource +) : GitHubCommitRepository { + + override suspend fun getRecentCommits( + branchOrSha: String, + limit: Int + ): Result> { + return remoteDataSource.getRecentCommits(branchOrSha, limit).map { commits -> + commits.map(::toGitHubCommitModel) + } + } +} + +internal fun toGitHubCommitModel( + response: GitHubRemoteDataSource.GitHubCommitResponse +): GitHubCommitModel { + val firstMessageLine = response.commit.message.lineSequence() + .firstOrNull() + ?.trim() + .orEmpty() + + return GitHubCommitModel( + sha = response.sha, + message = firstMessageLine.ifBlank { response.sha.take(7) }, + authorName = response.commit.author?.name?.takeIf { it.isNotBlank() } ?: "Unknown author", + committedAt = response.commit.author?.date.orEmpty(), + htmlUrl = response.htmlUrl + ) +} 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 584241af3..5edc6bd41 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -246,8 +246,6 @@ internal class MessageRepositoryImpl( } is TdApi.UpdateMessageContent -> { - if (refreshPersistedMessage(update.chatId, update.messageId)) return - val extracted = messageMapper.extractCachedContent(update.newContent) chatLocalDataSource.updateMessageContent( chatId = update.chatId, @@ -257,16 +255,16 @@ internal class MessageRepositoryImpl( contentMeta = extracted.meta, mediaFileId = extracted.fileId, mediaPath = extracted.path, - editDate = 0 + editDate = cache.getMessage(update.chatId, update.messageId)?.editDate ?: 0 ) } is TdApi.UpdateMessageEdited -> { - refreshPersistedMessage(update.chatId, update.messageId) + persistCachedMessage(update.chatId, update.messageId) } is TdApi.UpdateMessageInteractionInfo -> { - if (refreshPersistedMessage(update.chatId, update.messageId)) return + if (persistCachedMessage(update.chatId, update.messageId)) return chatLocalDataSource.updateInteractionInfo( chatId = update.chatId, messageId = update.messageId, @@ -309,7 +307,7 @@ internal class MessageRepositoryImpl( is TdApi.UpdateMessageContentOpened -> update.messageId else -> 0L } - refreshPersistedMessage(chatId, messageId) + persistCachedMessage(chatId, messageId) } is TdApi.UpdateChatReadInbox -> { @@ -344,9 +342,9 @@ internal class MessageRepositoryImpl( ) } - private suspend fun refreshPersistedMessage(chatId: Long, messageId: Long): Boolean { - val refreshed = messageRemoteDataSource.getMessage(chatId, messageId) ?: return false - persistMappedMessage(refreshed) + private suspend fun persistCachedMessage(chatId: Long, messageId: Long): Boolean { + val cachedMessage = cache.getMessage(chatId, messageId) ?: return false + persistMappedMessage(cachedMessage) return true } @@ -928,7 +926,7 @@ internal class MessageRepositoryImpl( override suspend fun getRawMessageJson(chatId: Long, messageId: Long): String? = withContext(dispatcherProvider.io) { - val raw = messageRemoteDataSource.getMessage(chatId, messageId)?.toString() + val raw = cache.getMessage(chatId, messageId)?.toString() ?: return@withContext null runCatching { JSONObject(raw).toString(2) }.getOrElse { raw } } diff --git a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt index 38a0fc277..0163aedf3 100644 --- a/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt +++ b/data/src/test/java/org/monogram/data/infra/ConnectionManagerTest.kt @@ -566,6 +566,7 @@ class ConnectionManagerTest { override val inAppVibrate = MutableStateFlow(true) override val inAppPreview = MutableStateFlow(true) override val contactJoinedNotifications = MutableStateFlow(true) + override val scheduledMessagesNotifications = MutableStateFlow(true) override val pinnedMessagesNotifications = MutableStateFlow(true) override val backgroundServiceEnabled = MutableStateFlow(false) override val isPowerSavingMode = MutableStateFlow(false) @@ -761,6 +762,10 @@ class ConnectionManagerTest { contactJoinedNotifications.value = enabled } + override fun setScheduledMessagesNotifications(enabled: Boolean) { + scheduledMessagesNotifications.value = enabled + } + override fun setPinnedMessagesNotifications(enabled: Boolean) { pinnedMessagesNotifications.value = enabled } diff --git a/domain/src/main/java/org/monogram/domain/models/GitHubCommitModel.kt b/domain/src/main/java/org/monogram/domain/models/GitHubCommitModel.kt new file mode 100644 index 000000000..886f001e6 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/GitHubCommitModel.kt @@ -0,0 +1,9 @@ +package org.monogram.domain.models + +data class GitHubCommitModel( + val sha: String, + val message: String, + val authorName: String, + val committedAt: String, + val htmlUrl: String +) diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt index 25e25bf4e..1fb06b6ea 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -83,6 +83,7 @@ interface AppPreferencesProvider { val inAppVibrate: StateFlow val inAppPreview: StateFlow val contactJoinedNotifications: StateFlow + val scheduledMessagesNotifications: StateFlow val pinnedMessagesNotifications: StateFlow val backgroundServiceEnabled: StateFlow val isPowerSavingMode: StateFlow @@ -142,6 +143,7 @@ interface AppPreferencesProvider { fun setInAppVibrate(enabled: Boolean) fun setInAppPreview(enabled: Boolean) fun setContactJoinedNotifications(enabled: Boolean) + fun setScheduledMessagesNotifications(enabled: Boolean) fun setPinnedMessagesNotifications(enabled: Boolean) fun setBackgroundServiceEnabled(enabled: Boolean) fun setPowerSavingMode(enabled: Boolean) diff --git a/domain/src/main/java/org/monogram/domain/repository/ClientOptionsRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ClientOptionsRepository.kt new file mode 100644 index 000000000..3642072a3 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/ClientOptionsRepository.kt @@ -0,0 +1,16 @@ +package org.monogram.domain.repository + +interface ClientOptionsRepository { + suspend fun getContactJoinedNotificationsEnabled(): Boolean + suspend fun setContactJoinedNotificationsEnabled(enabled: Boolean) + + suspend fun getSentScheduledMessageNotificationsEnabled(): Boolean + suspend fun setSentScheduledMessageNotificationsEnabled(enabled: Boolean) + + suspend fun getAnimatedEmojiEnabled(): Boolean + suspend fun setAnimatedEmojiEnabled(enabled: Boolean) + + suspend fun canArchiveAndMuteNewChatsFromUnknownUsers(): Boolean + suspend fun getArchiveAndMuteNewChatsFromUnknownUsersEnabled(): Boolean + suspend fun setArchiveAndMuteNewChatsFromUnknownUsersEnabled(enabled: Boolean) +} diff --git a/domain/src/main/java/org/monogram/domain/repository/GitHubCommitRepository.kt b/domain/src/main/java/org/monogram/domain/repository/GitHubCommitRepository.kt new file mode 100644 index 000000000..b2ae8e9aa --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/GitHubCommitRepository.kt @@ -0,0 +1,10 @@ +package org.monogram.domain.repository + +import org.monogram.domain.models.GitHubCommitModel + +interface GitHubCommitRepository { + suspend fun getRecentCommits( + branchOrSha: String, + limit: Int = 20 + ): Result> +} diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index b95eda01a..0656947c8 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -5,6 +5,37 @@ plugins { id("kotlin-parcelize") } +fun String.asBuildConfigString(): String = "\"${replace("\\", "\\\\").replace("\"", "\\\"")}\"" + +fun Project.resolveBuildInfoProperty(propertyName: String, envName: String): String? { + return providers.gradleProperty(propertyName).orNull?.takeIf { it.isNotBlank() } + ?: providers.environmentVariable(envName).orNull?.takeIf { it.isNotBlank() } +} + +fun Project.runCommandAndCaptureOutput(vararg command: String): String? { + return runCatching { + providers.exec { + commandLine(*command) + isIgnoreExitValue = true + }.standardOutput.asText.get().trim().takeIf { it.isNotBlank() } + }.getOrNull() +} + +val buildCommitHash = + resolveBuildInfoProperty("buildCommitHash", "BUILD_COMMIT_HASH") + ?: runCommandAndCaptureOutput("git", "rev-parse", "--short=7", "HEAD") + ?: "unknown" + +val buildBranch = + resolveBuildInfoProperty("buildBranch", "BUILD_BRANCH") + ?: runCommandAndCaptureOutput("git", "branch", "--show-current")?.takeIf { it.isNotBlank() } + ?: "detached" + +val buildTimeMillis = + resolveBuildInfoProperty("buildTimeMillis", "BUILD_TIME_MILLIS") + ?.toLongOrNull() + ?: System.currentTimeMillis() + android { namespace = "org.monogram.presentation" compileSdk = 37 @@ -12,6 +43,9 @@ android { defaultConfig { minSdk = 25 consumerProguardFiles("consumer-rules.pro") + buildConfigField("String", "BUILD_BRANCH", buildBranch.asBuildConfigString()) + buildConfigField("String", "BUILD_COMMIT_HASH", buildCommitHash.asBuildConfigString()) + buildConfigField("long", "BUILD_TIME_MILLIS", "${buildTimeMillis}L") externalNativeBuild { cmake { cppFlags("-std=c++17") diff --git a/presentation/src/main/java/org/monogram/presentation/core/media/AlphaVideoPlayer.kt b/presentation/src/main/java/org/monogram/presentation/core/media/AlphaVideoPlayer.kt index 31ed7fc4a..695f3f9f6 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/media/AlphaVideoPlayer.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/media/AlphaVideoPlayer.kt @@ -130,7 +130,10 @@ fun VideoStickerPlayer( val currentThumbnailData by rememberUpdatedState(thumbnailData) val currentAnimate by rememberUpdatedState(animate) val effectiveStreamingFileId = remember(currentPath, fileId) { - if (currentPath.startsWith("http://streaming/")) fileId else 0 + if (currentPath.startsWith("http://streaming/") && fileId != 0) fileId else 0 + } + val hasInvalidStreamingPath = remember(currentPath, effectiveStreamingFileId) { + currentPath.startsWith("http://streaming/") && effectiveStreamingFileId == 0 } val isGif = type == VideoType.Gif @@ -157,7 +160,7 @@ fun VideoStickerPlayer( } if (animate) { delay(80) - if (isActive) shouldLoadPlayer = true + if (isActive && !hasInvalidStreamingPath) shouldLoadPlayer = true } } @@ -210,6 +213,10 @@ fun VideoStickerPlayer( val isNetworkPath = currentPath.startsWith("http") || currentPath.startsWith("content") + if (hasInvalidStreamingPath) { + isDisposed.set(true) + return@DisposableEffect onDispose {} + } if (!isNetworkPath && !File(currentPath).exists()) { if (isGif) { Log.d( diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt index dc6aa67e6..8394ee901 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -267,6 +267,11 @@ class AppPreferences( private val _contactJoinedNotifications = MutableStateFlow(prefs.getBoolean(KEY_CONTACT_JOINED, true)) override val contactJoinedNotifications: StateFlow = _contactJoinedNotifications + private val _scheduledMessagesNotifications = + MutableStateFlow(prefs.getBoolean(KEY_SCHEDULED_MESSAGES, true)) + override val scheduledMessagesNotifications: StateFlow = + _scheduledMessagesNotifications + private val _pinnedMessagesNotifications = MutableStateFlow(prefs.getBoolean(KEY_PINNED_MESSAGES, true)) override val pinnedMessagesNotifications: StateFlow = _pinnedMessagesNotifications @@ -902,6 +907,11 @@ class AppPreferences( _contactJoinedNotifications.value = enabled } + override fun setScheduledMessagesNotifications(enabled: Boolean) { + prefs.edit().putBoolean(KEY_SCHEDULED_MESSAGES, enabled).apply() + _scheduledMessagesNotifications.value = enabled + } + override fun setPinnedMessagesNotifications(enabled: Boolean) { prefs.edit().putBoolean(KEY_PINNED_MESSAGES, enabled).apply() _pinnedMessagesNotifications.value = enabled @@ -1211,6 +1221,7 @@ class AppPreferences( _inAppVibrate.value = true _inAppPreview.value = true _contactJoinedNotifications.value = true + _scheduledMessagesNotifications.value = true _pinnedMessagesNotifications.value = true _backgroundServiceEnabled.value = false _isPowerSavingMode.value = false @@ -1364,6 +1375,7 @@ class AppPreferences( private const val KEY_IN_APP_VIBRATE = "in_app_vibrate" private const val KEY_IN_APP_PREVIEW = "in_app_preview" private const val KEY_CONTACT_JOINED = "contact_joined" + private const val KEY_SCHEDULED_MESSAGES = "scheduled_messages_notifications" private const val KEY_PINNED_MESSAGES = "pinned_messages" private const val KEY_BACKGROUND_SERVICE_ENABLED = "background_service_enabled" private const val KEY_POWER_SAVING_MODE = "power_saving_mode" diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/BuildInfo.kt b/presentation/src/main/java/org/monogram/presentation/core/util/BuildInfo.kt new file mode 100644 index 000000000..7a844a6dd --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/util/BuildInfo.kt @@ -0,0 +1,32 @@ +package org.monogram.presentation.core.util + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +private const val UNKNOWN_BUILD_INFO = "unknown" +private val utcTimeZone = TimeZone.getTimeZone("UTC") + +fun Long.toBuildTimeString(locale: Locale = Locale.getDefault()): String { + if (this <= 0L) return UNKNOWN_BUILD_INFO + + val formatter = SimpleDateFormat("dd.MM.yyyy HH:mm 'UTC'", locale) + formatter.timeZone = utcTimeZone + return formatter.format(Date(this)) +} + +fun String.toGitHubCommitTimeString(locale: Locale = Locale.getDefault()): String { + if (isBlank()) return UNKNOWN_BUILD_INFO + + val parser = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = utcTimeZone + } + val formatter = SimpleDateFormat("dd.MM.yyyy HH:mm 'UTC'", locale).apply { + timeZone = utcTimeZone + } + + return runCatching { + parser.parse(this)?.let(formatter::format) + }.getOrNull() ?: this +} diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt index 42ebf908b..1babe9ae3 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -24,6 +24,7 @@ import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSearchRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.ClientOptionsRepository import org.monogram.domain.repository.ContactEditRepository import org.monogram.domain.repository.EditorSnippetProvider import org.monogram.domain.repository.EmojiRepository @@ -31,6 +32,7 @@ import org.monogram.domain.repository.ExternalNavigator import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.ForumTopicsRepository import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.GitHubCommitRepository import org.monogram.domain.repository.InlineBotRepository import org.monogram.domain.repository.LinkHandlerRepository import org.monogram.domain.repository.LocationRepository @@ -106,6 +108,7 @@ interface RepositoriesContainer { val chatStatisticsRepository: ChatStatisticsRepository val sponsorRepository: SponsorRepository val notificationSettingsRepository: NotificationSettingsRepository + val clientOptionsRepository: ClientOptionsRepository val sessionRepository: SessionRepository val wallpaperRepository: WallpaperRepository val storageRepository: StorageRepository @@ -121,6 +124,7 @@ interface RepositoriesContainer { val gifRepository: GifRepository val emojiRepository: EmojiRepository val telegramLinkRepository: TelegramLinkRepository + val gitHubCommitRepository: GitHubCommitRepository val updateRepository: UpdateRepository val pushDebugRepository: PushDebugRepository } diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt index 94e33beb5..f9a91e957 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -25,6 +25,7 @@ import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSearchRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.ClientOptionsRepository import org.monogram.domain.repository.ContactEditRepository import org.monogram.domain.repository.EditorSnippetProvider import org.monogram.domain.repository.EmojiRepository @@ -32,6 +33,7 @@ import org.monogram.domain.repository.ExternalNavigator import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.ForumTopicsRepository import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.GitHubCommitRepository import org.monogram.domain.repository.InlineBotRepository import org.monogram.domain.repository.LinkHandlerRepository import org.monogram.domain.repository.LocationRepository @@ -107,6 +109,7 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer override val chatStatisticsRepository: ChatStatisticsRepository by lazy { koin.get() } override val sponsorRepository: SponsorRepository by lazy { koin.get() } override val notificationSettingsRepository: NotificationSettingsRepository by lazy { koin.get() } + override val clientOptionsRepository: ClientOptionsRepository by lazy { koin.get() } override val sessionRepository: SessionRepository by lazy { koin.get() } override val wallpaperRepository: WallpaperRepository by lazy { koin.get() } override val storageRepository: StorageRepository by lazy { koin.get() } @@ -122,6 +125,7 @@ class KoinRepositoriesContainer(private val koin: Koin) : RepositoriesContainer override val gifRepository: GifRepository by lazy { koin.get() } override val emojiRepository: EmojiRepository by lazy { koin.get() } override val telegramLinkRepository: TelegramLinkRepository by lazy { koin.get() } + override val gitHubCommitRepository: GitHubCommitRepository by lazy { koin.get() } override val updateRepository: UpdateRepository by lazy { koin.get() } override val pushDebugRepository: PushDebugRepository by lazy { koin.get() } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt index d204577b2..9f1b465e6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/logic/chat/ChatInfo.kt @@ -325,6 +325,17 @@ internal fun DefaultChatComponent.handleConfirmRestrict( private suspend fun DefaultChatComponent.refreshCurrentUserRestrictionState() { val me = runCatching { userRepository.getMe() }.getOrNull() ?: return val effectiveChatId = _state.value.effectiveThreadChatId(chatId) + val effectiveChat = runCatching { chatListRepository.getChatById(effectiveChatId) }.getOrNull() + if (effectiveChat?.isChannel == true) { + _state.update { + it.copy( + isCurrentUserRestricted = false, + restrictedUntilDate = 0, + effectiveInputPermissions = null + ) + } + return + } val status = runCatching { chatInfoRepository.getChatMember(effectiveChatId, me.id)?.status }.getOrNull() val restrictedStatus = status as? ChatMemberStatus.Restricted 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 fa8e3cdeb..deeec2e72 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 @@ -1059,6 +1059,12 @@ internal fun DefaultChatComponent.scrollToBottomInternal() { return } if (currentState.messages.isNotEmpty() && !currentState.isLatestLoaded) { + _state.update { it.enqueueRuntimeScrollToBottom(animated = true) } + ChatConversationLog.logViewportState( + event = "scroll_to_bottom_partial_runtime_enqueued", + state = _state.value, + componentInstanceId = componentInstanceId + ) if (loadNewerJob?.isActive == true || currentState.isLoadingNewer) return loadNewerJob = scope.launch { _state.update { it.copy(isLoadingNewer = true) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt index 781e23b33..ee853796b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/CompactMediaMosaic.kt @@ -497,14 +497,17 @@ fun VideoItem( var isRevealed by remember { mutableStateOf(!video.hasSpoiler) } Box(modifier = modifier.clipToBounds()) { + val canStream = video.supportsStreaming && video.fileId != 0 Crossfade( - targetState = hasPath || video.supportsStreaming, + targetState = hasPath || canStream, animationSpec = tween(300), label = "VideoLoading" ) { targetHasPathOrStreaming -> if (targetHasPathOrStreaming) { if (autoplayVideos) { - val videoPath = stablePath ?: "http://streaming/${video.fileId}" + val videoPath = stablePath ?: video.fileId.takeIf { it != 0 } + ?.let { "http://streaming/$it" } + if (videoPath == null) return@Crossfade VideoStickerPlayer( path = videoPath, type = VideoType.Gif, @@ -527,7 +530,7 @@ fun VideoItem( }, animate = !isAnyViewerOpen, contentScale = contentScale, - fileId = if (stablePath == null) video.fileId else 0, + fileId = if (stablePath == null && video.fileId != 0) video.fileId else 0, thumbnailData = video.minithumbnail ) } else { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt index 9aca60df7..1e69351d2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/channel/ChannelVideoMessageBubble.kt @@ -219,9 +219,12 @@ fun ChannelVideoMessageBubble( .onGloballyPositioned { videoPosition = it.positionInWindow() } ) { - if (hasPath || content.supportsStreaming) { + val canStream = content.supportsStreaming && content.fileId != 0 + if (hasPath || canStream) { if (autoplayVideos) { - val videoPath = stablePath ?: "http://streaming/${content.fileId}" + val videoPath = stablePath ?: content.fileId.takeIf { it != 0 } + ?.let { "http://streaming/$it" } + if (videoPath == null) return@Box VideoStickerPlayer( path = videoPath, type = VideoType.Gif, @@ -236,7 +239,7 @@ fun ChannelVideoMessageBubble( currentPositionSeconds = seconds } }, - fileId = content.fileId, + fileId = if (stablePath == null && content.fileId != 0) content.fileId else 0, thumbnailData = content.minithumbnail ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt index b0459d49d..1d6169fc2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/conversation/ui/message/VideoMessageBubble.kt @@ -233,9 +233,12 @@ fun VideoMessageBubble( } ) { - if (hasPath || content.supportsStreaming) { + val canStream = content.supportsStreaming && content.fileId != 0 + if (hasPath || canStream) { if (autoplayVideos) { - val videoPath = stablePath ?: "http://streaming/${content.fileId}" + val videoPath = stablePath ?: content.fileId.takeIf { it != 0 } + ?.let { "http://streaming/$it" } + if (videoPath == null) return@Box VideoStickerPlayer( path = videoPath, type = VideoType.Gif, @@ -250,7 +253,7 @@ fun VideoMessageBubble( currentPositionSecondsState.intValue = seconds } }, - fileId = content.fileId, + fileId = if (stablePath == null && content.fileId != 0) content.fileId else 0, thumbnailData = content.minithumbnail ) 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 7265a01d0..6ac962a5e 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -115,6 +115,7 @@ class DefaultRootComponent( private val phoneManager: PhoneManager = container.utils.phoneManager() private val stringProvider: StringProvider = container.utils.stringProvider() private val updateRepository: UpdateRepository = container.repositories.updateRepository + private val gitHubCommitRepository = container.repositories.gitHubCommitRepository private val userRepository: UserRepository = container.repositories.userRepository private val cacheProvider: CacheProvider = container.cacheProvider @@ -989,6 +990,7 @@ class DefaultRootComponent( DefaultAboutComponent( context = context, updateRepository = updateRepository, + gitHubCommitRepository = gitHubCommitRepository, onBack = { navigation.pop() }, onTermsOfService = { externalNavigator.openUrl("https://telegram.org/tos") }, onOpenSourceLicenses = { externalNavigator.openOssLicenses() } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/about/AboutComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/about/AboutComponent.kt index a5d0408f4..707642df5 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/about/AboutComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/about/AboutComponent.kt @@ -3,22 +3,40 @@ package org.monogram.presentation.settings.about 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 org.monogram.domain.models.GitHubCommitModel import org.monogram.domain.models.UpdateState +import org.monogram.domain.repository.GitHubCommitRepository import org.monogram.domain.repository.UpdateRepository import org.monogram.presentation.BuildConfig import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext +data class RecentCommitsState( + val isVisible: Boolean = false, + val isLoading: Boolean = false, + val commits: List = emptyList(), + val errorMessage: String? = null +) + interface AboutComponent { val updateState: StateFlow val tdLibVersion: StateFlow val tdLibCommitHash: StateFlow + val recentCommitsState: StateFlow + val buildBranch: String + val buildCommitHash: String + val buildTimeMillis: Long + val currentCommitUrl: String? val hasOpenSourceLicenses: Boolean fun onBackClicked() fun checkForUpdates() fun downloadUpdate() fun installUpdate() + fun onRecentCommitsClicked() + fun onRecentCommitsDismissed() + fun retryRecentCommits() fun onTermsOfServiceClicked() fun onOpenSourceLicensesClicked() } @@ -26,6 +44,7 @@ interface AboutComponent { class DefaultAboutComponent( context: AppComponentContext, private val updateRepository: UpdateRepository, + private val gitHubCommitRepository: GitHubCommitRepository, private val onBack: () -> Unit, private val onTermsOfService: () -> Unit, private val onOpenSourceLicenses: () -> Unit @@ -35,6 +54,12 @@ class DefaultAboutComponent( private val isTelemtBuild = BuildConfig.ENABLE_TELEMT_DNS private val stringProvider = container.utils.stringProvider() override val hasOpenSourceLicenses: Boolean = BuildConfig.HAS_OSS_LICENSES + override val buildBranch: String = BuildConfig.BUILD_BRANCH + override val buildCommitHash: String = BuildConfig.BUILD_COMMIT_HASH + override val buildTimeMillis: Long = BuildConfig.BUILD_TIME_MILLIS + override val currentCommitUrl: String? = + buildCommitHash.takeIf { it.isNotBlank() && it != "unknown" } + ?.let { "https://github.com/monogram-android/monogram/commit/$it" } private val _tdLibVersion = MutableStateFlow(stringProvider.getString("loading_text")) override val tdLibVersion: StateFlow = _tdLibVersion.asStateFlow() @@ -42,6 +67,10 @@ class DefaultAboutComponent( private val _tdLibCommitHash = MutableStateFlow("") override val tdLibCommitHash: StateFlow = _tdLibCommitHash.asStateFlow() + private val _recentCommitsState = MutableStateFlow(RecentCommitsState()) + override val recentCommitsState: StateFlow = + _recentCommitsState.asStateFlow() + init { scope.launch { _tdLibVersion.value = updateRepository.getTdLibVersion() @@ -70,6 +99,21 @@ class DefaultAboutComponent( updateRepository.installUpdate() } + override fun onRecentCommitsClicked() { + _recentCommitsState.update { it.copy(isVisible = true) } + if (_recentCommitsState.value.commits.isEmpty() && !_recentCommitsState.value.isLoading) { + loadRecentCommits(forceRefresh = true) + } + } + + override fun onRecentCommitsDismissed() { + _recentCommitsState.update { it.copy(isVisible = false) } + } + + override fun retryRecentCommits() { + loadRecentCommits(forceRefresh = true) + } + override fun onTermsOfServiceClicked() { onTermsOfService() } @@ -77,4 +121,47 @@ class DefaultAboutComponent( override fun onOpenSourceLicensesClicked() { onOpenSourceLicenses() } + + private fun loadRecentCommits(forceRefresh: Boolean) { + val currentState = _recentCommitsState.value + if (currentState.isLoading) return + + val ref = buildBranch + .takeIf { it.isNotBlank() && it != "unknown" && it != "detached" } + ?: buildCommitHash + + scope.launch { + _recentCommitsState.update { + it.copy( + isVisible = true, + isLoading = true, + commits = if (forceRefresh) emptyList() else it.commits, + errorMessage = null + ) + } + + val result = gitHubCommitRepository.getRecentCommits(ref) + result.fold( + onSuccess = { commits -> + _recentCommitsState.update { + it.copy( + isVisible = true, + isLoading = false, + commits = commits, + errorMessage = null + ) + } + }, + onFailure = { + _recentCommitsState.update { + it.copy( + isVisible = true, + isLoading = false, + errorMessage = stringProvider.getString("recent_commits_error") + ) + } + } + ) + } + } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/about/AboutContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/about/AboutContent.kt index cc23f34a1..476fa26b9 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/about/AboutContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/about/AboutContent.kt @@ -22,6 +22,7 @@ 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.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -35,6 +36,7 @@ import androidx.compose.material.icons.rounded.Error import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.Forum import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Public import androidx.compose.material.icons.rounded.SystemUpdate @@ -63,6 +65,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -83,6 +86,8 @@ import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsItem import org.monogram.presentation.core.util.AppUtils import org.monogram.presentation.core.util.buildRichText +import org.monogram.presentation.core.util.toBuildTimeString +import org.monogram.presentation.core.util.toGitHubCommitTimeString @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -91,6 +96,7 @@ fun AboutContent(component: AboutComponent) { val updateState by component.updateState.collectAsState() val tdLibVersion by component.tdLibVersion.collectAsState() val tdLibCommitHash by component.tdLibCommitHash.collectAsState() + val recentCommitsState by component.recentCommitsState.collectAsState() val hasOpenSourceLicenses = component.hasOpenSourceLicenses val uriHandler = LocalUriHandler.current val context = LocalContext.current @@ -102,7 +108,35 @@ fun AboutContent(component: AboutComponent) { if (isTelemtBuild) append(" (Telemt)") } } + val buildTimeText = remember(component.buildTimeMillis) { + component.buildTimeMillis.toBuildTimeString() + } + val buildInfoText = stringResource( + R.string.build_info_format, + component.buildBranch, + component.buildCommitHash.take(7), + buildTimeText + ) val loadingText = stringResource(R.string.loading_text) + val recentCommitsSubtitle = + if (component.buildBranch != "unknown" && component.buildBranch != "detached") { + stringResource(R.string.recent_commits_subtitle_format, component.buildBranch) + } else { + stringResource(R.string.recent_commits_subtitle) + } + val termsOfServiceTitle = stringResource(R.string.terms_of_service_title) + val termsOfServiceSubtitle = stringResource(R.string.terms_of_service_subtitle) + val openSourceLicensesTitle = stringResource(R.string.open_source_licenses_title) + val openSourceLicensesSubtitle = stringResource(R.string.open_source_licenses_subtitle) + val githubTitle = stringResource(R.string.github_title) + val githubSubtitle = stringResource(R.string.github_subtitle) + val currentCommitTitle = stringResource(R.string.current_commit_title) + val currentCommitSubtitle = stringResource( + R.string.current_commit_subtitle_format, + component.buildBranch, + component.buildCommitHash.take(7) + ) + val recentCommitsTitle = stringResource(R.string.recent_commits_title) val versionWithHashFormat = stringResource(R.string.tdlib_version_with_hash) val displayTdLibVersion = remember(tdLibVersion, tdLibCommitHash, versionWithHashFormat, loadingText) { @@ -167,38 +201,68 @@ fun AboutContent(component: AboutComponent) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + text = buildInfoText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) Spacer(modifier = Modifier.height(32.dp)) } item { - SettingsItem( - icon = Icons.Rounded.Description, - title = stringResource(R.string.terms_of_service_title), - subtitle = stringResource(R.string.terms_of_service_subtitle), - iconBackgroundColor = Color(0xFF4285F4), - position = ItemPosition.TOP, - onClick = { uriHandler.openUri("https://telegram.org/tos") } + val infoItems = listOfNotNull( + AboutActionItem( + icon = Icons.Rounded.Description, + title = termsOfServiceTitle, + subtitle = termsOfServiceSubtitle, + iconBackgroundColor = Color(0xFF4285F4), + onClick = component::onTermsOfServiceClicked + ), + hasOpenSourceLicenses.takeIf { it }?.let { + AboutActionItem( + icon = Icons.Rounded.Code, + title = openSourceLicensesTitle, + subtitle = openSourceLicensesSubtitle, + iconBackgroundColor = Color(0xFF34A853), + onClick = component::onOpenSourceLicensesClicked + ) + }, + AboutActionItem( + icon = Icons.Rounded.Public, + title = githubTitle, + subtitle = githubSubtitle, + iconBackgroundColor = Color(0xFF24292E), + onClick = { uriHandler.openUri("https://github.com/monogram-android/monogram") } + ), + component.currentCommitUrl?.let { commitUrl -> + AboutActionItem( + icon = Icons.Rounded.Code, + title = currentCommitTitle, + subtitle = currentCommitSubtitle, + iconBackgroundColor = Color(0xFF6D4C41), + onClick = { uriHandler.openUri(commitUrl) } + ) + }, + AboutActionItem( + icon = Icons.Rounded.History, + title = recentCommitsTitle, + subtitle = recentCommitsSubtitle, + iconBackgroundColor = Color(0xFF1A73E8), + onClick = component::onRecentCommitsClicked + ) ) - if (hasOpenSourceLicenses) { + + infoItems.forEachIndexed { index, item -> SettingsItem( - icon = Icons.Rounded.Code, - title = stringResource(R.string.open_source_licenses_title), - subtitle = stringResource(R.string.open_source_licenses_subtitle), - iconBackgroundColor = Color(0xFF34A853), - position = ItemPosition.MIDDLE, - onClick = component::onOpenSourceLicensesClicked + icon = item.icon, + title = item.title, + subtitle = item.subtitle, + iconBackgroundColor = item.iconBackgroundColor, + position = if (index == 0) ItemPosition.TOP else ItemPosition.MIDDLE, + onClick = item.onClick ) } - SettingsItem( - icon = Icons.Rounded.Public, - title = stringResource(R.string.github_title), - subtitle = stringResource(R.string.github_subtitle), - iconBackgroundColor = Color(0xFF24292E), - position = ItemPosition.MIDDLE, - onClick = { uriHandler.openUri("https://github.com/monogram-android/monogram") } - ) - SettingsItem( icon = Icons.Rounded.Terminal, title = stringResource(R.string.tdlib_version_title), @@ -337,8 +401,26 @@ fun AboutContent(component: AboutComponent) { } } } + + if (recentCommitsState.isVisible) { + RecentCommitsSheet( + state = recentCommitsState, + branch = component.buildBranch, + onDismiss = component::onRecentCommitsDismissed, + onRetry = component::retryRecentCommits, + onCommitClicked = { uriHandler.openUri(it) } + ) + } } +private data class AboutActionItem( + val icon: ImageVector, + val title: String, + val subtitle: String, + val iconBackgroundColor: Color, + val onClick: () -> Unit +) + @Composable private fun UpdateSection(state: UpdateState, component: AboutComponent) { var showChangelog by remember { mutableStateOf(false) } @@ -564,3 +646,118 @@ private fun ChangelogSheet( } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RecentCommitsSheet( + state: RecentCommitsState, + branch: String, + onDismiss: () -> Unit, + onRetry: () -> Unit, + onCommitClicked: (String) -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 32.dp) + ) { + item { + Text( + text = stringResource(R.string.recent_commits_sheet_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 4.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.recent_commits_sheet_branch_format, branch), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + when { + state.isLoading -> { + item { + Text( + text = stringResource(R.string.loading_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + + state.errorMessage != null -> { + item { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedButton(onClick = onRetry) { + Text(stringResource(R.string.retry_action)) + } + } + } + } + } + + state.commits.isEmpty() -> { + item { + Text( + text = stringResource(R.string.recent_commits_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + + else -> { + itemsIndexed( + state.commits, + key = { _, commit -> commit.sha }) { index, commit -> + val subtitle = stringResource( + R.string.recent_commit_item_subtitle_format, + commit.sha.take(7), + commit.authorName, + commit.committedAt.toGitHubCommitTimeString() + ) + SettingsItem( + icon = Icons.Rounded.Code, + title = commit.message, + subtitle = subtitle, + iconBackgroundColor = Color(0xFF6D4C41), + position = when { + state.commits.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == state.commits.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + }, + onClick = { onCommitClicked(commit.htmlUrl) } + ) + } + } + } + } + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt index a95f5107a..aff46efd3 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt @@ -16,6 +16,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.monogram.domain.managers.DistrManager import org.monogram.domain.models.ChatModel +import org.monogram.domain.repository.ClientOptionsRepository import org.monogram.domain.repository.NotificationSettingsRepository import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import org.monogram.domain.repository.PushProvider @@ -35,6 +36,7 @@ interface NotificationsComponent { fun onInAppVibrateToggled(enabled: Boolean) fun onInAppPreviewToggled(enabled: Boolean) fun onContactJoinedToggled(enabled: Boolean) + fun onScheduledMessagesToggled(enabled: Boolean) fun onPinnedMessagesToggled(enabled: Boolean) fun onBackgroundServiceToggled(enabled: Boolean) fun onHideForegroundNotificationToggled(enabled: Boolean) @@ -56,6 +58,7 @@ interface NotificationsComponent { val inAppVibrate: Boolean = true, val inAppPreview: Boolean = true, val contactJoined: Boolean = true, + val scheduledMessages: Boolean = true, val pinnedMessages: Boolean = true, val backgroundServiceEnabled: Boolean = false, val hideForegroundNotification: Boolean = false, @@ -85,6 +88,8 @@ class DefaultNotificationsComponent( private val appPreferences: AppPreferences = container.preferences.appPreferences private val notificationSettingsRepository: NotificationSettingsRepository = container.repositories.notificationSettingsRepository + private val clientOptionsRepository: ClientOptionsRepository = + container.repositories.clientOptionsRepository private val distrManager: DistrManager = container.utils.distrManager() private val scope = componentScope @@ -136,6 +141,10 @@ class DefaultNotificationsComponent( _state.update { it.copy(contactJoined = value) } }.launchIn(scope) + appPreferences.scheduledMessagesNotifications.onEach { value -> + _state.update { it.copy(scheduledMessages = value) } + }.launchIn(scope) + appPreferences.pinnedMessagesNotifications.onEach { value -> _state.update { it.copy(pinnedMessages = value) } }.launchIn(scope) @@ -189,6 +198,15 @@ class DefaultNotificationsComponent( val channelsEnabled = notificationSettingsRepository.getNotificationSettings(TdNotificationScope.CHANNELS) appPreferences.setChannelsNotifications(channelsEnabled) + + runCatching { + appPreferences.setContactJoinedNotifications( + clientOptionsRepository.getContactJoinedNotificationsEnabled() + ) + appPreferences.setScheduledMessagesNotifications( + clientOptionsRepository.getSentScheduledMessageNotificationsEnabled() + ) + } } } @@ -257,6 +275,16 @@ class DefaultNotificationsComponent( override fun onContactJoinedToggled(enabled: Boolean) { appPreferences.setContactJoinedNotifications(enabled) + scope.launch { + clientOptionsRepository.setContactJoinedNotificationsEnabled(enabled) + } + } + + override fun onScheduledMessagesToggled(enabled: Boolean) { + appPreferences.setScheduledMessagesNotifications(enabled) + scope.launch { + clientOptionsRepository.setSentScheduledMessageNotificationsEnabled(enabled) + } } override fun onPinnedMessagesToggled(enabled: Boolean) { @@ -299,6 +327,7 @@ class DefaultNotificationsComponent( onInAppVibrateToggled(true) onInAppPreviewToggled(true) onContactJoinedToggled(true) + onScheduledMessagesToggled(true) onPinnedMessagesToggled(true) onBackgroundServiceToggled(false) onHideForegroundNotificationToggled(false) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt index cc81c53a7..cf1834a0d 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsContent.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.rounded.PriorityHigh import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.Sync import androidx.compose.material.icons.rounded.Vibration import androidx.compose.material.icons.rounded.Visibility @@ -309,6 +310,15 @@ private fun NotificationsMainContent(component: NotificationsComponent) { position = ItemPosition.TOP, onCheckedChange = component::onContactJoinedToggled ) + SettingsSwitchTile( + icon = Icons.Rounded.Schedule, + title = stringResource(R.string.scheduled_messages_notifications_title), + subtitle = stringResource(R.string.scheduled_messages_notifications_subtitle), + checked = state.scheduledMessages, + iconColor = Color(0xFF03A9F4), + position = ItemPosition.MIDDLE, + onCheckedChange = component::onScheduledMessagesToggled + ) SettingsSwitchTile( icon = Icons.Rounded.PushPin, title = stringResource(R.string.pinned_messages_title), diff --git a/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt index f42720994..bcbd19a57 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingComponent.kt @@ -5,7 +5,9 @@ import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.ClientOptionsRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -14,6 +16,7 @@ interface PowerSavingComponent { fun onBackClicked() fun onChatAnimationsToggled(enabled: Boolean) + fun onAnimatedEmojiToggled(enabled: Boolean) fun onBackgroundServiceToggled(enabled: Boolean) fun onPowerSavingModeToggled(enabled: Boolean) fun onWakeLockToggled(enabled: Boolean) @@ -21,6 +24,7 @@ interface PowerSavingComponent { data class State( val isChatAnimationsEnabled: Boolean = true, + val isAnimatedEmojiEnabled: Boolean = true, val backgroundServiceEnabled: Boolean = false, val isPowerSavingModeEnabled: Boolean = false, val isWakeLockEnabled: Boolean = false, @@ -34,6 +38,8 @@ class DefaultPowerSavingComponent( ) : PowerSavingComponent, AppComponentContext by context { private val appPreferences: AppPreferencesProvider = container.preferences.appPreferences + private val clientOptionsRepository: ClientOptionsRepository = + container.repositories.clientOptionsRepository private val _state = MutableValue(PowerSavingComponent.State()) override val state: Value = _state private val scope = componentScope @@ -58,6 +64,15 @@ class DefaultPowerSavingComponent( appPreferences.batteryOptimizationEnabled.onEach { value -> _state.update { it.copy(batteryOptimizationEnabled = value) } }.launchIn(scope) + + scope.launch { + val isAnimatedEmojiEnabled = clientOptionsRepository.getAnimatedEmojiEnabled() + _state.update { + it.copy( + isAnimatedEmojiEnabled = isAnimatedEmojiEnabled + ) + } + } } override fun onBackClicked() { @@ -68,6 +83,13 @@ class DefaultPowerSavingComponent( appPreferences.setChatAnimationsEnabled(enabled) } + override fun onAnimatedEmojiToggled(enabled: Boolean) { + scope.launch { + clientOptionsRepository.setAnimatedEmojiEnabled(enabled) + _state.update { it.copy(isAnimatedEmojiEnabled = enabled) } + } + } + override fun onBackgroundServiceToggled(enabled: Boolean) { appPreferences.setBackgroundServiceEnabled(enabled) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingContent.kt index 7ea417aad..ce3564428 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/powersaving/PowerSavingContent.kt @@ -1,11 +1,27 @@ package org.monogram.presentation.settings.powersaving -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* +import androidx.compose.material.icons.rounded.Animation +import androidx.compose.material.icons.rounded.BatteryAlert +import androidx.compose.material.icons.rounded.BatterySaver +import androidx.compose.material.icons.rounded.EmojiEmotions +import androidx.compose.material.icons.rounded.Power +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -93,9 +109,18 @@ fun PowerSavingContent(component: PowerSavingComponent) { checked = if (state.isPowerSavingModeEnabled) false else state.isChatAnimationsEnabled, enabled = !state.isPowerSavingModeEnabled, iconColor = Color(0xFF4285F4), - position = ItemPosition.STANDALONE, + position = ItemPosition.TOP, onCheckedChange = component::onChatAnimationsToggled ) + SettingsSwitchTile( + icon = Icons.Rounded.EmojiEmotions, + title = stringResource(R.string.animated_emoji_title), + subtitle = stringResource(R.string.animated_emoji_subtitle), + checked = state.isAnimatedEmojiEnabled, + iconColor = Color(0xFF7E57C2), + position = ItemPosition.BOTTOM, + onCheckedChange = component::onAnimatedEmojiToggled + ) } item { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListComponent.kt index b6c6cfe76..19d94eea1 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListComponent.kt @@ -10,6 +10,7 @@ import org.monogram.domain.managers.DistrManager import org.monogram.domain.models.PrivacyRule import org.monogram.domain.models.PrivacyValue import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.ClientOptionsRepository import org.monogram.domain.repository.PrivacyKey import org.monogram.domain.repository.PrivacyRepository import org.monogram.presentation.core.util.componentScope @@ -31,6 +32,7 @@ interface PrivacyListComponent { fun onDeleteAccountClicked(reason: String) fun onAccountTtlChanged(days: Int) fun onSensitiveContentChanged(enabled: Boolean) + fun onArchiveAndMuteUnknownChatsChanged(enabled: Boolean) fun onPasscodeClicked() fun onPasscodeVerificationSubmitted(passcode: String) fun onPasscodeVerificationDismissed() @@ -50,6 +52,8 @@ interface PrivacyListComponent { val isTwoStepVerificationEnabled: Boolean = false, val canShowSensitiveContent: Boolean = false, val isSensitiveContentEnabled: Boolean = false, + val canArchiveAndMuteUnknownChats: Boolean = false, + val isArchiveAndMuteUnknownChatsEnabled: Boolean = false, val isPasscodeEnabled: Boolean = false, val isPasscodeVerificationVisible: Boolean = false, val isPasscodeVerificationInvalid: Boolean = false, @@ -69,6 +73,8 @@ class DefaultPrivacyListComponent( ) : PrivacyListComponent, AppComponentContext by context { private val privacyRepository: PrivacyRepository = container.repositories.privacyRepository + private val clientOptionsRepository: ClientOptionsRepository = + container.repositories.clientOptionsRepository private val appPreferences: AppPreferencesProvider = container.preferences.appPreferences private val distrManager: DistrManager = container.utils.distrManager() @@ -91,6 +97,10 @@ class DefaultPrivacyListComponent( val isTwoStepEnabled = privacyRepository.getPasswordState() val canShowSensitive = privacyRepository.canShowSensitiveContent() val isSensitiveEnabled = privacyRepository.isShowSensitiveContentEnabled() + val canArchiveAndMuteUnknown = + clientOptionsRepository.canArchiveAndMuteNewChatsFromUnknownUsers() + val isArchiveAndMuteUnknownEnabled = + clientOptionsRepository.getArchiveAndMuteNewChatsFromUnknownUsersEnabled() _state.update { it.copy( @@ -99,6 +109,8 @@ class DefaultPrivacyListComponent( isTwoStepVerificationEnabled = isTwoStepEnabled, canShowSensitiveContent = canShowSensitive, isSensitiveContentEnabled = isSensitiveEnabled, + canArchiveAndMuteUnknownChats = canArchiveAndMuteUnknown, + isArchiveAndMuteUnknownChatsEnabled = isArchiveAndMuteUnknownEnabled, isInstalledFromGooglePlay = distrManager.isInstalledFromGooglePlay() ) } @@ -258,6 +270,13 @@ class DefaultPrivacyListComponent( } } + override fun onArchiveAndMuteUnknownChatsChanged(enabled: Boolean) { + scope.launch { + clientOptionsRepository.setArchiveAndMuteNewChatsFromUnknownUsersEnabled(enabled) + _state.update { it.copy(isArchiveAndMuteUnknownChatsEnabled = enabled) } + } + } + override fun onPasscodeVerificationDismissed() { _state.update { it.copy( diff --git a/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListContent.kt index 07626d8c7..a7bf2650e 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/privacy/PrivacyListContent.kt @@ -9,15 +9,62 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable -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.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.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.AccessTime +import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Fingerprint +import androidx.compose.material.icons.rounded.Forward +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Phone +import androidx.compose.material.icons.rounded.Security +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ContainedLoadingIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -245,6 +292,21 @@ fun PrivacyListContent(component: PrivacyListComponent) { } } + if (state.canArchiveAndMuteUnknownChats) { + item { + SectionHeader(stringResource(R.string.archived_chats_title)) + SettingsSwitchTile( + icon = Icons.Rounded.Archive, + title = stringResource(R.string.archive_unknown_chats_title), + subtitle = stringResource(R.string.archive_unknown_chats_subtitle), + iconColor = blueColor, + checked = state.isArchiveAndMuteUnknownChatsEnabled, + position = ItemPosition.STANDALONE, + onCheckedChange = component::onArchiveAndMuteUnknownChatsChanged + ) + } + } + item { SectionHeader(stringResource(R.string.advanced_section_header)) SettingsTile( diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 2b9ebd3d0..7937ac87f 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -23,12 +23,24 @@ Назад MonoGram Версия %1$s + %1$s · %2$s · %3$s Условия использования Ознакомьтесь с условиями использования Лицензии Используемое ПО с открытым исходным кодом GitHub Исходный код приложения + Текущий коммит + %1$s · %2$s + Последние коммиты + Последние изменения с GitHub + Последние изменения из %1$s + Последние коммиты + Ветка: %1$s + Для этой ветки коммиты не найдены. + Не удалось загрузить коммиты с GitHub. + %1$s · %2$s · %3$s + Повторить Версия TDLib %1$s (%2$s) Сообщество @@ -640,6 +652,8 @@ Предпросмотр в приложении События Контакт присоединился к Telegram + Отложенные сообщения + Уведомлять, когда отложенные сообщения отправлены Закреплённые сообщения Сбросить все уведомления Сброс всех персональных настроек уведомлений для контактов и групп @@ -703,6 +717,8 @@ Архив всегда в начале списка чатов Всегда показывать архив Не скрывать закреплённый архив при прокрутке + Архивировать и заглушать новые чаты + Автоматически архивировать и заглушать новые чаты от незнакомых пользователей Показывать блок историй Показывать истории над списком чатов и в архиве Предпросмотр ссылок @@ -773,6 +789,8 @@ Анимации Анимации в чате Отключить для продления работы от аккумулятора + Анимированные эмодзи + Показывать большие анимированные эмодзи и связанные эффекты Фон Отключение может привести к задержке уведомлений diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 6089aa57b..e2ce565dd 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -34,12 +34,24 @@ Toggle sound MonoGram Version %1$s + %1$s · %2$s · %3$s Terms of Service Read our terms and conditions Open Source Licenses Software used in MonoGram GitHub View source code + Current Commit + %1$s · %2$s + Recent Commits + Latest changes from GitHub + Latest changes from %1$s + Recent Commits + Branch: %1$s + No commits found for this reference. + Couldn\'t load commits from GitHub. + %1$s · %2$s · %3$s + Retry TDLib Version %1$s (%2$s) Community @@ -1057,6 +1069,8 @@ In-App Preview Events Contact Joined Telegram + Scheduled Messages + Notify when scheduled messages are sent Pinned Messages Reset All Notifications Undo all custom notification settings for all your contacts and @@ -1126,6 +1140,8 @@ Keep archived chats at the top of the list Always Show Pinned Archive Keep pinned archive visible even when scrolling + Archive and Mute New Chats + Automatically archive and mute new chats from non-contacts Show Stories Block Display stories above the chat list and in archive Show Link Previews @@ -1201,6 +1217,8 @@ Animations Chat Animations Disable animations in chat to save battery + Animated Emoji + Play large animated emoji and related effects Background Disabling this will reduce power usage but may delay background notifications