From 0eca3563e8a7a336cf0722fc1b16fda989a05f3e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:36:30 +0300 Subject: [PATCH 01/83] refactor(data): replace ScopeProvider with CoroutineScope and remove dead scope wiring --- .../kotlin/org/monogram/core/ScopeProvider.kt | 7 ---- .../monogram/data/chats/ChatFileManager.kt | 6 +-- .../monogram/data/chats/ChatFolderManager.kt | 6 +-- .../monogram/data/chats/ChatModelFactory.kt | 7 ++-- .../remote/TdMessageRemoteDataSource.kt | 4 +- .../java/org/monogram/data/di/dataModule.kt | 42 +++++++++---------- .../monogram/data/infra/ConnectionManager.kt | 10 ++--- .../data/infra/DefaultScopeProvider.kt | 14 ------- .../monogram/data/infra/FileDownloadQueue.kt | 35 ++++++++-------- .../monogram/data/infra/FileUpdateHandler.kt | 12 +++--- .../org/monogram/data/infra/OfflineWarmup.kt | 6 +-- .../monogram/data/infra/SponsorSyncManager.kt | 10 ++--- .../org/monogram/data/mapper/MessageMapper.kt | 4 +- .../repository/AttachMenuBotRepositoryImpl.kt | 6 +-- .../data/repository/AuthRepositoryImpl.kt | 6 +-- .../repository/ChatsListRepositoryImpl.kt | 16 +++---- .../data/repository/EmojiRepositoryImpl.kt | 6 +-- .../data/repository/MessageRepositoryImpl.kt | 5 +-- .../NotificationSettingsRepositoryImpl.kt | 10 +---- .../data/repository/PrivacyRepositoryImpl.kt | 12 ++---- .../data/repository/StickerRepositoryImpl.kt | 6 +-- .../repository/StreamingRepositoryImpl.kt | 13 +++--- .../data/repository/UpdateRepositoryImpl.kt | 8 ++-- .../repository/WallpaperRepositoryImpl.kt | 6 +-- .../repository/user/UserRepositoryImpl.kt | 10 +---- .../data/stickers/StickerFileManager.kt | 6 +-- 26 files changed, 99 insertions(+), 174 deletions(-) delete mode 100644 core/src/main/kotlin/org/monogram/core/ScopeProvider.kt delete mode 100644 data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt diff --git a/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt b/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt deleted file mode 100644 index 8a62a55d..00000000 --- a/core/src/main/kotlin/org/monogram/core/ScopeProvider.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.monogram.core - -import kotlinx.coroutines.CoroutineScope - -interface ScopeProvider { - val appScope: CoroutineScope -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt index 04eb3b01..0966f341 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt @@ -1,9 +1,9 @@ package org.monogram.data.chats +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue @@ -17,11 +17,9 @@ class ChatFileManager( private val dispatchers: DispatcherProvider, private val fileQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val onUpdate: () -> Unit ) { - private val scope = scopeProvider.appScope - private val downloadingFiles: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val loadingEmojis: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val filePaths = ConcurrentHashMap() diff --git a/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt b/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt index 2212df88..230d0ab7 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatFolderManager.kt @@ -1,13 +1,13 @@ package org.monogram.data.chats import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.ChatFolderDao import org.monogram.data.db.model.ChatFolderEntity @@ -21,13 +21,11 @@ private const val TAG = "ChatFolderManager" class ChatFolderManager( private val gateway: TelegramGateway, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val foldersFlow: MutableStateFlow>, private val cacheProvider: CacheProvider, private val chatFolderDao: ChatFolderDao ) { - private val scope = scopeProvider.appScope - private val chatUnreadCounts = ConcurrentHashMap() private val folderChatIds = ConcurrentHashMap>() private val folderPinnedChatIds = ConcurrentHashMap>() 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 1e61ae07..ba96a4f7 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -1,10 +1,10 @@ package org.monogram.data.chats -import org.monogram.data.core.coRunCatching +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.gateway.TelegramGateway import org.monogram.data.mapper.ChatMapper @@ -22,7 +22,7 @@ import java.util.concurrent.ConcurrentHashMap class ChatModelFactory( private val gateway: TelegramGateway, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val cache: ChatCache, private val chatMapper: ChatMapper, private val fileManager: ChatFileManager, @@ -32,7 +32,6 @@ class ChatModelFactory( private val triggerUpdate: (Long?) -> Unit, private val fetchUser: (Long) -> Unit ) { - private val scope = scopeProvider.appScope private val missingUserFullInfoUntilMs = ConcurrentHashMap() fun mapChatToModel( 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 5911c70a..e3bbc16e 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 @@ -7,7 +7,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway @@ -31,10 +30,9 @@ class TdMessageRemoteDataSource( private val fileDownloadQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, private val dispatcherProvider: DispatcherProvider, - scopeProvider: ScopeProvider + val scope: CoroutineScope ) : MessageRemoteDataSource { - val scope = scopeProvider.appScope private val chatRequests = ConcurrentHashMap>() private val messageRequests = ConcurrentHashMap, Deferred>() private val refreshJobs = ConcurrentHashMap, Job>() 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 93d924e8..d13ee94e 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -5,12 +5,10 @@ import android.net.ConnectivityManager import androidx.room.Room import androidx.room.RoomDatabase import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.PlayerDataSourceFactoryImpl @@ -34,17 +32,16 @@ import org.monogram.data.stickers.StickerFileManager import org.monogram.domain.repository.* val dataModule = module { - single { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + single { CoroutineScope(SupervisorJob() + get().default) } single(createdAtStart = true) { TdLibClient() } single { DefaultDispatcherProvider() } - single { DefaultScopeProvider(get()) } single { AndroidStringProvider(androidContext()) } single { TdLibParametersProvider(androidContext()) } single(createdAtStart = true) { OfflineWarmup( - scopeProvider = get(), + scope = get(), dispatchers = get(), gateway = get(), chatDao = get(), @@ -59,7 +56,7 @@ val dataModule = module { } single(createdAtStart = true) { SponsorSyncManager( - scopeProvider = get(), + scope = get(), gateway = get(), sponsorDao = get(), authRepository = get() @@ -103,7 +100,7 @@ val dataModule = module { parametersProvider = get(), remote = get(), updates = get(), - scopeProvider = get() + scope = get() ) } @@ -181,7 +178,7 @@ val dataModule = module { chatLocal = get(), chatCache = get(), updates = get(), - scopeProvider = get(), + scope = get(), gateway = get(), fileQueue = get(), keyValueDao = get(), @@ -292,7 +289,7 @@ val dataModule = module { fileApi = get(), appPreferences = get(), cache = get(), - scopeProvider = get() + scope = get() ) } @@ -304,7 +301,7 @@ val dataModule = module { appPreferences = get(), dispatchers = get(), connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, - scopeProvider = get() + scope = get() ) } @@ -320,7 +317,7 @@ val dataModule = module { chatMapper = get(), messageMapper = get(), gateway = get(), - scopeProvider = get(), + scope = get(), chatLocalDataSource = get(), connectionManager = get(), databaseFile = androidContext().getDatabasePath("monogram_db"), @@ -357,7 +354,7 @@ val dataModule = module { cache = get(), chatsRemote = get(), updates = get(), - scopeProvider = get(), + scope = get(), dispatchers = get() ) } @@ -374,7 +371,7 @@ val dataModule = module { updates = get(), wallpaperDao = get(), dispatchers = get(), - scopeProvider = get() + scope = get() ) } @@ -404,7 +401,7 @@ val dataModule = module { updates = get(), dispatchers = get(), attachBotDao = get(), - scopeProvider = get() + scope = get() ) } @@ -423,7 +420,7 @@ val dataModule = module { fileDownloadQueue = get(), fileUpdateHandler = get(), dispatcherProvider = get(), - scopeProvider = get() + scope = get() ) } @@ -436,7 +433,7 @@ val dataModule = module { messageRemoteDataSource = get(), cache = get(), dispatcherProvider = get(), - scopeProvider = get(), + scope = get(), fileDataSource = get(), chatLocalDataSource = get(), userLocalDataSource = get(), @@ -499,7 +496,7 @@ val dataModule = module { fileQueue = get(), fileUpdateHandler = get(), dispatchers = get(), - scopeProvider = get() + scope = get() ) } @@ -511,7 +508,7 @@ val dataModule = module { cacheProvider = get(), dispatchers = get(), localDataSource = get(), - scopeProvider = get() + scope = get() ) } @@ -530,7 +527,7 @@ val dataModule = module { cacheProvider = get(), dispatchers = get(), context = androidContext(), - scopeProvider = get() + scope = get() ) } @@ -543,8 +540,7 @@ val dataModule = module { single { PrivacyRepositoryImpl( remote = get(), - updates = get(), - scopeProvider = get() + updates = get() ) } @@ -560,7 +556,7 @@ val dataModule = module { StreamingRepositoryImpl( fileDataSource = get(), updates = get(), - scopeProvider = get() + scope = get() ) } @@ -598,7 +594,7 @@ val dataModule = module { fileQueue = get(), fileUpdateHandler = get(), authRepository = get(), - scopeProvider = get(), + scope = get(), ) } diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt index df929ed2..1b5e313c 100644 --- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt +++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt @@ -1,6 +1,5 @@ package org.monogram.data.infra -import org.monogram.data.core.coRunCatching import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest @@ -8,13 +7,13 @@ import android.net.Uri import android.os.Build import android.util.Log import kotlinx.coroutines.* -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.ChatRemoteSource import org.monogram.data.datasource.remote.ProxyRemoteDataSource import org.monogram.data.gateway.UpdateDispatcher @@ -29,10 +28,9 @@ class ConnectionManager( private val appPreferences: AppPreferencesProvider, private val dispatchers: DispatcherProvider, private val connectivityManager: ConnectivityManager, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) { private val TAG = "ConnectionManager" - private val scope = scopeProvider.appScope private val _connectionStateFlow = MutableStateFlow(ConnectionStatus.Connecting) val connectionStateFlow = _connectionStateFlow.asStateFlow() diff --git a/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt b/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt deleted file mode 100644 index ae4b3fd8..00000000 --- a/data/src/main/java/org/monogram/data/infra/DefaultScopeProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.monogram.data.infra - -import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob - -class DefaultScopeProvider( - dispatcherProvider: DispatcherProvider -) : ScopeProvider { - override val appScope: CoroutineScope = CoroutineScope( - SupervisorJob() + dispatcherProvider.default - ) -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index cbec4832..567f0acc 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TdLibException @@ -20,7 +19,7 @@ class FileDownloadQueue( private val gateway: TelegramGateway, val registry: FileMessageRegistry, private val cache: ChatCache, - private val scope: ScopeProvider, + private val scope: CoroutineScope, private val dispatcherProvider: DispatcherProvider ) { enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } @@ -84,7 +83,7 @@ class FileDownloadQueue( private val trigger = Channel(Channel.CONFLATED) init { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { trigger.receive() coRunCatching { dispatchTasks() } @@ -92,7 +91,7 @@ class FileDownloadQueue( } } - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { delay(TimeUnit.MINUTES.toMillis(1)) coRunCatching { retryFailedDownloads() } @@ -100,7 +99,7 @@ class FileDownloadQueue( } } - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { delay(15_000) coRunCatching { recoverStalledDownloads() } @@ -108,7 +107,7 @@ class FileDownloadQueue( } } - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { while (isActive) { delay(TimeUnit.MINUTES.toMillis(5)) coRunCatching { cleanupDeadState() } @@ -165,7 +164,7 @@ class FileDownloadQueue( for (task in tasksToStart) { throttleTaskStart() - scope.appScope.launch(dispatcherProvider.io) { + scope.launch(dispatcherProvider.io) { processDownload(task) } } @@ -279,7 +278,7 @@ class FileDownloadQueue( failedRequests.remove(req.fileId) } trigger.trySend(Unit) - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { delay(backoffMs) trigger.trySend(Unit) } @@ -304,7 +303,7 @@ class FileDownloadQueue( failedRequests.remove(req.fileId) } trigger.trySend(Unit) - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { delay(cooldownMs) trigger.trySend(Unit) } @@ -375,7 +374,7 @@ class FileDownloadQueue( if (now - recoveredAt < stalledRecoveryCooldownMs) return@forEach stalledRecoveryAt[req.fileId] = now - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { val recovered = stateMutex.withLock { val active = activeRequests[req.fileId] ?: return@withLock false if (active.createdAt != req.createdAt || active.availableAt != req.availableAt) return@withLock false @@ -431,14 +430,14 @@ class FileDownloadQueue( failedRequests.remove(file.id) stalledRecoveryAt.remove(file.id) lastProgressAt.remove(file.id) - scope.appScope.launch { + scope.launch { stateMutex.withLock { pendingRequests.remove(file.id) } } notifyDownloadComplete(file.id) } else if (oldFile?.local?.isDownloadingActive == true && !file.local.isDownloadingActive) { val type = fileDownloadTypes[file.id] if (type == DownloadType.STICKER || manualDownloadIds.contains(file.id)) { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { enqueue( fileId = file.id, priority = if (type == DownloadType.STICKER) 32 else calculatePriority(file.id), @@ -477,7 +476,7 @@ class FileDownloadQueue( nearbyMessageIds[chatId] = nearby.toSet() activeChatId = chatId - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { cancelIrrelevantDownloads() (visible + nearby).forEach { messageId -> registry.getFileIdsForMessage(chatId, messageId).forEach { fileId -> @@ -498,7 +497,7 @@ class FileDownloadQueue( synchronous: Boolean = false, ignoreSuppression: Boolean = false ) { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { if (!ignoreSuppression && suppressedAutoDownloadIds.contains(fileId)) { return@launch } @@ -556,7 +555,7 @@ class FileDownloadQueue( val shouldKick = merged != active || cache.fileCache[fileId]?.local?.isDownloadingActive != true if (shouldKick) { activeRequests[fileId] = merged - scope.appScope.launch(dispatcherProvider.io) { + scope.launch(dispatcherProvider.io) { try { gateway.execute( TdApi.DownloadFile( @@ -600,7 +599,7 @@ class FileDownloadQueue( suppressedAutoDownloadIds.add(fileId) } - scope.appScope.launch(dispatcherProvider.io) { + scope.launch(dispatcherProvider.io) { try { gateway.execute(TdApi.CancelDownloadFile(fileId, false)) } catch (_: Exception) { @@ -709,7 +708,7 @@ class FileDownloadQueue( } private fun cancelIrrelevantDownloads() { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { val toCancel = mutableListOf() for ((fileId, _) in pendingRequests) { @@ -721,7 +720,7 @@ class FileDownloadQueue( } private fun flushIrrelevantBackgroundDownloads() { - scope.appScope.launch(dispatcherProvider.default) { + scope.launch(dispatcherProvider.default) { val toCancel = mutableListOf() stateMutex.withLock { diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt index 9002a3e9..b97381b9 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -1,11 +1,11 @@ package org.monogram.data.infra +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.gateway.UpdateDispatcher import java.util.concurrent.ConcurrentHashMap @@ -13,7 +13,7 @@ class FileUpdateHandler( private val registry: FileMessageRegistry, private val queue: FileDownloadQueue, private val updates: UpdateDispatcher, - private val scope: ScopeProvider + private val scope: CoroutineScope ) { val customEmojiPaths = ConcurrentHashMap() val fileIdToCustomEmojiId = ConcurrentHashMap() @@ -33,7 +33,7 @@ class FileUpdateHandler( val uploadProgress = _uploadProgress.asSharedFlow() init { - scope.appScope.launch { + scope.launch { updates.file.collect { update -> handle(update.file) } } } @@ -52,7 +52,7 @@ class FileUpdateHandler( val entries = registry.getMessages(file.id) if (entries.isNotEmpty()) { - scope.appScope.launch { + scope.launch { if (downloadDone) { handleCustomEmoji(file.id, file.local?.path ?: "") _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) @@ -74,7 +74,7 @@ class FileUpdateHandler( } } } else if (registry.standaloneFileIds.contains(file.id)) { - scope.appScope.launch { + scope.launch { if (downloadDone) { _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) _fileDownloadProgress.emit(file.id.toLong() to 1f) @@ -88,7 +88,7 @@ class FileUpdateHandler( } } } else { - scope.appScope.launch { + scope.launch { if (downloadDone) { _fileDownloadCompleted.emit(file.id.toLong() to (file.local?.path ?: "")) _fileDownloadProgress.emit(file.id.toLong() to 1f) 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 5c5e2827..9d3c9130 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -1,11 +1,11 @@ package org.monogram.data.infra import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.* @@ -20,7 +20,7 @@ import org.monogram.domain.repository.StickerRepository private const val TAG = "OfflineWarmup" class OfflineWarmup( - private val scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val dispatchers: DispatcherProvider, private val gateway: TelegramGateway, private val chatDao: ChatDao, @@ -32,8 +32,6 @@ class OfflineWarmup( private val chatCache: ChatCache, private val stickerRepository: StickerRepository ) { - private val scope = scopeProvider.appScope - @Volatile private var warmupStarted = false diff --git a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt index d6f20302..c7c96cb8 100644 --- a/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt +++ b/data/src/main/java/org/monogram/data/infra/SponsorSyncManager.kt @@ -1,12 +1,12 @@ package org.monogram.data.infra -import org.monogram.data.core.coRunCatching import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.SponsorDao import org.monogram.data.db.model.SponsorEntity import org.monogram.data.gateway.TelegramGateway @@ -26,7 +26,7 @@ private const val POST_LOGIN_SYNC_DELAY_MS = 60L * 1000L private const val ONE_DAY_MS = 24L * 60L * 60L * 1000L class SponsorSyncManager( - private val scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val gateway: TelegramGateway, private val sponsorDao: SponsorDao, private val authRepository: AuthRepository @@ -41,7 +41,7 @@ class SponsorSyncManager( fun start() { if (!started.compareAndSet(false, true)) return - scopeProvider.appScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { loadFromDatabase() var wasAuthorized = authRepository.authState.value is AuthStep.Ready @@ -78,7 +78,7 @@ class SponsorSyncManager( } fun forceSync() { - scopeProvider.appScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { syncOnce(force = true) } } 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 0139f67a..b498cdc8 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -5,7 +5,6 @@ import android.net.NetworkCapabilities import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.remote.MessageFileApi import org.monogram.data.datasource.remote.TdMessageRemoteDataSource @@ -27,9 +26,8 @@ class MessageMapper( private val fileApi: MessageFileApi, private val appPreferences: AppPreferencesProvider, private val cache: ChatCache, - scopeProvider: ScopeProvider + val scope: CoroutineScope ) { - val scope = scopeProvider.appScope private val customEmojiPaths = fileUpdateHandler.customEmojiPaths private val fileIdToCustomEmojiId = fileUpdateHandler.fileIdToCustomEmojiId diff --git a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt index ddd86da2..83c67501 100644 --- a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt @@ -1,12 +1,12 @@ package org.monogram.data.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.cache.SettingsCacheDataSource import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.AttachBotDao @@ -24,10 +24,8 @@ class AttachMenuBotRepositoryImpl( private val updates: UpdateDispatcher, private val dispatchers: DispatcherProvider, private val attachBotDao: AttachBotDao, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : AttachMenuBotRepository { - - private val scope = scopeProvider.appScope private val attachMenuBots = MutableStateFlow>(cacheProvider.attachBots.value) init { diff --git a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt index 79b307c8..7dd3ff63 100644 --- a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt @@ -1,9 +1,9 @@ package org.monogram.data.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.AuthRemoteDataSource import org.monogram.data.gateway.UpdateDispatcher @@ -17,10 +17,8 @@ class AuthRepositoryImpl( private val parametersProvider: TdLibParametersProvider, private val remote: AuthRemoteDataSource, private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : AuthRepository { - private val scope = scopeProvider.appScope - private val _authState = MutableStateFlow(AuthStep.Loading) override val authState = _authState.asStateFlow() 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 980e9888..df356557 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -1,16 +1,12 @@ package org.monogram.data.repository import android.util.Log -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.* import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.ChatLocalDataSource @@ -46,7 +42,7 @@ class ChatsListRepositoryImpl( private val chatMapper: ChatMapper, private val messageMapper: MessageMapper, private val gateway: TelegramGateway, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val chatLocalDataSource: ChatLocalDataSource, private val connectionManager: ConnectionManager, private val databaseFile: File, @@ -64,8 +60,6 @@ class ChatsListRepositoryImpl( ChatSettingsRepository, ChatCreationRepository { - private val scope = scopeProvider.appScope - private val _chatListFlow = MutableStateFlow>(emptyList()) override val chatListFlow: StateFlow> = _chatListFlow.asStateFlow() @@ -96,7 +90,7 @@ class ChatsListRepositoryImpl( private val fileManager = ChatFileManager( gateway = gateway, dispatchers = dispatchers, - scopeProvider = scopeProvider, + scope = scope, fileQueue = fileQueue, fileUpdateHandler = fileUpdateHandler, onUpdate = { @@ -126,7 +120,7 @@ class ChatsListRepositoryImpl( private val modelFactory = ChatModelFactory( gateway = gateway, dispatchers = dispatchers, - scopeProvider = scopeProvider, + scope = scope, cache = cache, chatMapper = chatMapper, fileManager = fileManager, @@ -151,7 +145,7 @@ class ChatsListRepositoryImpl( private val folderManager = ChatFolderManager( gateway = gateway, dispatchers = dispatchers, - scopeProvider = scopeProvider, + scope = scope, foldersFlow = _foldersFlow, cacheProvider = cacheProvider, chatFolderDao = chatFolderDao diff --git a/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt index a61a6123..0b8b9c13 100644 --- a/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/EmojiRepositoryImpl.kt @@ -1,11 +1,11 @@ package org.monogram.data.repository import android.content.Context +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.datasource.remote.EmojiRemoteSource import org.monogram.data.infra.EmojiLoader @@ -20,11 +20,9 @@ class EmojiRepositoryImpl( private val cacheProvider: CacheProvider, private val dispatchers: DispatcherProvider, private val context: Context, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : EmojiRepository { - private val scope = scopeProvider.appScope - override val recentEmojis: Flow> = cacheProvider.recentEmojis private var cachedEmojis: List? = null 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 10e2905d..a4963fba 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -2,12 +2,12 @@ package org.monogram.data.repository import android.content.Context import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.FileDataSource @@ -39,13 +39,12 @@ class MessageRepositoryImpl( private val cache: ChatCache, private val fileDataSource: FileDataSource, private val dispatcherProvider: DispatcherProvider, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val chatLocalDataSource: ChatLocalDataSource, private val userLocalDataSource: UserLocalDataSource, private val fileUpdateHandler: FileUpdateHandler, private val textCompositionStyleDao: TextCompositionStyleDao ) : MessageRepository { - private val scope = scopeProvider.appScope private val _textCompositionStyles = MutableStateFlow>(emptyList()) override val newMessageFlow = messageRemoteDataSource.newMessageFlow diff --git a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt index 74cad092..923b0eef 100644 --- a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt @@ -1,12 +1,8 @@ package org.monogram.data.repository -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.cache.SettingsCacheDataSource import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.datasource.remote.SettingsRemoteDataSource @@ -22,12 +18,10 @@ class NotificationSettingsRepositoryImpl( private val cache: SettingsCacheDataSource, private val chatsRemote: ChatsRemoteDataSource, private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider, + private val scope: CoroutineScope, private val dispatchers: DispatcherProvider ) : NotificationSettingsRepository { - private val scope = scopeProvider.appScope - init { scope.launch { updates.newChat.collect { update -> diff --git a/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt index 14f52fe2..6d212661 100644 --- a/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/PrivacyRepositoryImpl.kt @@ -1,9 +1,5 @@ package org.monogram.data.repository -import org.monogram.core.ScopeProvider -import org.monogram.domain.models.PrivacyRule -import org.monogram.domain.repository.PrivacyKey -import org.monogram.domain.repository.PrivacyRepository import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* import org.drinkless.tdlib.TdApi @@ -11,15 +7,15 @@ import org.monogram.data.datasource.remote.PrivacyRemoteDataSource import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.toApi import org.monogram.data.mapper.toDomain +import org.monogram.domain.models.PrivacyRule +import org.monogram.domain.repository.PrivacyKey +import org.monogram.domain.repository.PrivacyRepository class PrivacyRepositoryImpl( private val remote: PrivacyRemoteDataSource, - private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider + private val updates: UpdateDispatcher ) : PrivacyRepository { - private val scope = scopeProvider.appScope - override fun getPrivacyRules(key: PrivacyKey): Flow> = callbackFlow { val setting = key.toApi() diff --git a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt index ac9f9edc..38833a69 100644 --- a/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StickerRepositoryImpl.kt @@ -1,6 +1,7 @@ package org.monogram.data.repository import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive @@ -9,7 +10,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.datasource.remote.StickerRemoteSource @@ -28,11 +28,9 @@ class StickerRepositoryImpl( private val cacheProvider: CacheProvider, private val dispatchers: DispatcherProvider, private val localDataSource: StickerLocalDataSource, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : StickerRepository { - private val scope = scopeProvider.appScope - override val installedStickerSets: StateFlow> = cacheProvider.installedStickerSets override val customEmojiStickerSets: StateFlow> = cacheProvider.customEmojiStickerSets diff --git a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt index 9544d3f1..c3c9818b 100644 --- a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt @@ -1,11 +1,14 @@ package org.monogram.data.repository import androidx.media3.datasource.DataSource -import org.monogram.core.ScopeProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.* -import org.monogram.data.datasource.FileDataSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.TelegramStreamingDataSource import org.monogram.data.gateway.UpdateDispatcher import org.monogram.domain.repository.PlayerDataSourceFactory @@ -14,11 +17,9 @@ import org.monogram.domain.repository.StreamingRepository class StreamingRepositoryImpl( private val fileDataSource: FileDataSource, private val updates: UpdateDispatcher, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : StreamingRepository, PlayerDataSourceFactory { - private val scope = scopeProvider.appScope - private val _fileProgressFlow = MutableSharedFlow>( replay = 1, extraBufferCapacity = 100, diff --git a/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt index 4efbb88a..4d9c4e8a 100644 --- a/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/UpdateRepositoryImpl.kt @@ -1,6 +1,5 @@ package org.monogram.data.repository -import org.monogram.data.core.coRunCatching import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -8,11 +7,12 @@ import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import androidx.core.content.FileProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.monogram.core.ScopeProvider +import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.UpdateRemoteDateSource import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler @@ -31,11 +31,9 @@ class UpdateRepositoryImpl( private val fileQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, private val authRepository: AuthRepository, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : UpdateRepository { - private val scope = scopeProvider.appScope - private val _updateState = MutableStateFlow(UpdateState.Idle) override val updateState: StateFlow = _updateState.asStateFlow() diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt index eb1a3771..4e67e638 100644 --- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -1,12 +1,12 @@ package org.monogram.data.repository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.WallpaperDao import org.monogram.data.db.model.WallpaperEntity @@ -20,11 +20,9 @@ class WallpaperRepositoryImpl( private val updates: UpdateDispatcher, private val wallpaperDao: WallpaperDao, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : WallpaperRepository { - private val scope = scopeProvider.appScope - private val wallpaperUpdates = MutableSharedFlow(extraBufferCapacity = 1) private val wallpapers = MutableStateFlow>(emptyList()) diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt index c0828556..e0b958c6 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt @@ -1,13 +1,9 @@ package org.monogram.data.repository.user -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi -import org.monogram.core.ScopeProvider import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.ChatLocalDataSource @@ -35,10 +31,8 @@ class UserRepositoryImpl( fileQueue: FileDownloadQueue, private val keyValueDao: KeyValueDao, private val cacheProvider: CacheProvider, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) : UserRepository { - - private val scope = scopeProvider.appScope private val mediaResolver = UserMediaResolver(gateway = gateway, fileQueue = fileQueue) private var currentUserId: Long = 0L private val userRequests = ConcurrentHashMap>() diff --git a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt index 5fa219f3..e0d003b6 100644 --- a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt +++ b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt @@ -1,12 +1,12 @@ package org.monogram.data.stickers import android.util.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.monogram.core.DispatcherProvider -import org.monogram.core.ScopeProvider import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.infra.FileDownloadQueue @@ -23,10 +23,8 @@ class StickerFileManager( private val fileQueue: FileDownloadQueue, private val fileUpdateHandler: FileUpdateHandler, private val dispatchers: DispatcherProvider, - scopeProvider: ScopeProvider + private val scope: CoroutineScope ) { - private val scope = scopeProvider.appScope - private val tgsCache = mutableMapOf() private val filePathsCache = ConcurrentHashMap() From 5aca914fa79896dcaf076f6ea112f1ee4ae86761 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:56:28 +0300 Subject: [PATCH 02/83] Rewritten mappings of chats/messages (#200) --- .../main/kotlin/org/monogram/core/Mapper.kt | 5 - .../kotlin/org/monogram/core/SuspendMapper.kt | 5 - .../monogram/data/chats/ChatModelFactory.kt | 14 +- .../remote/SettingsRemoteDataSource.kt | 5 + .../remote/TdSettingsRemoteDataSource.kt | 17 +- .../java/org/monogram/data/di/dataModule.kt | 70 +- .../monogram/data/mapper/ChatEntityMapper.kt | 62 +- .../org/monogram/data/mapper/ChatMapper.kt | 221 +- .../data/mapper/ChatPermissionsMapper.kt | 125 + .../data/mapper/ChatPositionsMapper.kt | 20 + .../monogram/data/mapper/ChatTypeHelper.kt | 41 + .../monogram/data/mapper/CustomEmojiLoader.kt | 46 + .../org/monogram/data/mapper/FilePathUtils.kt | 7 + .../monogram/data/mapper/InstantViewMapper.kt | 10 +- .../org/monogram/data/mapper/MessageMapper.kt | 2340 +++-------------- .../monogram/data/mapper/ReplyMarkupMapper.kt | 72 + .../data/mapper/SenderNameResolver.kt | 15 + .../monogram/data/mapper/StickersMapper.kt | 4 +- .../org/monogram/data/mapper/TdFileHelper.kt | 120 + .../monogram/data/mapper/TextEntityMapper.kt | 58 + .../org/monogram/data/mapper/UpdateMapper.kt | 23 +- .../data/mapper/UserStatusFormatter.kt | 60 + .../monogram/data/mapper/WallpaperMapper.kt | 125 +- .../org/monogram/data/mapper/WebPageMapper.kt | 267 ++ .../mapper/message/MessageContentMapper.kt | 593 +++++ .../message/MessagePersistenceMapper.kt | 746 ++++++ .../mapper/message/MessageSenderResolver.kt | 279 ++ .../monogram/data/mapper/user/UserMapper.kt | 82 +- .../data/repository/MessageRepositoryImpl.kt | 19 +- .../repository/ProfilePhotoRepositoryImpl.kt | 15 +- .../repository/WallpaperRepositoryImpl.kt | 36 + .../data/repository/user/UserMediaResolver.kt | 15 +- .../data/stickers/StickerFileManager.kt | 13 +- .../monogram/domain/models/WallpaperModel.kt | 14 +- .../domain/repository/WallpaperRepository.kt | 13 +- .../chatContent/ChatContentBackground.kt | 11 +- .../chatSettings/ChatSettingsComponent.kt | 65 +- .../chatSettings/ChatSettingsContent.kt | 75 +- .../components/WallpaperBackground.kt | 102 +- .../src/main/res/values-es/string.xml | 1 + .../src/main/res/values-hy/string.xml | 1 + .../src/main/res/values-pt-rBR/string.xml | 1 + .../src/main/res/values-ru-rRU/string.xml | 1 + .../src/main/res/values-sk/string.xml | 1 + .../src/main/res/values-uk/string.xml | 1 + .../src/main/res/values-zh-rCN/string.xml | 1 + presentation/src/main/res/values/string.xml | 1 + 47 files changed, 3334 insertions(+), 2484 deletions(-) delete mode 100644 core/src/main/kotlin/org/monogram/core/Mapper.kt delete mode 100644 core/src/main/kotlin/org/monogram/core/SuspendMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt diff --git a/core/src/main/kotlin/org/monogram/core/Mapper.kt b/core/src/main/kotlin/org/monogram/core/Mapper.kt deleted file mode 100644 index c7a496da..00000000 --- a/core/src/main/kotlin/org/monogram/core/Mapper.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.monogram.core - -interface Mapper { - fun map(input: I): O -} \ No newline at end of file diff --git a/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt b/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt deleted file mode 100644 index c059a41f..00000000 --- a/core/src/main/kotlin/org/monogram/core/SuspendMapper.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.monogram.core - -interface SuspendMapper { - suspend fun map(input: I): O -} \ No newline at end of file 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 ba96a4f7..4523914b 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -7,16 +7,12 @@ import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.mapper.ChatMapper -import org.monogram.data.mapper.isForcedVerifiedChat -import org.monogram.data.mapper.isForcedVerifiedUser -import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.* import org.monogram.data.mapper.user.toEntity import org.monogram.data.mapper.user.toTdApi import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UsernamesModel import org.monogram.domain.repository.AppPreferencesProvider -import java.io.File import java.util.concurrent.ConcurrentHashMap class ChatModelFactory( @@ -295,12 +291,12 @@ class ChatModelFactory( } val localPath = photoFile.local.path - if (isValidPath(localPath)) { + if (isValidFilePath(localPath)) { return localPath } val cachedPath = photoFile.id.takeIf { it != 0 }?.let { fileManager.getFilePath(it) } - if (isValidPath(cachedPath)) { + if (isValidFilePath(cachedPath)) { return cachedPath } @@ -330,10 +326,6 @@ class ChatModelFactory( return (memberCount ?: 0) to (onlineCount ?: 0) } - private fun isValidPath(path: String?): Boolean { - return !path.isNullOrBlank() && File(path).exists() - } - companion object { private const val USER_FULL_INFO_RETRY_TTL_MS = 5 * 60 * 1000L } 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 6503514b..4a06c2b0 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,11 @@ interface SettingsRemoteDataSource { scope: TdApi.NotificationSettingsScope, compareSound: Boolean ): TdApi.Chats? + suspend fun setDefaultBackground( + background: TdApi.InputBackground?, + type: TdApi.BackgroundType?, + forDarkTheme: Boolean + ): TdApi.Background? // Setters suspend fun setScopeNotificationSettings( 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 56d27828..fcef9c0a 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 @@ -1,8 +1,8 @@ package org.monogram.data.datasource.remote -import org.monogram.data.core.coRunCatching import android.util.Log import org.drinkless.tdlib.TdApi +import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue @@ -32,6 +32,21 @@ class TdSettingsRemoteDataSource( result }.getOrNull() + override suspend fun setDefaultBackground( + background: TdApi.InputBackground?, + type: TdApi.BackgroundType?, + forDarkTheme: Boolean + ): TdApi.Background? = + coRunCatching { + val result = gateway.execute(TdApi.SetDefaultBackground(background, type, forDarkTheme)) + result.document?.thumbnail?.file?.let { file -> + if (file.local.path.isEmpty()) { + fileQueue.enqueue(file.id, 1, FileDownloadQueue.DownloadType.DEFAULT) + } + } + result + }.getOrNull() + override suspend fun getStorageStatistics(chatLimit: Int): TdApi.StorageStatistics? = coRunCatching { gateway.execute(TdApi.GetStorageStatistics(chatLimit)) }.getOrNull() 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 d13ee94e..2cb0be8f 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -22,10 +22,10 @@ import org.monogram.data.gateway.TelegramGatewayImpl import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.gateway.UpdateDispatcherImpl import org.monogram.data.infra.* -import org.monogram.data.mapper.ChatMapper -import org.monogram.data.mapper.MessageMapper -import org.monogram.data.mapper.NetworkMapper -import org.monogram.data.mapper.StorageMapper +import org.monogram.data.mapper.* +import org.monogram.data.mapper.message.MessageContentMapper +import org.monogram.data.mapper.message.MessagePersistenceMapper +import org.monogram.data.mapper.message.MessageSenderResolver import org.monogram.data.repository.* import org.monogram.data.repository.user.UserRepositoryImpl import org.monogram.data.stickers.StickerFileManager @@ -280,19 +280,70 @@ val dataModule = module { } single { - MessageMapper( + TdFileHelper( connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager, + fileApi = get(), + appPreferences = get(), + cache = get() + ) + } + + single { + CustomEmojiLoader( gateway = get(), - userRepository = get(), - chatInfoRepository = get(), - fileUpdateHandler = get(), fileApi = get(), + fileUpdateHandler = get(), + fileHelper = get() + ) + } + + single { + WebPageMapper( + fileHelper = get(), + appPreferences = get() + ) + } + + single { + MessageContentMapper( + fileHelper = get(), appPreferences = get(), - cache = get(), + customEmojiLoader = get(), + webPageMapper = get(), scope = get() ) } + single { + MessageSenderResolver( + gateway = get(), + userRepository = get(), + chatInfoRepository = get(), + cache = get(), + fileHelper = get() + ) + } + + single { + MessagePersistenceMapper( + cache = get(), + fileHelper = get() + ) + } + + single { + MessageMapper( + gateway = get(), + userRepository = get(), + cache = get(), + fileHelper = get(), + senderResolver = get(), + contentMapper = get(), + persistenceMapper = get(), + customEmojiLoader = get() + ) + } + single { ConnectionManager( chatRemoteSource = get(), @@ -432,6 +483,7 @@ val dataModule = module { messageMapper = get(), messageRemoteDataSource = get(), cache = get(), + fileHelper = get(), dispatcherProvider = get(), scope = get(), fileDataSource = get(), diff --git a/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt index 66cbe9d3..c0235ffe 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatEntityMapper.kt @@ -4,19 +4,17 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.db.model.ChatEntity fun TdApi.Chat.toEntity(): ChatEntity { - val isChannel = (type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false + val isChannel = type.isChannelType() val isArchived = positions.any { it.list is TdApi.ChatListArchive } - val permissions = permissions ?: TdApi.ChatPermissions() val cachedCounts = parseCachedCounts(clientData) + val typeIds = type.extractTypeIds() + val chatPermissions = permissions.toDomainChatPermissions() val senderId = when (val sender = messageSenderId) { is TdApi.MessageSenderUser -> sender.userId is TdApi.MessageSenderChat -> sender.chatId else -> null } - val privateUserId = (type as? TdApi.ChatTypePrivate)?.userId ?: 0L - val basicGroupId = (type as? TdApi.ChatTypeBasicGroup)?.basicGroupId ?: 0L - val supergroupId = (type as? TdApi.ChatTypeSupergroup)?.supergroupId ?: 0L - val secretChatId = (type as? TdApi.ChatTypeSecret)?.secretChatId ?: 0 + return ChatEntity( id = id, title = title, @@ -29,19 +27,13 @@ fun TdApi.Chat.toEntity(): ChatEntity { isPinned = positions.firstOrNull()?.isPinned ?: false, isMuted = notificationSettings.muteFor > 0, isChannel = isChannel, - isGroup = type is TdApi.ChatTypeBasicGroup || (type is TdApi.ChatTypeSupergroup && !isChannel), - type = when (type) { - is TdApi.ChatTypePrivate -> "PRIVATE" - is TdApi.ChatTypeBasicGroup -> "BASIC_GROUP" - is TdApi.ChatTypeSupergroup -> "SUPERGROUP" - is TdApi.ChatTypeSecret -> "SECRET" - else -> "PRIVATE" - }, - privateUserId = privateUserId, - basicGroupId = basicGroupId, - supergroupId = supergroupId, - secretChatId = secretChatId, - positionsCache = encodePositions(positions), + isGroup = type.isGroupType(), + type = type.toEntityChatType(), + privateUserId = typeIds.privateUserId, + basicGroupId = typeIds.basicGroupId, + supergroupId = typeIds.supergroupId, + secretChatId = typeIds.secretChatId, + positionsCache = encodeChatPositions(positions), isArchived = isArchived, memberCount = cachedCounts.first, onlineCount = cachedCounts.second, @@ -80,38 +72,8 @@ fun TdApi.Chat.toEntity(): ChatEntity { username = null, description = null, inviteLink = null, - permissionCanSendBasicMessages = permissions.canSendBasicMessages, - permissionCanSendAudios = permissions.canSendAudios, - permissionCanSendDocuments = permissions.canSendDocuments, - permissionCanSendPhotos = permissions.canSendPhotos, - permissionCanSendVideos = permissions.canSendVideos, - permissionCanSendVideoNotes = permissions.canSendVideoNotes, - permissionCanSendVoiceNotes = permissions.canSendVoiceNotes, - permissionCanSendPolls = permissions.canSendPolls, - permissionCanSendOtherMessages = permissions.canSendOtherMessages, - permissionCanAddLinkPreviews = permissions.canAddLinkPreviews, - permissionCanEditTag = permissions.canEditTag, - permissionCanChangeInfo = permissions.canChangeInfo, - permissionCanInviteUsers = permissions.canInviteUsers, - permissionCanPinMessages = permissions.canPinMessages, - permissionCanCreateTopics = permissions.canCreateTopics, createdAt = System.currentTimeMillis() - ) -} - -private fun encodePositions(positions: Array): String? { - if (positions.isEmpty()) return null - val encoded = positions.mapNotNull { pos -> - if (pos.order == 0L) return@mapNotNull null - val pinned = if (pos.isPinned) 1 else 0 - when (val list = pos.list) { - is TdApi.ChatListMain -> "m:${pos.order}:$pinned" - is TdApi.ChatListArchive -> "a:${pos.order}:$pinned" - is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned" - else -> null - } - } - return if (encoded.isEmpty()) null else encoded.joinToString("|") + ).withPermissions(chatPermissions) } private fun parseCachedCounts(clientData: String?): Pair { diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index 5d7dbe27..3a0ed425 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -1,6 +1,5 @@ package org.monogram.data.mapper -import android.text.format.DateUtils import org.drinkless.tdlib.TdApi import org.monogram.data.db.model.ChatEntity import org.monogram.domain.models.* @@ -40,30 +39,14 @@ class ChatMapper(private val stringProvider: StringProvider) { hasAutomaticTranslation: Boolean = false, personalAvatarPath: String? = null ): ChatModel { - val p = chat.permissions ?: TdApi.ChatPermissions() - val permissions = ChatPermissionsModel( - canSendBasicMessages = p.canSendBasicMessages, - canSendAudios = p.canSendAudios, - canSendDocuments = p.canSendDocuments, - canSendPhotos = p.canSendPhotos, - canSendVideos = p.canSendVideos, - canSendVideoNotes = p.canSendVideoNotes, - canSendVoiceNotes = p.canSendVoiceNotes, - canSendPolls = p.canSendPolls, - canSendOtherMessages = p.canSendOtherMessages, - canAddLinkPreviews = p.canAddLinkPreviews, - canEditTag = p.canEditTag, - canChangeInfo = p.canChangeInfo, - canInviteUsers = p.canInviteUsers, - canPinMessages = p.canPinMessages, - canCreateTopics = p.canCreateTopics, - ) - - val isChannel = (chat.type as? TdApi.ChatTypeSupergroup)?.isChannel ?: false + val permissions = chat.permissions.toDomainChatPermissions() + val isChannel = chat.type.isChannelType() val draft = chat.draftMessage?.inputMessageText as? TdApi.InputMessageText val draftText = draft?.text?.text - val draftEntities = draft?.text?.entities?.map { mapEntity(it) } ?: emptyList() + val draftEntities = draft?.text?.entities + ?.mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) } + ?: emptyList() return ChatModel( id = chat.id, @@ -78,7 +61,7 @@ class ChatMapper(private val stringProvider: StringProvider) { lastMessageTime = lastMessageTime, lastMessageDate = lastMessageDate, order = order, - isGroup = chat.type is TdApi.ChatTypeBasicGroup || (chat.type is TdApi.ChatTypeSupergroup && !isChannel), + isGroup = chat.type.isGroupType(), isSupergroup = chat.type is TdApi.ChatTypeSupergroup, isChannel = isChannel, memberCount = memberCount, @@ -126,13 +109,7 @@ class ChatMapper(private val stringProvider: StringProvider) { usernames = usernames, description = description, inviteLink = inviteLink, - type = when (chat.type) { - is TdApi.ChatTypePrivate -> ChatType.PRIVATE - is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP - is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP - is TdApi.ChatTypeSecret -> ChatType.SECRET - else -> ChatType.PRIVATE - }, + type = chat.type.toDomainChatType(), permissions = permissions, isMember = isMember ) @@ -192,23 +169,7 @@ class ChatMapper(private val stringProvider: StringProvider) { username = entity.username, description = entity.description, inviteLink = entity.inviteLink, - permissions = ChatPermissionsModel( - canSendBasicMessages = entity.permissionCanSendBasicMessages, - canSendAudios = entity.permissionCanSendAudios, - canSendDocuments = entity.permissionCanSendDocuments, - canSendPhotos = entity.permissionCanSendPhotos, - canSendVideos = entity.permissionCanSendVideos, - canSendVideoNotes = entity.permissionCanSendVideoNotes, - canSendVoiceNotes = entity.permissionCanSendVoiceNotes, - canSendPolls = entity.permissionCanSendPolls, - canSendOtherMessages = entity.permissionCanSendOtherMessages, - canAddLinkPreviews = entity.permissionCanAddLinkPreviews, - canEditTag = entity.permissionCanEditTag, - canChangeInfo = entity.permissionCanChangeInfo, - canInviteUsers = entity.permissionCanInviteUsers, - canPinMessages = entity.permissionCanPinMessages, - canCreateTopics = entity.permissionCanCreateTopics - ) + permissions = entity.toDomainChatPermissionsModel() ) } @@ -271,65 +232,15 @@ class ChatMapper(private val stringProvider: StringProvider) { username = domain.username, description = domain.description, inviteLink = domain.inviteLink, - permissionCanSendBasicMessages = domain.permissions.canSendBasicMessages, - permissionCanSendAudios = domain.permissions.canSendAudios, - permissionCanSendDocuments = domain.permissions.canSendDocuments, - permissionCanSendPhotos = domain.permissions.canSendPhotos, - permissionCanSendVideos = domain.permissions.canSendVideos, - permissionCanSendVideoNotes = domain.permissions.canSendVideoNotes, - permissionCanSendVoiceNotes = domain.permissions.canSendVoiceNotes, - permissionCanSendPolls = domain.permissions.canSendPolls, - permissionCanSendOtherMessages = domain.permissions.canSendOtherMessages, - permissionCanAddLinkPreviews = domain.permissions.canAddLinkPreviews, - permissionCanEditTag = domain.permissions.canEditTag, - permissionCanChangeInfo = domain.permissions.canChangeInfo, - permissionCanInviteUsers = domain.permissions.canInviteUsers, - permissionCanPinMessages = domain.permissions.canPinMessages, - permissionCanCreateTopics = domain.permissions.canCreateTopics, lastMessageContentType = "text", lastMessageSenderName = "", createdAt = System.currentTimeMillis() - ) + ).withPermissions(domain.permissions) } fun mapToEntity(chat: TdApi.Chat, domain: ChatModel): ChatEntity { - val privateUserId: Long - val basicGroupId: Long - val supergroupId: Long - val secretChatId: Int - when (val t = chat.type) { - is TdApi.ChatTypePrivate -> { - privateUserId = t.userId - basicGroupId = 0L - supergroupId = 0L - secretChatId = 0 - } - is TdApi.ChatTypeBasicGroup -> { - privateUserId = 0L - basicGroupId = t.basicGroupId - supergroupId = 0L - secretChatId = 0 - } - is TdApi.ChatTypeSupergroup -> { - privateUserId = 0L - basicGroupId = 0L - supergroupId = t.supergroupId - secretChatId = 0 - } - is TdApi.ChatTypeSecret -> { - privateUserId = 0L - basicGroupId = 0L - supergroupId = 0L - secretChatId = t.secretChatId - } - else -> { - privateUserId = 0L - basicGroupId = 0L - supergroupId = 0L - secretChatId = 0 - } - } - val encodedPositions = encodePositions(chat.positions) + val typeIds = chat.type.extractTypeIds() + val encodedPositions = encodeChatPositions(chat.positions) val (lastMessageContentType, lastMessageSenderName) = chat.lastMessage?.let { message -> val type = when (message.content) { is TdApi.MessageText -> "text" @@ -357,10 +268,10 @@ class ChatMapper(private val stringProvider: StringProvider) { } ?: ("text" to "") return mapToEntity(domain).copy( - privateUserId = privateUserId, - basicGroupId = basicGroupId, - supergroupId = supergroupId, - secretChatId = secretChatId, + privateUserId = typeIds.privateUserId, + basicGroupId = typeIds.basicGroupId, + supergroupId = typeIds.supergroupId, + secretChatId = typeIds.secretChatId, positionsCache = encodedPositions, lastMessageDate = chat.lastMessage?.date ?: domain.lastMessageDate, lastMessageContentType = lastMessageContentType, @@ -368,23 +279,6 @@ class ChatMapper(private val stringProvider: StringProvider) { ) } - private fun encodePositions(positions: Array): String? { - if (positions.isEmpty()) return null - - val encoded = positions.mapNotNull { pos -> - if (pos.order == 0L) return@mapNotNull null - val pinned = if (pos.isPinned) 1 else 0 - when (val list = pos.list) { - is TdApi.ChatListMain -> "m:${pos.order}:$pinned" - is TdApi.ChatListArchive -> "a:${pos.order}:$pinned" - is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${pos.order}:$pinned" - else -> null - } - } - - return if (encoded.isEmpty()) null else encoded.joinToString("|") - } - fun formatMessageInfo( lastMsg: TdApi.Message?, chat: TdApi.Chat?, @@ -396,7 +290,9 @@ class ChatMapper(private val stringProvider: StringProvider) { fun captionOrFallback(caption: TdApi.FormattedText?, emojiPrefix: String, fallbackKey: String): String { val text = caption?.text?.trim().orEmpty() if (text.isNotEmpty()) { - entities = caption?.entities?.map { mapEntity(it) } ?: emptyList() + entities = caption?.entities + ?.mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) } + ?: emptyList() return "$emojiPrefix$text" } return stringProvider.getString(fallbackKey) @@ -404,7 +300,8 @@ class ChatMapper(private val stringProvider: StringProvider) { var txt = when (val c = lastMsg.content) { is TdApi.MessageText -> { - entities = c.text.entities.map { mapEntity(it) } + entities = c.text.entities + .mapNotNull { it.toMessageEntityOrNull(mapUnsupportedToOther = true) } c.text.text } is TdApi.MessagePhoto -> captionOrFallback(c.caption, "📷 ", "chat_mapper_photo") @@ -510,79 +407,11 @@ class ChatMapper(private val stringProvider: StringProvider) { return String(chars) } - private fun mapEntity(entity: TdApi.TextEntity): MessageEntity { - return MessageEntity( - offset = entity.offset, - length = entity.length, - type = when (entity.type) { - is TdApi.TextEntityTypeBold -> MessageEntityType.Bold - is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic - is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline - is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough - is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler - is TdApi.TextEntityTypeCode -> MessageEntityType.Code - is TdApi.TextEntityTypePre -> MessageEntityType.Pre() - is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl((entity.type as TdApi.TextEntityTypeTextUrl).url) - is TdApi.TextEntityTypeMention -> MessageEntityType.Mention - is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention((entity.type as TdApi.TextEntityTypeMentionName).userId) - is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag - is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand - is TdApi.TextEntityTypeUrl -> MessageEntityType.Url - is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email - is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber - is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber - is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji((entity.type as TdApi.TextEntityTypeCustomEmoji).customEmojiId) - is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote - is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable - else -> MessageEntityType.Other(entity.type.javaClass.simpleName) - } - ) - } - fun formatUserStatus(status: TdApi.UserStatus, isBot: Boolean = false): String { - if (isBot) return stringProvider.getString("chat_mapper_bot") - return when (status) { - is TdApi.UserStatusOnline -> stringProvider.getString("chat_mapper_online") - is TdApi.UserStatusOffline -> { - val wasOnline = status.wasOnline.toLong() * 1000L - if (wasOnline == 0L) return stringProvider.getString("chat_mapper_offline") - val now = System.currentTimeMillis() - val diff = now - wasOnline - when { - diff < 60 * 1000 -> stringProvider.getString("chat_mapper_seen_just_now") - diff < 60 * 60 * 1000 -> { - val minutes = diff / (60 * 1000L) - if (minutes == 1L) stringProvider.getString("chat_mapper_seen_minutes_ago", 1) - else stringProvider.getString("chat_mapper_seen_minutes_ago_plural", minutes) - } - DateUtils.isToday(wasOnline) -> { - val date = Date(wasOnline) - val format = SimpleDateFormat("HH:mm", Locale.getDefault()) - stringProvider.getString("chat_mapper_seen_at", format.format(date)) - } - - isYesterday(wasOnline) -> { - val date = Date(wasOnline) - val format = SimpleDateFormat("HH:mm", Locale.getDefault()) - stringProvider.getString("chat_mapper_seen_yesterday", format.format(date)) - } - else -> { - val date = Date(wasOnline) - val format = SimpleDateFormat("dd.MM.yy", Locale.getDefault()) - stringProvider.getString("chat_mapper_seen_date", format.format(date)) - } - } - } - - is TdApi.UserStatusRecently -> stringProvider.getString("chat_mapper_seen_recently") - is TdApi.UserStatusLastWeek -> stringProvider.getString("chat_mapper_seen_week") - is TdApi.UserStatusLastMonth -> stringProvider.getString("chat_mapper_seen_month") - is TdApi.UserStatusEmpty -> stringProvider.getString("chat_mapper_offline") - else -> "" - } - } - - private fun isYesterday(timestamp: Long): Boolean { - return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS) + return formatChatUserStatus( + status = status, + stringProvider = stringProvider, + isBot = isBot + ) } } diff --git a/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt new file mode 100644 index 00000000..fb93b8dd --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ChatPermissionsMapper.kt @@ -0,0 +1,125 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.ChatEntity +import org.monogram.domain.models.ChatPermissionsModel + +internal data class ChatEntityPermissionValues( + val canSendBasicMessages: Boolean, + val canSendAudios: Boolean, + val canSendDocuments: Boolean, + val canSendPhotos: Boolean, + val canSendVideos: Boolean, + val canSendVideoNotes: Boolean, + val canSendVoiceNotes: Boolean, + val canSendPolls: Boolean, + val canSendOtherMessages: Boolean, + val canAddLinkPreviews: Boolean, + val canEditTag: Boolean, + val canChangeInfo: Boolean, + val canInviteUsers: Boolean, + val canPinMessages: Boolean, + val canCreateTopics: Boolean +) + +internal fun TdApi.ChatPermissions?.toDomainChatPermissions(): ChatPermissionsModel { + val permissions = this ?: TdApi.ChatPermissions() + return ChatPermissionsModel( + canSendBasicMessages = permissions.canSendBasicMessages, + canSendAudios = permissions.canSendAudios, + canSendDocuments = permissions.canSendDocuments, + canSendPhotos = permissions.canSendPhotos, + canSendVideos = permissions.canSendVideos, + canSendVideoNotes = permissions.canSendVideoNotes, + canSendVoiceNotes = permissions.canSendVoiceNotes, + canSendPolls = permissions.canSendPolls, + canSendOtherMessages = permissions.canSendOtherMessages, + canAddLinkPreviews = permissions.canAddLinkPreviews, + canEditTag = permissions.canEditTag, + canChangeInfo = permissions.canChangeInfo, + canInviteUsers = permissions.canInviteUsers, + canPinMessages = permissions.canPinMessages, + canCreateTopics = permissions.canCreateTopics, + ) +} + +internal fun ChatPermissionsModel.toTdApiChatPermissions(): TdApi.ChatPermissions { + return TdApi.ChatPermissions( + canSendBasicMessages, + canSendAudios, + canSendDocuments, + canSendPhotos, + canSendVideos, + canSendVideoNotes, + canSendVoiceNotes, + canSendPolls, + canSendOtherMessages, + canAddLinkPreviews, + canEditTag, + canChangeInfo, + canInviteUsers, + canPinMessages, + canCreateTopics + ) +} + +internal fun ChatEntity.toDomainChatPermissionsModel(): ChatPermissionsModel { + return ChatPermissionsModel( + canSendBasicMessages = permissionCanSendBasicMessages, + canSendAudios = permissionCanSendAudios, + canSendDocuments = permissionCanSendDocuments, + canSendPhotos = permissionCanSendPhotos, + canSendVideos = permissionCanSendVideos, + canSendVideoNotes = permissionCanSendVideoNotes, + canSendVoiceNotes = permissionCanSendVoiceNotes, + canSendPolls = permissionCanSendPolls, + canSendOtherMessages = permissionCanSendOtherMessages, + canAddLinkPreviews = permissionCanAddLinkPreviews, + canEditTag = permissionCanEditTag, + canChangeInfo = permissionCanChangeInfo, + canInviteUsers = permissionCanInviteUsers, + canPinMessages = permissionCanPinMessages, + canCreateTopics = permissionCanCreateTopics + ) +} + +internal fun ChatPermissionsModel.toEntityPermissionValues(): ChatEntityPermissionValues { + return ChatEntityPermissionValues( + canSendBasicMessages = canSendBasicMessages, + canSendAudios = canSendAudios, + canSendDocuments = canSendDocuments, + canSendPhotos = canSendPhotos, + canSendVideos = canSendVideos, + canSendVideoNotes = canSendVideoNotes, + canSendVoiceNotes = canSendVoiceNotes, + canSendPolls = canSendPolls, + canSendOtherMessages = canSendOtherMessages, + canAddLinkPreviews = canAddLinkPreviews, + canEditTag = canEditTag, + canChangeInfo = canChangeInfo, + canInviteUsers = canInviteUsers, + canPinMessages = canPinMessages, + canCreateTopics = canCreateTopics + ) +} + +internal fun ChatEntity.withPermissions(permissions: ChatPermissionsModel): ChatEntity { + val values = permissions.toEntityPermissionValues() + return copy( + permissionCanSendBasicMessages = values.canSendBasicMessages, + permissionCanSendAudios = values.canSendAudios, + permissionCanSendDocuments = values.canSendDocuments, + permissionCanSendPhotos = values.canSendPhotos, + permissionCanSendVideos = values.canSendVideos, + permissionCanSendVideoNotes = values.canSendVideoNotes, + permissionCanSendVoiceNotes = values.canSendVoiceNotes, + permissionCanSendPolls = values.canSendPolls, + permissionCanSendOtherMessages = values.canSendOtherMessages, + permissionCanAddLinkPreviews = values.canAddLinkPreviews, + permissionCanEditTag = values.canEditTag, + permissionCanChangeInfo = values.canChangeInfo, + permissionCanInviteUsers = values.canInviteUsers, + permissionCanPinMessages = values.canPinMessages, + permissionCanCreateTopics = values.canCreateTopics + ) +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt new file mode 100644 index 00000000..825ed0e5 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ChatPositionsMapper.kt @@ -0,0 +1,20 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi + +internal fun encodeChatPositions(positions: Array): String? { + if (positions.isEmpty()) return null + + val encoded = positions.mapNotNull { position -> + if (position.order == 0L) return@mapNotNull null + val pinned = if (position.isPinned) 1 else 0 + when (val list = position.list) { + is TdApi.ChatListMain -> "m:${position.order}:$pinned" + is TdApi.ChatListArchive -> "a:${position.order}:$pinned" + is TdApi.ChatListFolder -> "f:${list.chatFolderId}:${position.order}:$pinned" + else -> null + } + } + + return if (encoded.isEmpty()) null else encoded.joinToString("|") +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt b/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt new file mode 100644 index 00000000..041b92f4 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ChatTypeHelper.kt @@ -0,0 +1,41 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.ChatType + +internal data class TdChatTypeIds( + val privateUserId: Long = 0L, + val basicGroupId: Long = 0L, + val supergroupId: Long = 0L, + val secretChatId: Int = 0 +) + +internal fun TdApi.ChatType.toDomainChatType(): ChatType { + return when (this) { + is TdApi.ChatTypePrivate -> ChatType.PRIVATE + is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP + is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP + is TdApi.ChatTypeSecret -> ChatType.SECRET + else -> ChatType.PRIVATE + } +} + +internal fun TdApi.ChatType.toEntityChatType(): String = toDomainChatType().name + +internal fun TdApi.ChatType.isChannelType(): Boolean { + return (this as? TdApi.ChatTypeSupergroup)?.isChannel ?: false +} + +internal fun TdApi.ChatType.isGroupType(): Boolean { + return this is TdApi.ChatTypeBasicGroup || (this is TdApi.ChatTypeSupergroup && !isChannel) +} + +internal fun TdApi.ChatType.extractTypeIds(): TdChatTypeIds { + return when (this) { + is TdApi.ChatTypePrivate -> TdChatTypeIds(privateUserId = userId) + is TdApi.ChatTypeBasicGroup -> TdChatTypeIds(basicGroupId = basicGroupId) + is TdApi.ChatTypeSupergroup -> TdChatTypeIds(supergroupId = supergroupId) + is TdApi.ChatTypeSecret -> TdChatTypeIds(secretChatId = secretChatId) + else -> TdChatTypeIds() + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt b/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt new file mode 100644 index 00000000..a3169cf6 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/CustomEmojiLoader.kt @@ -0,0 +1,46 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.MessageFileApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.infra.FileUpdateHandler + +internal class CustomEmojiLoader( + private val gateway: TelegramGateway, + private val fileApi: MessageFileApi, + private val fileUpdateHandler: FileUpdateHandler, + private val fileHelper: TdFileHelper +) { + fun getPathIfValid(emojiId: Long): String? { + return fileUpdateHandler.customEmojiPaths[emojiId] + ?.takeIf { fileHelper.isValidPath(it) } + } + + suspend fun loadIfNeeded(emojiId: Long, chatId: Long, messageId: Long, autoDownload: Boolean) { + if (getPathIfValid(emojiId) != null) return + + val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) + if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { + val fileToUse = result.stickers.first().sticker + + fileUpdateHandler.fileIdToCustomEmojiId[fileToUse.id] = emojiId + fileApi.registerFileForMessage(fileToUse.id, chatId, messageId) + + if (!fileHelper.isValidPath(fileToUse.local.path)) { + if (autoDownload) { + fileApi.enqueueDownload( + fileToUse.id, + 32, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } else { + fileUpdateHandler.customEmojiPaths[emojiId] = fileToUse.local.path + } + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt b/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt new file mode 100644 index 00000000..b4b401ac --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/FilePathUtils.kt @@ -0,0 +1,7 @@ +package org.monogram.data.mapper + +import java.io.File + +internal fun isValidFilePath(path: String?): Boolean { + return !path.isNullOrEmpty() && File(path).exists() +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt b/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt index 5651929b..7c1c4f9e 100644 --- a/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/InstantViewMapper.kt @@ -144,7 +144,7 @@ private fun TdApi.PageBlockRelatedArticle.toRelatedArticle() = PageBlockRelatedA private fun TdApi.Photo.toPhoto(): WebPage.Photo { val size = sizes.lastOrNull() return WebPage.Photo( - path = size?.photo?.local?.path?.ifEmpty { null }, + path = size?.photo?.local?.path?.takeIf { isValidFilePath(it) }, width = size?.width ?: 0, height = size?.height ?: 0, fileId = size?.photo?.id ?: 0, @@ -153,7 +153,7 @@ private fun TdApi.Photo.toPhoto(): WebPage.Photo { } private fun TdApi.Animation.toAnimation() = WebPage.Animation( - path = animation.local.path.ifEmpty { null }, + path = animation.local.path.takeIf { isValidFilePath(it) }, width = width, height = height, duration = duration, @@ -161,7 +161,7 @@ private fun TdApi.Animation.toAnimation() = WebPage.Animation( ) private fun TdApi.Audio.toAudio() = WebPage.Audio( - path = audio.local.path.ifEmpty { null }, + path = audio.local.path.takeIf { isValidFilePath(it) }, duration = duration, title = title, performer = performer, @@ -169,7 +169,7 @@ private fun TdApi.Audio.toAudio() = WebPage.Audio( ) private fun TdApi.Video.toVideo() = WebPage.Video( - path = video.local.path.ifEmpty { null }, + path = video.local.path.takeIf { isValidFilePath(it) }, width = width, height = height, duration = duration, @@ -177,7 +177,7 @@ private fun TdApi.Video.toVideo() = WebPage.Video( ) private fun TdApi.Document.toDocument() = WebPage.Document( - path = document.local.path.ifEmpty { null }, + path = document.local.path.takeIf { isValidFilePath(it) }, fileName = fileName, mimeType = mimeType, size = document.size, 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 b498cdc8..8e8a9caf 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -1,489 +1,344 @@ package org.monogram.data.mapper -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import org.drinkless.tdlib.TdApi import org.monogram.data.chats.ChatCache -import org.monogram.data.datasource.remote.MessageFileApi -import org.monogram.data.datasource.remote.TdMessageRemoteDataSource import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.mapper.message.ContentMappingContext +import org.monogram.data.mapper.message.MessageContentMapper +import org.monogram.data.mapper.message.MessagePersistenceMapper +import org.monogram.data.mapper.message.MessageSenderResolver import org.monogram.domain.models.* -import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.ChatInfoRepository import org.monogram.domain.repository.UserRepository -import java.io.File -import java.util.concurrent.ConcurrentHashMap -class MessageMapper( - private val connectivityManager: ConnectivityManager, +class MessageMapper internal constructor( private val gateway: TelegramGateway, private val userRepository: UserRepository, - private val chatInfoRepository: ChatInfoRepository, - private val fileUpdateHandler: FileUpdateHandler, - private val fileApi: MessageFileApi, - private val appPreferences: AppPreferencesProvider, private val cache: ChatCache, - val scope: CoroutineScope + private val fileHelper: TdFileHelper, + private val senderResolver: MessageSenderResolver, + private val contentMapper: MessageContentMapper, + private val persistenceMapper: MessagePersistenceMapper, + private val customEmojiLoader: CustomEmojiLoader ) { - private val customEmojiPaths = fileUpdateHandler.customEmojiPaths - private val fileIdToCustomEmojiId = fileUpdateHandler.fileIdToCustomEmojiId - - private data class SenderUserSnapshot( - val name: String, - val avatar: String?, - val personalAvatar: String?, - val isVerified: Boolean, - val isPremium: Boolean, - val statusEmojiId: Long, - val statusEmojiPath: String? - ) - - private data class SenderChatSnapshot( - val name: String, - val avatar: String? - ) - - private val senderUserSnapshotCache = ConcurrentHashMap() - private val senderChatSnapshotCache = ConcurrentHashMap() - private val senderRankCache = ConcurrentHashMap() - private val queuedAvatarDownloads = ConcurrentHashMap.newKeySet() - val senderUpdateFlow: Flow - get() = userRepository.anyUserUpdateFlow + get() = senderResolver.senderUpdateFlow fun invalidateSenderCache(userId: Long) { - if (userId <= 0L) return - senderUserSnapshotCache.remove(userId) - senderChatSnapshotCache.remove(userId) - senderRankCache.entries.removeIf { it.key.endsWith(":$userId") } - } - - private companion object { - private const val NO_RANK_SENTINEL = "__NO_RANK__" - private const val META_SEPARATOR = '\u001F' - private const val MESSAGE_MAP_TIMEOUT_MS = 2500L - } - - private fun getCurrentNetworkType(): TdApi.NetworkType { - val activeNetwork = connectivityManager.activeNetwork - val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) - - return when { - capabilities == null -> TdApi.NetworkTypeNone() - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TdApi.NetworkTypeWiFi() - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { - if (connectivityManager.isDefaultNetworkActive && capabilities.hasCapability( - NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) - .not() - ) { - TdApi.NetworkTypeMobileRoaming() - } else { - TdApi.NetworkTypeMobile() - } - } - else -> TdApi.NetworkTypeNone() - } - } - - private fun isNetworkAutoDownloadEnabled(): Boolean { - return when (getCurrentNetworkType()) { - is TdApi.NetworkTypeWiFi -> appPreferences.autoDownloadWifi.value - is TdApi.NetworkTypeMobile -> appPreferences.autoDownloadMobile.value - is TdApi.NetworkTypeMobileRoaming -> appPreferences.autoDownloadRoaming.value - else -> appPreferences.autoDownloadWifi.value - } - } - - private fun isValidPath(path: String?): Boolean { - return !path.isNullOrEmpty() && File(path).exists() - } - - private fun encodeMeta(vararg parts: Any?): String { - return parts.joinToString(META_SEPARATOR.toString()) { it?.toString().orEmpty() } - } - - private fun decodeMeta(raw: String?): List { - if (raw.isNullOrBlank()) return emptyList() - return if (raw.contains(META_SEPARATOR)) raw.split(META_SEPARATOR) else raw.split('|') - } - - private fun resolveLegacyMediaFromMeta(contentType: String, meta: List): Pair { - return when (contentType) { - "photo" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) - "video" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) - "voice" -> (meta.getOrNull(1)?.toIntOrNull() ?: 0) to meta.getOrNull(2) - "video_note" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) - "sticker" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(6) - "document" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) - "audio" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(5) - "gif" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) - else -> 0 to null - } + senderResolver.invalidateCache(userId) } - private fun resolveCachedPath(fileId: Int, storedPath: String?): String? { - val fromStored = storedPath - ?.takeIf { it.isNotBlank() } - ?.takeIf { isValidPath(it) } - if (fromStored != null) return fromStored - - return fileId.takeIf { it != 0 } - ?.let { cache.fileCache[it]?.local?.path } - ?.takeIf { isValidPath(it) } - } + suspend fun mapMessageToModel( + msg: TdApi.Message, + isChatOpen: Boolean = false, + isReply: Boolean = false + ): MessageModel = coroutineScope { + withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) { + val sender = senderResolver.resolveSender(msg) - private fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) { - if (fileId != 0) { - fileApi.registerFileForMessage(fileId, chatId, messageId) - } - } + val (replyToMsgId, replyToMsg) = resolveReplyInfo( + msg = msg, + isChatOpen = isChatOpen, + isReply = isReply + ) - private fun resolveLocalFilePath(file: TdApi.File?): String? { - if (file == null) return null - val directPath = file.local.path.takeIf { isValidPath(it) } - if (directPath != null) return directPath + val forwardInfo = resolveForwardInfo(msg) + val views = msg.interactionInfo?.viewCount + val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0 + val sendingState = resolveSendingState(msg) + val reactions = resolveReactions(msg, isReply, isChatOpen) + val threadId = resolveThreadId(msg) + val viaBotName = resolveViaBotName(msg) - return cache.fileCache[file.id]?.local?.path?.takeIf { isValidPath(it) } + createMessageModel( + msg = msg, + senderName = sender.senderName, + senderId = sender.senderId, + senderAvatar = sender.senderAvatar, + isReadOverride = false, + replyToMsgId = replyToMsgId, + replyToMsg = replyToMsg, + forwardInfo = forwardInfo, + views = views, + viewCount = views, + mediaAlbumId = msg.mediaAlbumId, + sendingState = sendingState, + isChatOpen = isChatOpen, + readDate = 0, + reactions = reactions, + isSenderVerified = sender.isSenderVerified, + threadId = threadId, + replyCount = replyCount, + isReply = isReply, + viaBotUserId = msg.viaBotUserId, + viaBotName = viaBotName, + senderPersonalAvatar = sender.senderPersonalAvatar, + senderCustomTitle = sender.senderCustomTitle, + isSenderPremium = sender.isSenderPremium, + senderStatusEmojiId = sender.senderStatusEmojiId, + senderStatusEmojiPath = sender.senderStatusEmojiPath + ) + } ?: mapMessageToModelFallback(msg, isChatOpen, isReply) } - private fun resolveSenderNameFromCache(senderId: Long, fallback: String): String { - val user = cache.getUser(senderId) - if (user != null) { - return listOfNotNull( - user.firstName.takeIf { it.isNotBlank() }, - user.lastName?.takeIf { it.isNotBlank() } - ).joinToString(" ").ifBlank { fallback.ifBlank { "User" } } - } - - val chat = cache.getChat(senderId) - if (chat != null) { - return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" } + suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int { + val chat = cache.getChat(chatId) + if (chat?.type !is TdApi.ChatTypePrivate) { + return 0 } - return fallback.ifBlank { "User" } - } - - private fun findBestAvailablePath(mainFile: TdApi.File?, sizes: Array? = null): String? { - if (mainFile != null && isValidPath(mainFile.local.path)) { - return mainFile.local.path + val sevenDaysAgo = (System.currentTimeMillis() / 1000) - (7 * 24 * 60 * 60) + if (messageDate < sevenDaysAgo) { + return 0 } - if (sizes != null) { - return sizes.sortedByDescending { it.width } - .map { getUpdatedFile(it.photo) } - .firstOrNull { isValidPath(it.local.path) } - ?.local?.path + return try { + val result = gateway.execute(TdApi.GetMessageReadDate(chatId, messageId)) + if (result is TdApi.MessageReadDateRead) { + result.readDate + } else { + 0 + } + } catch (_: Exception) { + 0 } - return null } - private fun resolveFallbackSender(msg: TdApi.Message): Triple { - return when (val sender = msg.senderId) { - is TdApi.MessageSenderUser -> { - val senderId = sender.userId - val snapshot = senderUserSnapshotCache[senderId] - if (snapshot != null) { - val avatar = snapshot.avatar ?: snapshot.personalAvatar - Triple(senderId, snapshot.name.ifBlank { "User" }, avatar) - } else { - val user = cache.getUser(senderId) - val fallbackName = if (user != null) { - listOfNotNull( - user.firstName.takeIf { it.isNotBlank() }, - user.lastName?.takeIf { it.isNotBlank() } - ).joinToString(" ").ifBlank { "User" } - } else { - "User" - } - val avatar = user?.profilePhoto?.small?.local?.path?.takeIf { isValidPath(it) } - ?: user?.profilePhoto?.big?.local?.path?.takeIf { isValidPath(it) } - Triple(senderId, fallbackName, avatar) - } - } - - is TdApi.MessageSenderChat -> { - val senderId = sender.chatId - val snapshot = senderChatSnapshotCache[senderId] - if (snapshot != null) { - Triple(senderId, snapshot.name.ifBlank { "User" }, snapshot.avatar) - } else { - val chat = cache.getChat(senderId) - val fallbackName = chat?.title?.takeIf { it.isNotBlank() } ?: "User" - val avatar = chat?.photo?.small?.local?.path?.takeIf { isValidPath(it) } - Triple(senderId, fallbackName, avatar) - } - } - - else -> Triple(0L, "User", null) - } + suspend fun mapMessageToModelSync( + msg: TdApi.Message, + inboxLimit: Long, + outboxLimit: Long, + isChatOpen: Boolean = false, + isReply: Boolean = false + ): MessageModel { + val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit + val baseModel = mapMessageToModel(msg, isChatOpen, isReply) + return baseModel.copy(isRead = isRead) } - private fun mapMessageToModelFallback( + internal fun createMessageModel( msg: TdApi.Message, - isChatOpen: Boolean, - isReply: Boolean + senderName: String, + senderId: Long, + senderAvatar: String?, + isReadOverride: Boolean = false, + replyToMsgId: Long? = null, + replyToMsg: MessageModel? = null, + forwardInfo: ForwardInfo? = null, + views: Int? = null, + viewCount: Int? = null, + mediaAlbumId: Long = 0L, + sendingState: MessageSendingState? = null, + isChatOpen: Boolean = false, + readDate: Int = 0, + reactions: List = emptyList(), + isSenderVerified: Boolean = false, + threadId: Long? = null, + replyCount: Int = 0, + isReply: Boolean = false, + viaBotUserId: Long = 0L, + viaBotName: String? = null, + senderPersonalAvatar: String? = null, + senderCustomTitle: String? = null, + isSenderPremium: Boolean = false, + senderStatusEmojiId: Long = 0L, + senderStatusEmojiPath: String? = null ): MessageModel { - val (senderId, senderName, senderAvatar) = resolveFallbackSender(msg) - return createMessageModel( + val networkAutoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled() + val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending + + val content = contentMapper.mapContent( msg = msg, + context = ContentMappingContext( + chatId = msg.chatId, + messageId = msg.id, + senderName = senderName, + networkAutoDownload = networkAutoDownload, + isActuallyUploading = isActuallyUploading + ) + ) + + val isServiceMessage = content is MessageContent.Service + val canEdit = msg.isOutgoing && !isServiceMessage + val canForward = !isServiceMessage + val canSave = !isServiceMessage + val hasInteraction = msg.interactionInfo != null + + return MessageModel( + id = msg.id, + date = contentMapper.resolveMessageDate(msg), + isOutgoing = msg.isOutgoing, senderName = senderName, + chatId = msg.chatId, + content = content, senderId = senderId, senderAvatar = senderAvatar, - isChatOpen = isChatOpen, - isReply = isReply + senderPersonalAvatar = senderPersonalAvatar, + senderCustomTitle = senderCustomTitle, + isRead = isReadOverride, + replyToMsgId = replyToMsgId, + replyToMsg = replyToMsg, + forwardInfo = forwardInfo, + views = views, + viewCount = viewCount, + mediaAlbumId = mediaAlbumId, + editDate = msg.editDate, + sendingState = sendingState, + readDate = readDate, + reactions = reactions, + isSenderVerified = isSenderVerified, + threadId = threadId, + replyCount = replyCount, + canBeEdited = canEdit, + canBeForwarded = canForward, + canBeDeletedOnlyForSelf = true, + canBeDeletedForAllUsers = msg.isOutgoing, + canBeSaved = canSave, + canGetMessageThread = msg.interactionInfo?.replyInfo != null, + canGetStatistics = hasInteraction, + canGetReadReceipts = hasInteraction, + canGetViewers = hasInteraction, + replyMarkup = if (isReply) null else msg.replyMarkup.toDomainReplyMarkup(), + viaBotUserId = viaBotUserId, + viaBotName = viaBotName, + isSenderPremium = isSenderPremium, + senderStatusEmojiId = senderStatusEmojiId, + senderStatusEmojiPath = senderStatusEmojiPath ) } - suspend fun mapMessageToModel( + fun mapToEntity( msg: TdApi.Message, - isChatOpen: Boolean = false, - isReply: Boolean = false - ): MessageModel = coroutineScope { - withTimeoutOrNull(MESSAGE_MAP_TIMEOUT_MS) { - var senderName = "User" - var senderAvatar: String? = null - var senderPersonalAvatar: String? = null - var senderCustomTitle: String? = null - var isSenderVerified = false - var isSenderPremium = false - var senderStatusEmojiId = 0L - var senderStatusEmojiPath: String? = null - val senderId: Long + getSenderName: ((Long) -> String?)? = null + ): org.monogram.data.db.model.MessageEntity { + return persistenceMapper.mapToEntity(msg, getSenderName) + } - when (val sender = msg.senderId) { - is TdApi.MessageSenderUser -> { - senderId = sender.userId - val cachedSnapshot = senderUserSnapshotCache[senderId] - if (cachedSnapshot != null) { - senderName = cachedSnapshot.name - senderAvatar = cachedSnapshot.avatar - senderPersonalAvatar = cachedSnapshot.personalAvatar - isSenderVerified = cachedSnapshot.isVerified - isSenderPremium = cachedSnapshot.isPremium - senderStatusEmojiId = cachedSnapshot.statusEmojiId - senderStatusEmojiPath = cachedSnapshot.statusEmojiPath - } else { - val user = try { - withTimeout(500) { userRepository.getUser(senderId) } - } catch (e: Exception) { - null - } - if (user != null) { - senderName = listOfNotNull( - user.firstName.takeIf { it.isNotBlank() }, - user.lastName?.takeIf { it.isNotBlank() } - ).joinToString(" ") + internal fun extractCachedContent(content: TdApi.MessageContent): MessagePersistenceMapper.CachedMessageContent { + return persistenceMapper.extractCachedContent(content) + } - if (senderName.isBlank()) senderName = "User" + fun mapEntityToModel(entity: org.monogram.data.db.model.MessageEntity): MessageModel { + return persistenceMapper.mapEntityToModel(entity) + } - senderAvatar = user.avatarPath.takeIf { isValidPath(it) } - senderPersonalAvatar = user.personalAvatarPath.takeIf { isValidPath(it) } - isSenderVerified = user.isVerified - isSenderPremium = user.isPremium - senderStatusEmojiId = user.statusEmojiId - senderStatusEmojiPath = user.statusEmojiPath + private suspend fun resolveReplyInfo( + msg: TdApi.Message, + isChatOpen: Boolean, + isReply: Boolean + ): Pair { + if (isReply || msg.replyTo == null) return null to null + val replyTo = msg.replyTo + if (replyTo !is TdApi.MessageReplyToMessage) return null to null + + val replyToMsgId = replyTo.messageId + val repliedMessage = try { + withTimeout(500) { + cache.getMessage(msg.chatId, replyToMsgId) + ?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) } + } + } catch (_: Exception) { + null + } - senderUserSnapshotCache[senderId] = SenderUserSnapshot( - name = senderName, - avatar = senderAvatar, - personalAvatar = senderPersonalAvatar, - isVerified = isSenderVerified, - isPremium = isSenderPremium, - statusEmojiId = senderStatusEmojiId, - statusEmojiPath = senderStatusEmojiPath - ) - } - } + val replyToMsg = repliedMessage?.let { + mapMessageToModel( + msg = it, + isChatOpen = isChatOpen, + isReply = true + ).copy(replyToMsg = null, replyToMsgId = null) + } - val chat = cache.getChat(msg.chatId) - val canGetMember = when (chat?.type) { - is TdApi.ChatTypePrivate, is TdApi.ChatTypeSecret -> true - is TdApi.ChatTypeBasicGroup -> true - is TdApi.ChatTypeSupergroup -> { - val supergroup = (chat.type as TdApi.ChatTypeSupergroup) - val cachedSupergroup = cache.getSupergroup(supergroup.supergroupId) - !(cachedSupergroup?.isChannel ?: false) || (chat.permissions?.canSendBasicMessages ?: false) - } - else -> false - } + return replyToMsgId to replyToMsg + } - if (canGetMember) { - val rankKey = "${msg.chatId}:$senderId" - val cachedRank = senderRankCache[rankKey] - if (cachedRank != null) { - senderCustomTitle = cachedRank.takeUnless { it == NO_RANK_SENTINEL } - } else { - val member = try { - withTimeout(500) { chatInfoRepository.getChatMember(msg.chatId, senderId) } - } catch (e: Exception) { - null - } - senderCustomTitle = member?.rank - senderRankCache[rankKey] = senderCustomTitle ?: NO_RANK_SENTINEL - } - } - } + private suspend fun resolveForwardInfo(msg: TdApi.Message): ForwardInfo? { + val fwd = msg.forwardInfo ?: return null + val origin = fwd.origin + var originName = "Unknown" + var originPeerId = 0L + var originChatId: Long? = null + var originMessageId: Long? = null - is TdApi.MessageSenderChat -> { - senderId = sender.chatId - val cachedSnapshot = senderChatSnapshotCache[senderId] - if (cachedSnapshot != null) { - senderName = cachedSnapshot.name - senderAvatar = cachedSnapshot.avatar - } else { - val chat = try { - withTimeout(500) { - cache.getChat(senderId) ?: gateway.execute(TdApi.GetChat(senderId)).also { cache.putChat(it) } - } - } catch (e: Exception) { - null - } - if (chat != null) { - senderName = chat.title - val photo = chat.photo?.small - if (photo != null) { - senderAvatar = photo.local.path.takeIf { isValidPath(it) } - if (senderAvatar.isNullOrEmpty() && queuedAvatarDownloads.add(photo.id)) { - fileApi.enqueueDownload( - photo.id, - 16, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } + when (origin) { + is TdApi.MessageOriginUser -> { + originPeerId = origin.senderUserId + val user = try { + withTimeout(500) { userRepository.getUser(originPeerId) } + } catch (_: Exception) { + null + } - senderChatSnapshotCache[senderId] = SenderChatSnapshot( - name = senderName, - avatar = senderAvatar - ) + if (user != null) { + val username = user.username?.takeIf { it.isNotBlank() } + val baseName = SenderNameResolver.fromPartsOrBlank(user.firstName, user.lastName) + originName = if (baseName.isNotBlank()) { + if (username != null) "$baseName (@$username)" else baseName + } else { + username?.let { "@$it" } ?: "Unknown" } } } - else -> senderId = 0L - } - - var replyToMsgId: Long? = null - var replyToMsg: MessageModel? = null - - if (!isReply && msg.replyTo != null) { - val replyTo = msg.replyTo - if (replyTo is TdApi.MessageReplyToMessage) { - replyToMsgId = replyTo.messageId - - val repliedMessage = try { + is TdApi.MessageOriginChat -> { + originPeerId = origin.senderChatId + val chat = try { withTimeout(500) { - cache.getMessage(msg.chatId, replyToMsgId) - ?: gateway.execute(TdApi.GetMessage(msg.chatId, replyToMsgId)).also { cache.putMessage(it) } + cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) + .also { cache.putChat(it) } } - } catch (e: Exception) { + } catch (_: Exception) { null } - if (repliedMessage != null) { - replyToMsg = - mapMessageToModel( - repliedMessage, - isChatOpen, - isReply = true - ).copy(replyToMsg = null, replyToMsgId = null) + if (chat != null) { + originName = chat.title } } - } - - var forwardInfo: ForwardInfo? = null - if (msg.forwardInfo != null) { - val fwd = msg.forwardInfo - val origin = fwd?.origin - var originName = "Unknown" - var originPeerId = 0L - var originChatId: Long? = null - var originMessageId: Long? = null - - when (origin) { - is TdApi.MessageOriginUser -> { - originPeerId = origin.senderUserId - val user = try { - withTimeout(500) { userRepository.getUser(originPeerId) } - } catch (e: Exception) { - null - } - - if (user != null) { - val first = user.firstName.takeIf { it.isNotBlank() } - val last = user.lastName?.takeIf { it.isNotBlank() } - val username = user.username?.takeIf { it.isNotBlank() } - - val baseName = listOfNotNull(first, last).joinToString(" ") - - originName = if (baseName.isNotBlank()) { - if (username != null) "$baseName (@$username)" else baseName - } else { - username?.let { "@$it" } ?: "Unknown" - } - } - } - - is TdApi.MessageOriginChat -> { - originPeerId = origin.senderChatId - val chat = try { - withTimeout(500) { - cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) - .also { cache.putChat(it) } - } - } catch (e: Exception) { - null - } - if (chat != null) { - originName = chat.title - } - } - is TdApi.MessageOriginChannel -> { - originPeerId = origin.chatId - originChatId = origin.chatId - originMessageId = origin.messageId - val chat = try { - withTimeout(500) { - cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) - .also { cache.putChat(it) } - } - } catch (e: Exception) { - null - } - if (chat != null) { - originName = chat.title + is TdApi.MessageOriginChannel -> { + originPeerId = origin.chatId + originChatId = origin.chatId + originMessageId = origin.messageId + val chat = try { + withTimeout(500) { + cache.getChat(originPeerId) ?: gateway.execute(TdApi.GetChat(originPeerId)) + .also { cache.putChat(it) } } + } catch (_: Exception) { + null } - - is TdApi.MessageOriginHiddenUser -> { - originName = origin.senderName + if (chat != null) { + originName = chat.title } } - forwardInfo = - ForwardInfo(fwd?.date ?: 0, originPeerId, originName, originChatId, originMessageId) - } - val views = msg.interactionInfo?.viewCount - val replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0 - - val sendingState = when (val state = msg.sendingState) { - is TdApi.MessageSendingStatePending -> MessageSendingState.Pending - is TdApi.MessageSendingStateFailed -> MessageSendingState.Failed( - state.error.code, - state.error.message - ) + is TdApi.MessageOriginHiddenUser -> { + originName = origin.senderName + } - else -> null + null -> Unit } - val reactions = - if (isReply) emptyList() else msg.interactionInfo?.reactions?.reactions?.map { reaction -> + return ForwardInfo( + date = fwd.date, + fromId = originPeerId, + fromName = originName, + originChatId = originChatId, + originMessageId = originMessageId + ) + } + + private suspend fun resolveReactions( + msg: TdApi.Message, + isReply: Boolean, + isChatOpen: Boolean + ): List { + if (isReply) return emptyList() + val reactionItems = msg.interactionInfo?.reactions?.reactions ?: return emptyList() + + return coroutineScope { + reactionItems.map { reaction -> async { val recentSenders = try { withTimeout(1000) { @@ -493,35 +348,37 @@ class MessageMapper( is TdApi.MessageSenderUser -> { val user = try { withTimeout(500) { userRepository.getUser(senderId.userId) } - } catch (e: Exception) { + } catch (_: Exception) { null } ReactionSender( id = senderId.userId, - name = listOfNotNull( - user?.firstName, - user?.lastName - ).joinToString(" "), - avatar = user?.avatarPath.takeIf { isValidPath(it) } + name = SenderNameResolver.fromPartsOrBlank( + firstName = user?.firstName, + lastName = user?.lastName + ), + avatar = user?.avatarPath.takeIf { fileHelper.isValidPath(it) } ) } is TdApi.MessageSenderChat -> { val chat = try { withTimeout(500) { - cache.getChat(senderId.chatId) ?: gateway.execute( - TdApi.GetChat( - senderId.chatId - ) - ).also { cache.putChat(it) } + cache.getChat(senderId.chatId) + ?: gateway.execute(TdApi.GetChat(senderId.chatId)) + .also { cache.putChat(it) } } - } catch (e: Exception) { + } catch (_: Exception) { null } ReactionSender( id = senderId.chatId, name = chat?.title ?: "", - avatar = chat?.photo?.small?.local?.path.takeIf { isValidPath(it) } + avatar = chat?.photo?.small?.local?.path.takeIf { + fileHelper.isValidPath( + it + ) + } ) } @@ -530,7 +387,7 @@ class MessageMapper( } }.awaitAll() } - } catch (e: Exception) { + } catch (_: Exception) { emptyList() } @@ -546,15 +403,17 @@ class MessageMapper( is TdApi.ReactionTypeCustomEmoji -> { val emojiId = type.customEmojiId - val path = customEmojiPaths[emojiId].takeIf { isValidPath(it) } + var path = customEmojiLoader.getPathIfValid(emojiId) if (path == null) { - loadCustomEmoji( - emojiId, - msg.chatId, - msg.id, - isChatOpen && isNetworkAutoDownloadEnabled() + customEmojiLoader.loadIfNeeded( + emojiId = emojiId, + chatId = msg.chatId, + messageId = msg.id, + autoDownload = isChatOpen && fileHelper.isNetworkAutoDownloadEnabled() ) + path = customEmojiLoader.getPathIfValid(emojiId) } + MessageReactionModel( customEmojiId = emojiId, customEmojiPath = path, @@ -567,1594 +426,59 @@ class MessageMapper( else -> null } } - }?.awaitAll()?.filterNotNull() ?: emptyList() + }.awaitAll().filterNotNull() + } + } - val threadId = when (val topic = msg.topicId) { + private fun resolveThreadId(msg: TdApi.Message): Long? { + return when (val topic = msg.topicId) { is TdApi.MessageTopicForum -> topic.forumTopicId.toLong() is TdApi.MessageTopicThread -> topic.messageThreadId else -> null } + } - var viaBotName: String? = null - if (msg.viaBotUserId != 0L) { - val bot = try { - withTimeout(500) { userRepository.getUser(msg.viaBotUserId) } - } catch (e: Exception) { - null - } - viaBotName = bot?.username ?: bot?.firstName + private suspend fun resolveViaBotName(msg: TdApi.Message): String? { + if (msg.viaBotUserId == 0L) return null + val bot = try { + withTimeout(500) { userRepository.getUser(msg.viaBotUserId) } + } catch (_: Exception) { + null } + return bot?.username ?: bot?.firstName + } - createMessageModel( - msg, - senderName, - senderId, - senderAvatar, - isReadOverride = false, - replyToMsgId = replyToMsgId, - replyToMsg = replyToMsg, - forwardInfo = forwardInfo, - views = views, - viewCount = views, - mediaAlbumId = msg.mediaAlbumId, - sendingState = sendingState, - isChatOpen = isChatOpen, - readDate = 0, - reactions = reactions, - isSenderVerified = isSenderVerified, - threadId = threadId, - replyCount = replyCount, - isReply = isReply, - viaBotUserId = msg.viaBotUserId, - viaBotName = viaBotName, - senderPersonalAvatar = senderPersonalAvatar, - senderCustomTitle = senderCustomTitle, - isSenderPremium = isSenderPremium, - senderStatusEmojiId = senderStatusEmojiId, - senderStatusEmojiPath = senderStatusEmojiPath - ) - } ?: mapMessageToModelFallback(msg, isChatOpen, isReply) - } - - suspend fun getMessageReadDate(chatId: Long, messageId: Long, messageDate: Int): Int { - val chat = cache.getChat(chatId) - if (chat?.type !is TdApi.ChatTypePrivate) { - return 0 - } - - val sevenDaysAgo = (System.currentTimeMillis() / 1000) - (7 * 24 * 60 * 60) - if (messageDate < sevenDaysAgo) { - return 0 - } - - return try { - val result = gateway.execute(TdApi.GetMessageReadDate(chatId, messageId)) - if (result is TdApi.MessageReadDateRead) { - result.readDate - } else { - 0 - } - } catch (e: Exception) { - 0 - } - } - - suspend fun mapMessageToModelSync( - msg: TdApi.Message, - inboxLimit: Long, - outboxLimit: Long, - isChatOpen: Boolean = false, - isReply: Boolean = false - ): MessageModel { - val isRead = if (msg.isOutgoing) msg.id <= outboxLimit else msg.id <= inboxLimit - val baseModel = mapMessageToModel(msg, isChatOpen, isReply) - return baseModel.copy(isRead = isRead) - } - - private fun getUpdatedFile(file: TdApi.File): TdApi.File { - return cache.fileCache[file.id] ?: file - } - - private fun mapEntities( - entities: Array, - chatId: Long, - messageId: Long, - networkAutoDownload: Boolean - ): List { - return entities.map { entity -> - val type = when (val entityType = entity.type) { - is TdApi.TextEntityTypeBold -> MessageEntityType.Bold - is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic - is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline - is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough - is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler - is TdApi.TextEntityTypeCode -> MessageEntityType.Code - is TdApi.TextEntityTypePre -> MessageEntityType.Pre() - is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language) - is TdApi.TextEntityTypeUrl -> MessageEntityType.Url - is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url) - is TdApi.TextEntityTypeMention -> MessageEntityType.Mention - is TdApi.TextEntityTypeMentionName -> MessageEntityType.Mention - is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag - is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand - is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email - is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber - is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber - is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote - is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable - is TdApi.TextEntityTypeCustomEmoji -> { - val emojiId = entityType.customEmojiId - val path = customEmojiPaths[emojiId].takeIf { isValidPath(it) } - if (path == null) { - scope.launch { - loadCustomEmoji(emojiId, chatId, messageId, networkAutoDownload) - } - } - MessageEntityType.CustomEmoji(emojiId, path) - } - - else -> MessageEntityType.Other(entityType.javaClass.simpleName) - } - MessageEntity(entity.offset, entity.length, type) - } - } - - private fun mapWebPage( - webPage: TdApi.LinkPreview?, - chatId: Long, - messageId: Long, - networkAutoDownload: Boolean - ): WebPage? { - if (webPage == null) return null - - var photoObj: TdApi.Photo? = null - var videoObj: TdApi.Video? = null - var audioObj: TdApi.Audio? = null - var documentObj: TdApi.Document? = null - var stickerObj: TdApi.Sticker? = null - var animationObj: TdApi.Animation? = null - var duration = 0 - - val linkPreviewType = when (val t = webPage.type) { - is TdApi.LinkPreviewTypePhoto -> { - photoObj = t.photo - WebPage.LinkPreviewType.Photo - } - - is TdApi.LinkPreviewTypeVideo -> { - videoObj = t.video - WebPage.LinkPreviewType.Video - } - - is TdApi.LinkPreviewTypeAnimation -> { - animationObj = t.animation - WebPage.LinkPreviewType.Animation - } - - is TdApi.LinkPreviewTypeAudio -> { - audioObj = t.audio - WebPage.LinkPreviewType.Audio - } - - is TdApi.LinkPreviewTypeDocument -> { - documentObj = t.document - WebPage.LinkPreviewType.Document - } - - is TdApi.LinkPreviewTypeSticker -> { - stickerObj = t.sticker - WebPage.LinkPreviewType.Sticker - } - - is TdApi.LinkPreviewTypeVideoNote -> WebPage.LinkPreviewType.VideoNote - is TdApi.LinkPreviewTypeVoiceNote -> WebPage.LinkPreviewType.VoiceNote - is TdApi.LinkPreviewTypeAlbum -> WebPage.LinkPreviewType.Album - is TdApi.LinkPreviewTypeArticle -> WebPage.LinkPreviewType.Article - is TdApi.LinkPreviewTypeApp -> WebPage.LinkPreviewType.App - is TdApi.LinkPreviewTypeExternalVideo -> { - duration = t.duration - WebPage.LinkPreviewType.ExternalVideo(t.url) - } - - is TdApi.LinkPreviewTypeExternalAudio -> { - duration = t.duration - WebPage.LinkPreviewType.ExternalAudio(t.url) - } - - is TdApi.LinkPreviewTypeEmbeddedVideoPlayer -> { - duration = t.duration - WebPage.LinkPreviewType.EmbeddedVideo(t.url) - } - - is TdApi.LinkPreviewTypeEmbeddedAudioPlayer -> { - duration = t.duration - WebPage.LinkPreviewType.EmbeddedAudio(t.url) - } - - is TdApi.LinkPreviewTypeEmbeddedAnimationPlayer -> { - duration = t.duration - WebPage.LinkPreviewType.EmbeddedAnimation(t.url) - } - is TdApi.LinkPreviewTypeUser -> WebPage.LinkPreviewType.User(0) - is TdApi.LinkPreviewTypeChat -> WebPage.LinkPreviewType.Chat(0) - is TdApi.LinkPreviewTypeStory -> WebPage.LinkPreviewType.Story(t.storyPosterChatId, t.storyId) - is TdApi.LinkPreviewTypeTheme -> WebPage.LinkPreviewType.Theme - is TdApi.LinkPreviewTypeBackground -> WebPage.LinkPreviewType.Background - is TdApi.LinkPreviewTypeInvoice -> WebPage.LinkPreviewType.Invoice - is TdApi.LinkPreviewTypeMessage -> WebPage.LinkPreviewType.Message - else -> WebPage.LinkPreviewType.Unknown - } - - fun processTdFile( - file: TdApi.File, - downloadType: TdMessageRemoteDataSource.DownloadType, - supportsStreaming: Boolean = false - ): TdApi.File { - val updatedFile = getUpdatedFile(file) - fileApi.registerFileForMessage(updatedFile.id, chatId, messageId) - - val autoDownload = when (downloadType) { - TdMessageRemoteDataSource.DownloadType.VIDEO -> supportsStreaming && networkAutoDownload - TdMessageRemoteDataSource.DownloadType.DEFAULT -> { - if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload - } - TdMessageRemoteDataSource.DownloadType.STICKER -> networkAutoDownload && appPreferences.autoDownloadStickers.value - TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> networkAutoDownload && appPreferences.autoDownloadVideoNotes.value - else -> networkAutoDownload - } - - if (!isValidPath(updatedFile.local.path) && autoDownload) { - fileApi.enqueueDownload(updatedFile.id, 1, downloadType, 0, 0, false) - } - return updatedFile - } - - val photo = photoObj?.let { p -> - val size = p.sizes.firstOrNull() - if (size != null) { - val f = processTdFile(size.photo, TdMessageRemoteDataSource.DownloadType.DEFAULT) - val bestPath = findBestAvailablePath(f, p.sizes) - - WebPage.Photo( - path = bestPath, - width = size.width, - height = size.height, - fileId = f.id, - minithumbnail = p.minithumbnail?.data - ) - } else null - } - - val video = videoObj?.let { v -> - val f = processTdFile(v.video, TdMessageRemoteDataSource.DownloadType.VIDEO, v.supportsStreaming) - WebPage.Video(f.local.path.takeIf { isValidPath(it) }, v.width, v.height, v.duration, f.id, v.supportsStreaming) - } - - val audio = audioObj?.let { a -> - val f = processTdFile(a.audio, TdMessageRemoteDataSource.DownloadType.DEFAULT) - WebPage.Audio(a.audio.local.path.takeIf { isValidPath(it) }, a.duration, a.title, a.performer, f.id) - } - - val document = documentObj?.let { d -> - val f = processTdFile(d.document, TdMessageRemoteDataSource.DownloadType.DEFAULT) - WebPage.Document(d.document.local.path.takeIf { isValidPath(it) }, d.fileName, d.mimeType, f.size, f.id) - } - - val sticker = stickerObj?.let { s -> - val f = processTdFile(s.sticker, TdMessageRemoteDataSource.DownloadType.STICKER) - WebPage.Sticker(s.sticker.local.path.takeIf { isValidPath(it) }, s.width, s.height, s.emoji, f.id) - } - - val animation = animationObj?.let { anim -> - val f = processTdFile(anim.animation, TdMessageRemoteDataSource.DownloadType.GIF) - WebPage.Animation(anim.animation.local.path.takeIf { isValidPath(it) }, anim.width, anim.height, anim.duration, f.id) - } - - return WebPage( - url = webPage.url, - displayUrl = webPage.displayUrl, - type = linkPreviewType, - siteName = webPage.siteName, - title = webPage.title, - description = webPage.description?.text, - photo = photo, - embedUrl = null, - embedType = null, - embedWidth = 0, - embedHeight = 0, - duration = duration, - author = webPage.author, - video = video, - audio = audio, - document = document, - sticker = sticker, - animation = animation, - instantViewVersion = webPage.instantViewVersion - ) - } - - private fun mapReplyMarkup(markup: TdApi.ReplyMarkup?): ReplyMarkupModel? { - return when (markup) { - is TdApi.ReplyMarkupInlineKeyboard -> { - ReplyMarkupModel.InlineKeyboard( - rows = markup.rows.map { row -> - row.map { button -> - InlineKeyboardButtonModel( - text = button.text, - type = when (val type = button.type) { - is TdApi.InlineKeyboardButtonTypeUrl -> InlineKeyboardButtonType.Url( - type.url - ) - - is TdApi.InlineKeyboardButtonTypeCallback -> InlineKeyboardButtonType.Callback( - type.data - ) - - is TdApi.InlineKeyboardButtonTypeWebApp -> InlineKeyboardButtonType.WebApp( - type.url - ) - - is TdApi.InlineKeyboardButtonTypeLoginUrl -> InlineKeyboardButtonType.LoginUrl( - type.url, - type.id - ) - - is TdApi.InlineKeyboardButtonTypeSwitchInline -> InlineKeyboardButtonType.SwitchInline( - query = type.query - ) - - is TdApi.InlineKeyboardButtonTypeBuy -> InlineKeyboardButtonType.Buy() - is TdApi.InlineKeyboardButtonTypeUser -> InlineKeyboardButtonType.User( - type.userId - ) - - else -> InlineKeyboardButtonType.Unsupported - } - ) - } - } - ) - } - - is TdApi.ReplyMarkupShowKeyboard -> { - ReplyMarkupModel.ShowKeyboard( - rows = markup.rows.map { row -> - row.map { button -> - KeyboardButtonModel( - text = button.text, - type = when (val type = button.type) { - is TdApi.KeyboardButtonTypeText -> KeyboardButtonType.Text - is TdApi.KeyboardButtonTypeRequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber - is TdApi.KeyboardButtonTypeRequestLocation -> KeyboardButtonType.RequestLocation - is TdApi.KeyboardButtonTypeRequestPoll -> KeyboardButtonType.RequestPoll( - type.forceQuiz, - type.forceRegular - ) - - is TdApi.KeyboardButtonTypeWebApp -> KeyboardButtonType.WebApp( - type.url - ) - - is TdApi.KeyboardButtonTypeRequestUsers -> KeyboardButtonType.RequestUsers( - type.id - ) - - is TdApi.KeyboardButtonTypeRequestChat -> KeyboardButtonType.RequestChat( - type.id - ) - - else -> KeyboardButtonType.Unsupported - } - ) - } - }, - isPersistent = markup.isPersistent, - resizeKeyboard = markup.resizeKeyboard, - oneTime = markup.oneTime, - isPersonal = markup.isPersonal, - inputFieldPlaceholder = markup.inputFieldPlaceholder - ) - } - - is TdApi.ReplyMarkupRemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(markup.isPersonal) - is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply( - markup.isPersonal, - markup.inputFieldPlaceholder - ) - + private fun resolveSendingState(msg: TdApi.Message): MessageSendingState? { + return when (val state = msg.sendingState) { + is TdApi.MessageSendingStatePending -> MessageSendingState.Pending + is TdApi.MessageSendingStateFailed -> MessageSendingState.Failed(state.error.code, state.error.message) else -> null } } - fun createMessageModel( - msg: TdApi.Message, - senderName: String, - senderId: Long, - senderAvatar: String?, - isReadOverride: Boolean = false, - replyToMsgId: Long? = null, - replyToMsg: MessageModel? = null, - forwardInfo: ForwardInfo? = null, - views: Int? = null, - viewCount: Int? = null, - mediaAlbumId: Long = 0L, - sendingState: MessageSendingState? = null, - isChatOpen: Boolean = false, - readDate: Int = 0, - reactions: List = emptyList(), - isSenderVerified: Boolean = false, - threadId: Long? = null, - replyCount: Int = 0, - isReply: Boolean = false, - viaBotUserId: Long = 0L, - viaBotName: String? = null, - senderPersonalAvatar: String? = null, - senderCustomTitle: String? = null, - isSenderPremium: Boolean = false, - senderStatusEmojiId: Long = 0L, - senderStatusEmojiPath: String? = null - ): MessageModel { - val networkAutoDownload = isChatOpen && isNetworkAutoDownloadEnabled() - val isActuallyUploading = msg.sendingState is TdApi.MessageSendingStatePending - - val content = when (val c = msg.content) { - is TdApi.MessageText -> { - val entities = mapEntities(c.text.entities, msg.chatId, msg.id, networkAutoDownload) - val webPage = mapWebPage(c.linkPreview, msg.chatId, msg.id, networkAutoDownload) - MessageContent.Text(c.text.text, entities, webPage) - } - - is TdApi.MessagePhoto -> { - - val sizes = c.photo.sizes - val photoSize = sizes.find { it.type == "x" } - ?: sizes.find { it.type == "m" } - ?: sizes.getOrNull(sizes.size / 2) - ?: sizes.lastOrNull() - val thumbnailSize = sizes.find { it.type == "m" } - ?: sizes.find { it.type == "s" } - ?: sizes.firstOrNull() - - val photoFile = photoSize?.photo?.let { getUpdatedFile(it) } - val thumbnailFile = thumbnailSize?.photo?.let { getUpdatedFile(it) } - - val path = findBestAvailablePath(photoFile, sizes) - val thumbnailPath = resolveLocalFilePath(thumbnailFile) - - if (photoFile != null) { - fileApi.registerFileForMessage(photoFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload(photoFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - if (thumbnailFile != null) { - fileApi.registerFileForMessage(thumbnailFile.id, msg.chatId, msg.id) - if (thumbnailPath == null && networkAutoDownload) { - fileApi.enqueueDownload( - thumbnailFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - } - val isDownloading = photoFile?.local?.isDownloadingActive ?: false - val isQueued = photoFile?.let { fileApi.isFileQueued(it.id) } ?: false - val downloadProgress = if ((photoFile?.size ?: 0) > 0) { - photoFile!!.local.downloadedSize.toFloat() / photoFile.size.toFloat() - } else 0f - - MessageContent.Photo( - path = path, - thumbnailPath = thumbnailPath, - width = photoSize?.width ?: 0, - height = photoSize?.height ?: 0, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && (photoFile?.remote?.isUploadingActive ?: false), - uploadProgress = if ((photoFile?.size ?: 0) > 0) photoFile!!.remote.uploadedSize.toFloat() / photoFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = photoFile?.id ?: 0, - minithumbnail = c.photo.minithumbnail?.data - ) - } - - is TdApi.MessageVideo -> { - val video = c.video - val videoFile = getUpdatedFile(video.video) - val path = videoFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(videoFile.id, msg.chatId, msg.id) - - val thumbFile = video.thumbnail?.file?.let { getUpdatedFile(it) } - val thumbnailPath = resolveLocalFilePath(thumbFile) - - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (thumbnailPath == null && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - - if (path == null && networkAutoDownload && video.supportsStreaming) { - fileApi.enqueueDownload(videoFile.id, 1, TdMessageRemoteDataSource.DownloadType.VIDEO, 0, 0, false) - } - - val isDownloading = videoFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(videoFile.id) - val downloadProgress = if (videoFile.size > 0) { - videoFile.local.downloadedSize.toFloat() / videoFile.size.toFloat() - } else 0f - - MessageContent.Video( - path = path, - thumbnailPath = thumbnailPath, - width = video.width, - height = video.height, - duration = video.duration, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && videoFile.remote.isUploadingActive, - uploadProgress = if (videoFile.size > 0) videoFile.remote.uploadedSize.toFloat() / videoFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = videoFile.id, - minithumbnail = video.minithumbnail?.data, - supportsStreaming = video.supportsStreaming - ) - } - - is TdApi.MessageVoiceNote -> { - val voice = c.voiceNote - val voiceFile = getUpdatedFile(voice.voice) - val path = voiceFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(voiceFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload(voiceFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - val isDownloading = voiceFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(voiceFile.id) - val downloadProgress = if (voiceFile.size > 0) { - voiceFile.local.downloadedSize.toFloat() / voiceFile.size.toFloat() - } else 0f - - MessageContent.Voice( - path = path, - duration = voice.duration, - waveform = voice.waveform, - isUploading = isActuallyUploading && voiceFile.remote.isUploadingActive, - uploadProgress = if (voiceFile.size > 0) voiceFile.remote.uploadedSize.toFloat() / voiceFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = voiceFile.id - ) - } - - is TdApi.MessageVideoNote -> { - val note = c.videoNote - val videoFile = getUpdatedFile(note.video) - val videoPath = videoFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(videoFile.id, msg.chatId, msg.id) - - if (videoPath == null && networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) { - fileApi.enqueueDownload(videoFile.id, 1, TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE, 0, 0, false) - } - - val thumbFile = note.thumbnail?.file?.let { getUpdatedFile(it) } - val thumbPath = thumbFile?.local?.path?.takeIf { isValidPath(it) } - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (thumbPath == null && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - val isUploading = isActuallyUploading && videoFile.remote.isUploadingActive - val progress = if (videoFile.size > 0) { - videoFile.remote.uploadedSize.toFloat() / videoFile.size.toFloat() - } else 0f - - val isDownloading = videoFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(videoFile.id) - val downloadProgress = if (videoFile.size > 0) { - videoFile.local.downloadedSize.toFloat() / videoFile.size.toFloat() - } else 0f - - MessageContent.VideoNote( - path = videoPath, - thumbnail = thumbPath, - duration = note.duration, - length = note.length, - isUploading = isUploading, - uploadProgress = progress, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = videoFile.id - ) - } - - is TdApi.MessageSticker -> { - val sticker = c.sticker - val stickerFile = getUpdatedFile(sticker.sticker) - val path = stickerFile.local.path.takeIf { isValidPath(it) } - - fileApi.registerFileForMessage(stickerFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload && appPreferences.autoDownloadStickers.value) { - fileApi.enqueueDownload(stickerFile.id, 1, TdMessageRemoteDataSource.DownloadType.STICKER, 0, 0, false) - } - - val format = when (sticker.format) { - is TdApi.StickerFormatWebp -> StickerFormat.STATIC - is TdApi.StickerFormatTgs -> StickerFormat.ANIMATED - is TdApi.StickerFormatWebm -> StickerFormat.VIDEO - else -> StickerFormat.UNKNOWN - } - - val isDownloading = stickerFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(stickerFile.id) - val downloadProgress = if (stickerFile.size > 0) { - stickerFile.local.downloadedSize.toFloat() / stickerFile.size.toFloat() - } else 0f - - MessageContent.Sticker( - id = sticker.sticker.id.toLong(), - setId = sticker.setId, - path = path, - width = sticker.width, - height = sticker.height, - emoji = sticker.emoji, - format = format, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = stickerFile.id - ) - } - - is TdApi.MessageAnimation -> { - val animation = c.animation - val animationFile = getUpdatedFile(animation.animation) - val path = animationFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(animationFile.id, msg.chatId, msg.id) - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload(animationFile.id, 1, TdMessageRemoteDataSource.DownloadType.GIF, 0, 0, false) - } - - val thumbFile = animation.thumbnail?.file?.let { getUpdatedFile(it) } - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (!isValidPath(thumbFile.local.path) && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - - val isDownloading = animationFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(animationFile.id) - val downloadProgress = if (animationFile.size > 0) { - animationFile.local.downloadedSize.toFloat() / animationFile.size.toFloat() - } else 0f - - MessageContent.Gif( - path = path, - width = animation.width, - height = animation.height, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && animationFile.remote.isUploadingActive, - uploadProgress = if (animationFile.size > 0) animationFile.remote.uploadedSize.toFloat() / animationFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = animationFile.id, - minithumbnail = animation.minithumbnail?.data - ) - } - - is TdApi.MessageAnimatedEmoji -> MessageContent.Text(c.emoji) - is TdApi.MessageDice -> { - val valueStr = if (c.value != 0) " (Result: ${c.value})" else "" - MessageContent.Text("${c.emoji}$valueStr") - } - - is TdApi.MessageDocument -> { - val doc = c.document - val docFile = getUpdatedFile(doc.document) - val path = docFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(docFile.id, msg.chatId, msg.id) - - val thumbFile = doc.thumbnail?.file?.let { getUpdatedFile(it) } - if (thumbFile != null) { - fileApi.registerFileForMessage(thumbFile.id, msg.chatId, msg.id) - if (!isValidPath(thumbFile.local.path) && networkAutoDownload) { - fileApi.enqueueDownload(thumbFile.id, 1, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } - - val isDownloading = docFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(docFile.id) - val downloadProgress = if (docFile.size > 0) { - docFile.local.downloadedSize.toFloat() / docFile.size.toFloat() - } else 0f - - MessageContent.Document( - path = path, - fileName = doc.fileName, - mimeType = doc.mimeType, - size = docFile.size, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && docFile.remote.isUploadingActive, - uploadProgress = if (docFile.size > 0) docFile.remote.uploadedSize.toFloat() / docFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = docFile.id - ) - } - - is TdApi.MessageAudio -> { - val audio = c.audio - val audioFile = getUpdatedFile(audio.audio) - val path = audioFile.local.path.takeIf { isValidPath(it) } - fileApi.registerFileForMessage(audioFile.id, msg.chatId, msg.id) - - if (path == null && networkAutoDownload) { - fileApi.enqueueDownload( - audioFile.id, - 1, - TdMessageRemoteDataSource.DownloadType.DEFAULT, - 0, - 0, - false - ) - } - - val isDownloading = audioFile.local.isDownloadingActive - val isQueued = fileApi.isFileQueued(audioFile.id) - val downloadProgress = if (audioFile.size > 0) { - audioFile.local.downloadedSize.toFloat() / audioFile.size.toFloat() - } else 0f - - MessageContent.Audio( - path = path, - duration = audio.duration, - title = audio.title ?: "Unknown", - performer = audio.performer ?: "Unknown", - fileName = audio.fileName ?: "audio.mp3", - mimeType = audio.mimeType ?: "audio/mpeg", - size = audioFile.size, - caption = c.caption.text, - entities = mapEntities(c.caption.entities, msg.chatId, msg.id, networkAutoDownload), - isUploading = isActuallyUploading && audioFile.remote.isUploadingActive, - uploadProgress = if (audioFile.size > 0) audioFile.remote.uploadedSize.toFloat() / audioFile.size.toFloat() else 0f, - isDownloading = isDownloading || isQueued, - downloadProgress = downloadProgress, - fileId = audioFile.id - ) - } - - is TdApi.MessageCall -> MessageContent.Text("📞 Call (${c.duration}s)") - is TdApi.MessageContact -> { - val contact = c.contact - MessageContent.Contact( - phoneNumber = contact.phoneNumber, - firstName = contact.firstName, - lastName = contact.lastName, - vcard = contact.vcard, - userId = contact.userId - ) - } - is TdApi.MessageLocation -> { - val loc = c.location - MessageContent.Location( - latitude = loc.latitude, - longitude = loc.longitude, - horizontalAccuracy = loc.horizontalAccuracy, - livePeriod = c.livePeriod, - heading = c.heading, - proximityAlertRadius = c.proximityAlertRadius - ) - } - - is TdApi.MessageVenue -> { - val v = c.venue - MessageContent.Venue( - latitude = v.location.latitude, - longitude = v.location.longitude, - title = v.title, - address = v.address, - provider = v.provider, - venueId = v.id, - venueType = v.type - ) - } - is TdApi.MessagePoll -> { - val poll = c.poll - val type = when (val pollType = poll.type) { - is TdApi.PollTypeRegular -> PollType.Regular(poll.allowsMultipleAnswers) - is TdApi.PollTypeQuiz -> PollType.Quiz(pollType.correctOptionIds.firstOrNull() ?: -1, pollType.explanation?.text) - else -> PollType.Regular(poll.allowsMultipleAnswers) - } - MessageContent.Poll( - id = poll.id, - question = poll.question.text, - options = poll.options.map { option -> - PollOption( - text = option.text.text, - voterCount = option.voterCount, - votePercentage = option.votePercentage, - isChosen = option.isChosen, - isBeingChosen = false - ) - }, - totalVoterCount = poll.totalVoterCount, - isClosed = poll.isClosed, - isAnonymous = poll.isAnonymous, - type = type, - openPeriod = poll.openPeriod, - closeDate = poll.closeDate - ) - } - is TdApi.MessageGame -> MessageContent.Text("🎮 Game: ${c.game.title}") - is TdApi.MessageInvoice -> { - val productInfo = c.productInfo - MessageContent.Text("💳 Invoice: ${productInfo.title}") - } - is TdApi.MessageStory -> MessageContent.Text("📖 Story") - is TdApi.MessageExpiredPhoto -> MessageContent.Text("📷 Photo has expired") - is TdApi.MessageExpiredVideo -> MessageContent.Text("📹 Video has expired") - - is TdApi.MessageChatJoinByLink -> MessageContent.Service("$senderName has joined the group via invite link") - is TdApi.MessageChatAddMembers -> MessageContent.Service("$senderName added members") - is TdApi.MessageChatDeleteMember -> MessageContent.Service("$senderName left the chat") - is TdApi.MessagePinMessage -> MessageContent.Service("$senderName pinned a message") - is TdApi.MessageChatChangeTitle -> MessageContent.Service("$senderName changed group name to \"${c.title}\"") - is TdApi.MessageChatChangePhoto -> MessageContent.Service("$senderName changed group photo") - is TdApi.MessageChatDeletePhoto -> MessageContent.Service("$senderName removed group photo") - is TdApi.MessageScreenshotTaken -> MessageContent.Service("$senderName took a screenshot") - is TdApi.MessageContactRegistered -> MessageContent.Service("$senderName joined Telegram!") - is TdApi.MessageChatUpgradeTo -> MessageContent.Service("$senderName upgraded to supergroup") - is TdApi.MessageChatUpgradeFrom -> MessageContent.Service("group created") - is TdApi.MessageBasicGroupChatCreate -> MessageContent.Service("created the group \"${c.title}\"") - is TdApi.MessageSupergroupChatCreate -> MessageContent.Service("created the supergroup \"${c.title}\"") - is TdApi.MessagePaymentSuccessful -> MessageContent.Service("Payment successful: ${c.currency} ${c.totalAmount}") - is TdApi.MessagePaymentSuccessfulBot -> MessageContent.Service("Payment successful") - is TdApi.MessagePassportDataSent -> MessageContent.Service("Passport data sent") - is TdApi.MessagePassportDataReceived -> MessageContent.Service("Passport data received") - is TdApi.MessageProximityAlertTriggered -> MessageContent.Service("is within ${c.distance}m") - is TdApi.MessageForumTopicCreated -> MessageContent.Service("$senderName created topic \"${c.name}\"") - is TdApi.MessageForumTopicEdited -> MessageContent.Service("$senderName edited topic") - is TdApi.MessageForumTopicIsClosedToggled -> MessageContent.Service("$senderName toggled topic closed status") - is TdApi.MessageForumTopicIsHiddenToggled -> MessageContent.Service("$senderName toggled topic hidden status") - is TdApi.MessageSuggestProfilePhoto -> MessageContent.Service("$senderName suggested a profile photo") - is TdApi.MessageCustomServiceAction -> MessageContent.Service(c.text) - is TdApi.MessageChatBoost -> MessageContent.Service("Chat boost: ${c.boostCount}") - is TdApi.MessageChatSetTheme -> MessageContent.Service("Chat theme changed to ${c.theme}") - is TdApi.MessageGameScore -> MessageContent.Service("Game score: ${c.score}") - is TdApi.MessageVideoChatScheduled -> MessageContent.Service("Video chat scheduled for ${c.startDate}") - is TdApi.MessageVideoChatStarted -> MessageContent.Service("Video chat started") - is TdApi.MessageVideoChatEnded -> MessageContent.Service("Video chat ended") - is TdApi.MessageChatSetBackground -> MessageContent.Service("Chat background changed") - else -> MessageContent.Text("ℹ️ Unsupported message type: ${c.javaClass.simpleName}") - } - - val isServiceMessage = content is MessageContent.Service - - val canEdit = msg.isOutgoing && !isServiceMessage - val canForward = !isServiceMessage - val canSave = !isServiceMessage - - val hasInteraction = msg.interactionInfo != null - - return MessageModel( - id = msg.id, - date = resolveMessageDate(msg), - isOutgoing = msg.isOutgoing, - senderName = senderName, - chatId = msg.chatId, - content = content, - senderId = senderId, - senderAvatar = senderAvatar, - senderPersonalAvatar = senderPersonalAvatar, - senderCustomTitle = senderCustomTitle, - isRead = isReadOverride, - replyToMsgId = replyToMsgId, - replyToMsg = replyToMsg, - forwardInfo = forwardInfo, - views = views, - viewCount = views, - mediaAlbumId = msg.mediaAlbumId, - editDate = msg.editDate, - sendingState = sendingState, - readDate = readDate, - reactions = reactions, - isSenderVerified = isSenderVerified, - threadId = threadId, - replyCount = replyCount, - canBeEdited = canEdit, - canBeForwarded = canForward, - canBeDeletedOnlyForSelf = true, - canBeDeletedForAllUsers = msg.isOutgoing, - canBeSaved = canSave, - canGetMessageThread = msg.interactionInfo?.replyInfo != null, - canGetStatistics = hasInteraction, - canGetReadReceipts = hasInteraction, - canGetViewers = hasInteraction, - replyMarkup = if (isReply) null else mapReplyMarkup(msg.replyMarkup), - viaBotUserId = viaBotUserId, - viaBotName = viaBotName, - isSenderPremium = isSenderPremium, - senderStatusEmojiId = senderStatusEmojiId, - senderStatusEmojiPath = senderStatusEmojiPath - ) - } - - data class CachedMessageContent( - val type: String, - val text: String, - val meta: String?, - val fileId: Int = 0, - val path: String? = null, - val thumbnailPath: String? = null, - val minithumbnail: ByteArray? = null - ) - - private data class CachedReplyPreview( - val senderName: String, - val contentType: String, - val text: String - ) - - private data class CachedForwardOrigin( - val fromName: String, - val fromId: Long, - val originChatId: Long? = null, - val originMessageId: Long? = null - ) - - fun mapToEntity( + private fun mapMessageToModelFallback( msg: TdApi.Message, - getSenderName: ((Long) -> String?)? = null - ): org.monogram.data.db.model.MessageEntity { - val senderId = when (val sender = msg.senderId) { - is TdApi.MessageSenderUser -> sender.userId - is TdApi.MessageSenderChat -> sender.chatId - else -> 0L - } - val senderName = getSenderName?.invoke(senderId).orEmpty() - val content = extractCachedContent(msg.content) - val entitiesEncoded = encodeEntities(msg.content) - val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L - val replyToPreview = buildReplyPreview(msg) - val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin) - - return org.monogram.data.db.model.MessageEntity( - id = msg.id, - chatId = msg.chatId, - senderId = senderId, - senderName = senderName, - content = content.text, - contentType = content.type, - contentMeta = content.meta, - mediaFileId = content.fileId, - mediaPath = content.path, - mediaThumbnailPath = content.thumbnailPath, - minithumbnail = content.minithumbnail, - date = resolveMessageDate(msg), - isOutgoing = msg.isOutgoing, - isRead = false, - replyToMessageId = replyToMessageId, - replyToPreview = replyToPreview?.let(::encodeReplyPreview), - replyToPreviewType = replyToPreview?.contentType, - replyToPreviewText = replyToPreview?.text, - replyToPreviewSenderName = replyToPreview?.senderName, - replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0, - forwardFromName = forwardOrigin?.fromName, - forwardFromId = forwardOrigin?.fromId ?: 0L, - forwardOriginChatId = forwardOrigin?.originChatId, - forwardOriginMessageId = forwardOrigin?.originMessageId, - forwardDate = msg.forwardInfo?.date ?: 0, - editDate = msg.editDate, - mediaAlbumId = msg.mediaAlbumId, - entities = entitiesEncoded, - viewCount = msg.interactionInfo?.viewCount ?: 0, - forwardCount = msg.interactionInfo?.forwardCount ?: 0, - createdAt = System.currentTimeMillis() - ) - } - - fun extractCachedContent(content: TdApi.MessageContent): CachedMessageContent { - return when (content) { - is TdApi.MessageText -> CachedMessageContent("text", content.text.text, null) - is TdApi.MessagePhoto -> { - val sizes = content.photo.sizes - val best = sizes.find { it.type == "x" } - ?: sizes.find { it.type == "m" } - ?: sizes.getOrNull(sizes.size / 2) - ?: sizes.lastOrNull() - val thumbnail = sizes.find { it.type == "m" } - ?: sizes.find { it.type == "s" } - val fileId = best?.photo?.id ?: 0 - val path = best?.photo?.local?.path?.takeIf { it.isNotBlank() } - val thumbnailPath = thumbnail?.photo?.local?.path?.takeIf { it.isNotBlank() } - CachedMessageContent( - "photo", - content.caption?.text.orEmpty(), - encodeMeta(best?.width ?: 0, best?.height ?: 0), - fileId = fileId, - path = path, - thumbnailPath = thumbnailPath, - minithumbnail = content.photo.minithumbnail?.data - ) - } - - is TdApi.MessageVideo -> { - val fileId = content.video.video.id - val path = content.video.video.local?.path?.takeIf { it.isNotBlank() } - CachedMessageContent( - "video", - content.caption?.text.orEmpty(), - encodeMeta( - content.video.width, - content.video.height, - content.video.duration, - content.video.thumbnail?.file?.local?.path.orEmpty(), - if (content.video.supportsStreaming) 1 else 0 - ), - fileId = fileId, - path = path, - thumbnailPath = content.video.thumbnail?.file?.local?.path?.takeIf { it.isNotBlank() }, - minithumbnail = content.video.minithumbnail?.data - ) - } - - is TdApi.MessageVoiceNote -> CachedMessageContent( - "voice", - content.caption?.text.orEmpty(), - encodeMeta(content.voiceNote.duration), - fileId = content.voiceNote.voice.id, - path = content.voiceNote.voice.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageVideoNote -> CachedMessageContent( - "video_note", - "", - encodeMeta( - content.videoNote.duration, - content.videoNote.length, - content.videoNote.thumbnail?.file?.local?.path.orEmpty() - ), - fileId = content.videoNote.video.id, - path = content.videoNote.video.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageSticker -> { - val format = when (content.sticker.format) { - is TdApi.StickerFormatWebp -> "webp" - is TdApi.StickerFormatTgs -> "tgs" - is TdApi.StickerFormatWebm -> "webm" - else -> "unknown" - } - CachedMessageContent( - "sticker", - content.sticker.emoji, - encodeMeta( - content.sticker.setId, - content.sticker.emoji, - content.sticker.width, - content.sticker.height, - format - ), - fileId = content.sticker.sticker.id, - path = content.sticker.sticker.local?.path?.takeIf { it.isNotBlank() } - ) - } - - is TdApi.MessageDocument -> CachedMessageContent( - "document", - content.caption?.text.orEmpty(), - encodeMeta(content.document.fileName, content.document.mimeType, content.document.document.size), - fileId = content.document.document.id, - path = content.document.document.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageAudio -> CachedMessageContent( - "audio", - content.caption?.text.orEmpty(), - encodeMeta( - content.audio.duration, - content.audio.title.orEmpty(), - content.audio.performer.orEmpty(), - content.audio.fileName.orEmpty() - ), - fileId = content.audio.audio.id, - path = content.audio.audio.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessageAnimation -> CachedMessageContent( - "gif", - content.caption?.text.orEmpty(), - encodeMeta( - content.animation.width, - content.animation.height, - content.animation.duration, - content.animation.thumbnail?.file?.local?.path.orEmpty() - ), - fileId = content.animation.animation.id, - path = content.animation.animation.local?.path?.takeIf { it.isNotBlank() } - ) - - is TdApi.MessagePoll -> CachedMessageContent( - "poll", - content.poll.question.text, - encodeMeta(content.poll.options.size, if (content.poll.isClosed) 1 else 0) - ) - - is TdApi.MessageContact -> CachedMessageContent( - "contact", - listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() } - .joinToString(" "), - encodeMeta( - content.contact.phoneNumber, - content.contact.firstName, - content.contact.lastName, - content.contact.userId - ) - ) - - is TdApi.MessageLocation -> CachedMessageContent( - "location", - "", - encodeMeta(content.location.latitude, content.location.longitude, content.livePeriod) - ) - - is TdApi.MessageCall -> CachedMessageContent("service", "Call (${content.duration}s)", null) - is TdApi.MessagePinMessage -> CachedMessageContent("service", "Pinned a message", null) - is TdApi.MessageChatAddMembers -> CachedMessageContent("service", "Added members", null) - is TdApi.MessageChatDeleteMember -> CachedMessageContent("service", "Removed a member", null) - is TdApi.MessageChatChangeTitle -> CachedMessageContent("service", "Changed title", null) - is TdApi.MessageAnimatedEmoji -> CachedMessageContent("text", content.emoji, null) - is TdApi.MessageDice -> CachedMessageContent("text", content.emoji, null) - else -> CachedMessageContent("unsupported", "", null) - } - } - - fun mapEntityToModel(entity: org.monogram.data.db.model.MessageEntity): MessageModel { - val meta = decodeMeta(entity.contentMeta) - val usesLegacyEmbeddedMedia = entity.mediaFileId == 0 && entity.mediaPath.isNullOrBlank() - val (legacyFileId, legacyPath) = if (usesLegacyEmbeddedMedia) { - resolveLegacyMediaFromMeta(entity.contentType, meta) - } else { - 0 to null - } - val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: legacyFileId - val mediaPath = entity.mediaPath?.takeIf { it.isNotBlank() } ?: legacyPath - val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L } - val replyPreview = resolveReplyPreview(entity) - val replyPreviewModel = - if (replyToMsgId != null && replyPreview != null) createReplyPreviewModel(entity, replyToMsgId, replyPreview) else null - - val cachedSenderUser = entity.senderId.takeIf { it > 0L }?.let { cache.getUser(it) } - val cachedSenderChat = if (cachedSenderUser == null && entity.senderId > 0L) { - cache.getChat(entity.senderId) - } else { - null - } - - val resolvedSenderName = resolveSenderNameFromCache(entity.senderId, entity.senderName) - val resolvedSenderAvatar = when { - cachedSenderUser != null -> resolveLocalFilePath(cachedSenderUser.profilePhoto?.small) - cachedSenderChat != null -> resolveLocalFilePath(cachedSenderChat.photo?.small) - else -> null - } - val resolvedSenderPersonalAvatar = cache.getUserFullInfo(entity.senderId) - ?.personalPhoto - ?.sizes - ?.firstOrNull() - ?.photo - ?.let { resolveLocalFilePath(it) } - - val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) { - is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId - is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId - else -> 0L - } - - val forwardInfo = entity.forwardFromName - ?.takeIf { it.isNotBlank() } - ?.let { fromName -> - ForwardInfo( - date = entity.forwardDate.takeIf { it > 0 } ?: entity.date, - fromId = entity.forwardFromId, - fromName = fromName, - originChatId = entity.forwardOriginChatId, - originMessageId = entity.forwardOriginMessageId - ) - } - - val content: MessageContent = when (entity.contentType) { - "text" -> MessageContent.Text(entity.content) - - "photo" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Photo( - path = resolveCachedPath(fileId, mediaPath), - thumbnailPath = entity.mediaThumbnailPath?.takeIf { isValidPath(it) }, - width = meta.getOrNull(0)?.toIntOrNull() ?: 0, - height = meta.getOrNull(1)?.toIntOrNull() ?: 0, - caption = entity.content, - fileId = fileId, - minithumbnail = entity.minithumbnail - ) - } - - "video" -> { - val fileId = mediaFileId - val supportsStreaming = if (usesLegacyEmbeddedMedia) { - (meta.getOrNull(6)?.toIntOrNull() ?: 0) == 1 - } else { - (meta.getOrNull(4)?.toIntOrNull() ?: 0) == 1 - } - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Video( - path = resolveCachedPath(fileId, mediaPath), - thumbnailPath = ( - entity.mediaThumbnailPath?.takeIf { isValidPath(it) } - ?: meta.getOrNull(3) - )?.takeIf { isValidPath(it) }, - width = meta.getOrNull(0)?.toIntOrNull() ?: 0, - height = meta.getOrNull(1)?.toIntOrNull() ?: 0, - duration = meta.getOrNull(2)?.toIntOrNull() ?: 0, - caption = entity.content, - fileId = fileId, - supportsStreaming = supportsStreaming, - minithumbnail = entity.minithumbnail - ) - } - - "voice" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Voice( - path = resolveCachedPath(fileId, mediaPath), - duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, - fileId = fileId - ) - } - - "video_note" -> { - val fileId = mediaFileId - val storedThumbPath = if (usesLegacyEmbeddedMedia) meta.getOrNull(4) else meta.getOrNull(2) - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.VideoNote( - path = resolveCachedPath(fileId, mediaPath), - thumbnail = storedThumbPath?.takeIf { isValidPath(it) }, - duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, - length = meta.getOrNull(1)?.toIntOrNull() ?: 0, - fileId = fileId - ) - } - - "sticker" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Sticker( - id = 0L, - setId = meta.getOrNull(0)?.toLongOrNull() ?: 0L, - path = resolveCachedPath(fileId, mediaPath), - width = meta.getOrNull(2)?.toIntOrNull() ?: 0, - height = meta.getOrNull(3)?.toIntOrNull() ?: 0, - emoji = entity.content, - fileId = fileId - ) - } - - "document" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Document( - path = resolveCachedPath(fileId, mediaPath), - fileName = meta.getOrNull(0).orEmpty(), - mimeType = meta.getOrNull(1).orEmpty(), - size = meta.getOrNull(2)?.toLongOrNull() ?: 0L, - caption = entity.content, - fileId = fileId - ) - } - - "audio" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Audio( - path = resolveCachedPath(fileId, mediaPath), - duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, - title = meta.getOrNull(1).orEmpty(), - performer = meta.getOrNull(2).orEmpty(), - fileName = meta.getOrNull(3).orEmpty(), - mimeType = "", - size = 0L, - caption = entity.content, - fileId = fileId - ) - } - - "gif" -> { - val fileId = mediaFileId - registerCachedFile(fileId, entity.chatId, entity.id) - MessageContent.Gif( - path = resolveCachedPath(fileId, mediaPath), - width = meta.getOrNull(0)?.toIntOrNull() ?: 0, - height = meta.getOrNull(1)?.toIntOrNull() ?: 0, - caption = entity.content, - fileId = fileId - ) - } - - "poll" -> MessageContent.Poll( - id = 0L, - question = entity.content, - options = emptyList(), - totalVoterCount = 0, - isClosed = (meta.getOrNull(1)?.toIntOrNull() ?: 0) == 1, - isAnonymous = true, - type = PollType.Regular(false), - openPeriod = 0, - closeDate = 0 - ) - - "contact" -> MessageContent.Contact( - phoneNumber = meta.getOrNull(0).orEmpty(), - firstName = meta.getOrNull(1).orEmpty(), - lastName = meta.getOrNull(2).orEmpty(), - vcard = "", - userId = meta.getOrNull(3)?.toLongOrNull() ?: 0L - ) - - "location" -> MessageContent.Location( - latitude = meta.getOrNull(0)?.toDoubleOrNull() ?: 0.0, - longitude = meta.getOrNull(1)?.toDoubleOrNull() ?: 0.0, - livePeriod = meta.getOrNull(2)?.toIntOrNull() ?: 0 - ) - - "service" -> MessageContent.Service(entity.content) - else -> MessageContent.Text(entity.content) - } - - return MessageModel( - id = entity.id, - date = entity.date, - isOutgoing = entity.isOutgoing, - senderName = resolvedSenderName, - chatId = entity.chatId, - content = content, - senderId = entity.senderId, - senderAvatar = resolvedSenderAvatar, - senderPersonalAvatar = resolvedSenderPersonalAvatar, - isRead = entity.isRead, - replyToMsgId = replyToMsgId, - replyToMsg = replyPreviewModel, - forwardInfo = forwardInfo, - mediaAlbumId = entity.mediaAlbumId, - editDate = entity.editDate, - views = entity.viewCount, - viewCount = entity.viewCount, - replyCount = entity.replyCount, - isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false, - isSenderPremium = cachedSenderUser?.isPremium ?: false, - senderStatusEmojiId = senderStatusEmojiId - ) - } - - private fun buildReplyPreview(msg: TdApi.Message): CachedReplyPreview? { - val reply = msg.replyTo as? TdApi.MessageReplyToMessage ?: return null - val replied = cache.getMessage(msg.chatId, reply.messageId) ?: return null - val replySenderName = when (val sender = replied.senderId) { - is TdApi.MessageSenderUser -> { - val user = cache.getUser(sender.userId) - listOfNotNull(user?.firstName?.takeIf { it.isNotBlank() }, user?.lastName?.takeIf { it.isNotBlank() }) - .joinToString(" ") - } - - is TdApi.MessageSenderChat -> cache.getChat(sender.chatId)?.title.orEmpty() - else -> "" - } - val extracted = extractCachedContent(replied.content) - return CachedReplyPreview( - senderName = replySenderName, - contentType = extracted.type, - text = extracted.text.take(100) - ) - } - - private fun encodeReplyPreview(preview: CachedReplyPreview): String { - return "${preview.senderName}|${preview.contentType}|${preview.text}" - } - - private fun parseReplyPreview(raw: String?): CachedReplyPreview? { - if (raw.isNullOrBlank()) return null - val firstSeparator = raw.indexOf('|') - val secondSeparator = raw.indexOf('|', firstSeparator + 1) - if (firstSeparator < 0 || secondSeparator <= firstSeparator) return null - - val senderName = raw.substring(0, firstSeparator) - val contentType = raw.substring(firstSeparator + 1, secondSeparator) - val text = raw.substring(secondSeparator + 1) - if (contentType.isBlank()) return null - - return CachedReplyPreview(senderName = senderName, contentType = contentType, text = text) - } - - private fun resolveReplyPreview(entity: org.monogram.data.db.model.MessageEntity): CachedReplyPreview? { - val encodedPreview = parseReplyPreview(entity.replyToPreview) - val senderName = entity.replyToPreviewSenderName ?: encodedPreview?.senderName - val contentType = entity.replyToPreviewType ?: encodedPreview?.contentType - val text = entity.replyToPreviewText ?: encodedPreview?.text ?: "" - - if (senderName.isNullOrBlank() && contentType.isNullOrBlank() && text.isBlank()) { - return null - } - - return CachedReplyPreview( - senderName = senderName.orEmpty(), - contentType = contentType?.ifBlank { "text" } ?: "text", - text = text - ) - } - - private fun createReplyPreviewModel( - entity: org.monogram.data.db.model.MessageEntity, - replyToMsgId: Long, - preview: CachedReplyPreview + isChatOpen: Boolean, + isReply: Boolean ): MessageModel { - return MessageModel( - id = replyToMsgId, - date = entity.date, - isOutgoing = false, - senderName = preview.senderName.ifBlank { "Unknown" }, - chatId = entity.chatId, - content = mapReplyPreviewContent(preview), - senderId = 0L, - isRead = true + val sender = senderResolver.resolveFallbackSender(msg) + return createMessageModel( + msg = msg, + senderName = sender.senderName, + senderId = sender.senderId, + senderAvatar = sender.senderAvatar, + isChatOpen = isChatOpen, + isReply = isReply, + senderPersonalAvatar = sender.senderPersonalAvatar, + senderCustomTitle = sender.senderCustomTitle, + isSenderVerified = sender.isSenderVerified, + isSenderPremium = sender.isSenderPremium, + senderStatusEmojiId = sender.senderStatusEmojiId, + senderStatusEmojiPath = sender.senderStatusEmojiPath ) } - private fun mapReplyPreviewContent(preview: CachedReplyPreview): MessageContent { - return when (preview.contentType) { - "photo" -> MessageContent.Photo(path = null, width = 0, height = 0, caption = preview.text) - "video" -> MessageContent.Video(path = null, width = 0, height = 0, duration = 0, caption = preview.text) - "voice" -> MessageContent.Voice(path = null, duration = 0) - "video_note" -> MessageContent.VideoNote(path = null, thumbnail = null, duration = 0, length = 0) - "sticker" -> MessageContent.Sticker(id = 0L, setId = 0L, path = null, width = 0, height = 0, emoji = preview.text) - "document" -> MessageContent.Document(path = null, fileName = "", mimeType = "", size = 0L, caption = preview.text) - "audio" -> MessageContent.Audio( - path = null, - duration = 0, - title = "", - performer = "", - fileName = "", - mimeType = "", - size = 0L, - caption = preview.text - ) - "gif" -> MessageContent.Gif(path = null, width = 0, height = 0, caption = preview.text) - "poll" -> MessageContent.Poll( - id = 0L, - question = preview.text, - options = emptyList(), - totalVoterCount = 0, - isClosed = false, - isAnonymous = true, - type = PollType.Regular(false), - openPeriod = 0, - closeDate = 0 - ) - "contact" -> MessageContent.Contact( - phoneNumber = "", - firstName = preview.text, - lastName = "", - vcard = "", - userId = 0L - ) - "location" -> MessageContent.Location(latitude = 0.0, longitude = 0.0) - "service" -> MessageContent.Service(preview.text) - else -> MessageContent.Text(preview.text) - } - } - - private fun extractForwardOrigin(origin: TdApi.MessageOrigin): CachedForwardOrigin { - return when (origin) { - is TdApi.MessageOriginUser -> { - val user = cache.getUser(origin.senderUserId) - val name = listOfNotNull(user?.firstName?.takeIf { it.isNotBlank() }, user?.lastName?.takeIf { it.isNotBlank() }) - .joinToString(" ") - .ifBlank { "User" } - CachedForwardOrigin(fromName = name, fromId = origin.senderUserId) - } - - is TdApi.MessageOriginChat -> CachedForwardOrigin( - fromName = cache.getChat(origin.senderChatId)?.title ?: "Chat", - fromId = origin.senderChatId - ) - - is TdApi.MessageOriginChannel -> CachedForwardOrigin( - fromName = cache.getChat(origin.chatId)?.title ?: "Channel", - fromId = origin.chatId, - originChatId = origin.chatId, - originMessageId = origin.messageId - ) - - is TdApi.MessageOriginHiddenUser -> CachedForwardOrigin( - fromName = origin.senderName.ifBlank { "Hidden user" }, - fromId = 0L - ) - - else -> CachedForwardOrigin(fromName = "Unknown", fromId = 0L) - } - } - - private fun encodeEntities(content: TdApi.MessageContent): String? { - val formatted = when (content) { - is TdApi.MessageText -> content.text - is TdApi.MessagePhoto -> content.caption - is TdApi.MessageVideo -> content.caption - is TdApi.MessageDocument -> content.caption - is TdApi.MessageAudio -> content.caption - is TdApi.MessageAnimation -> content.caption - is TdApi.MessageVoiceNote -> content.caption - else -> null - } ?: return null - - if (formatted.entities.isNullOrEmpty()) return null - - return buildString { - formatted.entities.forEachIndexed { index, entity -> - if (index > 0) append('|') - append(entity.offset).append(',').append(entity.length).append(',') - when (val type = entity.type) { - is TdApi.TextEntityTypeBold -> append("b") - is TdApi.TextEntityTypeItalic -> append("i") - is TdApi.TextEntityTypeUnderline -> append("u") - is TdApi.TextEntityTypeStrikethrough -> append("s") - is TdApi.TextEntityTypeSpoiler -> append("sp") - is TdApi.TextEntityTypeCode -> append("c") - is TdApi.TextEntityTypePre -> append("p") - is TdApi.TextEntityTypeUrl -> append("url") - is TdApi.TextEntityTypeTextUrl -> append("turl,").append(type.url) - is TdApi.TextEntityTypeMention -> append("m") - is TdApi.TextEntityTypeMentionName -> append("mn,").append(type.userId) - is TdApi.TextEntityTypeHashtag -> append("h") - is TdApi.TextEntityTypeBotCommand -> append("bc") - is TdApi.TextEntityTypeCustomEmoji -> append("ce,").append(type.customEmojiId) - is TdApi.TextEntityTypeEmailAddress -> append("em") - is TdApi.TextEntityTypePhoneNumber -> append("ph") - else -> append("?") - } - } - } - } - - private fun resolveMessageDate(msg: TdApi.Message): Int { - return when (val schedulingState = msg.schedulingState) { - is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate - else -> msg.date - } - } - - private suspend fun loadCustomEmoji(emojiId: Long, chatId: Long, messageId: Long, autoDownload: Boolean) { - val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) - - if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { - val fileToUse = result.stickers.first().sticker - - fileIdToCustomEmojiId[fileToUse.id] = emojiId - fileApi.registerFileForMessage(fileToUse.id, chatId, messageId) - - if (!isValidPath(fileToUse.local.path)) { - if (autoDownload) { - fileApi.enqueueDownload(fileToUse.id, 32, TdMessageRemoteDataSource.DownloadType.DEFAULT, 0, 0, false) - } - } else { - customEmojiPaths[emojiId] = fileToUse.local.path - } - } + private companion object { + private const val MESSAGE_MAP_TIMEOUT_MS = 2500L } } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt new file mode 100644 index 00000000..c173a813 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/ReplyMarkupMapper.kt @@ -0,0 +1,72 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.* + +internal fun TdApi.ReplyMarkup?.toDomainReplyMarkup(): ReplyMarkupModel? { + return when (this) { + is TdApi.ReplyMarkupInlineKeyboard -> { + ReplyMarkupModel.InlineKeyboard( + rows = rows.map { row -> + row.map { button -> + InlineKeyboardButtonModel( + text = button.text, + type = when (val type = button.type) { + is TdApi.InlineKeyboardButtonTypeUrl -> InlineKeyboardButtonType.Url(type.url) + is TdApi.InlineKeyboardButtonTypeCallback -> InlineKeyboardButtonType.Callback(type.data) + is TdApi.InlineKeyboardButtonTypeWebApp -> InlineKeyboardButtonType.WebApp(type.url) + is TdApi.InlineKeyboardButtonTypeLoginUrl -> InlineKeyboardButtonType.LoginUrl( + type.url, + type.id + ) + + is TdApi.InlineKeyboardButtonTypeSwitchInline -> InlineKeyboardButtonType.SwitchInline( + query = type.query + ) + + is TdApi.InlineKeyboardButtonTypeBuy -> InlineKeyboardButtonType.Buy() + is TdApi.InlineKeyboardButtonTypeUser -> InlineKeyboardButtonType.User(type.userId) + else -> InlineKeyboardButtonType.Unsupported + } + ) + } + } + ) + } + + is TdApi.ReplyMarkupShowKeyboard -> { + ReplyMarkupModel.ShowKeyboard( + rows = rows.map { row -> + row.map { button -> + KeyboardButtonModel( + text = button.text, + type = when (val type = button.type) { + is TdApi.KeyboardButtonTypeText -> KeyboardButtonType.Text + is TdApi.KeyboardButtonTypeRequestPhoneNumber -> KeyboardButtonType.RequestPhoneNumber + is TdApi.KeyboardButtonTypeRequestLocation -> KeyboardButtonType.RequestLocation + is TdApi.KeyboardButtonTypeRequestPoll -> KeyboardButtonType.RequestPoll( + type.forceQuiz, + type.forceRegular + ) + + is TdApi.KeyboardButtonTypeWebApp -> KeyboardButtonType.WebApp(type.url) + is TdApi.KeyboardButtonTypeRequestUsers -> KeyboardButtonType.RequestUsers(type.id) + is TdApi.KeyboardButtonTypeRequestChat -> KeyboardButtonType.RequestChat(type.id) + else -> KeyboardButtonType.Unsupported + } + ) + } + }, + isPersistent = isPersistent, + resizeKeyboard = resizeKeyboard, + oneTime = oneTime, + isPersonal = isPersonal, + inputFieldPlaceholder = inputFieldPlaceholder + ) + } + + is TdApi.ReplyMarkupRemoveKeyboard -> ReplyMarkupModel.RemoveKeyboard(isPersonal) + is TdApi.ReplyMarkupForceReply -> ReplyMarkupModel.ForceReply(isPersonal, inputFieldPlaceholder) + else -> null + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt b/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt new file mode 100644 index 00000000..72534374 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/SenderNameResolver.kt @@ -0,0 +1,15 @@ +package org.monogram.data.mapper + +internal object SenderNameResolver { + fun fromPartsOrBlank(firstName: String?, lastName: String?): String { + return listOfNotNull( + firstName?.takeIf { it.isNotBlank() }, + lastName?.takeIf { it.isNotBlank() } + ).joinToString(" ") + } + + fun fromParts(firstName: String?, lastName: String?, fallback: String = "User"): String { + val normalizedFallback = fallback.ifBlank { "User" } + return fromPartsOrBlank(firstName, lastName).ifBlank { normalizedFallback } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt b/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt index 5ff71c4e..e9fc03d1 100644 --- a/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/StickersMapper.kt @@ -12,7 +12,7 @@ fun TdApi.Sticker.toDomain(): StickerModel = StickerModel( width = width, height = height, emoji = emoji, - path = sticker.local.path.ifEmpty { null }, + path = sticker.local.path.takeIf { isValidFilePath(it) }, format = format.toDomain() ) @@ -27,7 +27,7 @@ fun TdApi.StickerSet.toDomain(): StickerSetModel = StickerSetModel( width = thumb.width, height = thumb.height, emoji = "", - path = thumb.file.local.path.ifEmpty { null }, + path = thumb.file.local.path.takeIf { isValidFilePath(it) }, format = stickers.firstOrNull()?.format.toDomain() ) }, diff --git a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt new file mode 100644 index 00000000..d113c63c --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt @@ -0,0 +1,120 @@ +package org.monogram.data.mapper + +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import org.drinkless.tdlib.TdApi +import org.monogram.data.chats.ChatCache +import org.monogram.data.datasource.remote.MessageFileApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.domain.repository.AppPreferencesProvider + +class TdFileHelper( + private val connectivityManager: ConnectivityManager, + private val fileApi: MessageFileApi, + private val appPreferences: AppPreferencesProvider, + private val cache: ChatCache +) { + fun isValidPath(path: String?): Boolean { + return isValidFilePath(path) + } + + fun getUpdatedFile(file: TdApi.File): TdApi.File { + return cache.fileCache[file.id] ?: file + } + + fun resolveLocalFilePath(file: TdApi.File?): String? { + if (file == null) return null + val directPath = file.local.path.takeIf { isValidPath(it) } + if (directPath != null) return directPath + return cache.fileCache[file.id]?.local?.path?.takeIf { isValidPath(it) } + } + + fun findBestAvailablePath(mainFile: TdApi.File?, sizes: Array? = null): String? { + if (mainFile != null && isValidPath(mainFile.local.path)) { + return mainFile.local.path + } + + if (sizes != null) { + return sizes.sortedByDescending { it.width } + .map { getUpdatedFile(it.photo) } + .firstOrNull { isValidPath(it.local.path) } + ?.local?.path + } + + return null + } + + fun resolveCachedPath(fileId: Int, storedPath: String?): String? { + val fromStored = storedPath + ?.takeIf { it.isNotBlank() } + ?.takeIf { isValidPath(it) } + if (fromStored != null) return fromStored + + return fileId.takeIf { it != 0 } + ?.let { cache.fileCache[it]?.local?.path } + ?.takeIf { isValidPath(it) } + } + + fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) { + if (fileId != 0) { + fileApi.registerFileForMessage(fileId, chatId, messageId) + } + } + + fun enqueueDownload( + fileId: Int, + priority: Int, + downloadType: TdMessageRemoteDataSource.DownloadType, + offset: Int = 0, + limit: Int = 0, + synchronous: Boolean = false + ) { + fileApi.enqueueDownload(fileId, priority, downloadType, offset.toLong(), limit.toLong(), synchronous) + } + + fun isFileQueued(fileId: Int): Boolean = fileApi.isFileQueued(fileId) + + fun computeDownloadProgress(file: TdApi.File): Float { + return if (file.size > 0) { + file.local.downloadedSize.toFloat() / file.size.toFloat() + } else { + 0f + } + } + + fun computeUploadProgress(file: TdApi.File): Float { + return if (file.size > 0) { + file.remote.uploadedSize.toFloat() / file.size.toFloat() + } else { + 0f + } + } + + fun isNetworkAutoDownloadEnabled(): Boolean { + return when (getCurrentNetworkType()) { + is TdApi.NetworkTypeWiFi -> appPreferences.autoDownloadWifi.value + is TdApi.NetworkTypeMobile -> appPreferences.autoDownloadMobile.value + is TdApi.NetworkTypeMobileRoaming -> appPreferences.autoDownloadRoaming.value + else -> appPreferences.autoDownloadWifi.value + } + } + + private fun getCurrentNetworkType(): TdApi.NetworkType { + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + + return when { + capabilities == null -> TdApi.NetworkTypeNone() + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TdApi.NetworkTypeWiFi() + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> { + if (connectivityManager.isDefaultNetworkActive && !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)) { + TdApi.NetworkTypeMobileRoaming() + } else { + TdApi.NetworkTypeMobile() + } + } + + else -> TdApi.NetworkTypeNone() + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt new file mode 100644 index 00000000..debacc5f --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/TextEntityMapper.kt @@ -0,0 +1,58 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType + +internal fun TdApi.TextEntity.toMessageEntityOrNull( + mapUnsupportedToOther: Boolean = false, + mentionNameAsMention: Boolean = false, + customEmojiPathResolver: ((Long) -> String?)? = null, + onMissingCustomEmoji: ((Long) -> Unit)? = null +): MessageEntity? { + val mappedType = when (val entityType = type) { + is TdApi.TextEntityTypeBold -> MessageEntityType.Bold + is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic + is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline + is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough + is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler + is TdApi.TextEntityTypeCode -> MessageEntityType.Code + is TdApi.TextEntityTypePre -> MessageEntityType.Pre() + is TdApi.TextEntityTypePreCode -> MessageEntityType.Pre(entityType.language) + is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(entityType.url) + is TdApi.TextEntityTypeMention -> MessageEntityType.Mention + is TdApi.TextEntityTypeMentionName -> { + if (mentionNameAsMention) { + MessageEntityType.Mention + } else { + MessageEntityType.TextMention(entityType.userId) + } + } + + is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag + is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand + is TdApi.TextEntityTypeUrl -> MessageEntityType.Url + is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email + is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber + is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber + is TdApi.TextEntityTypeBlockQuote -> MessageEntityType.BlockQuote + is TdApi.TextEntityTypeExpandableBlockQuote -> MessageEntityType.BlockQuoteExpandable + is TdApi.TextEntityTypeCustomEmoji -> { + val path = customEmojiPathResolver?.invoke(entityType.customEmojiId) + if (path == null) { + onMissingCustomEmoji?.invoke(entityType.customEmojiId) + } + MessageEntityType.CustomEmoji(entityType.customEmojiId, path) + } + + else -> { + if (mapUnsupportedToOther) { + MessageEntityType.Other(entityType.javaClass.simpleName) + } else { + null + } + } + } ?: return null + + return MessageEntity(offset = offset, length = length, type = mappedType) +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt b/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt index 906d43db..b8804329 100644 --- a/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/UpdateMapper.kt @@ -2,7 +2,6 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi import org.monogram.domain.models.MessageEntity -import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.RichText import org.monogram.domain.models.UpdateInfo @@ -45,27 +44,7 @@ fun TdApi.FormattedText.toChangelog(): List { } fun TdApi.TextEntity.toDomain(): MessageEntity? { - val type = when (val t = this.type) { - is TdApi.TextEntityTypeBold -> MessageEntityType.Bold - is TdApi.TextEntityTypeItalic -> MessageEntityType.Italic - is TdApi.TextEntityTypeUnderline -> MessageEntityType.Underline - is TdApi.TextEntityTypeStrikethrough -> MessageEntityType.Strikethrough - is TdApi.TextEntityTypeSpoiler -> MessageEntityType.Spoiler - is TdApi.TextEntityTypeCode -> MessageEntityType.Code - is TdApi.TextEntityTypePre -> MessageEntityType.Pre() - is TdApi.TextEntityTypeTextUrl -> MessageEntityType.TextUrl(t.url) - is TdApi.TextEntityTypeMention -> MessageEntityType.Mention - is TdApi.TextEntityTypeMentionName -> MessageEntityType.TextMention(t.userId) - is TdApi.TextEntityTypeHashtag -> MessageEntityType.Hashtag - is TdApi.TextEntityTypeBotCommand -> MessageEntityType.BotCommand - is TdApi.TextEntityTypeUrl -> MessageEntityType.Url - is TdApi.TextEntityTypeEmailAddress -> MessageEntityType.Email - is TdApi.TextEntityTypePhoneNumber -> MessageEntityType.PhoneNumber - is TdApi.TextEntityTypeBankCardNumber -> MessageEntityType.BankCardNumber - is TdApi.TextEntityTypeCustomEmoji -> MessageEntityType.CustomEmoji(t.customEmojiId) - else -> return null - } - return MessageEntity(this.offset, this.length, type) + return toMessageEntityOrNull() } fun TdApi.MessageDocument.toUpdateInfo(): UpdateInfo? { diff --git a/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt new file mode 100644 index 00000000..5fd50ab0 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt @@ -0,0 +1,60 @@ +package org.monogram.data.mapper + +import android.text.format.DateUtils +import org.drinkless.tdlib.TdApi +import org.monogram.domain.repository.StringProvider +import java.text.SimpleDateFormat +import java.util.* + +internal fun formatChatUserStatus( + status: TdApi.UserStatus, + stringProvider: StringProvider, + isBot: Boolean = false +): String { + if (isBot) return stringProvider.getString("chat_mapper_bot") + return when (status) { + is TdApi.UserStatusOnline -> stringProvider.getString("chat_mapper_online") + is TdApi.UserStatusOffline -> { + val wasOnline = status.wasOnline.toLong() * 1000L + if (wasOnline == 0L) return stringProvider.getString("chat_mapper_offline") + val now = System.currentTimeMillis() + val diff = now - wasOnline + when { + diff < 60 * 1000 -> stringProvider.getString("chat_mapper_seen_just_now") + diff < 60 * 60 * 1000 -> { + val minutes = diff / (60 * 1000L) + if (minutes == 1L) stringProvider.getString("chat_mapper_seen_minutes_ago", 1) + else stringProvider.getString("chat_mapper_seen_minutes_ago_plural", minutes) + } + + DateUtils.isToday(wasOnline) -> { + val date = Date(wasOnline) + val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + stringProvider.getString("chat_mapper_seen_at", format.format(date)) + } + + isYesterday(wasOnline) -> { + val date = Date(wasOnline) + val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + stringProvider.getString("chat_mapper_seen_yesterday", format.format(date)) + } + + else -> { + val date = Date(wasOnline) + val format = SimpleDateFormat("dd.MM.yy", Locale.getDefault()) + stringProvider.getString("chat_mapper_seen_date", format.format(date)) + } + } + } + + is TdApi.UserStatusRecently -> stringProvider.getString("chat_mapper_seen_recently") + is TdApi.UserStatusLastWeek -> stringProvider.getString("chat_mapper_seen_week") + is TdApi.UserStatusLastMonth -> stringProvider.getString("chat_mapper_seen_month") + is TdApi.UserStatusEmpty -> stringProvider.getString("chat_mapper_offline") + else -> "" + } +} + +private fun isYesterday(timestamp: Long): Boolean { + return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS) +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt index d4bfee65..b671664e 100644 --- a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt @@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi import org.monogram.domain.models.ThumbnailModel import org.monogram.domain.models.WallpaperModel import org.monogram.domain.models.WallpaperSettings +import org.monogram.domain.models.WallpaperType fun mapBackgrounds(backgrounds: Array): List { val defaultWallpapers = listOf( @@ -11,6 +12,7 @@ fun mapBackgrounds(backgrounds: Array): List { id = -1, slug = "default_blue", title = "Default Blue", + type = WallpaperType.FILL, pattern = false, documentId = 0, thumbnail = null, @@ -21,8 +23,11 @@ fun mapBackgrounds(backgrounds: Array): List { fourthBackgroundColor = null, intensity = null, rotation = 45, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ), + themeName = null, isDownloaded = true, localPath = null, isDefault = true @@ -38,10 +43,12 @@ fun TdApi.Background.toDomain(): WallpaperModel { id = this.id, slug = this.name, title = this.name, + type = this.type.toWallpaperType(), pattern = this.type is TdApi.BackgroundTypePattern, documentId = doc?.document?.id?.toLong() ?: 0L, thumbnail = doc?.thumbnail?.toDomain(), settings = this.type.toWallpaperSettings(), + themeName = this.type.toThemeName(), isDownloaded = file?.local?.isDownloadingCompleted == true, localPath = file?.local?.path?.ifEmpty { null }, isDefault = this.isDefault @@ -55,10 +62,36 @@ fun TdApi.Thumbnail.toDomain(): ThumbnailModel = ThumbnailModel( localPath = this.file.local.path ) +fun TdApi.BackgroundType.toWallpaperType(): WallpaperType = when (this) { + is TdApi.BackgroundTypeWallpaper -> WallpaperType.WALLPAPER + is TdApi.BackgroundTypePattern -> WallpaperType.PATTERN + is TdApi.BackgroundTypeFill -> WallpaperType.FILL + is TdApi.BackgroundTypeChatTheme -> WallpaperType.CHAT_THEME + else -> WallpaperType.WALLPAPER +} + +fun TdApi.BackgroundType.toThemeName(): String? = when (this) { + is TdApi.BackgroundTypeChatTheme -> themeName + else -> null +} + fun TdApi.BackgroundType.toWallpaperSettings(): WallpaperSettings? = when (this) { is TdApi.BackgroundTypePattern -> fill.toWallpaperSettings() - ?.copy(intensity = intensity, isInverted = isInverted) + ?.copy(intensity = intensity, isInverted = isInverted, isMoving = isMoving) is TdApi.BackgroundTypeFill -> fill.toWallpaperSettings() + is TdApi.BackgroundTypeWallpaper -> WallpaperSettings( + backgroundColor = null, + secondBackgroundColor = null, + thirdBackgroundColor = null, + fourthBackgroundColor = null, + intensity = null, + rotation = null, + isInverted = null, + isMoving = isMoving, + isBlurred = isBlurred + ) + + is TdApi.BackgroundTypeChatTheme -> null else -> null } @@ -70,7 +103,9 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this) fourthBackgroundColor = null, intensity = null, rotation = null, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ) is TdApi.BackgroundFillGradient -> WallpaperSettings( backgroundColor = topColor, @@ -79,7 +114,9 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this) fourthBackgroundColor = null, intensity = null, rotation = rotationAngle, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ) is TdApi.BackgroundFillFreeformGradient -> WallpaperSettings( backgroundColor = colors.getOrNull(0), @@ -88,7 +125,83 @@ fun TdApi.BackgroundFill.toWallpaperSettings(): WallpaperSettings? = when (this) fourthBackgroundColor = colors.getOrNull(3), intensity = null, rotation = null, - isInverted = null + isInverted = null, + isMoving = null, + isBlurred = null ) else -> null -} \ No newline at end of file +} + +fun WallpaperModel.toInputBackground(): TdApi.InputBackground? = when (resolveWallpaperType()) { + WallpaperType.WALLPAPER -> when { + id > 0L -> TdApi.InputBackgroundRemote(id) + !localPath.isNullOrBlank() -> TdApi.InputBackgroundLocal(TdApi.InputFileLocal(localPath)) + else -> null + } + + WallpaperType.PATTERN, + WallpaperType.FILL, + WallpaperType.CHAT_THEME -> if (id > 0L) TdApi.InputBackgroundRemote(id) else null +} + +fun WallpaperModel.toBackgroundType(isBlurred: Boolean, isMoving: Boolean): TdApi.BackgroundType? = + when (resolveWallpaperType()) { + WallpaperType.WALLPAPER -> TdApi.BackgroundTypeWallpaper(isBlurred, isMoving) + + WallpaperType.PATTERN -> { + val wallpaperSettings = settings ?: return null + val fill = wallpaperSettings.toBackgroundFill() ?: return null + TdApi.BackgroundTypePattern( + fill, + wallpaperSettings.intensity ?: 50, + wallpaperSettings.isInverted == true, + isMoving + ) + } + + WallpaperType.FILL -> { + val wallpaperSettings = settings ?: return null + val fill = wallpaperSettings.toBackgroundFill() ?: return null + TdApi.BackgroundTypeFill(fill) + } + + WallpaperType.CHAT_THEME -> { + val name = themeName?.takeIf { it.isNotBlank() } ?: slug.takeIf { it.isNotBlank() } + name?.let { TdApi.BackgroundTypeChatTheme(it) } + } + } + +private fun WallpaperSettings.toBackgroundFill(): TdApi.BackgroundFill? { + val first = backgroundColor?.toTdColor() + val second = secondBackgroundColor?.toTdColor() + val third = thirdBackgroundColor?.toTdColor() + val fourth = fourthBackgroundColor?.toTdColor() + + val freeform = intArrayOfNotNull(first, second, third, fourth) + if (freeform.size >= 3) { + return TdApi.BackgroundFillFreeformGradient(freeform) + } + + if (first != null && second != null) { + return TdApi.BackgroundFillGradient(first, second, rotation ?: 0) + } + + if (first != null) { + return TdApi.BackgroundFillSolid(first) + } + + return null +} + +private fun WallpaperModel.resolveWallpaperType(): WallpaperType = when { + type == WallpaperType.PATTERN || pattern -> WallpaperType.PATTERN + type == WallpaperType.CHAT_THEME || slug.startsWith("emoji") -> WallpaperType.CHAT_THEME + type == WallpaperType.FILL -> WallpaperType.FILL + documentId != 0L || slug == "built-in" -> WallpaperType.WALLPAPER + else -> WallpaperType.FILL +} + +private fun intArrayOfNotNull(vararg values: Int?): IntArray = + values.filterNotNull().toIntArray() + +private fun Int.toTdColor(): Int = this and 0x00FFFFFF diff --git a/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt new file mode 100644 index 00000000..ab1a68e9 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/WebPageMapper.kt @@ -0,0 +1,267 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.domain.models.WebPage +import org.monogram.domain.repository.AppPreferencesProvider + +internal class WebPageMapper( + private val fileHelper: TdFileHelper, + private val appPreferences: AppPreferencesProvider +) { + fun map( + webPage: TdApi.LinkPreview?, + chatId: Long, + messageId: Long, + networkAutoDownload: Boolean + ): WebPage? { + if (webPage == null) return null + + var photoObj: TdApi.Photo? = null + var videoObj: TdApi.Video? = null + var audioObj: TdApi.Audio? = null + var documentObj: TdApi.Document? = null + var stickerObj: TdApi.Sticker? = null + var animationObj: TdApi.Animation? = null + var duration = 0 + + val linkPreviewType = when (val type = webPage.type) { + is TdApi.LinkPreviewTypePhoto -> { + photoObj = type.photo + WebPage.LinkPreviewType.Photo + } + + is TdApi.LinkPreviewTypeVideo -> { + videoObj = type.video + WebPage.LinkPreviewType.Video + } + + is TdApi.LinkPreviewTypeAnimation -> { + animationObj = type.animation + WebPage.LinkPreviewType.Animation + } + + is TdApi.LinkPreviewTypeAudio -> { + audioObj = type.audio + WebPage.LinkPreviewType.Audio + } + + is TdApi.LinkPreviewTypeDocument -> { + documentObj = type.document + WebPage.LinkPreviewType.Document + } + + is TdApi.LinkPreviewTypeSticker -> { + stickerObj = type.sticker + WebPage.LinkPreviewType.Sticker + } + + is TdApi.LinkPreviewTypeVideoNote -> { + WebPage.LinkPreviewType.VideoNote + } + + is TdApi.LinkPreviewTypeVoiceNote -> { + WebPage.LinkPreviewType.VoiceNote + } + + is TdApi.LinkPreviewTypeAlbum -> { + WebPage.LinkPreviewType.Album + } + + is TdApi.LinkPreviewTypeArticle -> { + WebPage.LinkPreviewType.Article + } + + is TdApi.LinkPreviewTypeApp -> { + WebPage.LinkPreviewType.App + } + + is TdApi.LinkPreviewTypeExternalVideo -> { + duration = type.duration + WebPage.LinkPreviewType.ExternalVideo(type.url) + } + + is TdApi.LinkPreviewTypeExternalAudio -> { + duration = type.duration + WebPage.LinkPreviewType.ExternalAudio(type.url) + } + + is TdApi.LinkPreviewTypeEmbeddedVideoPlayer -> { + duration = type.duration + WebPage.LinkPreviewType.EmbeddedVideo(type.url) + } + + is TdApi.LinkPreviewTypeEmbeddedAudioPlayer -> { + duration = type.duration + WebPage.LinkPreviewType.EmbeddedAudio(type.url) + } + + is TdApi.LinkPreviewTypeEmbeddedAnimationPlayer -> { + duration = type.duration + WebPage.LinkPreviewType.EmbeddedAnimation(type.url) + } + + is TdApi.LinkPreviewTypeUser -> { + WebPage.LinkPreviewType.User(0) + } + + is TdApi.LinkPreviewTypeChat -> { + WebPage.LinkPreviewType.Chat(0) + } + + is TdApi.LinkPreviewTypeStory -> { + WebPage.LinkPreviewType.Story(type.storyPosterChatId, type.storyId) + } + + is TdApi.LinkPreviewTypeTheme -> { + WebPage.LinkPreviewType.Theme + } + + is TdApi.LinkPreviewTypeBackground -> { + WebPage.LinkPreviewType.Background + } + + is TdApi.LinkPreviewTypeInvoice -> { + WebPage.LinkPreviewType.Invoice + } + + is TdApi.LinkPreviewTypeMessage -> { + WebPage.LinkPreviewType.Message + } + + else -> WebPage.LinkPreviewType.Unknown + } + + fun processTdFile( + file: TdApi.File, + downloadType: TdMessageRemoteDataSource.DownloadType, + supportsStreaming: Boolean = false + ): TdApi.File { + val updatedFile = fileHelper.getUpdatedFile(file) + fileHelper.registerCachedFile(updatedFile.id, chatId, messageId) + + val autoDownload = when (downloadType) { + TdMessageRemoteDataSource.DownloadType.VIDEO -> supportsStreaming && networkAutoDownload + TdMessageRemoteDataSource.DownloadType.DEFAULT -> { + if (linkPreviewType == WebPage.LinkPreviewType.Document) false else networkAutoDownload + } + + TdMessageRemoteDataSource.DownloadType.STICKER -> { + networkAutoDownload && appPreferences.autoDownloadStickers.value + } + + TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE -> { + networkAutoDownload && appPreferences.autoDownloadVideoNotes.value + } + + else -> networkAutoDownload + } + + if (!fileHelper.isValidPath(updatedFile.local.path) && autoDownload) { + fileHelper.enqueueDownload(updatedFile.id, 1, downloadType, 0, 0, false) + } + + return updatedFile + } + + val photo = photoObj?.let { photoObject -> + val size = photoObject.sizes.firstOrNull() + if (size != null) { + val file = processTdFile(size.photo, TdMessageRemoteDataSource.DownloadType.DEFAULT) + val bestPath = fileHelper.findBestAvailablePath(file, photoObject.sizes) + + WebPage.Photo( + path = bestPath, + width = size.width, + height = size.height, + fileId = file.id, + minithumbnail = photoObject.minithumbnail?.data + ) + } else { + null + } + } + + val video = videoObj?.let { videoObject -> + val file = processTdFile( + videoObject.video, + TdMessageRemoteDataSource.DownloadType.VIDEO, + videoObject.supportsStreaming + ) + WebPage.Video( + path = fileHelper.resolveLocalFilePath(file), + width = videoObject.width, + height = videoObject.height, + duration = videoObject.duration, + fileId = file.id, + supportsStreaming = videoObject.supportsStreaming + ) + } + + val audio = audioObj?.let { audioObject -> + val file = processTdFile(audioObject.audio, TdMessageRemoteDataSource.DownloadType.DEFAULT) + WebPage.Audio( + path = fileHelper.resolveLocalFilePath(file), + duration = audioObject.duration, + title = audioObject.title, + performer = audioObject.performer, + fileId = file.id + ) + } + + val document = documentObj?.let { documentObject -> + val file = processTdFile(documentObject.document, TdMessageRemoteDataSource.DownloadType.DEFAULT) + WebPage.Document( + path = fileHelper.resolveLocalFilePath(file), + fileName = documentObject.fileName, + mimeType = documentObject.mimeType, + size = file.size, + fileId = file.id + ) + } + + val sticker = stickerObj?.let { stickerObject -> + val file = processTdFile(stickerObject.sticker, TdMessageRemoteDataSource.DownloadType.STICKER) + WebPage.Sticker( + path = fileHelper.resolveLocalFilePath(file), + width = stickerObject.width, + height = stickerObject.height, + emoji = stickerObject.emoji, + fileId = file.id + ) + } + + val animation = animationObj?.let { animationObject -> + val file = processTdFile(animationObject.animation, TdMessageRemoteDataSource.DownloadType.GIF) + WebPage.Animation( + path = fileHelper.resolveLocalFilePath(file), + width = animationObject.width, + height = animationObject.height, + duration = animationObject.duration, + fileId = file.id + ) + } + + return WebPage( + url = webPage.url, + displayUrl = webPage.displayUrl, + type = linkPreviewType, + siteName = webPage.siteName, + title = webPage.title, + description = webPage.description?.text, + photo = photo, + embedUrl = null, + embedType = null, + embedWidth = 0, + embedHeight = 0, + duration = duration, + author = webPage.author, + video = video, + audio = audio, + document = document, + sticker = sticker, + animation = animation, + instantViewVersion = webPage.instantViewVersion + ) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt new file mode 100644 index 00000000..3f4c382a --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt @@ -0,0 +1,593 @@ +package org.monogram.data.mapper.message + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.drinkless.tdlib.TdApi +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.data.mapper.CustomEmojiLoader +import org.monogram.data.mapper.TdFileHelper +import org.monogram.data.mapper.WebPageMapper +import org.monogram.data.mapper.toMessageEntityOrNull +import org.monogram.domain.models.* +import org.monogram.domain.repository.AppPreferencesProvider + +internal data class ContentMappingContext( + val chatId: Long, + val messageId: Long, + val senderName: String, + val networkAutoDownload: Boolean, + val isActuallyUploading: Boolean +) + +internal class MessageContentMapper( + private val fileHelper: TdFileHelper, + private val appPreferences: AppPreferencesProvider, + private val customEmojiLoader: CustomEmojiLoader, + private val webPageMapper: WebPageMapper, + private val scope: CoroutineScope +) { + fun mapContent(msg: TdApi.Message, context: ContentMappingContext): MessageContent { + return when (val content = msg.content) { + is TdApi.MessageText -> { + val entities = mapEntities( + entities = content.text.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ) + val webPage = webPageMapper.map( + webPage = content.linkPreview, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ) + MessageContent.Text(content.text.text, entities, webPage) + } + + is TdApi.MessagePhoto -> { + val sizes = content.photo.sizes + val photoSize = sizes.find { it.type == "x" } + ?: sizes.find { it.type == "m" } + ?: sizes.getOrNull(sizes.size / 2) + ?: sizes.lastOrNull() + val thumbnailSize = sizes.find { it.type == "m" } + ?: sizes.find { it.type == "s" } + ?: sizes.firstOrNull() + + val photoFile = photoSize?.photo?.let(fileHelper::getUpdatedFile) + val thumbnailFile = thumbnailSize?.photo?.let(fileHelper::getUpdatedFile) + + val path = fileHelper.findBestAvailablePath(photoFile, sizes) + val thumbnailPath = fileHelper.resolveLocalFilePath(thumbnailFile) + + if (photoFile != null) { + fileHelper.registerCachedFile(photoFile.id, context.chatId, context.messageId) + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + photoFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + if (thumbnailFile != null) { + fileHelper.registerCachedFile(thumbnailFile.id, context.chatId, context.messageId) + if (thumbnailPath == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbnailFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isDownloading = photoFile?.local?.isDownloadingActive ?: false + val isQueued = photoFile?.let { fileHelper.isFileQueued(it.id) } ?: false + val downloadProgress = photoFile?.let(fileHelper::computeDownloadProgress) ?: 0f + + MessageContent.Photo( + path = path, + thumbnailPath = thumbnailPath, + width = photoSize?.width ?: 0, + height = photoSize?.height ?: 0, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && (photoFile?.remote?.isUploadingActive ?: false), + uploadProgress = photoFile?.let(fileHelper::computeUploadProgress) ?: 0f, + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = photoFile?.id ?: 0, + minithumbnail = content.photo.minithumbnail?.data + ) + } + + is TdApi.MessageVideo -> { + val video = content.video + val videoFile = fileHelper.getUpdatedFile(video.video) + val path = fileHelper.resolveLocalFilePath(videoFile) + fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId) + + val thumbFile = video.thumbnail?.file?.let(fileHelper::getUpdatedFile) + val thumbnailPath = fileHelper.resolveLocalFilePath(thumbFile) + + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (thumbnailPath == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + if (path == null && context.networkAutoDownload && video.supportsStreaming) { + fileHelper.enqueueDownload( + videoFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.VIDEO, + 0, + 0, + false + ) + } + + val isDownloading = videoFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(videoFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(videoFile) + + MessageContent.Video( + path = path, + thumbnailPath = thumbnailPath, + width = video.width, + height = video.height, + duration = video.duration, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(videoFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = videoFile.id, + minithumbnail = video.minithumbnail?.data, + supportsStreaming = video.supportsStreaming + ) + } + + is TdApi.MessageVoiceNote -> { + val voice = content.voiceNote + val voiceFile = fileHelper.getUpdatedFile(voice.voice) + val path = fileHelper.resolveLocalFilePath(voiceFile) + fileHelper.registerCachedFile(voiceFile.id, context.chatId, context.messageId) + + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + voiceFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + + val isDownloading = voiceFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(voiceFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(voiceFile) + + MessageContent.Voice( + path = path, + duration = voice.duration, + waveform = voice.waveform, + isUploading = context.isActuallyUploading && voiceFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(voiceFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = voiceFile.id + ) + } + + is TdApi.MessageVideoNote -> { + val note = content.videoNote + val videoFile = fileHelper.getUpdatedFile(note.video) + val videoPath = fileHelper.resolveLocalFilePath(videoFile) + fileHelper.registerCachedFile(videoFile.id, context.chatId, context.messageId) + + if (videoPath == null && context.networkAutoDownload && appPreferences.autoDownloadVideoNotes.value) { + fileHelper.enqueueDownload( + videoFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.VIDEO_NOTE, + 0, + 0, + false + ) + } + + val thumbFile = note.thumbnail?.file?.let(fileHelper::getUpdatedFile) + val thumbPath = fileHelper.resolveLocalFilePath(thumbFile) + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (thumbPath == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isUploading = context.isActuallyUploading && videoFile.remote.isUploadingActive + val uploadProgress = fileHelper.computeUploadProgress(videoFile) + val isDownloading = videoFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(videoFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(videoFile) + + MessageContent.VideoNote( + path = videoPath, + thumbnail = thumbPath, + duration = note.duration, + length = note.length, + isUploading = isUploading, + uploadProgress = uploadProgress, + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = videoFile.id + ) + } + + is TdApi.MessageSticker -> { + val sticker = content.sticker + val stickerFile = fileHelper.getUpdatedFile(sticker.sticker) + val path = fileHelper.resolveLocalFilePath(stickerFile) + + fileHelper.registerCachedFile(stickerFile.id, context.chatId, context.messageId) + if (path == null && context.networkAutoDownload && appPreferences.autoDownloadStickers.value) { + fileHelper.enqueueDownload( + stickerFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.STICKER, + 0, + 0, + false + ) + } + + val format = when (sticker.format) { + is TdApi.StickerFormatWebp -> StickerFormat.STATIC + is TdApi.StickerFormatTgs -> StickerFormat.ANIMATED + is TdApi.StickerFormatWebm -> StickerFormat.VIDEO + else -> StickerFormat.UNKNOWN + } + + val isDownloading = stickerFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(stickerFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(stickerFile) + + MessageContent.Sticker( + id = sticker.sticker.id.toLong(), + setId = sticker.setId, + path = path, + width = sticker.width, + height = sticker.height, + emoji = sticker.emoji, + format = format, + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = stickerFile.id + ) + } + + is TdApi.MessageAnimation -> { + val animation = content.animation + val animationFile = fileHelper.getUpdatedFile(animation.animation) + val path = fileHelper.resolveLocalFilePath(animationFile) + fileHelper.registerCachedFile(animationFile.id, context.chatId, context.messageId) + + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + animationFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.GIF, + 0, + 0, + false + ) + } + + val thumbFile = animation.thumbnail?.file?.let(fileHelper::getUpdatedFile) + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isDownloading = animationFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(animationFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(animationFile) + + MessageContent.Gif( + path = path, + width = animation.width, + height = animation.height, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && animationFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(animationFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = animationFile.id, + minithumbnail = animation.minithumbnail?.data + ) + } + + is TdApi.MessageAnimatedEmoji -> MessageContent.Text(content.emoji) + is TdApi.MessageDice -> { + val valueStr = if (content.value != 0) " (Result: ${content.value})" else "" + MessageContent.Text("${content.emoji}$valueStr") + } + + is TdApi.MessageDocument -> { + val document = content.document + val documentFile = fileHelper.getUpdatedFile(document.document) + val path = fileHelper.resolveLocalFilePath(documentFile) + fileHelper.registerCachedFile(documentFile.id, context.chatId, context.messageId) + + val thumbFile = document.thumbnail?.file?.let(fileHelper::getUpdatedFile) + if (thumbFile != null) { + fileHelper.registerCachedFile(thumbFile.id, context.chatId, context.messageId) + if (!fileHelper.isValidPath(thumbFile.local.path) && context.networkAutoDownload) { + fileHelper.enqueueDownload( + thumbFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + val isDownloading = documentFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(documentFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(documentFile) + + MessageContent.Document( + path = path, + fileName = document.fileName, + mimeType = document.mimeType, + size = documentFile.size, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && documentFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(documentFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = documentFile.id + ) + } + + is TdApi.MessageAudio -> { + val audio = content.audio + val audioFile = fileHelper.getUpdatedFile(audio.audio) + val path = fileHelper.resolveLocalFilePath(audioFile) + fileHelper.registerCachedFile(audioFile.id, context.chatId, context.messageId) + + if (path == null && context.networkAutoDownload) { + fileHelper.enqueueDownload( + audioFile.id, + 1, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + + val isDownloading = audioFile.local.isDownloadingActive + val isQueued = fileHelper.isFileQueued(audioFile.id) + val downloadProgress = fileHelper.computeDownloadProgress(audioFile) + + MessageContent.Audio( + path = path, + duration = audio.duration, + title = audio.title ?: "Unknown", + performer = audio.performer ?: "Unknown", + fileName = audio.fileName ?: "audio.mp3", + mimeType = audio.mimeType ?: "audio/mpeg", + size = audioFile.size, + caption = content.caption.text, + entities = mapEntities( + entities = content.caption.entities, + chatId = context.chatId, + messageId = context.messageId, + networkAutoDownload = context.networkAutoDownload + ), + isUploading = context.isActuallyUploading && audioFile.remote.isUploadingActive, + uploadProgress = fileHelper.computeUploadProgress(audioFile), + isDownloading = isDownloading || isQueued, + downloadProgress = downloadProgress, + fileId = audioFile.id + ) + } + + is TdApi.MessageCall -> MessageContent.Text("📞 Call (${content.duration}s)") + is TdApi.MessageContact -> { + val contact = content.contact + MessageContent.Contact( + phoneNumber = contact.phoneNumber, + firstName = contact.firstName, + lastName = contact.lastName, + vcard = contact.vcard, + userId = contact.userId + ) + } + + is TdApi.MessageLocation -> { + val location = content.location + MessageContent.Location( + latitude = location.latitude, + longitude = location.longitude, + horizontalAccuracy = location.horizontalAccuracy, + livePeriod = content.livePeriod, + heading = content.heading, + proximityAlertRadius = content.proximityAlertRadius + ) + } + + is TdApi.MessageVenue -> { + val venue = content.venue + MessageContent.Venue( + latitude = venue.location.latitude, + longitude = venue.location.longitude, + title = venue.title, + address = venue.address, + provider = venue.provider, + venueId = venue.id, + venueType = venue.type + ) + } + + is TdApi.MessagePoll -> { + val poll = content.poll + val pollType = when (val type = poll.type) { + is TdApi.PollTypeRegular -> PollType.Regular(poll.allowsMultipleAnswers) + is TdApi.PollTypeQuiz -> { + PollType.Quiz(type.correctOptionIds.firstOrNull() ?: -1, type.explanation?.text) + } + + else -> PollType.Regular(poll.allowsMultipleAnswers) + } + + MessageContent.Poll( + id = poll.id, + question = poll.question.text, + options = poll.options.map { option -> + PollOption( + text = option.text.text, + voterCount = option.voterCount, + votePercentage = option.votePercentage, + isChosen = option.isChosen, + isBeingChosen = false + ) + }, + totalVoterCount = poll.totalVoterCount, + isClosed = poll.isClosed, + isAnonymous = poll.isAnonymous, + type = pollType, + openPeriod = poll.openPeriod, + closeDate = poll.closeDate + ) + } + + is TdApi.MessageGame -> MessageContent.Text("🎮 Game: ${content.game.title}") + is TdApi.MessageInvoice -> MessageContent.Text("💳 Invoice: ${content.productInfo.title}") + is TdApi.MessageStory -> MessageContent.Text("📖 Story") + is TdApi.MessageExpiredPhoto -> MessageContent.Text("📷 Photo has expired") + is TdApi.MessageExpiredVideo -> MessageContent.Text("📹 Video has expired") + + is TdApi.MessageChatJoinByLink -> MessageContent.Service("${context.senderName} has joined the group via invite link") + is TdApi.MessageChatAddMembers -> MessageContent.Service("${context.senderName} added members") + is TdApi.MessageChatDeleteMember -> MessageContent.Service("${context.senderName} left the chat") + is TdApi.MessagePinMessage -> MessageContent.Service("${context.senderName} pinned a message") + is TdApi.MessageChatChangeTitle -> MessageContent.Service("${context.senderName} changed group name to \"${content.title}\"") + is TdApi.MessageChatChangePhoto -> MessageContent.Service("${context.senderName} changed group photo") + is TdApi.MessageChatDeletePhoto -> MessageContent.Service("${context.senderName} removed group photo") + is TdApi.MessageScreenshotTaken -> MessageContent.Service("${context.senderName} took a screenshot") + is TdApi.MessageContactRegistered -> MessageContent.Service("${context.senderName} joined Telegram!") + is TdApi.MessageChatUpgradeTo -> MessageContent.Service("${context.senderName} upgraded to supergroup") + is TdApi.MessageChatUpgradeFrom -> MessageContent.Service("group created") + is TdApi.MessageBasicGroupChatCreate -> MessageContent.Service("created the group \"${content.title}\"") + is TdApi.MessageSupergroupChatCreate -> MessageContent.Service("created the supergroup \"${content.title}\"") + is TdApi.MessagePaymentSuccessful -> MessageContent.Service("Payment successful: ${content.currency} ${content.totalAmount}") + is TdApi.MessagePaymentSuccessfulBot -> MessageContent.Service("Payment successful") + is TdApi.MessagePassportDataSent -> MessageContent.Service("Passport data sent") + is TdApi.MessagePassportDataReceived -> MessageContent.Service("Passport data received") + is TdApi.MessageProximityAlertTriggered -> MessageContent.Service("is within ${content.distance}m") + is TdApi.MessageForumTopicCreated -> MessageContent.Service("${context.senderName} created topic \"${content.name}\"") + is TdApi.MessageForumTopicEdited -> MessageContent.Service("${context.senderName} edited topic") + is TdApi.MessageForumTopicIsClosedToggled -> MessageContent.Service("${context.senderName} toggled topic closed status") + is TdApi.MessageForumTopicIsHiddenToggled -> MessageContent.Service("${context.senderName} toggled topic hidden status") + is TdApi.MessageSuggestProfilePhoto -> MessageContent.Service("${context.senderName} suggested a profile photo") + is TdApi.MessageCustomServiceAction -> MessageContent.Service(content.text) + is TdApi.MessageChatBoost -> MessageContent.Service("Chat boost: ${content.boostCount}") + is TdApi.MessageChatSetTheme -> MessageContent.Service("Chat theme changed to ${content.theme}") + is TdApi.MessageGameScore -> MessageContent.Service("Game score: ${content.score}") + is TdApi.MessageVideoChatScheduled -> MessageContent.Service("Video chat scheduled for ${content.startDate}") + is TdApi.MessageVideoChatStarted -> MessageContent.Service("Video chat started") + is TdApi.MessageVideoChatEnded -> MessageContent.Service("Video chat ended") + is TdApi.MessageChatSetBackground -> MessageContent.Service("Chat background changed") + else -> MessageContent.Text("ℹ️ Unsupported message type: ${content.javaClass.simpleName}") + } + } + + fun mapEntities( + entities: Array, + chatId: Long, + messageId: Long, + networkAutoDownload: Boolean + ): List { + return entities.mapNotNull { entity -> + entity.toMessageEntityOrNull( + mapUnsupportedToOther = true, + mentionNameAsMention = true, + customEmojiPathResolver = { emojiId -> + customEmojiLoader.getPathIfValid(emojiId) + }, + onMissingCustomEmoji = { emojiId -> + scope.launch { + customEmojiLoader.loadIfNeeded(emojiId, chatId, messageId, networkAutoDownload) + } + } + ) + } + } + + fun resolveMessageDate(msg: TdApi.Message): Int { + return when (val schedulingState = msg.schedulingState) { + is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate + else -> msg.date + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt new file mode 100644 index 00000000..f82170ec --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessagePersistenceMapper.kt @@ -0,0 +1,746 @@ +package org.monogram.data.mapper.message + +import org.drinkless.tdlib.TdApi +import org.monogram.data.chats.ChatCache +import org.monogram.data.mapper.SenderNameResolver +import org.monogram.data.mapper.TdFileHelper +import org.monogram.domain.models.ForwardInfo +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.PollType +import org.monogram.data.db.model.MessageEntity as MessageDbEntity + +internal class MessagePersistenceMapper( + private val cache: ChatCache, + private val fileHelper: TdFileHelper +) { + data class CachedMessageContent( + val type: String, + val text: String, + val meta: String?, + val fileId: Int = 0, + val path: String? = null, + val thumbnailPath: String? = null, + val minithumbnail: ByteArray? = null + ) + + private data class CachedReplyPreview( + val senderName: String, + val contentType: String, + val text: String + ) + + private data class CachedForwardOrigin( + val fromName: String, + val fromId: Long, + val originChatId: Long? = null, + val originMessageId: Long? = null + ) + + fun mapToEntity( + msg: TdApi.Message, + getSenderName: ((Long) -> String?)? = null + ): MessageDbEntity { + val senderId = when (val sender = msg.senderId) { + is TdApi.MessageSenderUser -> sender.userId + is TdApi.MessageSenderChat -> sender.chatId + else -> 0L + } + val senderName = getSenderName?.invoke(senderId).orEmpty() + val content = extractCachedContent(msg.content) + val entitiesEncoded = encodeEntities(msg.content) + val replyToMessageId = (msg.replyTo as? TdApi.MessageReplyToMessage)?.messageId ?: 0L + val replyToPreview = buildReplyPreview(msg) + val forwardOrigin = msg.forwardInfo?.origin?.let(::extractForwardOrigin) + + return MessageDbEntity( + id = msg.id, + chatId = msg.chatId, + senderId = senderId, + senderName = senderName, + content = content.text, + contentType = content.type, + contentMeta = content.meta, + mediaFileId = content.fileId, + mediaPath = content.path, + mediaThumbnailPath = content.thumbnailPath, + minithumbnail = content.minithumbnail, + date = resolveMessageDate(msg), + isOutgoing = msg.isOutgoing, + isRead = false, + replyToMessageId = replyToMessageId, + replyToPreview = replyToPreview?.let(::encodeReplyPreview), + replyToPreviewType = replyToPreview?.contentType, + replyToPreviewText = replyToPreview?.text, + replyToPreviewSenderName = replyToPreview?.senderName, + replyCount = msg.interactionInfo?.replyInfo?.replyCount ?: 0, + forwardFromName = forwardOrigin?.fromName, + forwardFromId = forwardOrigin?.fromId ?: 0L, + forwardOriginChatId = forwardOrigin?.originChatId, + forwardOriginMessageId = forwardOrigin?.originMessageId, + forwardDate = msg.forwardInfo?.date ?: 0, + editDate = msg.editDate, + mediaAlbumId = msg.mediaAlbumId, + entities = entitiesEncoded, + viewCount = msg.interactionInfo?.viewCount ?: 0, + forwardCount = msg.interactionInfo?.forwardCount ?: 0, + createdAt = System.currentTimeMillis() + ) + } + + fun extractCachedContent(content: TdApi.MessageContent): CachedMessageContent { + return when (content) { + is TdApi.MessageText -> CachedMessageContent("text", content.text.text, null) + is TdApi.MessagePhoto -> { + val sizes = content.photo.sizes + val best = sizes.find { it.type == "x" } + ?: sizes.find { it.type == "m" } + ?: sizes.getOrNull(sizes.size / 2) + ?: sizes.lastOrNull() + val thumbnail = sizes.find { it.type == "m" } + ?: sizes.find { it.type == "s" } + val fileId = best?.photo?.id ?: 0 + val path = best?.photo?.local?.path?.takeIf { fileHelper.isValidPath(it) } + val thumbnailPath = thumbnail?.photo?.local?.path?.takeIf { fileHelper.isValidPath(it) } + CachedMessageContent( + "photo", + content.caption.text, + encodeMeta(best?.width ?: 0, best?.height ?: 0), + fileId = fileId, + path = path, + thumbnailPath = thumbnailPath, + minithumbnail = content.photo.minithumbnail?.data + ) + } + + is TdApi.MessageVideo -> { + val fileId = content.video.video.id + val path = content.video.video.local.path.takeIf { fileHelper.isValidPath(it) } + CachedMessageContent( + "video", + content.caption.text, + encodeMeta( + content.video.width, + content.video.height, + content.video.duration, + content.video.thumbnail?.file?.local?.path + ?.takeIf { fileHelper.isValidPath(it) } + .orEmpty(), + if (content.video.supportsStreaming) 1 else 0 + ), + fileId = fileId, + path = path, + thumbnailPath = content.video.thumbnail?.file?.local?.path?.takeIf { fileHelper.isValidPath(it) }, + minithumbnail = content.video.minithumbnail?.data + ) + } + + is TdApi.MessageVoiceNote -> CachedMessageContent( + "voice", + content.caption.text, + encodeMeta(content.voiceNote.duration), + fileId = content.voiceNote.voice.id, + path = content.voiceNote.voice.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageVideoNote -> CachedMessageContent( + "video_note", + "", + encodeMeta( + content.videoNote.duration, + content.videoNote.length, + content.videoNote.thumbnail?.file?.local?.path + ?.takeIf { fileHelper.isValidPath(it) } + .orEmpty() + ), + fileId = content.videoNote.video.id, + path = content.videoNote.video.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageSticker -> { + val format = when (content.sticker.format) { + is TdApi.StickerFormatWebp -> "webp" + is TdApi.StickerFormatTgs -> "tgs" + is TdApi.StickerFormatWebm -> "webm" + else -> "unknown" + } + CachedMessageContent( + "sticker", + content.sticker.emoji, + encodeMeta( + content.sticker.setId, + content.sticker.emoji, + content.sticker.width, + content.sticker.height, + format + ), + fileId = content.sticker.sticker.id, + path = content.sticker.sticker.local.path.takeIf { fileHelper.isValidPath(it) } + ) + } + + is TdApi.MessageDocument -> CachedMessageContent( + "document", + content.caption.text, + encodeMeta(content.document.fileName, content.document.mimeType, content.document.document.size), + fileId = content.document.document.id, + path = content.document.document.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageAudio -> CachedMessageContent( + "audio", + content.caption.text, + encodeMeta( + content.audio.duration, + content.audio.title.orEmpty(), + content.audio.performer.orEmpty(), + content.audio.fileName.orEmpty() + ), + fileId = content.audio.audio.id, + path = content.audio.audio.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessageAnimation -> CachedMessageContent( + "gif", + content.caption.text, + encodeMeta( + content.animation.width, + content.animation.height, + content.animation.duration, + content.animation.thumbnail?.file?.local?.path + ?.takeIf { fileHelper.isValidPath(it) } + .orEmpty() + ), + fileId = content.animation.animation.id, + path = content.animation.animation.local.path.takeIf { fileHelper.isValidPath(it) } + ) + + is TdApi.MessagePoll -> CachedMessageContent( + "poll", + content.poll.question.text, + encodeMeta(content.poll.options.size, if (content.poll.isClosed) 1 else 0) + ) + + is TdApi.MessageContact -> CachedMessageContent( + "contact", + listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() } + .joinToString(" "), + encodeMeta( + content.contact.phoneNumber, + content.contact.firstName, + content.contact.lastName, + content.contact.userId + ) + ) + + is TdApi.MessageLocation -> CachedMessageContent( + "location", + "", + encodeMeta(content.location.latitude, content.location.longitude, content.livePeriod) + ) + + is TdApi.MessageCall -> CachedMessageContent("service", "Call (${content.duration}s)", null) + is TdApi.MessagePinMessage -> CachedMessageContent("service", "Pinned a message", null) + is TdApi.MessageChatAddMembers -> CachedMessageContent("service", "Added members", null) + is TdApi.MessageChatDeleteMember -> CachedMessageContent("service", "Removed a member", null) + is TdApi.MessageChatChangeTitle -> CachedMessageContent("service", "Changed title", null) + is TdApi.MessageAnimatedEmoji -> CachedMessageContent("text", content.emoji, null) + is TdApi.MessageDice -> CachedMessageContent("text", content.emoji, null) + else -> CachedMessageContent("unsupported", "", null) + } + } + + fun mapEntityToModel(entity: MessageDbEntity): MessageModel { + val meta = decodeMeta(entity.contentMeta) + val usesLegacyEmbeddedMedia = entity.mediaFileId == 0 && entity.mediaPath.isNullOrBlank() + val (legacyFileId, legacyPath) = if (usesLegacyEmbeddedMedia) { + resolveLegacyMediaFromMeta(entity.contentType, meta) + } else { + 0 to null + } + val mediaFileId = entity.mediaFileId.takeIf { it != 0 } ?: legacyFileId + val mediaPath = entity.mediaPath?.takeIf { it.isNotBlank() } ?: legacyPath + val replyToMsgId = entity.replyToMessageId.takeIf { it != 0L } + val replyPreview = resolveReplyPreview(entity) + val replyPreviewModel = + if (replyToMsgId != null && replyPreview != null) createReplyPreviewModel( + entity, + replyToMsgId, + replyPreview + ) else null + + val cachedSenderUser = entity.senderId.takeIf { it > 0L }?.let { cache.getUser(it) } + val cachedSenderChat = if (cachedSenderUser == null && entity.senderId > 0L) { + cache.getChat(entity.senderId) + } else { + null + } + + val resolvedSenderName = resolveSenderNameFromCache(entity.senderId, entity.senderName) + val resolvedSenderAvatar = when { + cachedSenderUser != null -> fileHelper.resolveLocalFilePath(cachedSenderUser.profilePhoto?.small) + cachedSenderChat != null -> fileHelper.resolveLocalFilePath(cachedSenderChat.photo?.small) + else -> null + } + val resolvedSenderPersonalAvatar = cache.getUserFullInfo(entity.senderId) + ?.personalPhoto + ?.sizes + ?.firstOrNull() + ?.photo + ?.let { fileHelper.resolveLocalFilePath(it) } + + val senderStatusEmojiId = when (val type = cachedSenderUser?.emojiStatus?.type) { + is TdApi.EmojiStatusTypeCustomEmoji -> type.customEmojiId + is TdApi.EmojiStatusTypeUpgradedGift -> type.modelCustomEmojiId + else -> 0L + } + + val forwardInfo = entity.forwardFromName + ?.takeIf { it.isNotBlank() } + ?.let { fromName -> + ForwardInfo( + date = entity.forwardDate.takeIf { it > 0 } ?: entity.date, + fromId = entity.forwardFromId, + fromName = fromName, + originChatId = entity.forwardOriginChatId, + originMessageId = entity.forwardOriginMessageId + ) + } + + val content: MessageContent = when (entity.contentType) { + "text" -> MessageContent.Text(entity.content) + + "photo" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Photo( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + thumbnailPath = entity.mediaThumbnailPath?.takeIf { fileHelper.isValidPath(it) }, + width = meta.getOrNull(0)?.toIntOrNull() ?: 0, + height = meta.getOrNull(1)?.toIntOrNull() ?: 0, + caption = entity.content, + fileId = fileId, + minithumbnail = entity.minithumbnail + ) + } + + "video" -> { + val fileId = mediaFileId + val supportsStreaming = if (usesLegacyEmbeddedMedia) { + (meta.getOrNull(6)?.toIntOrNull() ?: 0) == 1 + } else { + (meta.getOrNull(4)?.toIntOrNull() ?: 0) == 1 + } + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Video( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + thumbnailPath = ( + entity.mediaThumbnailPath?.takeIf { fileHelper.isValidPath(it) } + ?: meta.getOrNull(3) + )?.takeIf { fileHelper.isValidPath(it) }, + width = meta.getOrNull(0)?.toIntOrNull() ?: 0, + height = meta.getOrNull(1)?.toIntOrNull() ?: 0, + duration = meta.getOrNull(2)?.toIntOrNull() ?: 0, + caption = entity.content, + fileId = fileId, + supportsStreaming = supportsStreaming, + minithumbnail = entity.minithumbnail + ) + } + + "voice" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Voice( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, + fileId = fileId + ) + } + + "video_note" -> { + val fileId = mediaFileId + val storedThumbPath = if (usesLegacyEmbeddedMedia) meta.getOrNull(4) else meta.getOrNull(2) + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.VideoNote( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + thumbnail = storedThumbPath?.takeIf { fileHelper.isValidPath(it) }, + duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, + length = meta.getOrNull(1)?.toIntOrNull() ?: 0, + fileId = fileId + ) + } + + "sticker" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Sticker( + id = 0L, + setId = meta.getOrNull(0)?.toLongOrNull() ?: 0L, + path = fileHelper.resolveCachedPath(fileId, mediaPath), + width = meta.getOrNull(2)?.toIntOrNull() ?: 0, + height = meta.getOrNull(3)?.toIntOrNull() ?: 0, + emoji = entity.content, + fileId = fileId + ) + } + + "document" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Document( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + fileName = meta.getOrNull(0).orEmpty(), + mimeType = meta.getOrNull(1).orEmpty(), + size = meta.getOrNull(2)?.toLongOrNull() ?: 0L, + caption = entity.content, + fileId = fileId + ) + } + + "audio" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Audio( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + duration = meta.getOrNull(0)?.toIntOrNull() ?: 0, + title = meta.getOrNull(1).orEmpty(), + performer = meta.getOrNull(2).orEmpty(), + fileName = meta.getOrNull(3).orEmpty(), + mimeType = "", + size = 0L, + caption = entity.content, + fileId = fileId + ) + } + + "gif" -> { + val fileId = mediaFileId + fileHelper.registerCachedFile(fileId, entity.chatId, entity.id) + MessageContent.Gif( + path = fileHelper.resolveCachedPath(fileId, mediaPath), + width = meta.getOrNull(0)?.toIntOrNull() ?: 0, + height = meta.getOrNull(1)?.toIntOrNull() ?: 0, + caption = entity.content, + fileId = fileId + ) + } + + "poll" -> MessageContent.Poll( + id = 0L, + question = entity.content, + options = emptyList(), + totalVoterCount = 0, + isClosed = (meta.getOrNull(1)?.toIntOrNull() ?: 0) == 1, + isAnonymous = true, + type = PollType.Regular(false), + openPeriod = 0, + closeDate = 0 + ) + + "contact" -> MessageContent.Contact( + phoneNumber = meta.getOrNull(0).orEmpty(), + firstName = meta.getOrNull(1).orEmpty(), + lastName = meta.getOrNull(2).orEmpty(), + vcard = "", + userId = meta.getOrNull(3)?.toLongOrNull() ?: 0L + ) + + "location" -> MessageContent.Location( + latitude = meta.getOrNull(0)?.toDoubleOrNull() ?: 0.0, + longitude = meta.getOrNull(1)?.toDoubleOrNull() ?: 0.0, + livePeriod = meta.getOrNull(2)?.toIntOrNull() ?: 0 + ) + + "service" -> MessageContent.Service(entity.content) + else -> MessageContent.Text(entity.content) + } + + return MessageModel( + id = entity.id, + date = entity.date, + isOutgoing = entity.isOutgoing, + senderName = resolvedSenderName, + chatId = entity.chatId, + content = content, + senderId = entity.senderId, + senderAvatar = resolvedSenderAvatar, + senderPersonalAvatar = resolvedSenderPersonalAvatar, + isRead = entity.isRead, + replyToMsgId = replyToMsgId, + replyToMsg = replyPreviewModel, + forwardInfo = forwardInfo, + mediaAlbumId = entity.mediaAlbumId, + editDate = entity.editDate, + views = entity.viewCount, + viewCount = entity.viewCount, + replyCount = entity.replyCount, + isSenderVerified = cachedSenderUser?.verificationStatus?.isVerified ?: false, + isSenderPremium = cachedSenderUser?.isPremium ?: false, + senderStatusEmojiId = senderStatusEmojiId + ) + } + + private fun resolveSenderNameFromCache(senderId: Long, fallback: String): String { + val user = cache.getUser(senderId) + if (user != null) { + return SenderNameResolver.fromParts( + firstName = user.firstName, + lastName = user.lastName, + fallback = fallback.ifBlank { "User" } + ) + } + + val chat = cache.getChat(senderId) + if (chat != null) { + return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" } + } + + return fallback.ifBlank { "User" } + } + + private fun buildReplyPreview(msg: TdApi.Message): CachedReplyPreview? { + val reply = msg.replyTo as? TdApi.MessageReplyToMessage ?: return null + val replied = cache.getMessage(msg.chatId, reply.messageId) ?: return null + val replySenderName = when (val sender = replied.senderId) { + is TdApi.MessageSenderUser -> { + val user = cache.getUser(sender.userId) + SenderNameResolver.fromParts( + firstName = user?.firstName, + lastName = user?.lastName, + fallback = "User" + ) + } + + is TdApi.MessageSenderChat -> cache.getChat(sender.chatId)?.title.orEmpty() + else -> "" + } + val extracted = extractCachedContent(replied.content) + return CachedReplyPreview( + senderName = replySenderName, + contentType = extracted.type, + text = extracted.text.take(100) + ) + } + + private fun encodeReplyPreview(preview: CachedReplyPreview): String { + return "${preview.senderName}|${preview.contentType}|${preview.text}" + } + + private fun parseReplyPreview(raw: String?): CachedReplyPreview? { + if (raw.isNullOrBlank()) return null + val firstSeparator = raw.indexOf('|') + val secondSeparator = raw.indexOf('|', firstSeparator + 1) + if (firstSeparator < 0 || secondSeparator <= firstSeparator) return null + + val senderName = raw.substring(0, firstSeparator) + val contentType = raw.substring(firstSeparator + 1, secondSeparator) + val text = raw.substring(secondSeparator + 1) + if (contentType.isBlank()) return null + + return CachedReplyPreview(senderName = senderName, contentType = contentType, text = text) + } + + private fun resolveReplyPreview(entity: MessageDbEntity): CachedReplyPreview? { + val encodedPreview = parseReplyPreview(entity.replyToPreview) + val senderName = entity.replyToPreviewSenderName ?: encodedPreview?.senderName + val contentType = entity.replyToPreviewType ?: encodedPreview?.contentType + val text = entity.replyToPreviewText ?: encodedPreview?.text ?: "" + + if (senderName.isNullOrBlank() && contentType.isNullOrBlank() && text.isBlank()) { + return null + } + + return CachedReplyPreview( + senderName = senderName.orEmpty(), + contentType = contentType?.ifBlank { "text" } ?: "text", + text = text + ) + } + + private fun createReplyPreviewModel( + entity: MessageDbEntity, + replyToMsgId: Long, + preview: CachedReplyPreview + ): MessageModel { + return MessageModel( + id = replyToMsgId, + date = entity.date, + isOutgoing = false, + senderName = preview.senderName.ifBlank { "Unknown" }, + chatId = entity.chatId, + content = mapReplyPreviewContent(preview), + senderId = 0L, + isRead = true + ) + } + + private fun mapReplyPreviewContent(preview: CachedReplyPreview): MessageContent { + return when (preview.contentType) { + "photo" -> MessageContent.Photo(path = null, width = 0, height = 0, caption = preview.text) + "video" -> MessageContent.Video(path = null, width = 0, height = 0, duration = 0, caption = preview.text) + "voice" -> MessageContent.Voice(path = null, duration = 0) + "video_note" -> MessageContent.VideoNote(path = null, thumbnail = null, duration = 0, length = 0) + "sticker" -> MessageContent.Sticker( + id = 0L, + setId = 0L, + path = null, + width = 0, + height = 0, + emoji = preview.text + ) + + "document" -> MessageContent.Document( + path = null, + fileName = "", + mimeType = "", + size = 0L, + caption = preview.text + ) + + "audio" -> MessageContent.Audio( + path = null, + duration = 0, + title = "", + performer = "", + fileName = "", + mimeType = "", + size = 0L, + caption = preview.text + ) + + "gif" -> MessageContent.Gif(path = null, width = 0, height = 0, caption = preview.text) + "poll" -> MessageContent.Poll( + id = 0L, + question = preview.text, + options = emptyList(), + totalVoterCount = 0, + isClosed = false, + isAnonymous = true, + type = PollType.Regular(false), + openPeriod = 0, + closeDate = 0 + ) + + "contact" -> MessageContent.Contact( + phoneNumber = "", + firstName = preview.text, + lastName = "", + vcard = "", + userId = 0L + ) + + "location" -> MessageContent.Location(latitude = 0.0, longitude = 0.0) + "service" -> MessageContent.Service(preview.text) + else -> MessageContent.Text(preview.text) + } + } + + private fun extractForwardOrigin(origin: TdApi.MessageOrigin): CachedForwardOrigin { + return when (origin) { + is TdApi.MessageOriginUser -> { + val user = cache.getUser(origin.senderUserId) + val name = SenderNameResolver.fromParts( + firstName = user?.firstName, + lastName = user?.lastName, + fallback = "User" + ) + CachedForwardOrigin(fromName = name, fromId = origin.senderUserId) + } + + is TdApi.MessageOriginChat -> CachedForwardOrigin( + fromName = cache.getChat(origin.senderChatId)?.title ?: "Chat", + fromId = origin.senderChatId + ) + + is TdApi.MessageOriginChannel -> CachedForwardOrigin( + fromName = cache.getChat(origin.chatId)?.title ?: "Channel", + fromId = origin.chatId, + originChatId = origin.chatId, + originMessageId = origin.messageId + ) + + is TdApi.MessageOriginHiddenUser -> CachedForwardOrigin( + fromName = origin.senderName.ifBlank { "Hidden user" }, + fromId = 0L + ) + + else -> CachedForwardOrigin(fromName = "Unknown", fromId = 0L) + } + } + + private fun encodeEntities(content: TdApi.MessageContent): String? { + val formatted = when (content) { + is TdApi.MessageText -> content.text + is TdApi.MessagePhoto -> content.caption + is TdApi.MessageVideo -> content.caption + is TdApi.MessageDocument -> content.caption + is TdApi.MessageAudio -> content.caption + is TdApi.MessageAnimation -> content.caption + is TdApi.MessageVoiceNote -> content.caption + else -> null + } ?: return null + + if (formatted.entities.isNullOrEmpty()) return null + + return buildString { + formatted.entities.forEachIndexed { index, entity -> + if (index > 0) append('|') + append(entity.offset).append(',').append(entity.length).append(',') + when (val type = entity.type) { + is TdApi.TextEntityTypeBold -> append("b") + is TdApi.TextEntityTypeItalic -> append("i") + is TdApi.TextEntityTypeUnderline -> append("u") + is TdApi.TextEntityTypeStrikethrough -> append("s") + is TdApi.TextEntityTypeSpoiler -> append("sp") + is TdApi.TextEntityTypeCode -> append("c") + is TdApi.TextEntityTypePre -> append("p") + is TdApi.TextEntityTypeUrl -> append("url") + is TdApi.TextEntityTypeTextUrl -> append("turl,").append(type.url) + is TdApi.TextEntityTypeMention -> append("m") + is TdApi.TextEntityTypeMentionName -> append("mn,").append(type.userId) + is TdApi.TextEntityTypeHashtag -> append("h") + is TdApi.TextEntityTypeBotCommand -> append("bc") + is TdApi.TextEntityTypeCustomEmoji -> append("ce,").append(type.customEmojiId) + is TdApi.TextEntityTypeEmailAddress -> append("em") + is TdApi.TextEntityTypePhoneNumber -> append("ph") + else -> append("?") + } + } + } + } + + private fun encodeMeta(vararg parts: Any?): String { + return parts.joinToString(META_SEPARATOR.toString()) { it?.toString().orEmpty() } + } + + private fun decodeMeta(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return if (raw.contains(META_SEPARATOR)) raw.split(META_SEPARATOR) else raw.split('|') + } + + private fun resolveLegacyMediaFromMeta(contentType: String, meta: List): Pair { + return when (contentType) { + "photo" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) + "video" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) + "voice" -> (meta.getOrNull(1)?.toIntOrNull() ?: 0) to meta.getOrNull(2) + "video_note" -> (meta.getOrNull(2)?.toIntOrNull() ?: 0) to meta.getOrNull(3) + "sticker" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(6) + "document" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) + "audio" -> (meta.getOrNull(4)?.toIntOrNull() ?: 0) to meta.getOrNull(5) + "gif" -> (meta.getOrNull(3)?.toIntOrNull() ?: 0) to meta.getOrNull(4) + else -> 0 to null + } + } + + private fun resolveMessageDate(msg: TdApi.Message): Int { + return when (val schedulingState = msg.schedulingState) { + is TdApi.MessageSchedulingStateSendAtDate -> schedulingState.sendDate + else -> msg.date + } + } + + private companion object { + private const val META_SEPARATOR = '\u001F' + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt new file mode 100644 index 00000000..e25f046d --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageSenderResolver.kt @@ -0,0 +1,279 @@ +package org.monogram.data.mapper.message + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withTimeout +import org.drinkless.tdlib.TdApi +import org.monogram.data.chats.ChatCache +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.mapper.SenderNameResolver +import org.monogram.data.mapper.TdFileHelper +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.UserRepository +import java.util.concurrent.ConcurrentHashMap + +internal data class ResolvedSender( + val senderId: Long, + val senderName: String, + val senderAvatar: String? = null, + val senderPersonalAvatar: String? = null, + val senderCustomTitle: String? = null, + val isSenderVerified: Boolean = false, + val isSenderPremium: Boolean = false, + val senderStatusEmojiId: Long = 0L, + val senderStatusEmojiPath: String? = null +) + +internal class MessageSenderResolver( + private val gateway: TelegramGateway, + private val userRepository: UserRepository, + private val chatInfoRepository: ChatInfoRepository, + private val cache: ChatCache, + private val fileHelper: TdFileHelper +) { + private data class SenderUserSnapshot( + val name: String, + val avatar: String?, + val personalAvatar: String?, + val isVerified: Boolean, + val isPremium: Boolean, + val statusEmojiId: Long, + val statusEmojiPath: String? + ) + + private data class SenderChatSnapshot( + val name: String, + val avatar: String? + ) + + private val senderUserSnapshotCache = ConcurrentHashMap() + private val senderChatSnapshotCache = ConcurrentHashMap() + private val senderRankCache = ConcurrentHashMap() + private val queuedAvatarDownloads = ConcurrentHashMap.newKeySet() + + val senderUpdateFlow: Flow + get() = userRepository.anyUserUpdateFlow + + fun invalidateCache(userId: Long) { + if (userId <= 0L) return + senderUserSnapshotCache.remove(userId) + senderChatSnapshotCache.remove(userId) + senderRankCache.entries.removeIf { it.key.endsWith(":$userId") } + } + + fun resolveNameFromCache(senderId: Long, fallback: String): String { + val user = cache.getUser(senderId) + if (user != null) { + return SenderNameResolver.fromParts( + firstName = user.firstName, + lastName = user.lastName, + fallback = fallback.ifBlank { "User" } + ) + } + + val chat = cache.getChat(senderId) + if (chat != null) { + return chat.title.takeIf { it.isNotBlank() } ?: fallback.ifBlank { "User" } + } + + return fallback.ifBlank { "User" } + } + + fun resolveFallbackSender(msg: TdApi.Message): ResolvedSender { + return when (val sender = msg.senderId) { + is TdApi.MessageSenderUser -> { + val senderId = sender.userId + val snapshot = senderUserSnapshotCache[senderId] + if (snapshot != null) { + ResolvedSender( + senderId = senderId, + senderName = snapshot.name.ifBlank { "User" }, + senderAvatar = snapshot.avatar ?: snapshot.personalAvatar, + senderPersonalAvatar = snapshot.personalAvatar, + isSenderVerified = snapshot.isVerified, + isSenderPremium = snapshot.isPremium, + senderStatusEmojiId = snapshot.statusEmojiId, + senderStatusEmojiPath = snapshot.statusEmojiPath + ) + } else { + val user = cache.getUser(senderId) + val fallbackName = if (user != null) { + SenderNameResolver.fromParts(user.firstName, user.lastName, "User") + } else { + "User" + } + val avatar = user?.profilePhoto?.small?.local?.path?.takeIf { fileHelper.isValidPath(it) } + ?: user?.profilePhoto?.big?.local?.path?.takeIf { fileHelper.isValidPath(it) } + ResolvedSender(senderId = senderId, senderName = fallbackName, senderAvatar = avatar) + } + } + + is TdApi.MessageSenderChat -> { + val senderId = sender.chatId + val snapshot = senderChatSnapshotCache[senderId] + if (snapshot != null) { + ResolvedSender( + senderId = senderId, + senderName = snapshot.name.ifBlank { "User" }, + senderAvatar = snapshot.avatar + ) + } else { + val chat = cache.getChat(senderId) + val fallbackName = chat?.title?.takeIf { it.isNotBlank() } ?: "User" + val avatar = chat?.photo?.small?.local?.path?.takeIf { fileHelper.isValidPath(it) } + ResolvedSender(senderId = senderId, senderName = fallbackName, senderAvatar = avatar) + } + } + + else -> ResolvedSender(senderId = 0L, senderName = "User") + } + } + + suspend fun resolveSender(msg: TdApi.Message): ResolvedSender { + var senderName = "User" + var senderAvatar: String? = null + var senderPersonalAvatar: String? = null + var senderCustomTitle: String? = null + var isSenderVerified = false + var isSenderPremium = false + var senderStatusEmojiId = 0L + var senderStatusEmojiPath: String? = null + val senderId: Long + + when (val sender = msg.senderId) { + is TdApi.MessageSenderUser -> { + senderId = sender.userId + val cachedSnapshot = senderUserSnapshotCache[senderId] + if (cachedSnapshot != null) { + senderName = cachedSnapshot.name + senderAvatar = cachedSnapshot.avatar + senderPersonalAvatar = cachedSnapshot.personalAvatar + isSenderVerified = cachedSnapshot.isVerified + isSenderPremium = cachedSnapshot.isPremium + senderStatusEmojiId = cachedSnapshot.statusEmojiId + senderStatusEmojiPath = cachedSnapshot.statusEmojiPath + } else { + val user = try { + withTimeout(500) { userRepository.getUser(senderId) } + } catch (_: Exception) { + null + } + + if (user != null) { + senderName = SenderNameResolver.fromParts( + firstName = user.firstName, + lastName = user.lastName, + fallback = "User" + ) + + senderAvatar = user.avatarPath.takeIf { fileHelper.isValidPath(it) } + senderPersonalAvatar = user.personalAvatarPath.takeIf { fileHelper.isValidPath(it) } + isSenderVerified = user.isVerified + isSenderPremium = user.isPremium + senderStatusEmojiId = user.statusEmojiId + senderStatusEmojiPath = user.statusEmojiPath + + senderUserSnapshotCache[senderId] = SenderUserSnapshot( + name = senderName, + avatar = senderAvatar, + personalAvatar = senderPersonalAvatar, + isVerified = isSenderVerified, + isPremium = isSenderPremium, + statusEmojiId = senderStatusEmojiId, + statusEmojiPath = senderStatusEmojiPath + ) + } + } + + val chat = cache.getChat(msg.chatId) + val canGetMember = when (chat?.type) { + is TdApi.ChatTypePrivate, is TdApi.ChatTypeSecret -> true + is TdApi.ChatTypeBasicGroup -> true + is TdApi.ChatTypeSupergroup -> { + val supergroup = chat.type as TdApi.ChatTypeSupergroup + val cachedSupergroup = cache.getSupergroup(supergroup.supergroupId) + !(cachedSupergroup?.isChannel ?: false) || (chat.permissions?.canSendBasicMessages ?: false) + } + + else -> false + } + + if (canGetMember) { + val rankKey = "${msg.chatId}:$senderId" + val cachedRank = senderRankCache[rankKey] + if (cachedRank != null) { + senderCustomTitle = cachedRank.takeUnless { it == NO_RANK_SENTINEL } + } else { + val member = try { + withTimeout(500) { chatInfoRepository.getChatMember(msg.chatId, senderId) } + } catch (_: Exception) { + null + } + + senderCustomTitle = member?.rank + senderRankCache[rankKey] = senderCustomTitle ?: NO_RANK_SENTINEL + } + } + } + + is TdApi.MessageSenderChat -> { + senderId = sender.chatId + val cachedSnapshot = senderChatSnapshotCache[senderId] + if (cachedSnapshot != null) { + senderName = cachedSnapshot.name + senderAvatar = cachedSnapshot.avatar + } else { + val chat = try { + withTimeout(500) { + cache.getChat(senderId) + ?: gateway.execute(TdApi.GetChat(senderId)).also { cache.putChat(it) } + } + } catch (_: Exception) { + null + } + + if (chat != null) { + senderName = chat.title + val photo = chat.photo?.small + if (photo != null) { + senderAvatar = photo.local.path.takeIf { fileHelper.isValidPath(it) } + if (senderAvatar.isNullOrEmpty() && queuedAvatarDownloads.add(photo.id)) { + fileHelper.enqueueDownload( + photo.id, + 16, + TdMessageRemoteDataSource.DownloadType.DEFAULT, + 0, + 0, + false + ) + } + } + + senderChatSnapshotCache[senderId] = SenderChatSnapshot( + name = senderName, + avatar = senderAvatar + ) + } + } + } + + else -> senderId = 0L + } + + return ResolvedSender( + senderId = senderId, + senderName = senderName, + senderAvatar = senderAvatar, + senderPersonalAvatar = senderPersonalAvatar, + senderCustomTitle = senderCustomTitle, + isSenderVerified = isSenderVerified, + isSenderPremium = isSenderPremium, + senderStatusEmojiId = senderStatusEmojiId, + senderStatusEmojiPath = senderStatusEmojiPath + ) + } + + private companion object { + private const val NO_RANK_SENTINEL = "__NO_RANK__" + } +} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt index 652e2d39..0e84c952 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt @@ -5,8 +5,7 @@ import org.monogram.data.db.model.ChatEntity import org.monogram.data.db.model.ChatFullInfoEntity import org.monogram.data.db.model.UserEntity import org.monogram.data.db.model.UserFullInfoEntity -import org.monogram.data.mapper.isForcedVerifiedUser -import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.* import org.monogram.domain.models.* import org.monogram.domain.repository.ChatMemberStatus import org.monogram.domain.repository.ChatMembersFilter @@ -21,8 +20,8 @@ fun TdApi.User.toDomain( val personalAvatarPath = fullInfo?.personalPhoto?.let { personalPhoto -> val bestPhotoSize = personalPhoto.sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: personalPhoto.sizes.lastOrNull() - personalPhoto.animation?.file?.local?.path?.ifEmpty { null } - ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } + personalPhoto.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } } val lastSeen = (status as? TdApi.UserStatusOffline) @@ -150,7 +149,7 @@ fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted( isMember = isMember, restrictedUntilDate = restrictedUntilDate, - permissions = permissions.toDomain() + permissions = permissions.toDomainChatPermissions() ) is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate) is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left @@ -159,18 +158,16 @@ fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { } fun TdApi.Chat.toDomain(): ChatModel { - val isChannel = type is TdApi.ChatTypeSupergroup && - (type as TdApi.ChatTypeSupergroup).isChannel + val isChannel = type.isChannelType() return ChatModel( id = id, title = title, - avatarPath = photo?.small?.local?.path?.ifEmpty { null }, + avatarPath = photo?.small?.local?.path?.takeIf { isValidFilePath(it) }, unreadCount = unreadCount, isMuted = notificationSettings.muteFor > 0, isChannel = isChannel, - isGroup = type is TdApi.ChatTypeBasicGroup || - (type is TdApi.ChatTypeSupergroup && !isChannel), - type = type.toDomain(), + isGroup = type.isGroupType(), + type = type.toDomainChatType(), lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "" ) } @@ -214,7 +211,7 @@ fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted( isMember, restrictedUntilDate, - permissions.toApi() + permissions.toTdApiChatPermissions() ) is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft() is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate) @@ -222,17 +219,9 @@ fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { } } -private fun TdApi.ChatType.toDomain(): ChatType = when (this) { - is TdApi.ChatTypePrivate -> ChatType.PRIVATE - is TdApi.ChatTypeBasicGroup -> ChatType.BASIC_GROUP - is TdApi.ChatTypeSupergroup -> ChatType.SUPERGROUP - is TdApi.ChatTypeSecret -> ChatType.SECRET - else -> ChatType.PRIVATE -} - private fun TdApi.User.resolveAvatarPath(): String? { - val big = profilePhoto?.big?.local?.path?.ifEmpty { null } - val small = profilePhoto?.small?.local?.path?.ifEmpty { null } + val big = profilePhoto?.big?.local?.path?.takeIf { isValidFilePath(it) } + val small = profilePhoto?.small?.local?.path?.takeIf { isValidFilePath(it) } return big ?: small } @@ -289,60 +278,25 @@ private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel { ) }, startPage = startPage?.let { - BusinessStartPageModel(it.title, it.message, it.sticker?.sticker?.local?.path) + BusinessStartPageModel( + title = it.title, + message = it.message, + stickerPath = it.sticker?.sticker?.local?.path?.takeIf { path -> isValidFilePath(path) } + ) }, nextOpenIn = nextOpenIn, nextCloseIn = nextCloseIn ) } -private fun TdApi.ChatPermissions.toDomain(): ChatPermissionsModel { - return ChatPermissionsModel( - canSendBasicMessages = canSendBasicMessages, - canSendAudios = canSendAudios, - canSendDocuments = canSendDocuments, - canSendPhotos = canSendPhotos, - canSendVideos = canSendVideos, - canSendVideoNotes = canSendVideoNotes, - canSendVoiceNotes = canSendVoiceNotes, - canSendPolls = canSendPolls, - canSendOtherMessages = canSendOtherMessages, - canAddLinkPreviews = canAddLinkPreviews, - canChangeInfo = canChangeInfo, - canInviteUsers = canInviteUsers, - canPinMessages = canPinMessages, - canCreateTopics = canCreateTopics - ) -} - -private fun ChatPermissionsModel.toApi(): TdApi.ChatPermissions { - return TdApi.ChatPermissions( - canSendBasicMessages, - canSendAudios, - canSendDocuments, - canSendPhotos, - canSendVideos, - canSendVideoNotes, - canSendVoiceNotes, - canSendPolls, - canSendOtherMessages, - canAddLinkPreviews, - canEditTag, - canChangeInfo, - canInviteUsers, - canPinMessages, - canCreateTopics - ) -} - fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity { val businessLocation = businessInfo?.location val businessOpeningHours = businessInfo?.openingHours val businessStartPage = businessInfo?.startPage val birth = birthdate - val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.ifEmpty { null } + val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } - ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.ifEmpty { null } + ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } return UserFullInfoEntity( userId = userId, 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 a4963fba..a3b263e1 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -20,6 +20,7 @@ import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.mapper.MessageMapper +import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.map import org.monogram.data.mapper.toDomain import org.monogram.domain.models.* @@ -37,6 +38,7 @@ class MessageRepositoryImpl( private val messageMapper: MessageMapper, private val messageRemoteDataSource: MessageRemoteDataSource, private val cache: ChatCache, + private val fileHelper: TdFileHelper, private val fileDataSource: FileDataSource, private val dispatcherProvider: DispatcherProvider, private val scope: CoroutineScope, @@ -789,7 +791,7 @@ class MessageRepositoryImpl( override suspend fun getFilePath(fileId: Int): String? { val result = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() return if (result is TdApi.File) { - result.local.path.ifEmpty { null } + result.local.path.takeIf { fileHelper.isValidPath(it) } } else { null } @@ -917,7 +919,7 @@ class MessageRepositoryImpl( if (thumbnail == null) return null val file = thumbnail.file val updated = cache.fileCache[file.id] ?: file - if (updated.local.path.isNotEmpty()) return updated.local.path + if (fileHelper.isValidPath(updated.local.path)) return updated.local.path scope.launch { fileDataSource.downloadFile(updated.id, 32, 0, 0, false) } @@ -1392,7 +1394,7 @@ class MessageRepositoryImpl( cachedUser.lastName?.takeIf { it.isNotBlank() } ).joinToString(" ").ifBlank { model.senderName } - val resolvedAvatar = resolveFilePath(cachedUser.profilePhoto?.small) + val resolvedAvatar = fileHelper.resolveLocalFilePath(cachedUser.profilePhoto?.small) if (resolvedAvatar == null) { cachedUser.profilePhoto?.small?.id?.takeIf { it != 0 }?.let { avatarFileId -> messageRemoteDataSource.enqueueDownload(avatarFileId, priority = 16) @@ -1416,7 +1418,7 @@ class MessageRepositoryImpl( val cachedChat = cache.getChat(senderId) if (cachedChat != null) { val resolvedName = cachedChat.title.takeIf { it.isNotBlank() } ?: model.senderName - val resolvedAvatar = resolveFilePath(cachedChat.photo?.small) + val resolvedAvatar = fileHelper.resolveLocalFilePath(cachedChat.photo?.small) return model.copy( senderName = resolvedName, senderAvatar = resolvedAvatar ?: model.senderAvatar @@ -1426,15 +1428,6 @@ class MessageRepositoryImpl( return model } - private fun resolveFilePath(file: TdApi.File?): String? { - if (file == null) return null - val directPath = file.local.path.takeIf { it.isNotBlank() && File(it).exists() } - if (directPath != null) return directPath - - val cachedPath = cache.fileCache[file.id]?.local?.path - return cachedPath?.takeIf { it.isNotBlank() && File(it).exists() } - } - private fun TextCompositionStyleModel.toEntity(): TextCompositionStyleEntity { return TextCompositionStyleEntity( name = name, diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt index 4a910a0a..6e579c42 100644 --- a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt @@ -13,6 +13,7 @@ import org.monogram.data.datasource.remote.UserRemoteDataSource import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.mapper.isValidFilePath import org.monogram.data.mapper.toEntity import org.monogram.data.mapper.user.toTdApiChat import org.monogram.domain.repository.ProfilePhotoRepository @@ -134,7 +135,7 @@ class ProfilePhotoRepositoryImpl( photoInfo?.small ?: photoInfo?.big } ?: return null - val directPath = preferredFile.local.path.ifEmpty { null } + val directPath = preferredFile.local.path.takeIf { isValidFilePath(it) } if (directPath != null) { if (!ensureFullRes && bigId != null && bigId != preferredFile.id) { fileQueue.enqueue( @@ -206,7 +207,7 @@ class ProfilePhotoRepositoryImpl( ensureFullRes: Boolean ): String? { val animationFile = photo.animation?.file - val animationPath = animationFile?.local?.path?.ifEmpty { null } + val animationPath = animationFile?.local?.path?.takeIf { isValidFilePath(it) } if (animationPath != null) return animationPath val bestPhotoFile = photo.sizes @@ -215,7 +216,7 @@ class ProfilePhotoRepositoryImpl( ?: photo.sizes.lastOrNull()?.photo ?: return null - val directPath = bestPhotoFile.local.path.ifEmpty { null } + val directPath = bestPhotoFile.local.path.takeIf { isValidFilePath(it) } if (directPath != null) return directPath if (!ensureFullRes) { @@ -226,7 +227,7 @@ class ProfilePhotoRepositoryImpl( ?: photo.sizes.find { it.type == "a" }?.photo ?: photo.sizes.firstOrNull()?.photo - val fallbackDirectPath = fallbackFile?.local?.path?.ifEmpty { null } + val fallbackDirectPath = fallbackFile?.local?.path?.takeIf { isValidFilePath(it) } if (fallbackDirectPath != null) return fallbackDirectPath val fallbackDownloadedPath = resolveDownloadedFilePath(fallbackFile?.id) @@ -252,7 +253,11 @@ class ProfilePhotoRepositoryImpl( private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { if (fileId == null || fileId == 0) return null val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null - return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null + return if (file.local.isDownloadingCompleted) { + file.local.path.takeIf { isValidFilePath(it) } + } else { + null + } } companion object { diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt index 4e67e638..853c34b6 100644 --- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -6,12 +6,16 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.WallpaperDao import org.monogram.data.db.model.WallpaperEntity import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.mapBackgrounds +import org.monogram.data.mapper.toBackgroundType +import org.monogram.data.mapper.toDomain +import org.monogram.data.mapper.toInputBackground import org.monogram.domain.models.WallpaperModel import org.monogram.domain.repository.WallpaperRepository @@ -75,6 +79,38 @@ class WallpaperRepositoryImpl( remote.downloadFile(fileId, 1) } + override suspend fun setDefaultWallpaper( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? { + val background = wallpaper.toInputBackground() + val type = wallpaper.toBackgroundType(isBlurred = isBlurred, isMoving = isMoving) + val result = remote.setDefaultBackground( + background = background, + type = type, + forDarkTheme = false + ) ?: return null + + wallpaperUpdates.emit(Unit) + return result.toDomain() + } + + override suspend fun uploadWallpaper( + filePath: String, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? { + val result = remote.setDefaultBackground( + background = TdApi.InputBackgroundLocal(TdApi.InputFileLocal(filePath)), + type = TdApi.BackgroundTypeWallpaper(isBlurred, isMoving), + forDarkTheme = false + ) ?: return null + + wallpaperUpdates.emit(Unit) + return result.toDomain() + } + private suspend fun saveWallpapersToDb(wallpapers: List) { withContext(dispatchers.io) { wallpaperDao.clearAll() diff --git a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt index a1ef60f3..1d8808f5 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserMediaResolver.kt @@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.mapper.isValidFilePath import java.util.concurrent.ConcurrentHashMap internal class UserMediaResolver( @@ -25,7 +26,7 @@ internal class UserMediaResolver( val result = gateway.execute(TdApi.GetCustomEmojiStickers(longArrayOf(emojiId))) if (result is TdApi.Stickers && result.stickers.isNotEmpty()) { val file = result.stickers.first().sticker - if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) { + if (file.local.isDownloadingCompleted && isValidFilePath(file.local.path)) { emojiPathCache[emojiId] = file.local.path file.local.path } else { @@ -37,7 +38,7 @@ internal class UserMediaResolver( (gateway.execute(TdApi.GetFile(file.id)) as? TdApi.File) ?.local ?.path - ?.takeIf { it.isNotEmpty() } + ?.takeIf { isValidFilePath(it) } }.getOrNull() if (refreshedPath != null) { emojiPathCache[emojiId] = refreshedPath @@ -55,10 +56,10 @@ internal class UserMediaResolver( suspend fun resolveAvatarPath(user: TdApi.User): String? { val bigPhoto = user.profilePhoto?.big val smallPhoto = user.profilePhoto?.small - val bigDirectPath = bigPhoto?.local?.path?.ifEmpty { null } + val bigDirectPath = bigPhoto?.local?.path?.takeIf { isValidFilePath(it) } if (bigDirectPath != null) return bigDirectPath - val smallDirectPath = smallPhoto?.local?.path?.ifEmpty { null } + val smallDirectPath = smallPhoto?.local?.path?.takeIf { isValidFilePath(it) } if (smallDirectPath != null) { val bigId = bigPhoto?.id?.takeIf { it != 0 } if (bigId != null && bigId != smallPhoto.id) { @@ -121,7 +122,11 @@ internal class UserMediaResolver( private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { if (fileId == null || fileId == 0) return null val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null - return if (file.local.isDownloadingCompleted) file.local.path.ifEmpty { null } else null + return if (file.local.isDownloadingCompleted) { + file.local.path.takeIf { isValidFilePath(it) } + } else { + null + } } companion object { diff --git a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt index e0d003b6..0a32176b 100644 --- a/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt +++ b/data/src/main/java/org/monogram/data/stickers/StickerFileManager.kt @@ -11,6 +11,7 @@ import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.StickerLocalDataSource import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.mapper.isValidFilePath import org.monogram.domain.models.StickerModel import org.monogram.domain.models.StickerSetModel import java.io.File @@ -39,7 +40,7 @@ class StickerFileManager( val firstPath = withTimeoutOrNull(DOWNLOAD_TIMEOUT_MS) { fileUpdateHandler.fileDownloadCompleted .filter { it.first == fileId } - .mapNotNull { (_, path) -> path.takeIf(::isPathValid) } + .mapNotNull { (_, path) -> path.takeIf(::isValidFilePath) } .first() } @@ -125,7 +126,7 @@ class StickerFileManager( private suspend fun resolveAvailablePath(fileId: Long): String? { filePathsCache[fileId]?.let { path -> - if (isPathValid(path)) { + if (isValidFilePath(path)) { return path } filePathsCache.remove(fileId) @@ -134,7 +135,7 @@ class StickerFileManager( val dbPath = localDataSource.getPath(fileId) if (!dbPath.isNullOrEmpty()) { - if (isPathValid(dbPath)) { + if (isValidFilePath(dbPath)) { filePathsCache[fileId] = dbPath return dbPath } @@ -143,7 +144,7 @@ class StickerFileManager( val completedPath = fileUpdateHandler.fileDownloadCompleted .replayCache - .firstOrNull { it.first == fileId && isPathValid(it.second) } + .firstOrNull { it.first == fileId && isValidFilePath(it.second) } ?.second if (!completedPath.isNullOrEmpty()) { @@ -159,10 +160,6 @@ class StickerFileManager( fileQueue.enqueue(fileId.toInt(), priority, FileDownloadQueue.DownloadType.STICKER) } - private fun isPathValid(path: String): Boolean { - return path.isNotEmpty() && File(path).exists() - } - companion object { private const val TAG = "StickerFileManager" private const val DOWNLOAD_TIMEOUT_MS = 90_000L diff --git a/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt b/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt index d7f3c8ec..0b79f7f3 100644 --- a/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/WallpaperModel.kt @@ -7,15 +7,25 @@ data class WallpaperModel( val id: Long, val slug: String, val title: String, + val type: WallpaperType = WallpaperType.WALLPAPER, val pattern: Boolean, val documentId: Long, val thumbnail: ThumbnailModel?, val settings: WallpaperSettings?, + val themeName: String? = null, val isDownloaded: Boolean, val localPath: String?, val isDefault: Boolean = false ) +@Serializable +enum class WallpaperType { + WALLPAPER, + PATTERN, + FILL, + CHAT_THEME +} + @Serializable data class ThumbnailModel( val fileId: Int, @@ -32,5 +42,7 @@ data class WallpaperSettings( val fourthBackgroundColor: Int?, val intensity: Int?, val rotation: Int?, - val isInverted: Boolean? = null + val isInverted: Boolean? = null, + val isMoving: Boolean? = null, + val isBlurred: Boolean? = null ) diff --git a/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt index 415db6f8..9900cf19 100644 --- a/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/WallpaperRepository.kt @@ -6,4 +6,15 @@ import org.monogram.domain.models.WallpaperModel interface WallpaperRepository { fun getWallpapers(): Flow> suspend fun downloadWallpaper(fileId: Int) -} \ No newline at end of file + suspend fun setDefaultWallpaper( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? + + suspend fun uploadWallpaper( + filePath: String, + isBlurred: Boolean, + isMoving: Boolean + ): WallpaperModel? +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt index f8de1cb7..d576b029 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentBackground.kt @@ -32,27 +32,28 @@ fun ChatContentBackground( isGrayscale = state.isWallpaperGrayscale ) } else if (state.wallpaper != null) { - if (File(state.wallpaper!!).exists()) { - var imageModifier = Modifier.fillMaxSize() + val file = File(state.wallpaper) + if (file.exists()) { + var imageModifier = modifier.fillMaxSize() if (state.isWallpaperBlurred && state.wallpaperBlurIntensity > 0) { imageModifier = imageModifier.blur((state.wallpaperBlurIntensity / 4f).dp) } AsyncImage( - model = File(state.wallpaper!!), + model = file, contentDescription = null, modifier = imageModifier, contentScale = ContentScale.Crop ) } else { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) } } else { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) ) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt index a1c5d595..28d3d218 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt @@ -32,6 +32,7 @@ interface ChatSettingsComponent { fun onStickerSizeChanged(size: Float) fun onWallpaperChanged(wallpaper: String?) fun onWallpaperSelected(wallpaper: WallpaperModel) + fun onWallpaperUpload(path: String) fun onWallpaperBlurChanged(wallpaper: WallpaperModel, isBlurred: Boolean) fun onWallpaperBlurIntensityChanged(intensity: Int) fun onWallpaperMotionChanged(wallpaper: WallpaperModel, isMoving: Boolean) @@ -98,6 +99,7 @@ interface ChatSettingsComponent { val isWallpaperMoving: Boolean = false, val wallpaperDimming: Int = 0, val isWallpaperGrayscale: Boolean = false, + val isWallpaperUploading: Boolean = false, val availableWallpapers: List = emptyList(), val selectedWallpaper: WallpaperModel? = null, val isPlayerGesturesEnabled: Boolean = true, @@ -612,6 +614,29 @@ class DefaultChatSettingsComponent( .launchIn(scope) } + private fun wallpaperPreferenceKey(wallpaper: WallpaperModel): String? = when { + wallpaper.slug.isNotEmpty() -> wallpaper.slug + !wallpaper.localPath.isNullOrEmpty() -> wallpaper.localPath + else -> null + } + + private fun syncWallpaperOnServer( + wallpaper: WallpaperModel, + isBlurred: Boolean, + isMoving: Boolean + ) { + scope.launch { + wallpaperRepository.setDefaultWallpaper( + wallpaper = wallpaper, + isBlurred = isBlurred, + isMoving = isMoving + )?.let { syncedWallpaper -> + wallpaperPreferenceKey(syncedWallpaper)?.let { appPreferences.setWallpaper(it) } + _state.update { it.copy(selectedWallpaper = syncedWallpaper) } + } + } + } + override fun onBackClicked() { onBack() } @@ -639,24 +664,50 @@ class DefaultChatSettingsComponent( override fun onWallpaperSelected(wallpaper: WallpaperModel) { _state.update { it.copy(selectedWallpaper = wallpaper) } + val currentBlur = _state.value.isWallpaperBlurred + val currentMoving = _state.value.isWallpaperMoving + if (!wallpaper.isDownloaded && wallpaper.documentId != 0L) { scope.launch { wallpaperRepository.downloadWallpaper(wallpaper.documentId.toInt()) } } - val key = when { - wallpaper.slug.isNotEmpty() -> wallpaper.slug - !wallpaper.localPath.isNullOrEmpty() -> wallpaper.localPath - else -> null - } + wallpaperPreferenceKey(wallpaper)?.let { appPreferences.setWallpaper(it) } + syncWallpaperOnServer(wallpaper, currentBlur, currentMoving) + } - key?.let { appPreferences.setWallpaper(it) } + override fun onWallpaperUpload(path: String) { + val currentBlur = _state.value.isWallpaperBlurred + val currentMoving = _state.value.isWallpaperMoving + + appPreferences.setWallpaper(path) + _state.update { it.copy(isWallpaperUploading = true) } + + scope.launch { + val uploaded = wallpaperRepository.uploadWallpaper( + filePath = path, + isBlurred = currentBlur, + isMoving = currentMoving + ) + + if (uploaded != null) { + _state.update { it.copy(selectedWallpaper = uploaded) } + wallpaperPreferenceKey(uploaded)?.let { appPreferences.setWallpaper(it) } + if (!uploaded.isDownloaded && uploaded.documentId != 0L) { + wallpaperRepository.downloadWallpaper(uploaded.documentId.toInt()) + } + } + + _state.update { it.copy(isWallpaperUploading = false) } + } } override fun onWallpaperBlurChanged(wallpaper: WallpaperModel, isBlurred: Boolean) { + val currentMoving = _state.value.isWallpaperMoving _state.update { it.copy(isWallpaperBlurred = isBlurred) } appPreferences.setWallpaperBlurred(isBlurred) + syncWallpaperOnServer(wallpaper, isBlurred, currentMoving) } override fun onWallpaperBlurIntensityChanged(intensity: Int) { @@ -664,8 +715,10 @@ class DefaultChatSettingsComponent( } override fun onWallpaperMotionChanged(wallpaper: WallpaperModel, isMoving: Boolean) { + val currentBlur = _state.value.isWallpaperBlurred _state.update { it.copy(isWallpaperMoving = isMoving) } appPreferences.setWallpaperMoving(isMoving) + syncWallpaperOnServer(wallpaper, currentBlur, isMoving) } override fun onWallpaperDimmingChanged(dimming: Int) { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt index 224761fc..9364f2e7 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt @@ -1,7 +1,12 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.settings.chatSettings +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* @@ -22,6 +27,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -43,11 +49,22 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.get import org.monogram.presentation.settings.chatSettings.components.ChatListPreview import org.monogram.presentation.settings.chatSettings.components.ChatSettingsPreview import org.monogram.presentation.settings.chatSettings.components.WallpaperItem +import java.io.File +import java.io.FileOutputStream @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatSettingsContent(component: ChatSettingsComponent) { val state by component.state.subscribeAsState() + val context = LocalContext.current + + val wallpaperPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) { + copyUriToTempWallpaperPath(context, uri)?.let(component::onWallpaperUpload) + } + } val blueColor = Color(0xFF4285F4) val greenColor = Color(0xFF34A853) @@ -274,6 +291,40 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(16.dp) ) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(vertical = 4.dp) + .clickable(enabled = !state.isWallpaperUploading) { + wallpaperPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) { + Box( + modifier = Modifier + .size(80.dp, 120.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center + ) { + if (state.isWallpaperUploading) { + CircularProgressIndicator( + modifier = Modifier.size(26.dp), + strokeWidth = 2.5.dp + ) + } else { + Icon( + imageVector = Icons.Rounded.Upload, + contentDescription = stringResource(R.string.upload_wallpaper_cd), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + item { val isSelected = state.wallpaper == null @@ -1373,4 +1424,24 @@ private fun TimePickerDialogWrapper( content() } ) -} \ No newline at end of file +} + +private fun copyUriToTempWallpaperPath(context: Context, uri: Uri): String? = try { + if (uri.scheme == "file") return uri.path + + val mime = context.contentResolver.getType(uri).orEmpty() + val extension = when { + mime.contains("png") -> "png" + mime.contains("webp") -> "webp" + else -> "jpg" + } + + val file = File(context.cacheDir, "wallpaper_${System.nanoTime()}.$extension") + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { output -> input.copyTo(output) } + } ?: return null + + file.absolutePath +} catch (_: Exception) { + null +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt index 092c1fe3..794e5a49 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/components/WallpaperBackground.kt @@ -5,7 +5,6 @@ import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager -import android.util.Log import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -14,8 +13,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -23,6 +24,7 @@ import coil3.compose.AsyncImage import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.monogram.domain.models.WallpaperModel +import org.monogram.domain.models.WallpaperType import java.io.File @Composable @@ -137,16 +139,31 @@ fun WallpaperBackground( Box(modifier = modifier) { val settings = wallpaper.settings + val wallpaperType = remember( + wallpaper.type, + wallpaper.pattern, + wallpaper.slug, + wallpaper.documentId + ) { + wallpaper.resolveType() + } + + val colors = remember(settings) { + listOfNotNull( + settings?.backgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.secondBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.thirdBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, + settings?.fourthBackgroundColor?.let { Color(it or 0xFF000000.toInt()) } + ) + } - val hasColors = remember(settings) { - settings?.let { - it.backgroundColor != null || it.secondBackgroundColor != null || - it.thirdBackgroundColor != null || it.fourthBackgroundColor != null - } ?: false + val hasColors = remember(colors) { + colors.isNotEmpty() } - val isFullImage = remember(wallpaper) { - !wallpaper.pattern && !wallpaper.slug.startsWith("emoji") && (wallpaper.documentId != 0L || wallpaper.slug == "built-in") + val isFullImage = remember(wallpaperType, wallpaper.documentId, wallpaper.slug) { + wallpaperType == WallpaperType.WALLPAPER && + (wallpaper.documentId != 0L || wallpaper.slug == "built-in") } val isBackgroundDisabled = remember(isFullImage, hasColors) { @@ -156,49 +173,21 @@ fun WallpaperBackground( val shouldShowBackground = !isBackgroundDisabled || isChatSettings if (shouldShowBackground) { - val colors = remember(settings) { - listOfNotNull( - settings?.backgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.secondBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.thirdBackgroundColor?.let { Color(it or 0xFF000000.toInt()) }, - settings?.fourthBackgroundColor?.let { Color(it or 0xFF000000.toInt()) } - ) - } - val bgMod = Modifier.fillMaxSize() - if (colors.isNotEmpty()) { - if (colors.size == 1) { - Box(modifier = bgMod.background(colors[0])) - } else { - val rotation = settings?.rotation ?: 0 - Box( - modifier = bgMod.background( - Brush.linearGradient( - colors = colors, - start = Offset(0f, 0f), - end = when (rotation) { - 45 -> Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) - 90 -> Offset(Float.POSITIVE_INFINITY, 0f) - 135 -> Offset(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY) - 180 -> Offset(0f, Float.NEGATIVE_INFINITY) - 225 -> Offset(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY) - 270 -> Offset(Float.NEGATIVE_INFINITY, 0f) - 315 -> Offset(Float.NEGATIVE_INFINITY, Float.POSITIVE_INFINITY) - else -> Offset(0f, Float.POSITIVE_INFINITY) - } - ) - ) - ) - } - } else { - Box(modifier = bgMod.background(MaterialTheme.colorScheme.surface)) - } + val baseColor = colors.firstOrNull() ?: MaterialTheme.colorScheme.surface + Box(modifier = bgMod.background(baseColor)) } - val imagePath = if (wallpaper.isDownloaded && !wallpaper.localPath.isNullOrEmpty()) { - wallpaper.localPath + val supportsImageLayer = wallpaperType == WallpaperType.WALLPAPER || wallpaperType == WallpaperType.PATTERN + + val imagePath = if (supportsImageLayer) { + if (wallpaper.isDownloaded && !wallpaper.localPath.isNullOrEmpty()) { + wallpaper.localPath + } else { + wallpaper.thumbnail?.localPath + } } else { - wallpaper.thumbnail?.localPath + null } if (imagePath != null && File(imagePath).exists()) { @@ -227,7 +216,7 @@ fun WallpaperBackground( if (animatedBlur > 0f) it.blur((animatedBlur / 4f).dp) else it } - if (wallpaper.pattern) { + if (wallpaperType == WallpaperType.PATTERN) { val intensity = (settings?.intensity ?: 50) / 100f AsyncImage( model = file, @@ -245,9 +234,6 @@ fun WallpaperBackground( colorFilter = colorFilter ) } - } else if (wallpaper.slug.startsWith("emoji")) { - // TODO: Implement rendering with gradient and emojis - Log.d("WallpaperBackground", "Emoji wallpaper rendering not implemented for slug: ${wallpaper.slug}") } else if (!shouldShowBackground) { Box(modifier = Modifier .fillMaxSize() @@ -264,4 +250,12 @@ fun WallpaperBackground( ) } } -} \ No newline at end of file +} + +private fun WallpaperModel.resolveType(): WallpaperType = when { + type == WallpaperType.PATTERN || pattern -> WallpaperType.PATTERN + type == WallpaperType.CHAT_THEME || slug.startsWith("emoji") -> WallpaperType.CHAT_THEME + type == WallpaperType.FILL -> WallpaperType.FILL + documentId != 0L || slug == "built-in" -> WallpaperType.WALLPAPER + else -> WallpaperType.FILL +} diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 41a3940e..47711205 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -573,6 +573,7 @@ Restablecer Fondo de Pantalla del Chat Restablecer Fondo de Pantalla + Subir fondo de pantalla Estilo de Emoji Tema Modo Nocturno diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index d1c30d94..4ad42ab9 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -544,6 +544,7 @@ Վերակայել Չատի պաստառ Վերակայել պաստառը + Վերբեռնել պաստառ Էմոջիների ոճը Թեմա Գիշերային ռեժիմ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 6cb413bd..437a089c 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -574,6 +574,7 @@ Redefinir Papel de parede do chat Redefinir papel de parede + Carregar papel de parede Estilo de emoji Tema Modo noturno diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 61f22d30..4973ed2d 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -560,6 +560,7 @@ Сбросить Обои чата Сбросить обои + Загрузить обои Стиль эмодзи Тема Ночной режим diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 092a7498..674b1ef8 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -590,6 +590,7 @@ Obnoviť Tapeta chatu Obnoviť tapetu + Nahrať tapetu Štýl emoji Téma Nočný režim diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 612da2f9..6bca0472 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -560,6 +560,7 @@ Скинути Шпалери чату Скинути шпалери + Завантажити шпалери Стиль емодзі Тема Нічний режим diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 61a1e6da..7075eaaf 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -560,6 +560,7 @@ 重置 会话壁纸 重置壁纸 + 上传壁纸 表情风格 主题 夜间模式 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index cf71caef..030d3135 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -580,6 +580,7 @@ Reset Chat Wallpaper Reset Wallpaper + Upload Wallpaper Emoji Style Theme Night Mode From c2fa4908d86c187694107fa3e7ea6f224b55e30a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:35:44 +0300 Subject: [PATCH 03/83] Better mappings + memory logger (#204) --- app/src/main/java/org/monogram/app/App.kt | 33 + .../java/org/monogram/data/chats/ChatCache.kt | 8 + .../monogram/data/chats/ChatModelFactory.kt | 50 ++ .../remote/TdMessageRemoteDataSource.kt | 17 +- .../org/monogram/data/db/MonogramDatabase.kt | 2 +- .../monogram/data/db/MonogramMigrations.kt | 146 +++++ .../org/monogram/data/db/model/ChatEntity.kt | 11 + .../data/db/model/ChatFullInfoEntity.kt | 34 + .../org/monogram/data/db/model/UserEntity.kt | 26 + .../data/db/model/UserFullInfoEntity.kt | 55 ++ .../java/org/monogram/data/di/TdLibClient.kt | 4 +- .../java/org/monogram/data/di/dataModule.kt | 22 +- .../data/infra/DataMemoryPressureHandler.kt | 64 ++ .../monogram/data/infra/FileUpdateHandler.kt | 29 +- .../org/monogram/data/infra/OfflineWarmup.kt | 46 ++ .../monogram/data/infra/SynchronizedLruMap.kt | 40 ++ .../org/monogram/data/mapper/ChatMapper.kt | 44 ++ .../mapper/user/ChatFullInfoEntityMapper.kt | 271 ++++++++ .../data/mapper/user/ChatFullInfoMapper.kt | 292 +++++++++ .../data/mapper/user/ChatMemberMapper.kt | 107 ++++ .../data/mapper/user/EntityEncodingUtils.kt | 264 ++++++++ .../data/mapper/user/UserEntityMapper.kt | 161 ++++- .../mapper/user/UserFullInfoEntityMapper.kt | 338 ++++++++++ .../monogram/data/mapper/user/UserMapper.kt | 585 ++---------------- .../data/repository/BotRepositoryImpl.kt | 84 ++- .../repository/ChatsListRepositoryImpl.kt | 21 +- .../repository/ProfilePhotoRepositoryImpl.kt | 25 +- .../repository/WallpaperRepositoryImpl.kt | 10 +- .../monogram/domain/models/BotCommandModel.kt | 25 +- .../domain/models/ChatFullInfoModel.kt | 32 + .../org/monogram/domain/models/ChatModel.kt | 11 + .../domain/models/ProfileSecurityModels.kt | 110 ++++ .../org/monogram/domain/models/UserModel.kt | 13 +- .../presentation/di/coil/coilModule.kt | 10 +- .../features/profile/ProfileContent.kt | 9 + .../profile/components/ProfileHeader.kt | 53 +- .../components/ProfileHeaderTransformed.kt | 85 ++- .../profile/components/ProfileSections.kt | 105 +--- .../profile/components/ProfileTopBar.kt | 50 +- .../src/main/res/values-es/string.xml | 6 + .../src/main/res/values-hy/string.xml | 6 + .../src/main/res/values-pt-rBR/string.xml | 6 + .../src/main/res/values-ru-rRU/string.xml | 6 + .../src/main/res/values-sk/string.xml | 6 + .../src/main/res/values-uk/string.xml | 6 + .../src/main/res/values-zh-rCN/string.xml | 6 + presentation/src/main/res/values/string.xml | 6 + 47 files changed, 2652 insertions(+), 688 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt create mode 100644 data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt create mode 100644 data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt create mode 100644 domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt diff --git a/app/src/main/java/org/monogram/app/App.kt b/app/src/main/java/org/monogram/app/App.kt index 3ec92b19..9e70a00a 100644 --- a/app/src/main/java/org/monogram/app/App.kt +++ b/app/src/main/java/org/monogram/app/App.kt @@ -12,6 +12,7 @@ import org.koin.core.context.startKoin import org.maplibre.android.MapLibre import org.maplibre.android.WellKnownTileServer import org.monogram.app.di.appModule +import org.monogram.data.infra.DataMemoryPressureHandler import org.monogram.domain.managers.DistrManager import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider @@ -33,6 +34,19 @@ class App : Application(), SingletonImageLoader.Factory { checkPushAvailability() } + @Suppress("DEPRECATION") + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= TRIM_MEMORY_RUNNING_LOW) { + trimInMemoryCaches("onTrimMemory:$level") + } + } + + override fun onLowMemory() { + super.onLowMemory() + trimInMemoryCaches("onLowMemory") + } + private fun initCrashHandler() { val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> @@ -79,7 +93,26 @@ class App : Application(), SingletonImageLoader.Factory { } } + private fun trimInMemoryCaches(reason: String) { + if (!::container.isInitialized) return + runCatching { + get().clearDataCaches(reason) + }.onFailure { error -> + Log.w(TAG, "Failed to clear data caches for $reason", error) + } + + runCatching { + get().memoryCache?.clear() + }.onFailure { error -> + Log.w(TAG, "Failed to clear Coil memory cache for $reason", error) + } + } + override fun newImageLoader(context: PlatformContext): ImageLoader { return get() } + + companion object { + private const val TAG = "App" + } } 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 a7385306..8186256a 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -147,10 +147,18 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { if (user.profilePhoto != null || existing.profilePhoto == null) { existing.profilePhoto = user.profilePhoto } + existing.accentColorId = user.accentColorId + existing.backgroundCustomEmojiId = user.backgroundCustomEmojiId + existing.profileAccentColorId = user.profileAccentColorId + existing.profileBackgroundCustomEmojiId = user.profileBackgroundCustomEmojiId existing.emojiStatus = user.emojiStatus existing.isPremium = user.isPremium existing.verificationStatus = user.verificationStatus existing.isSupport = user.isSupport + existing.restrictionInfo = user.restrictionInfo + existing.activeStoryState = user.activeStoryState + existing.restrictsNewChats = user.restrictsNewChats + existing.paidMessageStarCount = user.paidMessageStarCount existing.haveAccess = user.haveAccess existing.type = user.type existing.languageCode = user.languageCode 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 4523914b..4c495535 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -46,6 +46,17 @@ class ChatModelFactory( var isOnline = false var userStatus = "" var isVerified = isForcedVerifiedChat(chat.id) + var isScam = false + var isFake = false + var botVerificationIconCustomEmojiId = 0L + var restrictionReason: String? = null + var hasSensitiveContent = false + var activeStoryStateType: String? = null + var activeStoryId = 0 + var boostLevel = 0 + var hasForumTabs = false + var isAdministeredDirectMessagesGroup = false + var paidMessageStarCount = 0L var isForum = false var isBot = false var isMember = true @@ -93,6 +104,17 @@ class ChatModelFactory( supergroup?.let { memberCount = it.memberCount isVerified = (it.verificationStatus?.isVerified ?: false) || isForcedVerifiedChat(chat.id) + isScam = it.verificationStatus?.isScam ?: false + isFake = it.verificationStatus?.isFake ?: false + botVerificationIconCustomEmojiId = it.verificationStatus?.botVerificationIconCustomEmojiId ?: 0L + restrictionReason = it.restrictionInfo?.restrictionReason?.ifEmpty { null } + hasSensitiveContent = it.restrictionInfo?.hasSensitiveContent ?: false + activeStoryStateType = it.activeStoryState.toTypeString() + activeStoryId = (it.activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0 + boostLevel = it.boostLevel + hasForumTabs = it.hasForumTabs + isAdministeredDirectMessagesGroup = it.isAdministeredDirectMessagesGroup + paidMessageStarCount = it.paidMessageStarCount isForum = it.isForum isMember = it.status !is TdApi.ChatMemberStatusLeft isAdmin = it.status is TdApi.ChatMemberStatusAdministrator || @@ -133,6 +155,14 @@ class ChatModelFactory( if (isOnline) onlineCount = 1 userStatus = chatMapper.formatUserStatus(user.status, isBot) isVerified = (user.verificationStatus?.isVerified ?: false) || isForcedVerifiedUser(user.id) + isScam = user.verificationStatus?.isScam ?: false + isFake = user.verificationStatus?.isFake ?: false + botVerificationIconCustomEmojiId = user.verificationStatus?.botVerificationIconCustomEmojiId ?: 0L + restrictionReason = user.restrictionInfo?.restrictionReason?.ifEmpty { null } + hasSensitiveContent = user.restrictionInfo?.hasSensitiveContent ?: false + activeStoryStateType = user.activeStoryState.toTypeString() + activeStoryId = (user.activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0 + paidMessageStarCount = user.paidMessageStarCount isSponsor = isSponsoredUser(user.id) username = user.usernames?.activeUsernames?.firstOrNull() usernames = user.usernames?.toDomain() @@ -239,6 +269,17 @@ class ChatModelFactory( isOnline = isOnline, userStatus = userStatus, isVerified = isVerified, + isScam = isScam, + isFake = isFake, + botVerificationIconCustomEmojiId = botVerificationIconCustomEmojiId, + restrictionReason = restrictionReason, + hasSensitiveContent = hasSensitiveContent, + activeStoryStateType = activeStoryStateType, + activeStoryId = activeStoryId, + boostLevel = boostLevel, + hasForumTabs = hasForumTabs, + isAdministeredDirectMessagesGroup = isAdministeredDirectMessagesGroup, + paidMessageStarCount = paidMessageStarCount, isSponsor = isSponsor, isForum = isForum, isBot = isBot, @@ -330,3 +371,12 @@ class ChatModelFactory( private const val USER_FULL_INFO_RETRY_TTL_MS = 5 * 60 * 1000L } } + +private fun TdApi.ActiveStoryState?.toTypeString(): String? { + return when (this) { + is TdApi.ActiveStoryStateLive -> "LIVE" + is TdApi.ActiveStoryStateUnread -> "UNREAD" + is TdApi.ActiveStoryStateRead -> "READ" + else -> null + } +} 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 e3bbc16e..a22ee018 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 @@ -3,6 +3,7 @@ package org.monogram.data.datasource.remote import android.os.Build import android.util.Log import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import org.drinkless.tdlib.TdApi @@ -41,29 +42,29 @@ class TdMessageRemoteDataSource( override val messageEditedFlow = MutableSharedFlow() override val messageReadFlow = MutableSharedFlow( replay = 1, - extraBufferCapacity = 100, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val messageUploadProgressFlow = MutableSharedFlow>() override val messageDownloadProgressFlow = MutableSharedFlow>() override val messageDownloadCancelledFlow = MutableSharedFlow() override val messageDeletedFlow = MutableSharedFlow>>( - extraBufferCapacity = 100, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val messageIdUpdateFlow = MutableSharedFlow>( - extraBufferCapacity = 100, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val messageDownloadCompletedFlow = MutableSharedFlow>() override val pinnedMessageFlow = MutableSharedFlow( extraBufferCapacity = 10, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.SUSPEND + onBufferOverflow = BufferOverflow.DROP_OLDEST ) override val mediaUpdateFlow = MutableSharedFlow( replay = 0, extraBufferCapacity = 10, - onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST + onBufferOverflow = BufferOverflow.DROP_OLDEST ) enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index 012a2834..1df2fac1 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -25,7 +25,7 @@ import org.monogram.data.db.model.* SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 27, + version = 28, exportSchema = false ) abstract class MonogramDatabase : RoomDatabase() { diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index be1ff163..fe7a272e 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -18,4 +18,150 @@ object MonogramMigrations { ) } } + + val MIGRATION_27_28 = object : Migration(27, 28) { + override fun migrate(db: SupportSQLiteDatabase) { + db.addColumn("users", "isScam", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "isFake", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanBeEdited", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanJoinGroups", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanReadAllGroupMessages", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeHasMainWebApp", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeHasTopics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeAllowsUsersToCreateTopics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanManageBots", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeIsInline", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeInlineQueryPlaceholder", "TEXT") + db.addColumn("users", "botTypeNeedLocation", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanConnectToBusiness", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeCanBeAddedToAttachmentMenu", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "botTypeActiveUserCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "userType", "TEXT NOT NULL DEFAULT 'UNKNOWN'") + db.addColumn("users", "restrictionReason", "TEXT") + db.addColumn("users", "hasSensitiveContent", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "activeStoryStateType", "TEXT") + db.addColumn("users", "activeStoryId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "restrictsNewChats", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "paidMessageStarCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "backgroundCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "profileBackgroundCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("users", "addedToAttachmentMenu", "INTEGER NOT NULL DEFAULT 0") + + db.addColumn("chats", "isScam", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "isFake", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "restrictionReason", "TEXT") + db.addColumn("chats", "hasSensitiveContent", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "activeStoryStateType", "TEXT") + db.addColumn("chats", "activeStoryId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "boostLevel", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "hasForumTabs", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "isAdministeredDirectMessagesGroup", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chats", "paidMessageStarCount", "INTEGER NOT NULL DEFAULT 0") + + db.addColumn("user_full_info", "botInfoShortDescription", "TEXT") + db.addColumn("user_full_info", "botInfoPhotoFileId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoPhotoPath", "TEXT") + db.addColumn("user_full_info", "botInfoAnimationFileId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoAnimationPath", "TEXT") + db.addColumn("user_full_info", "botInfoManagerBotUserId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoMenuButtonText", "TEXT") + db.addColumn("user_full_info", "botInfoMenuButtonUrl", "TEXT") + db.addColumn("user_full_info", "botInfoCommandsData", "TEXT") + db.addColumn("user_full_info", "botInfoPrivacyPolicyUrl", "TEXT") + db.addColumn("user_full_info", "botInfoDefaultGroupRightsData", "TEXT") + db.addColumn("user_full_info", "botInfoDefaultChannelRightsData", "TEXT") + db.addColumn("user_full_info", "botInfoAffiliateProgramData", "TEXT") + db.addColumn("user_full_info", "botInfoWebAppBackgroundLightColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn("user_full_info", "botInfoWebAppBackgroundDarkColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn("user_full_info", "botInfoWebAppHeaderLightColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn("user_full_info", "botInfoWebAppHeaderDarkColor", "INTEGER NOT NULL DEFAULT -1") + db.addColumn( + "user_full_info", + "botInfoVerificationParametersIconCustomEmojiId", + "INTEGER NOT NULL DEFAULT 0" + ) + db.addColumn("user_full_info", "botInfoVerificationParametersOrganizationName", "TEXT") + db.addColumn("user_full_info", "botInfoVerificationParametersDefaultCustomDescription", "TEXT") + db.addColumn( + "user_full_info", + "botInfoVerificationParametersCanSetCustomDescription", + "INTEGER NOT NULL DEFAULT 0" + ) + db.addColumn("user_full_info", "botInfoCanManageEmojiStatus", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoHasMediaPreviews", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botInfoEditCommandsLinkType", "TEXT") + db.addColumn("user_full_info", "botInfoEditDescriptionLinkType", "TEXT") + db.addColumn("user_full_info", "botInfoEditDescriptionMediaLinkType", "TEXT") + db.addColumn("user_full_info", "botInfoEditSettingsLinkType", "TEXT") + db.addColumn("user_full_info", "publicPhotoPath", "TEXT") + db.addColumn("user_full_info", "blockListType", "TEXT") + db.addColumn("user_full_info", "note", "TEXT") + db.addColumn("user_full_info", "hasSponsoredMessagesEnabled", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "needPhoneNumberPrivacyException", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "usesUnofficialApp", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botVerificationBotUserId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "botVerificationCustomDescription", "TEXT") + db.addColumn("user_full_info", "mainProfileTab", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioDuration", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "firstProfileAudioTitle", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioPerformer", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioFileName", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioMimeType", "TEXT") + db.addColumn("user_full_info", "firstProfileAudioFileId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "firstProfileAudioPath", "TEXT") + db.addColumn("user_full_info", "ratingLevel", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingIsMaximumLevelReached", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingCurrentLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "ratingNextLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingLevel", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingIsMaximumLevelReached", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingCurrentLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingNextLevelValue", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("user_full_info", "pendingRatingDate", "INTEGER NOT NULL DEFAULT 0") + + db.addColumn("chat_full_info", "directMessagesChatId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botInfoData", "TEXT") + db.addColumn("chat_full_info", "blockListType", "TEXT") + db.addColumn("chat_full_info", "publicPhotoPath", "TEXT") + db.addColumn("chat_full_info", "usesUnofficialApp", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasSponsoredMessagesEnabled", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "needPhoneNumberPrivacyException", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botVerificationBotUserId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botVerificationIconCustomEmojiId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botVerificationCustomDescription", "TEXT") + db.addColumn("chat_full_info", "mainProfileTab", "TEXT") + db.addColumn("chat_full_info", "firstProfileAudioData", "TEXT") + db.addColumn("chat_full_info", "ratingData", "TEXT") + db.addColumn("chat_full_info", "pendingRatingData", "TEXT") + db.addColumn("chat_full_info", "pendingRatingDate", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "slowModeDelayExpiresIn", "REAL NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canEnablePaidMessages", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canEnablePaidReaction", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasHiddenMembers", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canHideMembers", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canGetStarRevenueStatistics", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canToggleAggressiveAntiSpam", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "isAllHistoryAvailable", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "canHaveSponsoredMessages", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasAggressiveAntiSpamEnabled", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasPaidMediaAllowed", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "hasPinnedStories", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "myBoostCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "unrestrictBoostCount", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "stickerSetId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "customEmojiStickerSetId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "botCommandsData", "TEXT") + db.addColumn("chat_full_info", "upgradedFromBasicGroupId", "INTEGER NOT NULL DEFAULT 0") + db.addColumn("chat_full_info", "upgradedFromMaxMessageId", "INTEGER NOT NULL DEFAULT 0") + } + } + + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { + execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") + } } diff --git a/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt b/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt index 67c65702..aadceb8e 100644 --- a/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/ChatEntity.kt @@ -54,6 +54,17 @@ data class ChatEntity( val typingAction: String? = null, val draftMessage: String? = null, val isVerified: Boolean = false, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false, + val activeStoryStateType: String? = null, + val activeStoryId: Int = 0, + val boostLevel: Int = 0, + val hasForumTabs: Boolean = false, + val isAdministeredDirectMessagesGroup: Boolean = false, + val paidMessageStarCount: Long = 0L, val isSponsor: Boolean = false, val viewAsTopics: Boolean = false, val isForum: Boolean = false, diff --git a/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt b/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt index 1a72d4a1..71f57fb2 100644 --- a/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/ChatFullInfoEntity.kt @@ -13,17 +13,51 @@ data class ChatFullInfoEntity( val administratorCount: Int, val restrictedCount: Int, val bannedCount: Int, + val directMessagesChatId: Long = 0L, val commonGroupsCount: Int, val giftCount: Int = 0, val isBlocked: Boolean, val botInfo: String?, + val botInfoData: String? = null, + val blockListType: String? = null, + val publicPhotoPath: String? = null, + val usesUnofficialApp: Boolean = false, + val hasSponsoredMessagesEnabled: Boolean = false, + val needPhoneNumberPrivacyException: Boolean = false, + val botVerificationBotUserId: Long = 0L, + val botVerificationIconCustomEmojiId: Long = 0L, + val botVerificationCustomDescription: String? = null, + val mainProfileTab: String? = null, + val firstProfileAudioData: String? = null, + val ratingData: String? = null, + val pendingRatingData: String? = null, + val pendingRatingDate: Int = 0, val slowModeDelay: Int, + val slowModeDelayExpiresIn: Double = 0.0, val locationAddress: String?, + val canEnablePaidMessages: Boolean = false, + val canEnablePaidReaction: Boolean = false, + val hasHiddenMembers: Boolean = false, + val canHideMembers: Boolean = false, val canSetStickerSet: Boolean, val canSetLocation: Boolean, val canGetMembers: Boolean, val canGetStatistics: Boolean, val canGetRevenueStatistics: Boolean = false, + val canGetStarRevenueStatistics: Boolean = false, + val canToggleAggressiveAntiSpam: Boolean = false, + val isAllHistoryAvailable: Boolean = false, + val canHaveSponsoredMessages: Boolean = false, + val hasAggressiveAntiSpamEnabled: Boolean = false, + val hasPaidMediaAllowed: Boolean = false, + val hasPinnedStories: Boolean = false, + val myBoostCount: Int = 0, + val unrestrictBoostCount: Int = 0, + val stickerSetId: Long = 0L, + val customEmojiStickerSetId: Long = 0L, + val botCommandsData: String? = null, + val upgradedFromBasicGroupId: Long = 0L, + val upgradedFromMaxMessageId: Long = 0L, val linkedChatId: Long, val note: String?, val canBeCalled: Boolean, diff --git a/data/src/main/java/org/monogram/data/db/model/UserEntity.kt b/data/src/main/java/org/monogram/data/db/model/UserEntity.kt index 1f8959eb..65e1be7e 100644 --- a/data/src/main/java/org/monogram/data/db/model/UserEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/UserEntity.kt @@ -20,18 +20,44 @@ data class UserEntity( val personalAvatarPath: String? = null, val isPremium: Boolean, val isVerified: Boolean, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, val isSupport: Boolean = false, val isContact: Boolean = false, val isMutualContact: Boolean = false, val isCloseFriend: Boolean = false, + val botTypeCanBeEdited: Boolean = false, + val botTypeCanJoinGroups: Boolean = false, + val botTypeCanReadAllGroupMessages: Boolean = false, + val botTypeHasMainWebApp: Boolean = false, + val botTypeHasTopics: Boolean = false, + val botTypeAllowsUsersToCreateTopics: Boolean = false, + val botTypeCanManageBots: Boolean = false, + val botTypeIsInline: Boolean = false, + val botTypeInlineQueryPlaceholder: String? = null, + val botTypeNeedLocation: Boolean = false, + val botTypeCanConnectToBusiness: Boolean = false, + val botTypeCanBeAddedToAttachmentMenu: Boolean = false, + val botTypeActiveUserCount: Int = 0, + val userType: String = "UNKNOWN", + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false, + val activeStoryStateType: String? = null, + val activeStoryId: Int = 0, + val restrictsNewChats: Boolean = false, + val paidMessageStarCount: Long = 0L, val haveAccess: Boolean = true, val username: String?, val usernamesData: String? = null, val statusType: String = "OFFLINE", val accentColorId: Int = 0, + val backgroundCustomEmojiId: Long = 0L, val profileAccentColorId: Int = -1, + val profileBackgroundCustomEmojiId: Long = 0L, val statusEmojiId: Long = 0L, val languageCode: String? = null, + val addedToAttachmentMenu: Boolean = false, val lastSeen: Long, val createdAt: Long = System.currentTimeMillis() ) \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt b/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt index 7278a176..5f44a1ea 100644 --- a/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/UserFullInfoEntity.kt @@ -10,10 +10,39 @@ data class UserFullInfoEntity( val commonGroupsCount: Int, val giftCount: Int = 0, val botInfoDescription: String? = null, + val botInfoShortDescription: String? = null, + val botInfoPhotoFileId: Int = 0, + val botInfoPhotoPath: String? = null, + val botInfoAnimationFileId: Int = 0, + val botInfoAnimationPath: String? = null, + val botInfoManagerBotUserId: Long = 0L, + val botInfoMenuButtonText: String? = null, + val botInfoMenuButtonUrl: String? = null, + val botInfoCommandsData: String? = null, + val botInfoPrivacyPolicyUrl: String? = null, + val botInfoDefaultGroupRightsData: String? = null, + val botInfoDefaultChannelRightsData: String? = null, + val botInfoAffiliateProgramData: String? = null, + val botInfoWebAppBackgroundLightColor: Int = -1, + val botInfoWebAppBackgroundDarkColor: Int = -1, + val botInfoWebAppHeaderLightColor: Int = -1, + val botInfoWebAppHeaderDarkColor: Int = -1, + val botInfoVerificationParametersIconCustomEmojiId: Long = 0L, + val botInfoVerificationParametersOrganizationName: String? = null, + val botInfoVerificationParametersDefaultCustomDescription: String? = null, + val botInfoVerificationParametersCanSetCustomDescription: Boolean = false, + val botInfoCanManageEmojiStatus: Boolean = false, + val botInfoHasMediaPreviews: Boolean = false, + val botInfoEditCommandsLinkType: String? = null, + val botInfoEditDescriptionLinkType: String? = null, + val botInfoEditDescriptionMediaLinkType: String? = null, + val botInfoEditSettingsLinkType: String? = null, val personalChatId: Long = 0L, val birthdateDay: Int = 0, val birthdateMonth: Int = 0, val birthdateYear: Int = 0, + val publicPhotoPath: String? = null, + val blockListType: String? = null, val businessLocationAddress: String? = null, val businessLocationLatitude: Double = 0.0, val businessLocationLongitude: Double = 0.0, @@ -22,8 +51,34 @@ data class UserFullInfoEntity( val businessNextCloseIn: Int = 0, val businessStartPageTitle: String? = null, val businessStartPageMessage: String? = null, + val note: String? = null, val personalPhotoPath: String? = null, val isBlocked: Boolean, + val hasSponsoredMessagesEnabled: Boolean = false, + val needPhoneNumberPrivacyException: Boolean = false, + val usesUnofficialApp: Boolean = false, + val botVerificationBotUserId: Long = 0L, + val botVerificationIconCustomEmojiId: Long = 0L, + val botVerificationCustomDescription: String? = null, + val mainProfileTab: String? = null, + val firstProfileAudioDuration: Int = 0, + val firstProfileAudioTitle: String? = null, + val firstProfileAudioPerformer: String? = null, + val firstProfileAudioFileName: String? = null, + val firstProfileAudioMimeType: String? = null, + val firstProfileAudioFileId: Int = 0, + val firstProfileAudioPath: String? = null, + val ratingLevel: Int = 0, + val ratingIsMaximumLevelReached: Boolean = false, + val ratingValue: Long = 0L, + val ratingCurrentLevelValue: Long = 0L, + val ratingNextLevelValue: Long = 0L, + val pendingRatingLevel: Int = 0, + val pendingRatingIsMaximumLevelReached: Boolean = false, + val pendingRatingValue: Long = 0L, + val pendingRatingCurrentLevelValue: Long = 0L, + val pendingRatingNextLevelValue: Long = 0L, + val pendingRatingDate: Int = 0, val canBeCalled: Boolean, val supportsVideoCalls: Boolean, val hasPrivateCalls: Boolean, diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index d4f3fba7..2dad8845 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -18,8 +18,8 @@ internal class TdLibClient { private val TAG = "TdLibClient" private val globalRetryAfterUntilMs = AtomicLong(0L) private val _updates = MutableSharedFlow( - replay = 10, - extraBufferCapacity = 1000, + replay = 3, + extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST ) 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 2cb0be8f..057656f1 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.monogram.core.DispatcherProvider +import org.monogram.data.BuildConfig import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.PlayerDataSourceFactoryImpl @@ -124,7 +125,10 @@ val dataModule = module { "monogram_db" ) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .addMigrations(MonogramMigrations.MIGRATION_26_27) + .addMigrations( + MonogramMigrations.MIGRATION_26_27, + MonogramMigrations.MIGRATION_27_28 + ) .fallbackToDestructiveMigration(dropAllTables = true) .build() } @@ -542,6 +546,22 @@ val dataModule = module { ) } + single { + DataMemoryPressureHandler( + chatsListRepository = get(), + fileUpdateHandler = get() + ) + } + + if (BuildConfig.DEBUG) { + single(createdAtStart = true) { + DataMemoryDiagnostics( + scope = get(), + memoryPressureHandler = get() + ) + } + } + single { StickerFileManager( localDataSource = get(), diff --git a/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt b/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt new file mode 100644 index 00000000..d90dfe72 --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/DataMemoryPressureHandler.kt @@ -0,0 +1,64 @@ +package org.monogram.data.infra + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.monogram.data.BuildConfig +import org.monogram.data.repository.ChatsListRepositoryImpl + +class DataMemoryPressureHandler( + private val chatsListRepository: ChatsListRepositoryImpl, + private val fileUpdateHandler: FileUpdateHandler +) { + fun clearDataCaches(reason: String) { + chatsListRepository.clearMemoryCaches() + fileUpdateHandler.clearMemoryCaches() + if (BuildConfig.DEBUG) { + logSnapshot("after_clear:$reason") + } + } + + fun logSnapshot(reason: String) { + if (!BuildConfig.DEBUG) return + val runtime = Runtime.getRuntime() + val usedMb = (runtime.totalMemory() - runtime.freeMemory()) / MB + val maxMb = runtime.maxMemory() / MB + val chatSnapshot = chatsListRepository.memoryCacheSnapshot() + val fileSnapshot = fileUpdateHandler.memoryCacheSnapshot() + Log.d( + TAG, + "reason=$reason heap=${usedMb}MB/${maxMb}MB " + + "chatModelCache=${chatSnapshot.modelCacheSize} " + + "invalidatedModels=${chatSnapshot.invalidatedModelsSize} " + + "customEmojiPaths=${fileSnapshot.customEmojiPathsSize} " + + "fileToEmoji=${fileSnapshot.fileToEmojiSize}" + ) + } + + companion object { + private const val TAG = "DataMemoryPressure" + private const val MB = 1024L * 1024L + } +} + +class DataMemoryDiagnostics( + scope: CoroutineScope, + private val memoryPressureHandler: DataMemoryPressureHandler +) { + init { + if (BuildConfig.DEBUG) { + scope.launch { + while (isActive) { + delay(LOG_INTERVAL_MS) + memoryPressureHandler.logSnapshot("periodic") + } + } + } + } + + companion object { + private const val LOG_INTERVAL_MS = 60_000L + } +} diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt index b97381b9..a2d74a63 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.data.gateway.UpdateDispatcher -import java.util.concurrent.ConcurrentHashMap class FileUpdateHandler( private val registry: FileMessageRegistry, @@ -15,8 +14,8 @@ class FileUpdateHandler( private val updates: UpdateDispatcher, private val scope: CoroutineScope ) { - val customEmojiPaths = ConcurrentHashMap() - val fileIdToCustomEmojiId = ConcurrentHashMap() + val customEmojiPaths = SynchronizedLruMap(CUSTOM_EMOJI_CACHE_SIZE) + val fileIdToCustomEmojiId = SynchronizedLruMap(FILE_TO_EMOJI_CACHE_SIZE) private val _downloadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val _downloadCompleted = MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -104,4 +103,26 @@ class FileUpdateHandler( val emojiId = fileIdToCustomEmojiId[fileId] ?: return customEmojiPaths[emojiId] = path } -} \ No newline at end of file + + fun clearMemoryCaches() { + customEmojiPaths.clear() + fileIdToCustomEmojiId.clear() + } + + fun memoryCacheSnapshot(): MemoryCacheSnapshot { + return MemoryCacheSnapshot( + customEmojiPathsSize = customEmojiPaths.size(), + fileToEmojiSize = fileIdToCustomEmojiId.size() + ) + } + + data class MemoryCacheSnapshot( + val customEmojiPathsSize: Int, + val fileToEmojiSize: Int + ) + + companion object { + private const val CUSTOM_EMOJI_CACHE_SIZE = 512 + private const val FILE_TO_EMOJI_CACHE_SIZE = 512 + } +} 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 9d3c9130..b1382243 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -260,6 +260,8 @@ class OfflineWarmup( else -> 0L } + val botType = type as? TdApi.UserTypeBot + return UserEntity( id = id, firstName = firstName, @@ -269,18 +271,44 @@ class OfflineWarmup( personalAvatarPath = personalAvatarPath, isPremium = isPremium, isVerified = verificationStatus?.isVerified ?: false, + isScam = verificationStatus?.isScam ?: false, + isFake = verificationStatus?.isFake ?: false, + botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L, isSupport = isSupport, isContact = isContact, isMutualContact = isMutualContact, isCloseFriend = isCloseFriend, + botTypeCanBeEdited = botType?.canBeEdited ?: false, + botTypeCanJoinGroups = botType?.canJoinGroups ?: false, + botTypeCanReadAllGroupMessages = botType?.canReadAllGroupMessages ?: false, + botTypeHasMainWebApp = botType?.hasMainWebApp ?: false, + botTypeHasTopics = botType?.hasTopics ?: false, + botTypeAllowsUsersToCreateTopics = botType?.allowsUsersToCreateTopics ?: false, + botTypeCanManageBots = botType?.canManageBots ?: false, + botTypeIsInline = botType?.isInline ?: false, + botTypeInlineQueryPlaceholder = botType?.inlineQueryPlaceholder?.ifEmpty { null }, + botTypeNeedLocation = botType?.needLocation ?: false, + botTypeCanConnectToBusiness = botType?.canConnectToBusiness ?: false, + botTypeCanBeAddedToAttachmentMenu = botType?.canBeAddedToAttachmentMenu ?: false, + botTypeActiveUserCount = botType?.activeUserCount ?: 0, + userType = type.toTypeString(), + restrictionReason = restrictionInfo?.restrictionReason?.ifEmpty { null }, + hasSensitiveContent = restrictionInfo?.hasSensitiveContent ?: false, + activeStoryStateType = activeStoryState.toTypeString(), + activeStoryId = (activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0, + restrictsNewChats = restrictsNewChats, + paidMessageStarCount = paidMessageStarCount, haveAccess = haveAccess, username = usernames?.activeUsernames?.firstOrNull(), usernamesData = usernamesData, statusType = statusType, accentColorId = accentColorId, + backgroundCustomEmojiId = backgroundCustomEmojiId, profileAccentColorId = profileAccentColorId, + profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId, statusEmojiId = statusEmojiId, languageCode = languageCode.ifEmpty { null }, + addedToAttachmentMenu = addedToAttachmentMenu, lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L, createdAt = System.currentTimeMillis() ) @@ -293,6 +321,24 @@ class OfflineWarmup( ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } } + private fun TdApi.ActiveStoryState?.toTypeString(): String? { + return when (this) { + is TdApi.ActiveStoryStateLive -> "LIVE" + is TdApi.ActiveStoryStateUnread -> "UNREAD" + is TdApi.ActiveStoryStateRead -> "READ" + else -> null + } + } + + private fun TdApi.UserType?.toTypeString(): String { + return when (this) { + is TdApi.UserTypeRegular -> "REGULAR" + is TdApi.UserTypeBot -> "BOT" + is TdApi.UserTypeDeleted -> "DELETED" + else -> "UNKNOWN" + } + } + private companion object { private const val USER_WARMUP_LIMIT = 30 private const val USER_WARMUP_DELAY_MS = 75L diff --git a/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt b/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt new file mode 100644 index 00000000..d9d5210c --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/SynchronizedLruMap.kt @@ -0,0 +1,40 @@ +package org.monogram.data.infra + +class SynchronizedLruMap( + private val maxSize: Int +) { + init { + require(maxSize > 0) { "maxSize must be > 0" } + } + + private val lock = Any() + private val map = object : LinkedHashMap(maxSize, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean { + return size > maxSize + } + } + + operator fun get(key: K): V? { + return synchronized(lock) { map[key] } + } + + operator fun set(key: K, value: V) { + synchronized(lock) { + map[key] = value + } + } + + fun containsKey(key: K): Boolean { + return synchronized(lock) { map.containsKey(key) } + } + + fun clear() { + synchronized(lock) { + map.clear() + } + } + + fun size(): Int { + return synchronized(lock) { map.size } + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index 3a0ed425..acea7b6e 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -18,6 +18,17 @@ class ChatMapper(private val stringProvider: StringProvider) { isOnline: Boolean, userStatus: String, isVerified: Boolean, + isScam: Boolean, + isFake: Boolean, + botVerificationIconCustomEmojiId: Long, + restrictionReason: String?, + hasSensitiveContent: Boolean, + activeStoryStateType: String?, + activeStoryId: Int, + boostLevel: Int, + hasForumTabs: Boolean, + isAdministeredDirectMessagesGroup: Boolean, + paidMessageStarCount: Long, isSponsor: Boolean, isForum: Boolean, isBot: Boolean, @@ -101,6 +112,17 @@ class ChatMapper(private val stringProvider: StringProvider) { }, blockList = chat.blockList != null, isVerified = isVerified || isForcedVerifiedChat(chat.id), + isScam = isScam, + isFake = isFake, + botVerificationIconCustomEmojiId = botVerificationIconCustomEmojiId, + restrictionReason = restrictionReason, + hasSensitiveContent = hasSensitiveContent, + activeStoryStateType = activeStoryStateType, + activeStoryId = activeStoryId, + boostLevel = boostLevel, + hasForumTabs = hasForumTabs, + isAdministeredDirectMessagesGroup = isAdministeredDirectMessagesGroup, + paidMessageStarCount = paidMessageStarCount, isSponsor = isSponsor, viewAsTopics = chat.viewAsTopics, isForum = isForum, @@ -161,6 +183,17 @@ class ChatMapper(private val stringProvider: StringProvider) { typingAction = entity.typingAction, draftMessage = entity.draftMessage, isVerified = entity.isVerified || isForcedVerifiedChat(entity.id), + isScam = entity.isScam, + isFake = entity.isFake, + botVerificationIconCustomEmojiId = entity.botVerificationIconCustomEmojiId, + restrictionReason = entity.restrictionReason, + hasSensitiveContent = entity.hasSensitiveContent, + activeStoryStateType = entity.activeStoryStateType, + activeStoryId = entity.activeStoryId, + boostLevel = entity.boostLevel, + hasForumTabs = entity.hasForumTabs, + isAdministeredDirectMessagesGroup = entity.isAdministeredDirectMessagesGroup, + paidMessageStarCount = entity.paidMessageStarCount, isSponsor = entity.isSponsor || (entity.privateUserId != 0L && isSponsoredUser(entity.privateUserId)), viewAsTopics = entity.viewAsTopics, isForum = entity.isForum, @@ -224,6 +257,17 @@ class ChatMapper(private val stringProvider: StringProvider) { typingAction = domain.typingAction, draftMessage = domain.draftMessage, isVerified = domain.isVerified || isForcedVerifiedChat(domain.id), + isScam = domain.isScam, + isFake = domain.isFake, + botVerificationIconCustomEmojiId = domain.botVerificationIconCustomEmojiId, + restrictionReason = domain.restrictionReason, + hasSensitiveContent = domain.hasSensitiveContent, + activeStoryStateType = domain.activeStoryStateType, + activeStoryId = domain.activeStoryId, + boostLevel = domain.boostLevel, + hasForumTabs = domain.hasForumTabs, + isAdministeredDirectMessagesGroup = domain.isAdministeredDirectMessagesGroup, + paidMessageStarCount = domain.paidMessageStarCount, isSponsor = domain.isSponsor, viewAsTopics = domain.viewAsTopics, isForum = domain.isForum, diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt new file mode 100644 index 00000000..20272a6e --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoEntityMapper.kt @@ -0,0 +1,271 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.ChatEntity +import org.monogram.data.db.model.ChatFullInfoEntity +import org.monogram.domain.models.BotVerificationModel +import org.monogram.domain.models.ChatFullInfoModel + +fun TdApi.SupergroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { + return ChatFullInfoEntity( + chatId = chatId, + description = description.ifEmpty { null }, + inviteLink = inviteLink?.inviteLink, + memberCount = memberCount, + onlineCount = 0, + administratorCount = administratorCount, + restrictedCount = restrictedCount, + bannedCount = bannedCount, + directMessagesChatId = directMessagesChatId, + commonGroupsCount = 0, + giftCount = giftCount, + isBlocked = false, + botInfo = null, + botInfoData = null, + blockListType = null, + publicPhotoPath = null, + usesUnofficialApp = false, + hasSponsoredMessagesEnabled = false, + needPhoneNumberPrivacyException = false, + botVerificationBotUserId = botVerification?.botUserId ?: 0L, + botVerificationIconCustomEmojiId = botVerification?.iconCustomEmojiId ?: 0L, + botVerificationCustomDescription = botVerification?.customDescription?.text?.ifEmpty { null }, + mainProfileTab = mainProfileTab.toTypeString(), + firstProfileAudioData = null, + ratingData = null, + pendingRatingData = null, + pendingRatingDate = 0, + slowModeDelay = slowModeDelay, + slowModeDelayExpiresIn = slowModeDelayExpiresIn, + locationAddress = location?.address?.ifEmpty { null }, + canEnablePaidMessages = canEnablePaidMessages, + canEnablePaidReaction = canEnablePaidReaction, + hasHiddenMembers = hasHiddenMembers, + canHideMembers = canHideMembers, + canSetStickerSet = canSetStickerSet, + canSetLocation = canSetLocation, + canGetMembers = canGetMembers, + canGetStatistics = canGetStatistics, + canGetRevenueStatistics = canGetRevenueStatistics, + canGetStarRevenueStatistics = canGetStarRevenueStatistics, + canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam, + isAllHistoryAvailable = isAllHistoryAvailable, + canHaveSponsoredMessages = canHaveSponsoredMessages, + hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled, + hasPaidMediaAllowed = hasPaidMediaAllowed, + hasPinnedStories = hasPinnedStories, + myBoostCount = myBoostCount, + unrestrictBoostCount = unrestrictBoostCount, + stickerSetId = stickerSetId, + customEmojiStickerSetId = customEmojiStickerSetId, + botCommandsData = encodeBotCommands(botCommands), + upgradedFromBasicGroupId = upgradedFromBasicGroupId, + upgradedFromMaxMessageId = upgradedFromMaxMessageId, + linkedChatId = linkedChatId, + note = null, + canBeCalled = false, + supportsVideoCalls = false, + hasPrivateCalls = false, + hasPrivateForwards = false, + hasRestrictedVoiceAndVideoNoteMessages = false, + hasPostedToProfileStories = false, + setChatBackground = false, + incomingPaidMessageStarCount = 0, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount, + createdAt = System.currentTimeMillis() + ) +} + +fun TdApi.BasicGroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { + return ChatFullInfoEntity( + chatId = chatId, + description = description.ifEmpty { null }, + inviteLink = inviteLink?.inviteLink, + memberCount = members.size, + onlineCount = 0, + administratorCount = 0, + restrictedCount = 0, + bannedCount = 0, + commonGroupsCount = 0, + giftCount = 0, + isBlocked = false, + botInfo = null, + slowModeDelay = 0, + locationAddress = null, + canSetStickerSet = false, + canSetLocation = false, + canGetMembers = false, + canGetStatistics = false, + canGetRevenueStatistics = false, + linkedChatId = 0, + note = null, + canBeCalled = false, + supportsVideoCalls = false, + hasPrivateCalls = false, + hasPrivateForwards = false, + hasRestrictedVoiceAndVideoNoteMessages = false, + hasPostedToProfileStories = false, + setChatBackground = false, + incomingPaidMessageStarCount = 0, + outgoingPaidMessageStarCount = 0, + createdAt = System.currentTimeMillis() + ) +} + +fun ChatEntity.toTdApiChat(): TdApi.Chat { + return TdApi.Chat().apply { + id = this@toTdApiChat.id + title = this@toTdApiChat.title + unreadCount = this@toTdApiChat.unreadCount + unreadMentionCount = this@toTdApiChat.unreadMentionCount + unreadReactionCount = this@toTdApiChat.unreadReactionCount + photo = avatarPath?.let { path -> + TdApi.ChatPhotoInfo().apply { + small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } + } + } + lastMessage = TdApi.Message().apply { + content = TdApi.MessageText().apply { text = TdApi.FormattedText(lastMessageText, emptyArray()) } + date = lastMessageTime.toIntOrNull() ?: 0 + id = this@toTdApiChat.lastMessageId + isOutgoing = this@toTdApiChat.isLastMessageOutgoing + } + positions = arrayOf(TdApi.ChatPosition(TdApi.ChatListMain(), order, isPinned, null)) + notificationSettings = TdApi.ChatNotificationSettings().apply { + muteFor = if (isMuted) Int.MAX_VALUE else 0 + } + type = when (this@toTdApiChat.type) { + "PRIVATE" -> TdApi.ChatTypePrivate().apply { + userId = + if (this@toTdApiChat.privateUserId != 0L) this@toTdApiChat.privateUserId else (this@toTdApiChat.messageSenderId + ?: 0L) + } + + "BASIC_GROUP" -> TdApi.ChatTypeBasicGroup().apply { + basicGroupId = this@toTdApiChat.basicGroupId + } + + "SUPERGROUP" -> TdApi.ChatTypeSupergroup(this@toTdApiChat.supergroupId, isChannel) + "SECRET" -> TdApi.ChatTypeSecret().apply { + secretChatId = this@toTdApiChat.secretChatId + } + + else -> TdApi.ChatTypePrivate().apply { userId = this@toTdApiChat.privateUserId } + } + isMarkedAsUnread = this@toTdApiChat.isMarkedAsUnread + hasProtectedContent = this@toTdApiChat.hasProtectedContent + isTranslatable = this@toTdApiChat.isTranslatable + viewAsTopics = this@toTdApiChat.viewAsTopics + accentColorId = this@toTdApiChat.accentColorId + profileAccentColorId = this@toTdApiChat.profileAccentColorId + backgroundCustomEmojiId = this@toTdApiChat.backgroundCustomEmojiId + messageAutoDeleteTime = this@toTdApiChat.messageAutoDeleteTime + canBeDeletedOnlyForSelf = this@toTdApiChat.canBeDeletedOnlyForSelf + canBeDeletedForAllUsers = this@toTdApiChat.canBeDeletedForAllUsers + canBeReported = this@toTdApiChat.canBeReported + lastReadInboxMessageId = this@toTdApiChat.lastReadInboxMessageId + lastReadOutboxMessageId = this@toTdApiChat.lastReadOutboxMessageId + replyMarkupMessageId = this@toTdApiChat.replyMarkupMessageId + messageSenderId = this@toTdApiChat.messageSenderId?.let { TdApi.MessageSenderUser(it) } + blockList = if (this@toTdApiChat.blockList) TdApi.BlockListMain() else null + permissions = TdApi.ChatPermissions( + this@toTdApiChat.permissionCanSendBasicMessages, + this@toTdApiChat.permissionCanSendAudios, + this@toTdApiChat.permissionCanSendDocuments, + this@toTdApiChat.permissionCanSendPhotos, + this@toTdApiChat.permissionCanSendVideos, + this@toTdApiChat.permissionCanSendVideoNotes, + this@toTdApiChat.permissionCanSendVoiceNotes, + this@toTdApiChat.permissionCanSendPolls, + this@toTdApiChat.permissionCanSendOtherMessages, + this@toTdApiChat.permissionCanAddLinkPreviews, + this@toTdApiChat.permissionCanEditTag, + this@toTdApiChat.permissionCanChangeInfo, + this@toTdApiChat.permissionCanInviteUsers, + this@toTdApiChat.permissionCanPinMessages, + this@toTdApiChat.permissionCanCreateTopics + ) + clientData = "mc:${this@toTdApiChat.memberCount};oc:${this@toTdApiChat.onlineCount}" + } +} + +fun ChatFullInfoEntity.toDomain(): ChatFullInfoModel { + val botVerificationModel = if ( + botVerificationBotUserId != 0L || + botVerificationIconCustomEmojiId != 0L || + !botVerificationCustomDescription.isNullOrEmpty() + ) { + BotVerificationModel( + botUserId = botVerificationBotUserId, + iconCustomEmojiId = botVerificationIconCustomEmojiId, + customDescription = botVerificationCustomDescription + ) + } else { + null + } + + return ChatFullInfoModel( + description = description, + inviteLink = inviteLink, + memberCount = memberCount, + onlineCount = onlineCount, + administratorCount = administratorCount, + restrictedCount = restrictedCount, + bannedCount = bannedCount, + directMessagesChatId = directMessagesChatId, + commonGroupsCount = commonGroupsCount, + giftCount = giftCount, + isBlocked = isBlocked, + botInfo = botInfo, + botInfoModel = null, + blockListType = blockListType, + canGetRevenueStatistics = canGetRevenueStatistics, + canGetStarRevenueStatistics = canGetStarRevenueStatistics, + canEnablePaidMessages = canEnablePaidMessages, + canEnablePaidReaction = canEnablePaidReaction, + hasHiddenMembers = hasHiddenMembers, + canHideMembers = canHideMembers, + canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam, + isAllHistoryAvailable = isAllHistoryAvailable, + canHaveSponsoredMessages = canHaveSponsoredMessages, + hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled, + hasPaidMediaAllowed = hasPaidMediaAllowed, + hasPinnedStories = hasPinnedStories, + linkedChatId = linkedChatId, + businessInfo = null, + publicPhotoPath = publicPhotoPath, + note = note, + usesUnofficialApp = usesUnofficialApp, + hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled, + needPhoneNumberPrivacyException = needPhoneNumberPrivacyException, + botVerification = botVerificationModel, + mainProfileTab = mainProfileTab.toProfileTabType(), + firstProfileAudio = decodeProfileAudio(firstProfileAudioData), + rating = decodeUserRating(ratingData), + pendingRating = decodeUserRating(pendingRatingData), + pendingRatingDate = pendingRatingDate, + canBeCalled = canBeCalled, + supportsVideoCalls = supportsVideoCalls, + hasPrivateCalls = hasPrivateCalls, + hasPrivateForwards = hasPrivateForwards, + hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, + hasPostedToProfileStories = hasPostedToProfileStories, + setChatBackground = setChatBackground, + slowModeDelay = slowModeDelay, + slowModeDelayExpiresIn = slowModeDelayExpiresIn, + locationAddress = locationAddress, + canSetStickerSet = canSetStickerSet, + canSetLocation = canSetLocation, + canGetMembers = canGetMembers, + canGetStatistics = canGetStatistics, + myBoostCount = myBoostCount, + unrestrictBoostCount = unrestrictBoostCount, + stickerSetId = stickerSetId, + customEmojiStickerSetId = customEmojiStickerSetId, + botCommands = decodeBotCommands(botCommandsData), + upgradedFromBasicGroupId = upgradedFromBasicGroupId, + upgradedFromMaxMessageId = upgradedFromMaxMessageId, + incomingPaidMessageStarCount = incomingPaidMessageStarCount, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount + ) +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt new file mode 100644 index 00000000..7bffb8a8 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/ChatFullInfoMapper.kt @@ -0,0 +1,292 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.mapper.isChannelType +import org.monogram.data.mapper.isGroupType +import org.monogram.data.mapper.isValidFilePath +import org.monogram.data.mapper.toDomainChatType +import org.monogram.domain.models.* + +fun TdApi.UserFullInfo.mapUserFullInfoToChat(): ChatFullInfoModel { + val birthdate = birthdate?.let { date -> + BirthdateModel(date.day, date.month, if (date.year > 0) date.year else null) + } + return ChatFullInfoModel( + description = bio?.text?.ifEmpty { null }, + commonGroupsCount = groupInCommonCount, + giftCount = giftCount, + birthdate = birthdate, + isBlocked = blockList != null, + blockListType = blockList.toTypeString(), + botInfo = botInfo?.description?.ifEmpty { null }, + botInfoModel = botInfo?.toDomain(), + canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, + linkedChatId = personalChatId, + businessInfo = businessInfo?.toDomain(), + publicPhotoPath = publicPhoto.resolveChatPhotoPath(), + usesUnofficialApp = usesUnofficialApp, + hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled, + needPhoneNumberPrivacyException = needPhoneNumberPrivacyException, + botVerification = botVerification?.toDomain(), + mainProfileTab = mainProfileTab?.toDomain(), + firstProfileAudio = firstProfileAudio?.toDomain(), + rating = rating?.toDomain(), + pendingRating = pendingRating?.toDomain(), + pendingRatingDate = pendingRatingDate, + note = note?.text?.ifEmpty { null }, + canBeCalled = canBeCalled, + supportsVideoCalls = supportsVideoCalls, + hasPrivateCalls = hasPrivateCalls, + hasPrivateForwards = hasPrivateForwards, + hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, + hasPostedToProfileStories = hasPostedToProfileStories, + setChatBackground = setChatBackground, + incomingPaidMessageStarCount = incomingPaidMessageStarCount, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount + ) +} + +fun TdApi.SupergroupFullInfo.mapSupergroupFullInfoToChat( + supergroup: TdApi.Supergroup? +): ChatFullInfoModel { + val link = inviteLink?.inviteLink + ?: supergroup?.usernames?.activeUsernames?.firstOrNull()?.let { "t.me/$it" } + return ChatFullInfoModel( + description = description.ifEmpty { null }, + inviteLink = link, + memberCount = memberCount, + administratorCount = administratorCount, + restrictedCount = restrictedCount, + bannedCount = bannedCount, + directMessagesChatId = directMessagesChatId, + slowModeDelay = slowModeDelay, + slowModeDelayExpiresIn = slowModeDelayExpiresIn, + locationAddress = location?.address?.ifEmpty { null }, + giftCount = giftCount, + canEnablePaidMessages = canEnablePaidMessages, + canEnablePaidReaction = canEnablePaidReaction, + hasHiddenMembers = hasHiddenMembers, + canHideMembers = canHideMembers, + canSetStickerSet = canSetStickerSet, + canSetLocation = canSetLocation, + canGetMembers = canGetMembers, + canGetStatistics = canGetStatistics, + canGetRevenueStatistics = canGetRevenueStatistics, + canGetStarRevenueStatistics = canGetStarRevenueStatistics, + canToggleAggressiveAntiSpam = canToggleAggressiveAntiSpam, + isAllHistoryAvailable = isAllHistoryAvailable, + canHaveSponsoredMessages = canHaveSponsoredMessages, + hasAggressiveAntiSpamEnabled = hasAggressiveAntiSpamEnabled, + hasPaidMediaAllowed = hasPaidMediaAllowed, + hasPinnedStories = hasPinnedStories, + linkedChatId = linkedChatId, + botVerification = botVerification?.toDomain(), + mainProfileTab = mainProfileTab?.toDomain(), + myBoostCount = myBoostCount, + unrestrictBoostCount = unrestrictBoostCount, + stickerSetId = stickerSetId, + customEmojiStickerSetId = customEmojiStickerSetId, + botCommands = botCommands?.map { it.toDomain() } ?: emptyList(), + upgradedFromBasicGroupId = upgradedFromBasicGroupId, + upgradedFromMaxMessageId = upgradedFromMaxMessageId, + outgoingPaidMessageStarCount = outgoingPaidMessageStarCount + ) +} + +fun TdApi.BasicGroupFullInfo.mapBasicGroupFullInfoToChat(): ChatFullInfoModel { + return ChatFullInfoModel( + description = description.ifEmpty { null }, + inviteLink = inviteLink?.inviteLink, + memberCount = members.size + ) +} + +fun TdApi.Chat.toDomain(): ChatModel { + val isChannel = type.isChannelType() + return ChatModel( + id = id, + title = title, + avatarPath = photo?.small?.local?.path?.takeIf { isValidFilePath(it) }, + unreadCount = unreadCount, + isMuted = notificationSettings.muteFor > 0, + isChannel = isChannel, + isGroup = type.isGroupType(), + type = type.toDomainChatType(), + lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "" + ) +} + +private fun TdApi.BotVerification.toDomain(): BotVerificationModel { + return BotVerificationModel( + botUserId = botUserId, + iconCustomEmojiId = iconCustomEmojiId, + customDescription = customDescription.text.ifEmpty { null } + ) +} + +private fun TdApi.BotVerificationParameters.toDomain(): BotVerificationParametersModel { + return BotVerificationParametersModel( + iconCustomEmojiId = iconCustomEmojiId, + organizationName = organizationName.ifEmpty { null }, + defaultCustomDescription = defaultCustomDescription?.text?.ifEmpty { null }, + canSetCustomDescription = canSetCustomDescription + ) +} + +private fun TdApi.UserRating.toDomain(): UserRatingModel { + return UserRatingModel( + level = level, + isMaximumLevelReached = isMaximumLevelReached, + rating = rating, + currentLevelRating = currentLevelRating, + nextLevelRating = nextLevelRating + ) +} + +private fun TdApi.Audio.toDomain(): ProfileAudioModel { + val filePath = audio.local.path.takeIf { isValidFilePath(it) } + return ProfileAudioModel( + duration = duration, + title = title.ifEmpty { null }, + performer = performer.ifEmpty { null }, + fileName = fileName.ifEmpty { null }, + mimeType = mimeType.ifEmpty { null }, + fileId = audio.id, + filePath = filePath + ) +} + +private fun TdApi.ProfileTab.toDomain(): ProfileTabType { + return when (this) { + is TdApi.ProfileTabPosts -> ProfileTabType.POSTS + is TdApi.ProfileTabGifts -> ProfileTabType.GIFTS + is TdApi.ProfileTabMedia -> ProfileTabType.MEDIA + is TdApi.ProfileTabFiles -> ProfileTabType.FILES + is TdApi.ProfileTabLinks -> ProfileTabType.LINKS + is TdApi.ProfileTabMusic -> ProfileTabType.MUSIC + is TdApi.ProfileTabVoice -> ProfileTabType.VOICE + is TdApi.ProfileTabGifs -> ProfileTabType.GIFS + else -> ProfileTabType.UNKNOWN + } +} + +private fun TdApi.BotCommand.toDomain(): BotCommandModel { + return BotCommandModel( + command = command, + description = description + ) +} + +private fun TdApi.BotCommands.toDomain(): SupergroupBotCommandsModel { + return SupergroupBotCommandsModel( + botUserId = botUserId, + commands = commands?.map { it.toDomain() } ?: emptyList() + ) +} + +private fun TdApi.ChatAdministratorRights.toDomain(): ChatAdministratorRightsModel { + return ChatAdministratorRightsModel( + canManageChat = canManageChat, + canChangeInfo = canChangeInfo, + canPostMessages = canPostMessages, + canEditMessages = canEditMessages, + canDeleteMessages = canDeleteMessages, + canInviteUsers = canInviteUsers, + canRestrictMembers = canRestrictMembers, + canPinMessages = canPinMessages, + canManageTopics = canManageTopics, + canPromoteMembers = canPromoteMembers, + canManageVideoChats = canManageVideoChats, + canPostStories = canPostStories, + canEditStories = canEditStories, + canDeleteStories = canDeleteStories, + canManageDirectMessages = canManageDirectMessages, + canManageTags = canManageTags, + isAnonymous = isAnonymous + ) +} + +private fun TdApi.AffiliateProgramInfo.toDomain(): AffiliateProgramInfoModel { + return AffiliateProgramInfoModel( + commissionPerMille = parameters.commissionPerMille, + monthCount = parameters.monthCount, + endDate = endDate, + dailyRevenuePerUserStarCount = dailyRevenuePerUserAmount.starCount, + dailyRevenuePerUserNanostarCount = dailyRevenuePerUserAmount.nanostarCount + ) +} + +private fun TdApi.BotInfo.toDomain(): BotInfoModel { + return BotInfoModel( + commands = commands?.map { it.toDomain() } ?: emptyList(), + menuButton = when (val button = menuButton) { + is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(button.text, button.url) + null -> BotMenuButtonModel.Commands + else -> BotMenuButtonModel.Default + }, + shortDescription = shortDescription.ifEmpty { null }, + description = description.ifEmpty { null }, + photoFileId = photo?.sizes?.lastOrNull()?.photo?.id ?: 0, + photoPath = photo?.resolvePhotoPath(), + animationFileId = animation?.animation?.id ?: 0, + animationPath = animation?.animation?.local?.path?.takeIf { isValidFilePath(it) }, + managerBotUserId = managerBotUserId, + privacyPolicyUrl = privacyPolicyUrl.ifEmpty { null }, + defaultGroupAdministratorRights = defaultGroupAdministratorRights?.toDomain(), + defaultChannelAdministratorRights = defaultChannelAdministratorRights?.toDomain(), + affiliateProgram = affiliateProgram?.toDomain(), + webAppBackgroundLightColor = webAppBackgroundLightColor, + webAppBackgroundDarkColor = webAppBackgroundDarkColor, + webAppHeaderLightColor = webAppHeaderLightColor, + webAppHeaderDarkColor = webAppHeaderDarkColor, + verificationParameters = verificationParameters?.toDomain(), + canGetRevenueStatistics = canGetRevenueStatistics, + canManageEmojiStatus = canManageEmojiStatus, + hasMediaPreviews = hasMediaPreviews, + editCommandsLinkType = editCommandsLink?.javaClass?.simpleName, + editDescriptionLinkType = editDescriptionLink?.javaClass?.simpleName, + editDescriptionMediaLinkType = editDescriptionMediaLink?.javaClass?.simpleName, + editSettingsLinkType = editSettingsLink?.javaClass?.simpleName + ) +} + +private fun TdApi.Photo.resolvePhotoPath(): String? { + val bestPhotoSize = sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: sizes.lastOrNull() + return bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } +} + +private fun TdApi.ChatPhoto?.resolveChatPhotoPath(): String? { + if (this == null) return null + val bestPhotoSize = sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: sizes.lastOrNull() + return animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } +} + +private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel { + return BusinessInfoModel( + location = location?.let { + BusinessLocationModel( + it.location!!.latitude, + it.location!!.longitude, + it.address + ) + }, + openingHours = openingHours?.let { + BusinessOpeningHoursModel( + it.timeZoneId, + it.openingHours.map { interval -> + BusinessOpeningHoursIntervalModel(interval.startMinute, interval.endMinute) + } + ) + }, + startPage = startPage?.let { + BusinessStartPageModel( + title = it.title, + message = it.message, + stickerPath = it.sticker?.sticker?.local?.path?.takeIf { path -> isValidFilePath(path) } + ) + }, + nextOpenIn = nextOpenIn, + nextCloseIn = nextCloseIn + ) +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt new file mode 100644 index 00000000..d85bfc45 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/ChatMemberMapper.kt @@ -0,0 +1,107 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.mapper.toDomainChatPermissions +import org.monogram.data.mapper.toTdApiChatPermissions +import org.monogram.domain.models.GroupMemberModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.repository.ChatMemberStatus +import org.monogram.domain.repository.ChatMembersFilter + +fun TdApi.ChatMember.toDomain(user: UserModel): GroupMemberModel { + val rank = when (this.status) { + is TdApi.ChatMemberStatusCreator -> "Owner" + is TdApi.ChatMemberStatusAdministrator -> "Admin" + else -> null + } + return GroupMemberModel( + user = user, + rank = rank, + status = this.status.toDomain() + ) +} + +fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { + return when (this) { + is TdApi.ChatMemberStatusCreator -> ChatMemberStatus.Creator + is TdApi.ChatMemberStatusAdministrator -> ChatMemberStatus.Administrator( + customTitle = "", + canBeEdited = canBeEdited, + canManageChat = rights.canManageChat, + canChangeInfo = rights.canChangeInfo, + canPostMessages = rights.canPostMessages, + canEditMessages = rights.canEditMessages, + canDeleteMessages = rights.canDeleteMessages, + canInviteUsers = rights.canInviteUsers, + canRestrictMembers = rights.canRestrictMembers, + canPinMessages = rights.canPinMessages, + canManageTopics = rights.canManageTopics, + canPromoteMembers = rights.canPromoteMembers, + canManageVideoChats = rights.canManageVideoChats, + canPostStories = rights.canPostStories, + canEditStories = rights.canEditStories, + canDeleteStories = rights.canDeleteStories, + canManageDirectMessages = rights.canManageDirectMessages, + isAnonymous = rights.isAnonymous + ) + + is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted( + isMember = isMember, + restrictedUntilDate = restrictedUntilDate, + permissions = permissions.toDomainChatPermissions() + ) + + is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate) + is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left + else -> ChatMemberStatus.Member + } +} + +fun ChatMembersFilter.toApi(): TdApi.SupergroupMembersFilter { + return when (this) { + is ChatMembersFilter.Recent -> TdApi.SupergroupMembersFilterRecent() + is ChatMembersFilter.Administrators -> TdApi.SupergroupMembersFilterAdministrators() + is ChatMembersFilter.Banned -> TdApi.SupergroupMembersFilterBanned() + is ChatMembersFilter.Restricted -> TdApi.SupergroupMembersFilterRestricted() + is ChatMembersFilter.Bots -> TdApi.SupergroupMembersFilterBots() + is ChatMembersFilter.Search -> TdApi.SupergroupMembersFilterSearch(this.query) + } +} + +fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { + return when (this) { + is ChatMemberStatus.Member -> TdApi.ChatMemberStatusMember() + is ChatMemberStatus.Administrator -> TdApi.ChatMemberStatusAdministrator( + canBeEdited, + TdApi.ChatAdministratorRights( + canManageChat, + canChangeInfo, + canPostMessages, + canEditMessages, + canDeleteMessages, + canInviteUsers, + canRestrictMembers, + canPinMessages, + canManageTopics, + canPromoteMembers, + canManageVideoChats, + canPostStories, + canEditStories, + canDeleteStories, + canManageDirectMessages, + false, + isAnonymous + ) + ) + + is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted( + isMember, + restrictedUntilDate, + permissions.toTdApiChatPermissions() + ) + + is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft() + is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate) + is ChatMemberStatus.Creator -> TdApi.ChatMemberStatusCreator(false, true) + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt b/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt new file mode 100644 index 00000000..e750e83b --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/EntityEncodingUtils.kt @@ -0,0 +1,264 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.domain.models.* + +internal fun encodeChatAdministratorRights(rights: TdApi.ChatAdministratorRights?): String? { + if (rights == null) return null + return listOf( + rights.canManageChat, + rights.canChangeInfo, + rights.canPostMessages, + rights.canEditMessages, + rights.canDeleteMessages, + rights.canInviteUsers, + rights.canRestrictMembers, + rights.canPinMessages, + rights.canManageTopics, + rights.canPromoteMembers, + rights.canManageVideoChats, + rights.canPostStories, + rights.canEditStories, + rights.canDeleteStories, + rights.canManageDirectMessages, + rights.canManageTags, + rights.isAnonymous + ).joinToString("|") { if (it) "1" else "0" } +} + +internal fun decodeChatAdministratorRights(data: String?): TdApi.ChatAdministratorRights? { + if (data.isNullOrBlank()) return null + val values = data.split('|') + fun bit(index: Int): Boolean = values.getOrNull(index) == "1" + return TdApi.ChatAdministratorRights( + bit(0), + bit(1), + bit(2), + bit(3), + bit(4), + bit(5), + bit(6), + bit(7), + bit(8), + bit(9), + bit(10), + bit(11), + bit(12), + bit(13), + bit(14), + bit(15), + bit(16) + ) +} + +internal fun encodeAffiliateProgramInfo(affiliateProgram: TdApi.AffiliateProgramInfo?): String? { + if (affiliateProgram == null) return null + val params = affiliateProgram.parameters + val amount = affiliateProgram.dailyRevenuePerUserAmount + return listOf( + params.commissionPerMille.toString(), + params.monthCount.toString(), + affiliateProgram.endDate.toString(), + amount.starCount.toString(), + amount.nanostarCount.toString() + ).joinToString("|") +} + +internal fun decodeAffiliateProgramInfo(data: String?): TdApi.AffiliateProgramInfo? { + if (data.isNullOrBlank()) return null + val values = data.split('|') + val commissionPerMille = values.getOrNull(0)?.toIntOrNull() ?: return null + val monthCount = values.getOrNull(1)?.toIntOrNull() ?: return null + val endDate = values.getOrNull(2)?.toIntOrNull() ?: return null + val starCount = values.getOrNull(3)?.toLongOrNull() ?: return null + val nanostarCount = values.getOrNull(4)?.toIntOrNull() ?: return null + return TdApi.AffiliateProgramInfo( + TdApi.AffiliateProgramParameters(commissionPerMille, monthCount), + endDate, + TdApi.StarAmount(starCount, nanostarCount) + ) +} + +internal fun encodeProfileAudio(audio: ProfileAudioModel?): String? { + if (audio == null) return null + return listOf( + audio.duration.toString(), + audio.title.orEmpty().escapeStorage(), + audio.performer.orEmpty().escapeStorage(), + audio.fileName.orEmpty().escapeStorage(), + audio.mimeType.orEmpty().escapeStorage(), + audio.fileId.toString(), + audio.filePath.orEmpty().escapeStorage() + ).joinToString("|") +} + +internal fun decodeProfileAudio(data: String?): ProfileAudioModel? { + if (data.isNullOrBlank()) return null + val parts = data.split('|') + return ProfileAudioModel( + duration = parts.getOrNull(0)?.toIntOrNull() ?: 0, + title = parts.getOrNull(1)?.unescapeStorage()?.ifEmpty { null }, + performer = parts.getOrNull(2)?.unescapeStorage()?.ifEmpty { null }, + fileName = parts.getOrNull(3)?.unescapeStorage()?.ifEmpty { null }, + mimeType = parts.getOrNull(4)?.unescapeStorage()?.ifEmpty { null }, + fileId = parts.getOrNull(5)?.toIntOrNull() ?: 0, + filePath = parts.getOrNull(6)?.unescapeStorage()?.ifEmpty { null } + ) +} + +internal fun encodeUserRating(rating: UserRatingModel?): String? { + if (rating == null) return null + return listOf( + rating.level.toString(), + if (rating.isMaximumLevelReached) "1" else "0", + rating.rating.toString(), + rating.currentLevelRating.toString(), + rating.nextLevelRating.toString() + ).joinToString("|") +} + +internal fun decodeUserRating(data: String?): UserRatingModel? { + if (data.isNullOrBlank()) return null + val parts = data.split('|') + return UserRatingModel( + level = parts.getOrNull(0)?.toIntOrNull() ?: 0, + isMaximumLevelReached = parts.getOrNull(1) == "1", + rating = parts.getOrNull(2)?.toLongOrNull() ?: 0L, + currentLevelRating = parts.getOrNull(3)?.toLongOrNull() ?: 0L, + nextLevelRating = parts.getOrNull(4)?.toLongOrNull() ?: 0L + ) +} + +internal fun encodeBotCommands(commands: Array?): String? { + if (commands.isNullOrEmpty()) return null + return commands.joinToString("\n") { botCommands -> + val serializedCommands = (botCommands.commands ?: emptyArray()).joinToString(";") { command -> + "${command.command.escapeStorage()},${command.description.escapeStorage()}" + } + "${botCommands.botUserId}:$serializedCommands" + } +} + +internal fun encodeBotInfoCommands(commands: Array?): String? { + if (commands.isNullOrEmpty()) return null + return commands.joinToString(";") { command -> + "${command.command.escapeStorage()},${command.description.escapeStorage()}" + } +} + +internal fun decodeBotInfoCommands(data: String?): Array { + if (data.isNullOrBlank()) return emptyArray() + return data.split(';').mapNotNull { item -> + val commandSeparator = item.indexOf(',') + if (commandSeparator < 0) return@mapNotNull null + val command = item.substring(0, commandSeparator).unescapeStorage() + val description = item.substring(commandSeparator + 1).unescapeStorage() + TdApi.BotCommand(command, description) + }.toTypedArray() +} + +internal fun decodeBotCommands(data: String?): List { + if (data.isNullOrBlank()) return emptyList() + return data.split('\n').mapNotNull { line -> + val separator = line.indexOf(':') + if (separator <= 0) return@mapNotNull null + val botUserId = line.substring(0, separator).toLongOrNull() ?: return@mapNotNull null + val commandsRaw = line.substring(separator + 1) + val commands = if (commandsRaw.isBlank()) { + emptyList() + } else { + commandsRaw.split(';').mapNotNull { item -> + val commandSeparator = item.indexOf(',') + if (commandSeparator < 0) return@mapNotNull null + val command = item.substring(0, commandSeparator).unescapeStorage() + val description = item.substring(commandSeparator + 1).unescapeStorage() + BotCommandModel(command, description) + } + } + SupergroupBotCommandsModel(botUserId = botUserId, commands = commands) + } +} + +internal fun TdApi.ProfileTab?.toTypeString(): String? { + return when (this) { + is TdApi.ProfileTabPosts -> "POSTS" + is TdApi.ProfileTabGifts -> "GIFTS" + is TdApi.ProfileTabMedia -> "MEDIA" + is TdApi.ProfileTabFiles -> "FILES" + is TdApi.ProfileTabLinks -> "LINKS" + is TdApi.ProfileTabMusic -> "MUSIC" + is TdApi.ProfileTabVoice -> "VOICE" + is TdApi.ProfileTabGifs -> "GIFS" + else -> null + } +} + +internal fun String?.toTdApiProfileTab(): TdApi.ProfileTab? { + return when (this) { + "POSTS" -> TdApi.ProfileTabPosts() + "GIFTS" -> TdApi.ProfileTabGifts() + "MEDIA" -> TdApi.ProfileTabMedia() + "FILES" -> TdApi.ProfileTabFiles() + "LINKS" -> TdApi.ProfileTabLinks() + "MUSIC" -> TdApi.ProfileTabMusic() + "VOICE" -> TdApi.ProfileTabVoice() + "GIFS" -> TdApi.ProfileTabGifs() + else -> null + } +} + +internal fun String?.toProfileTabType(): ProfileTabType? { + return when (this) { + "POSTS" -> ProfileTabType.POSTS + "GIFTS" -> ProfileTabType.GIFTS + "MEDIA" -> ProfileTabType.MEDIA + "FILES" -> ProfileTabType.FILES + "LINKS" -> ProfileTabType.LINKS + "MUSIC" -> ProfileTabType.MUSIC + "VOICE" -> ProfileTabType.VOICE + "GIFS" -> ProfileTabType.GIFS + else -> null + } +} + +internal fun String?.toTdApiChatPhoto(): TdApi.ChatPhoto? { + if (this.isNullOrBlank()) return null + return TdApi.ChatPhoto().apply { + sizes = arrayOf( + TdApi.PhotoSize().apply { + type = "x" + width = 0 + height = 0 + photo = TdApi.File().apply { + local = TdApi.LocalFile().apply { path = this@toTdApiChatPhoto } + } + } + ) + } +} + +internal fun TdApi.BlockList?.toTypeString(): String? { + return when (this) { + is TdApi.BlockListMain -> "MAIN" + is TdApi.BlockListStories -> "STORIES" + else -> null + } +} + +internal fun String.escapeStorage(): String { + return this + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("|", "\\p") + .replace(",", "\\c") + .replace(";", "\\s") +} + +internal fun String.unescapeStorage(): String { + return this + .replace("\\s", ";") + .replace("\\c", ",") + .replace("\\p", "|") + .replace("\\n", "\n") + .replace("\\\\", "\\") +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt index d309e9ae..51620923 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/UserEntityMapper.kt @@ -28,6 +28,8 @@ fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity { else -> 0L } + val botType = type as? TdApi.UserTypeBot + return UserEntity( id = id, firstName = firstName, @@ -38,18 +40,44 @@ fun TdApi.User.toEntity(personalAvatarPath: String?): UserEntity { personalAvatarPath = personalAvatarPath, isPremium = isPremium, isVerified = verificationStatus?.isVerified ?: false, + isScam = verificationStatus?.isScam ?: false, + isFake = verificationStatus?.isFake ?: false, + botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L, isSupport = isSupport, isContact = isContact, isMutualContact = isMutualContact, isCloseFriend = isCloseFriend, + botTypeCanBeEdited = botType?.canBeEdited ?: false, + botTypeCanJoinGroups = botType?.canJoinGroups ?: false, + botTypeCanReadAllGroupMessages = botType?.canReadAllGroupMessages ?: false, + botTypeHasMainWebApp = botType?.hasMainWebApp ?: false, + botTypeHasTopics = botType?.hasTopics ?: false, + botTypeAllowsUsersToCreateTopics = botType?.allowsUsersToCreateTopics ?: false, + botTypeCanManageBots = botType?.canManageBots ?: false, + botTypeIsInline = botType?.isInline ?: false, + botTypeInlineQueryPlaceholder = botType?.inlineQueryPlaceholder?.ifEmpty { null }, + botTypeNeedLocation = botType?.needLocation ?: false, + botTypeCanConnectToBusiness = botType?.canConnectToBusiness ?: false, + botTypeCanBeAddedToAttachmentMenu = botType?.canBeAddedToAttachmentMenu ?: false, + botTypeActiveUserCount = botType?.activeUserCount ?: 0, + userType = type.toTypeString(), + restrictionReason = restrictionInfo?.restrictionReason?.ifEmpty { null }, + hasSensitiveContent = restrictionInfo?.hasSensitiveContent ?: false, + activeStoryStateType = activeStoryState.toTypeString(), + activeStoryId = (activeStoryState as? TdApi.ActiveStoryStateLive)?.storyId ?: 0, + restrictsNewChats = restrictsNewChats, + paidMessageStarCount = paidMessageStarCount, haveAccess = haveAccess, username = usernames?.activeUsernames?.firstOrNull(), usernamesData = usernamesData, statusType = statusType, accentColorId = accentColorId, + backgroundCustomEmojiId = backgroundCustomEmojiId, profileAccentColorId = profileAccentColorId, + profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId, statusEmojiId = statusEmojiId, languageCode = languageCode.ifEmpty { null }, + addedToAttachmentMenu = addedToAttachmentMenu, lastSeen = (status as? TdApi.UserStatusOffline)?.wasOnline?.toLong() ?: 0L, createdAt = System.currentTimeMillis() ) @@ -60,4 +88,135 @@ fun TdApi.UserFullInfo.extractPersonalAvatarPath(): String? { ?: personalPhoto?.sizes?.lastOrNull() return personalPhoto?.animation?.file?.local?.path?.ifEmpty { null } ?: bestPhotoSize?.photo?.local?.path?.ifEmpty { null } -} \ No newline at end of file +} + +fun UserEntity.toTdApi(): TdApi.User { + return TdApi.User().apply { + id = this@toTdApi.id + firstName = this@toTdApi.firstName + lastName = this@toTdApi.lastName ?: "" + phoneNumber = this@toTdApi.phoneNumber ?: "" + isPremium = this@toTdApi.isPremium + isSupport = this@toTdApi.isSupport + isContact = this@toTdApi.isContact + isMutualContact = this@toTdApi.isMutualContact + isCloseFriend = this@toTdApi.isCloseFriend + haveAccess = this@toTdApi.haveAccess + languageCode = this@toTdApi.languageCode ?: "" + accentColorId = this@toTdApi.accentColorId + backgroundCustomEmojiId = this@toTdApi.backgroundCustomEmojiId + profileAccentColorId = this@toTdApi.profileAccentColorId + profileBackgroundCustomEmojiId = this@toTdApi.profileBackgroundCustomEmojiId + verificationStatus = if ( + isVerified || + isScam || + isFake || + botVerificationIconCustomEmojiId != 0L + ) { + TdApi.VerificationStatus( + isVerified, + isScam, + isFake, + botVerificationIconCustomEmojiId + ) + } else { + null + } + val (active, disabled, editable, collectible) = decodeUsernames( + this@toTdApi.usernamesData, + this@toTdApi.username + ) + usernames = TdApi.Usernames(active, disabled, editable, collectible) + emojiStatus = this@toTdApi.statusEmojiId.takeIf { it != 0L }?.let { + TdApi.EmojiStatus(TdApi.EmojiStatusTypeCustomEmoji(it), 0) + } + status = when (this@toTdApi.statusType) { + "ONLINE" -> TdApi.UserStatusOnline(0) + "RECENTLY" -> TdApi.UserStatusRecently() + "LAST_WEEK" -> TdApi.UserStatusLastWeek() + "LAST_MONTH" -> TdApi.UserStatusLastMonth() + else -> TdApi.UserStatusOffline(lastSeen.toInt()) + } + type = when (this@toTdApi.userType) { + "REGULAR" -> TdApi.UserTypeRegular() + "BOT" -> TdApi.UserTypeBot( + botTypeCanBeEdited, + botTypeCanJoinGroups, + botTypeCanReadAllGroupMessages, + botTypeHasMainWebApp, + botTypeHasTopics, + botTypeAllowsUsersToCreateTopics, + botTypeCanManageBots, + botTypeIsInline, + botTypeInlineQueryPlaceholder.orEmpty(), + botTypeNeedLocation, + botTypeCanConnectToBusiness, + botTypeCanBeAddedToAttachmentMenu, + botTypeActiveUserCount + ) + + "DELETED" -> TdApi.UserTypeDeleted() + else -> TdApi.UserTypeUnknown() + } + restrictionInfo = if (!restrictionReason.isNullOrBlank() || hasSensitiveContent) { + TdApi.RestrictionInfo( + restrictionReason.orEmpty(), + hasSensitiveContent + ) + } else { + null + } + activeStoryState = when (activeStoryStateType) { + "LIVE" -> TdApi.ActiveStoryStateLive(activeStoryId) + "UNREAD" -> TdApi.ActiveStoryStateUnread() + "READ" -> TdApi.ActiveStoryStateRead() + else -> null + } + restrictsNewChats = this@toTdApi.restrictsNewChats + paidMessageStarCount = this@toTdApi.paidMessageStarCount + addedToAttachmentMenu = this@toTdApi.addedToAttachmentMenu + profilePhoto = avatarPath?.let { path -> + TdApi.ProfilePhoto().apply { + small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } + } + } + } +} + +private fun decodeUsernames(data: String?, fallbackUsername: String?): QuadUsernames { + if (data.isNullOrEmpty()) { + val active = fallbackUsername?.takeIf { it.isNotBlank() }?.let { arrayOf(it) } ?: emptyArray() + return QuadUsernames(active, emptyArray(), fallbackUsername.orEmpty(), emptyArray()) + } + val parts = data.split("\n", limit = 4) + val active = parts.getOrNull(0).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() + val disabled = parts.getOrNull(1).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() + val editable = parts.getOrNull(2).orEmpty() + val collectible = parts.getOrNull(3).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() + return QuadUsernames(active, disabled, editable, collectible) +} + +private data class QuadUsernames( + val active: Array, + val disabled: Array, + val editable: String, + val collectible: Array +) + +private fun TdApi.ActiveStoryState?.toTypeString(): String? { + return when (this) { + is TdApi.ActiveStoryStateLive -> "LIVE" + is TdApi.ActiveStoryStateUnread -> "UNREAD" + is TdApi.ActiveStoryStateRead -> "READ" + else -> null + } +} + +private fun TdApi.UserType?.toTypeString(): String { + return when (this) { + is TdApi.UserTypeRegular -> "REGULAR" + is TdApi.UserTypeBot -> "BOT" + is TdApi.UserTypeDeleted -> "DELETED" + else -> "UNKNOWN" + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt new file mode 100644 index 00000000..ab52de08 --- /dev/null +++ b/data/src/main/java/org/monogram/data/mapper/user/UserFullInfoEntityMapper.kt @@ -0,0 +1,338 @@ +package org.monogram.data.mapper.user + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.UserFullInfoEntity +import org.monogram.data.mapper.isValidFilePath + +fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity { + val businessLocation = businessInfo?.location + val businessOpeningHours = businessInfo?.openingHours + val businessStartPage = businessInfo?.startPage + val birth = birthdate + val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } + val publicPhotoPath = publicPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } + ?: (publicPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: publicPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } + val botInfoPhotoPath = botInfo?.photo?.let { photo -> + val bestPhotoSize = photo.sizes.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: photo.sizes.lastOrNull() + bestPhotoSize?.photo?.local?.path?.takeIf { isValidFilePath(it) } + } + val botInfoPhotoFileId = botInfo?.photo?.sizes?.lastOrNull()?.photo?.id ?: 0 + val botInfoAnimationFileId = botInfo?.animation?.animation?.id ?: 0 + val botInfoAnimationPath = botInfo?.animation?.animation?.local?.path?.takeIf { isValidFilePath(it) } + val botVerification = botVerification + val firstProfileAudio = firstProfileAudio + val rating = rating + val pendingRating = pendingRating + val botInfoVerificationParams = botInfo?.verificationParameters + + return UserFullInfoEntity( + userId = userId, + bio = bio?.text?.ifEmpty { null }, + commonGroupsCount = groupInCommonCount, + giftCount = giftCount, + botInfoDescription = botInfo?.description?.ifEmpty { null }, + botInfoShortDescription = botInfo?.shortDescription?.ifEmpty { null }, + botInfoPhotoFileId = botInfoPhotoFileId, + botInfoPhotoPath = botInfoPhotoPath, + botInfoAnimationFileId = botInfoAnimationFileId, + botInfoAnimationPath = botInfoAnimationPath, + botInfoManagerBotUserId = botInfo?.managerBotUserId ?: 0L, + botInfoMenuButtonText = botInfo?.menuButton?.text?.ifEmpty { null }, + botInfoMenuButtonUrl = botInfo?.menuButton?.url?.ifEmpty { null }, + botInfoCommandsData = encodeBotInfoCommands(botInfo?.commands), + botInfoPrivacyPolicyUrl = botInfo?.privacyPolicyUrl?.ifEmpty { null }, + botInfoDefaultGroupRightsData = encodeChatAdministratorRights(botInfo?.defaultGroupAdministratorRights), + botInfoDefaultChannelRightsData = encodeChatAdministratorRights(botInfo?.defaultChannelAdministratorRights), + botInfoAffiliateProgramData = encodeAffiliateProgramInfo(botInfo?.affiliateProgram), + botInfoWebAppBackgroundLightColor = botInfo?.webAppBackgroundLightColor ?: -1, + botInfoWebAppBackgroundDarkColor = botInfo?.webAppBackgroundDarkColor ?: -1, + botInfoWebAppHeaderLightColor = botInfo?.webAppHeaderLightColor ?: -1, + botInfoWebAppHeaderDarkColor = botInfo?.webAppHeaderDarkColor ?: -1, + botInfoVerificationParametersIconCustomEmojiId = botInfoVerificationParams?.iconCustomEmojiId ?: 0L, + botInfoVerificationParametersOrganizationName = botInfoVerificationParams?.organizationName?.ifEmpty { null }, + botInfoVerificationParametersDefaultCustomDescription = botInfoVerificationParams?.defaultCustomDescription?.text?.ifEmpty { null }, + botInfoVerificationParametersCanSetCustomDescription = botInfoVerificationParams?.canSetCustomDescription + ?: false, + botInfoCanManageEmojiStatus = botInfo?.canManageEmojiStatus ?: false, + botInfoHasMediaPreviews = botInfo?.hasMediaPreviews ?: false, + botInfoEditCommandsLinkType = botInfo?.editCommandsLink?.javaClass?.simpleName, + botInfoEditDescriptionLinkType = botInfo?.editDescriptionLink?.javaClass?.simpleName, + botInfoEditDescriptionMediaLinkType = botInfo?.editDescriptionMediaLink?.javaClass?.simpleName, + botInfoEditSettingsLinkType = botInfo?.editSettingsLink?.javaClass?.simpleName, + personalChatId = personalChatId, + birthdateDay = birth?.day ?: 0, + birthdateMonth = birth?.month ?: 0, + birthdateYear = birth?.year ?: 0, + publicPhotoPath = publicPhotoPath, + blockListType = blockList.toTypeString(), + businessLocationAddress = businessLocation?.address?.ifEmpty { null }, + businessLocationLatitude = businessLocation?.location?.latitude ?: 0.0, + businessLocationLongitude = businessLocation?.location?.longitude ?: 0.0, + businessOpeningHoursTimeZone = businessOpeningHours?.timeZoneId, + businessNextOpenIn = businessInfo?.nextOpenIn ?: 0, + businessNextCloseIn = businessInfo?.nextCloseIn ?: 0, + businessStartPageTitle = businessStartPage?.title?.ifEmpty { null }, + businessStartPageMessage = businessStartPage?.message?.ifEmpty { null }, + note = note?.text?.ifEmpty { null }, + personalPhotoPath = personalPhotoPath, + isBlocked = blockList != null, + hasSponsoredMessagesEnabled = hasSponsoredMessagesEnabled, + needPhoneNumberPrivacyException = needPhoneNumberPrivacyException, + usesUnofficialApp = usesUnofficialApp, + botVerificationBotUserId = botVerification?.botUserId ?: 0L, + botVerificationIconCustomEmojiId = botVerification?.iconCustomEmojiId ?: 0L, + botVerificationCustomDescription = botVerification?.customDescription?.text?.ifEmpty { null }, + mainProfileTab = mainProfileTab.toTypeString(), + firstProfileAudioDuration = firstProfileAudio?.duration ?: 0, + firstProfileAudioTitle = firstProfileAudio?.title?.ifEmpty { null }, + firstProfileAudioPerformer = firstProfileAudio?.performer?.ifEmpty { null }, + firstProfileAudioFileName = firstProfileAudio?.fileName?.ifEmpty { null }, + firstProfileAudioMimeType = firstProfileAudio?.mimeType?.ifEmpty { null }, + firstProfileAudioFileId = firstProfileAudio?.audio?.id ?: 0, + firstProfileAudioPath = firstProfileAudio?.audio?.local?.path?.takeIf { isValidFilePath(it) }, + ratingLevel = rating?.level ?: 0, + ratingIsMaximumLevelReached = rating?.isMaximumLevelReached ?: false, + ratingValue = rating?.rating ?: 0L, + ratingCurrentLevelValue = rating?.currentLevelRating ?: 0L, + ratingNextLevelValue = rating?.nextLevelRating ?: 0L, + pendingRatingLevel = pendingRating?.level ?: 0, + pendingRatingIsMaximumLevelReached = pendingRating?.isMaximumLevelReached ?: false, + pendingRatingValue = pendingRating?.rating ?: 0L, + pendingRatingCurrentLevelValue = pendingRating?.currentLevelRating ?: 0L, + pendingRatingNextLevelValue = pendingRating?.nextLevelRating ?: 0L, + pendingRatingDate = pendingRatingDate, + canBeCalled = canBeCalled, + supportsVideoCalls = supportsVideoCalls, + hasPrivateCalls = hasPrivateCalls, + hasPrivateForwards = hasPrivateForwards, + hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, + hasPostedToProfileStories = hasPostedToProfileStories, + setChatBackground = setChatBackground, + canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, + createdAt = System.currentTimeMillis() + ) +} + +fun UserFullInfoEntity.toTdApi(): TdApi.UserFullInfo { + return TdApi.UserFullInfo().apply { + bio = this@toTdApi.bio?.let { TdApi.FormattedText(it, emptyArray()) } + groupInCommonCount = commonGroupsCount + giftCount = this@toTdApi.giftCount + personalChatId = this@toTdApi.personalChatId + birthdate = if (birthdateDay > 0 && birthdateMonth > 0) { + TdApi.Birthdate(birthdateDay, birthdateMonth, birthdateYear) + } else { + null + } + botInfo = if ( + botInfoDescription != null || + botInfoShortDescription != null || + botInfoManagerBotUserId != 0L || + botInfoPrivacyPolicyUrl != null + ) { + TdApi.BotInfo().apply { + shortDescription = botInfoShortDescription.orEmpty() + description = botInfoDescription.orEmpty() + photo = if (botInfoPhotoFileId != 0 || !botInfoPhotoPath.isNullOrEmpty()) { + TdApi.Photo().apply { + sizes = arrayOf( + TdApi.PhotoSize().apply { + type = "x" + width = 0 + height = 0 + photo = TdApi.File().apply { + id = botInfoPhotoFileId + local = TdApi.LocalFile().apply { path = botInfoPhotoPath.orEmpty() } + } + } + ) + } + } else { + null + } + animation = if (botInfoAnimationFileId != 0 || !botInfoAnimationPath.isNullOrEmpty()) { + TdApi.Animation().apply { + animation = TdApi.File().apply { + id = botInfoAnimationFileId + local = TdApi.LocalFile().apply { path = botInfoAnimationPath.orEmpty() } + } + } + } else { + null + } + managerBotUserId = botInfoManagerBotUserId + menuButton = if (!botInfoMenuButtonText.isNullOrEmpty() || !botInfoMenuButtonUrl.isNullOrEmpty()) { + TdApi.BotMenuButton( + botInfoMenuButtonText.orEmpty(), + botInfoMenuButtonUrl.orEmpty() + ) + } else { + null + } + commands = decodeBotInfoCommands(botInfoCommandsData) + privacyPolicyUrl = botInfoPrivacyPolicyUrl.orEmpty() + defaultGroupAdministratorRights = decodeChatAdministratorRights(botInfoDefaultGroupRightsData) + defaultChannelAdministratorRights = decodeChatAdministratorRights(botInfoDefaultChannelRightsData) + affiliateProgram = decodeAffiliateProgramInfo(botInfoAffiliateProgramData) + webAppBackgroundLightColor = botInfoWebAppBackgroundLightColor + webAppBackgroundDarkColor = botInfoWebAppBackgroundDarkColor + webAppHeaderLightColor = botInfoWebAppHeaderLightColor + webAppHeaderDarkColor = botInfoWebAppHeaderDarkColor + verificationParameters = if ( + botInfoVerificationParametersIconCustomEmojiId != 0L || + !botInfoVerificationParametersOrganizationName.isNullOrEmpty() || + !botInfoVerificationParametersDefaultCustomDescription.isNullOrEmpty() + ) { + TdApi.BotVerificationParameters( + botInfoVerificationParametersIconCustomEmojiId, + botInfoVerificationParametersOrganizationName.orEmpty(), + botInfoVerificationParametersDefaultCustomDescription?.let { + TdApi.FormattedText(it, emptyArray()) + }, + botInfoVerificationParametersCanSetCustomDescription + ) + } else { + null + } + canGetRevenueStatistics = canGetRevenueStatistics + canManageEmojiStatus = botInfoCanManageEmojiStatus + hasMediaPreviews = botInfoHasMediaPreviews + editCommandsLink = null + editDescriptionLink = null + editDescriptionMediaLink = null + editSettingsLink = null + } + } else { + null + } + businessInfo = if ( + businessLocationAddress != null || + businessOpeningHoursTimeZone != null || + businessStartPageTitle != null || + businessStartPageMessage != null + ) { + TdApi.BusinessInfo().apply { + location = businessLocationAddress?.let { address -> + TdApi.BusinessLocation( + TdApi.Location(businessLocationLatitude, businessLocationLongitude, 0.0), + address + ) + } + openingHours = businessOpeningHoursTimeZone?.let { tz -> + TdApi.BusinessOpeningHours(tz, emptyArray()) + } + startPage = if (businessStartPageTitle != null || businessStartPageMessage != null) { + TdApi.BusinessStartPage( + businessStartPageTitle.orEmpty(), + businessStartPageMessage.orEmpty(), + null + ) + } else { + null + } + nextOpenIn = businessNextOpenIn + nextCloseIn = businessNextCloseIn + } + } else { + null + } + personalPhoto = personalPhotoPath.toTdApiChatPhoto() + photo = null + publicPhoto = publicPhotoPath.toTdApiChatPhoto() + blockList = when (blockListType) { + "STORIES" -> TdApi.BlockListStories() + "MAIN" -> TdApi.BlockListMain() + else -> if (isBlocked) TdApi.BlockListMain() else null + } + canBeCalled = this@toTdApi.canBeCalled + supportsVideoCalls = this@toTdApi.supportsVideoCalls + hasPrivateCalls = this@toTdApi.hasPrivateCalls + hasPrivateForwards = this@toTdApi.hasPrivateForwards + hasRestrictedVoiceAndVideoNoteMessages = this@toTdApi.hasRestrictedVoiceAndVideoNoteMessages + hasPostedToProfileStories = this@toTdApi.hasPostedToProfileStories + hasSponsoredMessagesEnabled = this@toTdApi.hasSponsoredMessagesEnabled + needPhoneNumberPrivacyException = this@toTdApi.needPhoneNumberPrivacyException + setChatBackground = this@toTdApi.setChatBackground + usesUnofficialApp = this@toTdApi.usesUnofficialApp + incomingPaidMessageStarCount = this@toTdApi.incomingPaidMessageStarCount + outgoingPaidMessageStarCount = this@toTdApi.outgoingPaidMessageStarCount + botVerification = if ( + botVerificationBotUserId != 0L || + botVerificationIconCustomEmojiId != 0L || + !botVerificationCustomDescription.isNullOrEmpty() + ) { + TdApi.BotVerification( + botVerificationBotUserId, + botVerificationIconCustomEmojiId, + TdApi.FormattedText(botVerificationCustomDescription.orEmpty(), emptyArray()) + ) + } else { + null + } + mainProfileTab = this@toTdApi.mainProfileTab.toTdApiProfileTab() + firstProfileAudio = if ( + firstProfileAudioFileId != 0 || + !firstProfileAudioPath.isNullOrEmpty() || + firstProfileAudioDuration > 0 || + !firstProfileAudioTitle.isNullOrEmpty() || + !firstProfileAudioPerformer.isNullOrEmpty() + ) { + TdApi.Audio( + firstProfileAudioDuration, + firstProfileAudioTitle.orEmpty(), + firstProfileAudioPerformer.orEmpty(), + firstProfileAudioFileName.orEmpty(), + firstProfileAudioMimeType.orEmpty(), + null, + null, + emptyArray(), + TdApi.File().apply { + id = firstProfileAudioFileId + local = TdApi.LocalFile().apply { path = firstProfileAudioPath.orEmpty() } + } + ) + } else { + null + } + rating = if ( + ratingLevel != 0 || + ratingIsMaximumLevelReached || + ratingValue != 0L || + ratingCurrentLevelValue != 0L || + ratingNextLevelValue != 0L + ) { + TdApi.UserRating( + ratingLevel, + ratingIsMaximumLevelReached, + ratingValue, + ratingCurrentLevelValue, + ratingNextLevelValue + ) + } else { + null + } + pendingRating = if ( + pendingRatingLevel != 0 || + pendingRatingIsMaximumLevelReached || + pendingRatingValue != 0L || + pendingRatingCurrentLevelValue != 0L || + pendingRatingNextLevelValue != 0L + ) { + TdApi.UserRating( + pendingRatingLevel, + pendingRatingIsMaximumLevelReached, + pendingRatingValue, + pendingRatingCurrentLevelValue, + pendingRatingNextLevelValue + ) + } else { + null + } + pendingRatingDate = this@toTdApi.pendingRatingDate + note = this@toTdApi.note?.let { TdApi.FormattedText(it, emptyArray()) } + } +} diff --git a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt index 0e84c952..20afafd0 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/UserMapper.kt @@ -1,14 +1,10 @@ package org.monogram.data.mapper.user import org.drinkless.tdlib.TdApi -import org.monogram.data.db.model.ChatEntity -import org.monogram.data.db.model.ChatFullInfoEntity -import org.monogram.data.db.model.UserEntity -import org.monogram.data.db.model.UserFullInfoEntity -import org.monogram.data.mapper.* +import org.monogram.data.mapper.isForcedVerifiedUser +import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.isValidFilePath import org.monogram.domain.models.* -import org.monogram.domain.repository.ChatMemberStatus -import org.monogram.domain.repository.ChatMembersFilter fun TdApi.User.toDomain( fullInfo: TdApi.UserFullInfo? = null, @@ -36,9 +32,17 @@ fun TdApi.User.toDomain( personalAvatarPath = personalAvatarPath, isPremium = isPremium, isVerified = (verificationStatus?.isVerified ?: false) || isForcedVerifiedUser(id), + isScam = verificationStatus?.isScam ?: false, + isFake = verificationStatus?.isFake ?: false, + botVerificationIconCustomEmojiId = verificationStatus?.botVerificationIconCustomEmojiId ?: 0L, isSponsor = isSponsoredUser(id), isSupport = isSupport, type = type.toDomain(), + botTypeInfo = (type as? TdApi.UserTypeBot)?.toDomain(), + restrictionInfo = restrictionInfo?.toDomain(), + activeStoryState = activeStoryState?.toDomain(), + restrictsNewChats = restrictsNewChats, + paidMessageStarCount = paidMessageStarCount, statusEmojiId = emojiStatusId, statusEmojiPath = customEmojiPath, username = username, @@ -50,175 +54,13 @@ fun TdApi.User.toDomain( isCloseFriend = isCloseFriend, haveAccess = haveAccess, languageCode = languageCode, - lastSeen = lastSeen + lastSeen = lastSeen, + backgroundCustomEmojiId = backgroundCustomEmojiId, + profileBackgroundCustomEmojiId = profileBackgroundCustomEmojiId, + addedToAttachmentMenu = addedToAttachmentMenu ) } -fun TdApi.ChatMember.toDomain(user: UserModel): GroupMemberModel { - val rank = when (this.status) { - is TdApi.ChatMemberStatusCreator -> "Owner" - is TdApi.ChatMemberStatusAdministrator -> "Admin" - else -> null - } - return GroupMemberModel( - user = user, - rank = rank, - status = this.status.toDomain() - ) -} - -fun TdApi.UserFullInfo.mapUserFullInfoToChat(): ChatFullInfoModel { - val birthdate = birthdate?.let { date -> - BirthdateModel(date.day, date.month, if (date.year > 0) date.year else null) - } - return ChatFullInfoModel( - description = bio?.text?.ifEmpty { null }, - commonGroupsCount = groupInCommonCount, - giftCount = giftCount, - birthdate = birthdate, - isBlocked = blockList != null, - botInfo = botInfo?.description?.ifEmpty { null }, - canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, - linkedChatId = personalChatId, - businessInfo = businessInfo?.let { businessInfo!!.toDomain() }, - canBeCalled = canBeCalled, - supportsVideoCalls = supportsVideoCalls, - hasPrivateCalls = hasPrivateCalls, - hasPrivateForwards = hasPrivateForwards, - hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, - hasPostedToProfileStories = hasPostedToProfileStories, - setChatBackground = setChatBackground - ) -} - -fun TdApi.SupergroupFullInfo.mapSupergroupFullInfoToChat( - supergroup: TdApi.Supergroup? -): ChatFullInfoModel { - val link = inviteLink?.inviteLink - ?: supergroup?.usernames?.activeUsernames?.firstOrNull()?.let { "t.me/$it" } - return ChatFullInfoModel( - description = description.ifEmpty { null }, - inviteLink = link, - memberCount = memberCount, - administratorCount = administratorCount, - restrictedCount = restrictedCount, - bannedCount = bannedCount, - slowModeDelay = slowModeDelay, - locationAddress = location?.address?.ifEmpty { null }, - giftCount = giftCount, - canSetStickerSet = canSetStickerSet, - canSetLocation = canSetLocation, - canGetMembers = canGetMembers, - canGetStatistics = canGetStatistics, - canGetRevenueStatistics = canGetRevenueStatistics, - linkedChatId = linkedChatId - ) -} - -fun TdApi.BasicGroupFullInfo.mapBasicGroupFullInfoToChat(): ChatFullInfoModel { - return ChatFullInfoModel( - description = description.ifEmpty { null }, - inviteLink = inviteLink?.inviteLink, - memberCount = members.size - ) -} - -fun TdApi.ChatMemberStatus.toDomain(): ChatMemberStatus { - return when (this) { - is TdApi.ChatMemberStatusCreator -> ChatMemberStatus.Creator - is TdApi.ChatMemberStatusAdministrator -> ChatMemberStatus.Administrator( - customTitle = "", - canBeEdited = canBeEdited, - canManageChat = rights.canManageChat, - canChangeInfo = rights.canChangeInfo, - canPostMessages = rights.canPostMessages, - canEditMessages = rights.canEditMessages, - canDeleteMessages = rights.canDeleteMessages, - canInviteUsers = rights.canInviteUsers, - canRestrictMembers = rights.canRestrictMembers, - canPinMessages = rights.canPinMessages, - canManageTopics = rights.canManageTopics, - canPromoteMembers = rights.canPromoteMembers, - canManageVideoChats = rights.canManageVideoChats, - canPostStories = rights.canPostStories, - canEditStories = rights.canEditStories, - canDeleteStories = rights.canDeleteStories, - canManageDirectMessages = rights.canManageDirectMessages, - isAnonymous = rights.isAnonymous - ) - is TdApi.ChatMemberStatusRestricted -> ChatMemberStatus.Restricted( - isMember = isMember, - restrictedUntilDate = restrictedUntilDate, - permissions = permissions.toDomainChatPermissions() - ) - is TdApi.ChatMemberStatusBanned -> ChatMemberStatus.Banned(bannedUntilDate) - is TdApi.ChatMemberStatusLeft -> ChatMemberStatus.Left - else -> ChatMemberStatus.Member - } -} - -fun TdApi.Chat.toDomain(): ChatModel { - val isChannel = type.isChannelType() - return ChatModel( - id = id, - title = title, - avatarPath = photo?.small?.local?.path?.takeIf { isValidFilePath(it) }, - unreadCount = unreadCount, - isMuted = notificationSettings.muteFor > 0, - isChannel = isChannel, - isGroup = type.isGroupType(), - type = type.toDomainChatType(), - lastMessageText = (lastMessage?.content as? TdApi.MessageText)?.text?.text ?: "" - ) -} - -fun ChatMembersFilter.toApi(): TdApi.SupergroupMembersFilter { - return when (this) { - is ChatMembersFilter.Recent -> TdApi.SupergroupMembersFilterRecent() - is ChatMembersFilter.Administrators -> TdApi.SupergroupMembersFilterAdministrators() - is ChatMembersFilter.Banned -> TdApi.SupergroupMembersFilterBanned() - is ChatMembersFilter.Restricted -> TdApi.SupergroupMembersFilterRestricted() - is ChatMembersFilter.Bots -> TdApi.SupergroupMembersFilterBots() - is ChatMembersFilter.Search -> TdApi.SupergroupMembersFilterSearch(this.query) - } -} - -fun ChatMemberStatus.toApi(): TdApi.ChatMemberStatus { - return when (this) { - is ChatMemberStatus.Member -> TdApi.ChatMemberStatusMember() - is ChatMemberStatus.Administrator -> TdApi.ChatMemberStatusAdministrator( - canBeEdited, - TdApi.ChatAdministratorRights( - canManageChat, - canChangeInfo, - canPostMessages, - canEditMessages, - canDeleteMessages, - canInviteUsers, - canRestrictMembers, - canPinMessages, - canManageTopics, - canPromoteMembers, - canManageVideoChats, - canPostStories, - canEditStories, - canDeleteStories, - canManageDirectMessages, - false, - isAnonymous - ) - ) - is ChatMemberStatus.Restricted -> TdApi.ChatMemberStatusRestricted( - isMember, - restrictedUntilDate, - permissions.toTdApiChatPermissions() - ) - is ChatMemberStatus.Left -> TdApi.ChatMemberStatusLeft() - is ChatMemberStatus.Banned -> TdApi.ChatMemberStatusBanned(bannedUntilDate) - is ChatMemberStatus.Creator -> TdApi.ChatMemberStatusCreator(false, true) - } -} - private fun TdApi.User.resolveAvatarPath(): String? { val big = profilePhoto?.big?.local?.path?.takeIf { isValidFilePath(it) } val small = profilePhoto?.small?.local?.path?.takeIf { isValidFilePath(it) } @@ -260,383 +102,36 @@ private fun TdApi.UserStatus?.toDomain(): UserStatusType { } } -private fun TdApi.BusinessInfo.toDomain(): BusinessInfoModel { - return BusinessInfoModel( - location = location?.let { - BusinessLocationModel( - it.location!!.latitude, - it.location!!.longitude, - it.address - ) - }, - openingHours = openingHours?.let { - BusinessOpeningHoursModel( - it.timeZoneId, - it.openingHours.map { interval -> - BusinessOpeningHoursIntervalModel(interval.startMinute, interval.endMinute) - } - ) - }, - startPage = startPage?.let { - BusinessStartPageModel( - title = it.title, - message = it.message, - stickerPath = it.sticker?.sticker?.local?.path?.takeIf { path -> isValidFilePath(path) } - ) - }, - nextOpenIn = nextOpenIn, - nextCloseIn = nextCloseIn +private fun TdApi.UserTypeBot.toDomain(): UserTypeBotInfoModel { + return UserTypeBotInfoModel( + canBeEdited = canBeEdited, + canJoinGroups = canJoinGroups, + canReadAllGroupMessages = canReadAllGroupMessages, + hasMainWebApp = hasMainWebApp, + hasTopics = hasTopics, + allowsUsersToCreateTopics = allowsUsersToCreateTopics, + canManageBots = canManageBots, + isInline = isInline, + inlineQueryPlaceholder = inlineQueryPlaceholder.ifEmpty { null }, + needLocation = needLocation, + canConnectToBusiness = canConnectToBusiness, + canBeAddedToAttachmentMenu = canBeAddedToAttachmentMenu, + activeUserCount = activeUserCount ) } -fun TdApi.UserFullInfo.toEntity(userId: Long): UserFullInfoEntity { - val businessLocation = businessInfo?.location - val businessOpeningHours = businessInfo?.openingHours - val businessStartPage = businessInfo?.startPage - val birth = birthdate - val personalPhotoPath = personalPhoto?.animation?.file?.local?.path?.takeIf { isValidFilePath(it) } - ?: (personalPhoto?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } - ?: personalPhoto?.sizes?.lastOrNull())?.photo?.local?.path?.takeIf { isValidFilePath(it) } - - return UserFullInfoEntity( - userId = userId, - bio = bio?.text?.ifEmpty { null }, - commonGroupsCount = groupInCommonCount, - giftCount = giftCount, - botInfoDescription = botInfo?.description?.ifEmpty { null }, - personalChatId = personalChatId, - birthdateDay = birth?.day ?: 0, - birthdateMonth = birth?.month ?: 0, - birthdateYear = birth?.year ?: 0, - businessLocationAddress = businessLocation?.address?.ifEmpty { null }, - businessLocationLatitude = businessLocation?.location?.latitude ?: 0.0, - businessLocationLongitude = businessLocation?.location?.longitude ?: 0.0, - businessOpeningHoursTimeZone = businessOpeningHours?.timeZoneId, - businessNextOpenIn = businessInfo?.nextOpenIn ?: 0, - businessNextCloseIn = businessInfo?.nextCloseIn ?: 0, - businessStartPageTitle = businessStartPage?.title?.ifEmpty { null }, - businessStartPageMessage = businessStartPage?.message?.ifEmpty { null }, - personalPhotoPath = personalPhotoPath, - isBlocked = blockList != null, - canBeCalled = canBeCalled, - supportsVideoCalls = supportsVideoCalls, - hasPrivateCalls = hasPrivateCalls, - hasPrivateForwards = hasPrivateForwards, - hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, - hasPostedToProfileStories = hasPostedToProfileStories, - setChatBackground = setChatBackground, - canGetRevenueStatistics = botInfo?.canGetRevenueStatistics ?: false, - createdAt = System.currentTimeMillis() +private fun TdApi.RestrictionInfo.toDomain(): RestrictionInfoModel { + return RestrictionInfoModel( + restrictionReason = restrictionReason.ifEmpty { null }, + hasSensitiveContent = hasSensitiveContent ) } -fun TdApi.SupergroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { - return ChatFullInfoEntity( - chatId = chatId, - description = description.ifEmpty { null }, - inviteLink = inviteLink?.inviteLink, - memberCount = memberCount, - onlineCount = 0, - administratorCount = administratorCount, - restrictedCount = restrictedCount, - bannedCount = bannedCount, - commonGroupsCount = 0, - giftCount = giftCount, - isBlocked = false, - botInfo = null, - slowModeDelay = slowModeDelay, - locationAddress = location?.address?.ifEmpty { null }, - canSetStickerSet = canSetStickerSet, - canSetLocation = canSetLocation, - canGetMembers = canGetMembers, - canGetStatistics = canGetStatistics, - canGetRevenueStatistics = canGetRevenueStatistics, - linkedChatId = linkedChatId, - note = null, - canBeCalled = false, - supportsVideoCalls = false, - hasPrivateCalls = false, - hasPrivateForwards = false, - hasRestrictedVoiceAndVideoNoteMessages = false, - hasPostedToProfileStories = false, - setChatBackground = false, - incomingPaidMessageStarCount = 0, - outgoingPaidMessageStarCount = outgoingPaidMessageStarCount, - createdAt = System.currentTimeMillis() - ) -} - -fun TdApi.BasicGroupFullInfo.toEntity(chatId: Long): ChatFullInfoEntity { - return ChatFullInfoEntity( - chatId = chatId, - description = description.ifEmpty { null }, - inviteLink = inviteLink?.inviteLink, - memberCount = members.size, - onlineCount = 0, - administratorCount = 0, - restrictedCount = 0, - bannedCount = 0, - commonGroupsCount = 0, - giftCount = 0, - isBlocked = false, - botInfo = null, - slowModeDelay = 0, - locationAddress = null, - canSetStickerSet = false, - canSetLocation = false, - canGetMembers = false, - canGetStatistics = false, - canGetRevenueStatistics = false, - linkedChatId = 0, - note = null, - canBeCalled = false, - supportsVideoCalls = false, - hasPrivateCalls = false, - hasPrivateForwards = false, - hasRestrictedVoiceAndVideoNoteMessages = false, - hasPostedToProfileStories = false, - setChatBackground = false, - incomingPaidMessageStarCount = 0, - outgoingPaidMessageStarCount = 0, - createdAt = System.currentTimeMillis() - ) -} - -fun UserEntity.toTdApi(): TdApi.User { - return TdApi.User().apply { - id = this@toTdApi.id - firstName = this@toTdApi.firstName - lastName = this@toTdApi.lastName ?: "" - phoneNumber = this@toTdApi.phoneNumber ?: "" - isPremium = this@toTdApi.isPremium - isSupport = this@toTdApi.isSupport - isContact = this@toTdApi.isContact - isMutualContact = this@toTdApi.isMutualContact - isCloseFriend = this@toTdApi.isCloseFriend - haveAccess = this@toTdApi.haveAccess - languageCode = this@toTdApi.languageCode ?: "" - accentColorId = this@toTdApi.accentColorId - profileAccentColorId = this@toTdApi.profileAccentColorId - verificationStatus = if (isVerified) TdApi.VerificationStatus(true, false, false, 0L) else null - val (active, disabled, editable, collectible) = decodeUsernames( - this@toTdApi.usernamesData, - this@toTdApi.username - ) - usernames = TdApi.Usernames(active, disabled, editable, collectible) - emojiStatus = this@toTdApi.statusEmojiId.takeIf { it != 0L }?.let { - TdApi.EmojiStatus(TdApi.EmojiStatusTypeCustomEmoji(it), 0) - } - status = when (this@toTdApi.statusType) { - "ONLINE" -> TdApi.UserStatusOnline(0) - "RECENTLY" -> TdApi.UserStatusRecently() - "LAST_WEEK" -> TdApi.UserStatusLastWeek() - "LAST_MONTH" -> TdApi.UserStatusLastMonth() - else -> TdApi.UserStatusOffline(lastSeen.toInt()) - } - profilePhoto = avatarPath?.let { path -> - TdApi.ProfilePhoto().apply { - small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } - } - } - } -} - -fun UserFullInfoEntity.toTdApi(): TdApi.UserFullInfo { - return TdApi.UserFullInfo().apply { - bio = this@toTdApi.bio?.let { TdApi.FormattedText(it, emptyArray()) } - groupInCommonCount = commonGroupsCount - giftCount = this@toTdApi.giftCount - personalChatId = this@toTdApi.personalChatId - birthdate = if (birthdateDay > 0 && birthdateMonth > 0) { - TdApi.Birthdate(birthdateDay, birthdateMonth, birthdateYear) - } else { - null - } - botInfo = botInfoDescription?.let { text -> - TdApi.BotInfo().apply { - description = text - canGetRevenueStatistics = canGetRevenueStatistics - } - } - businessInfo = if ( - businessLocationAddress != null || - businessOpeningHoursTimeZone != null || - businessStartPageTitle != null || - businessStartPageMessage != null - ) { - TdApi.BusinessInfo().apply { - location = businessLocationAddress?.let { address -> - TdApi.BusinessLocation( - TdApi.Location(businessLocationLatitude, businessLocationLongitude, 0.0), - address - ) - } - openingHours = businessOpeningHoursTimeZone?.let { tz -> - TdApi.BusinessOpeningHours(tz, emptyArray()) - } - startPage = if (businessStartPageTitle != null || businessStartPageMessage != null) { - TdApi.BusinessStartPage( - businessStartPageTitle.orEmpty(), - businessStartPageMessage.orEmpty(), - null - ) - } else { - null - } - nextOpenIn = businessNextOpenIn - nextCloseIn = businessNextCloseIn - } - } else { - null - } - personalPhoto = personalPhotoPath?.let { path -> - TdApi.ChatPhoto().apply { - sizes = arrayOf( - TdApi.PhotoSize().apply { - type = "x" - width = 0 - height = 0 - photo = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } - } - ) - } - } - blockList = if (isBlocked) TdApi.BlockListMain() else null - canBeCalled = this@toTdApi.canBeCalled - supportsVideoCalls = this@toTdApi.supportsVideoCalls - hasPrivateCalls = this@toTdApi.hasPrivateCalls - hasPrivateForwards = this@toTdApi.hasPrivateForwards - hasRestrictedVoiceAndVideoNoteMessages = this@toTdApi.hasRestrictedVoiceAndVideoNoteMessages - hasPostedToProfileStories = this@toTdApi.hasPostedToProfileStories - setChatBackground = this@toTdApi.setChatBackground - incomingPaidMessageStarCount = this@toTdApi.incomingPaidMessageStarCount - outgoingPaidMessageStarCount = this@toTdApi.outgoingPaidMessageStarCount - } -} - -private fun decodeUsernames(data: String?, fallbackUsername: String?): QuadUsernames { - if (data.isNullOrEmpty()) { - val active = fallbackUsername?.takeIf { it.isNotBlank() }?.let { arrayOf(it) } ?: emptyArray() - return QuadUsernames(active, emptyArray(), fallbackUsername.orEmpty(), emptyArray()) - } - val parts = data.split("\n", limit = 4) - val active = parts.getOrNull(0).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() - val disabled = parts.getOrNull(1).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() - val editable = parts.getOrNull(2).orEmpty() - val collectible = parts.getOrNull(3).orEmpty().split('|').filter { it.isNotBlank() }.toTypedArray() - return QuadUsernames(active, disabled, editable, collectible) -} - -private data class QuadUsernames( - val active: Array, - val disabled: Array, - val editable: String, - val collectible: Array -) - -fun ChatEntity.toTdApiChat(): TdApi.Chat { - return TdApi.Chat().apply { - id = this@toTdApiChat.id - title = this@toTdApiChat.title - unreadCount = this@toTdApiChat.unreadCount - unreadMentionCount = this@toTdApiChat.unreadMentionCount - unreadReactionCount = this@toTdApiChat.unreadReactionCount - photo = avatarPath?.let { path -> - TdApi.ChatPhotoInfo().apply { - small = TdApi.File().apply { local = TdApi.LocalFile().apply { this.path = path } } - } - } - lastMessage = TdApi.Message().apply { - content = TdApi.MessageText().apply { text = TdApi.FormattedText(lastMessageText, emptyArray()) } - date = lastMessageTime.toIntOrNull() ?: 0 - id = this@toTdApiChat.lastMessageId - isOutgoing = this@toTdApiChat.isLastMessageOutgoing - } - positions = arrayOf(TdApi.ChatPosition(TdApi.ChatListMain(), order, isPinned, null)) - notificationSettings = TdApi.ChatNotificationSettings().apply { - muteFor = if (isMuted) Int.MAX_VALUE else 0 - } - type = when (this@toTdApiChat.type) { - "PRIVATE" -> TdApi.ChatTypePrivate().apply { - userId = if (this@toTdApiChat.privateUserId != 0L) this@toTdApiChat.privateUserId else (this@toTdApiChat.messageSenderId ?: 0L) - } - "BASIC_GROUP" -> TdApi.ChatTypeBasicGroup().apply { - basicGroupId = this@toTdApiChat.basicGroupId - } - "SUPERGROUP" -> TdApi.ChatTypeSupergroup(this@toTdApiChat.supergroupId, isChannel) - "SECRET" -> TdApi.ChatTypeSecret().apply { - secretChatId = this@toTdApiChat.secretChatId - } - else -> TdApi.ChatTypePrivate().apply { userId = this@toTdApiChat.privateUserId } - } - isMarkedAsUnread = this@toTdApiChat.isMarkedAsUnread - hasProtectedContent = this@toTdApiChat.hasProtectedContent - isTranslatable = this@toTdApiChat.isTranslatable - viewAsTopics = this@toTdApiChat.viewAsTopics - accentColorId = this@toTdApiChat.accentColorId - profileAccentColorId = this@toTdApiChat.profileAccentColorId - backgroundCustomEmojiId = this@toTdApiChat.backgroundCustomEmojiId - messageAutoDeleteTime = this@toTdApiChat.messageAutoDeleteTime - canBeDeletedOnlyForSelf = this@toTdApiChat.canBeDeletedOnlyForSelf - canBeDeletedForAllUsers = this@toTdApiChat.canBeDeletedForAllUsers - canBeReported = this@toTdApiChat.canBeReported - lastReadInboxMessageId = this@toTdApiChat.lastReadInboxMessageId - lastReadOutboxMessageId = this@toTdApiChat.lastReadOutboxMessageId - replyMarkupMessageId = this@toTdApiChat.replyMarkupMessageId - messageSenderId = this@toTdApiChat.messageSenderId?.let { TdApi.MessageSenderUser(it) } - blockList = if (this@toTdApiChat.blockList) TdApi.BlockListMain() else null - permissions = TdApi.ChatPermissions( - this@toTdApiChat.permissionCanSendBasicMessages, - this@toTdApiChat.permissionCanSendAudios, - this@toTdApiChat.permissionCanSendDocuments, - this@toTdApiChat.permissionCanSendPhotos, - this@toTdApiChat.permissionCanSendVideos, - this@toTdApiChat.permissionCanSendVideoNotes, - this@toTdApiChat.permissionCanSendVoiceNotes, - this@toTdApiChat.permissionCanSendPolls, - this@toTdApiChat.permissionCanSendOtherMessages, - this@toTdApiChat.permissionCanAddLinkPreviews, - this@toTdApiChat.permissionCanEditTag, - this@toTdApiChat.permissionCanChangeInfo, - this@toTdApiChat.permissionCanInviteUsers, - this@toTdApiChat.permissionCanPinMessages, - this@toTdApiChat.permissionCanCreateTopics - ) - clientData = "mc:${this@toTdApiChat.memberCount};oc:${this@toTdApiChat.onlineCount}" +private fun TdApi.ActiveStoryState.toDomain(): ActiveStoryStateModel { + return when (this) { + is TdApi.ActiveStoryStateLive -> ActiveStoryStateModel(ActiveStoryStateType.LIVE, storyId) + is TdApi.ActiveStoryStateUnread -> ActiveStoryStateModel(ActiveStoryStateType.UNREAD, 0) + is TdApi.ActiveStoryStateRead -> ActiveStoryStateModel(ActiveStoryStateType.READ, 0) + else -> ActiveStoryStateModel(ActiveStoryStateType.UNKNOWN, 0) } } - -fun ChatFullInfoEntity.toDomain(): ChatFullInfoModel { - return ChatFullInfoModel( - description = description, - inviteLink = inviteLink, - memberCount = memberCount, - onlineCount = onlineCount, - administratorCount = administratorCount, - restrictedCount = restrictedCount, - bannedCount = bannedCount, - commonGroupsCount = commonGroupsCount, - giftCount = giftCount, - isBlocked = isBlocked, - botInfo = botInfo, - canGetRevenueStatistics = canGetRevenueStatistics, - linkedChatId = linkedChatId, - businessInfo = null, - note = note, - canBeCalled = canBeCalled, - supportsVideoCalls = supportsVideoCalls, - hasPrivateCalls = hasPrivateCalls, - hasPrivateForwards = hasPrivateForwards, - hasRestrictedVoiceAndVideoNoteMessages = hasRestrictedVoiceAndVideoNoteMessages, - hasPostedToProfileStories = hasPostedToProfileStories, - setChatBackground = setChatBackground, - slowModeDelay = slowModeDelay, - locationAddress = locationAddress, - canSetStickerSet = canSetStickerSet, - canSetLocation = canSetLocation, - canGetMembers = canGetMembers, - canGetStatistics = canGetStatistics, - incomingPaidMessageStarCount = incomingPaidMessageStarCount, - outgoingPaidMessageStarCount = outgoingPaidMessageStarCount - ) -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt index a4661e4c..5fcc1789 100644 --- a/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/BotRepositoryImpl.kt @@ -2,9 +2,8 @@ package org.monogram.data.repository import org.drinkless.tdlib.TdApi import org.monogram.data.datasource.remote.UserRemoteDataSource -import org.monogram.domain.models.BotCommandModel -import org.monogram.domain.models.BotInfoModel -import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.data.mapper.isValidFilePath +import org.monogram.domain.models.* import org.monogram.domain.repository.BotRepository class BotRepositoryImpl( @@ -20,13 +19,84 @@ class BotRepositoryImpl( override suspend fun getBotInfo(botId: Long): BotInfoModel? { val fullInfo = remote.getBotFullInfo(botId) ?: return null - val commands = fullInfo.botInfo?.commands?.map { + val info = fullInfo.botInfo + val commands = info?.commands?.map { BotCommandModel(it.command, it.description) } ?: emptyList() - val menuButton = when (val btn = fullInfo.botInfo?.menuButton) { + val menuButton = when (val btn = info?.menuButton) { is TdApi.BotMenuButton -> BotMenuButtonModel.WebApp(btn.text, btn.url) else -> BotMenuButtonModel.Default } - return BotInfoModel(commands, menuButton) + val bestPhoto = info?.photo?.sizes?.maxByOrNull { it.width.toLong() * it.height.toLong() } + ?: info?.photo?.sizes?.lastOrNull() + val photoPath = bestPhoto?.photo?.local?.path?.takeIf { isValidFilePath(it) } + val animationPath = info?.animation?.animation?.local?.path?.takeIf { isValidFilePath(it) } + + return BotInfoModel( + commands = commands, + menuButton = menuButton, + shortDescription = info?.shortDescription?.ifEmpty { null }, + description = info?.description?.ifEmpty { null }, + photoFileId = bestPhoto?.photo?.id ?: 0, + photoPath = photoPath, + animationFileId = info?.animation?.animation?.id ?: 0, + animationPath = animationPath, + managerBotUserId = info?.managerBotUserId ?: 0L, + privacyPolicyUrl = info?.privacyPolicyUrl?.ifEmpty { null }, + defaultGroupAdministratorRights = info?.defaultGroupAdministratorRights?.toDomain(), + defaultChannelAdministratorRights = info?.defaultChannelAdministratorRights?.toDomain(), + affiliateProgram = info?.affiliateProgram?.toDomain(), + webAppBackgroundLightColor = info?.webAppBackgroundLightColor ?: -1, + webAppBackgroundDarkColor = info?.webAppBackgroundDarkColor ?: -1, + webAppHeaderLightColor = info?.webAppHeaderLightColor ?: -1, + webAppHeaderDarkColor = info?.webAppHeaderDarkColor ?: -1, + verificationParameters = info?.verificationParameters?.let { + BotVerificationParametersModel( + iconCustomEmojiId = it.iconCustomEmojiId, + organizationName = it.organizationName.ifEmpty { null }, + defaultCustomDescription = it.defaultCustomDescription?.text?.ifEmpty { null }, + canSetCustomDescription = it.canSetCustomDescription + ) + }, + canGetRevenueStatistics = info?.canGetRevenueStatistics ?: false, + canManageEmojiStatus = info?.canManageEmojiStatus ?: false, + hasMediaPreviews = info?.hasMediaPreviews ?: false, + editCommandsLinkType = info?.editCommandsLink?.javaClass?.simpleName, + editDescriptionLinkType = info?.editDescriptionLink?.javaClass?.simpleName, + editDescriptionMediaLinkType = info?.editDescriptionMediaLink?.javaClass?.simpleName, + editSettingsLinkType = info?.editSettingsLink?.javaClass?.simpleName + ) } -} \ No newline at end of file +} + +private fun TdApi.ChatAdministratorRights.toDomain(): ChatAdministratorRightsModel { + return ChatAdministratorRightsModel( + canManageChat = canManageChat, + canChangeInfo = canChangeInfo, + canPostMessages = canPostMessages, + canEditMessages = canEditMessages, + canDeleteMessages = canDeleteMessages, + canInviteUsers = canInviteUsers, + canRestrictMembers = canRestrictMembers, + canPinMessages = canPinMessages, + canManageTopics = canManageTopics, + canPromoteMembers = canPromoteMembers, + canManageVideoChats = canManageVideoChats, + canPostStories = canPostStories, + canEditStories = canEditStories, + canDeleteStories = canDeleteStories, + canManageDirectMessages = canManageDirectMessages, + canManageTags = canManageTags, + isAnonymous = isAnonymous + ) +} + +private fun TdApi.AffiliateProgramInfo.toDomain(): AffiliateProgramInfoModel { + return AffiliateProgramInfoModel( + commissionPerMille = parameters.commissionPerMille, + monthCount = parameters.monthCount, + endDate = endDate, + dailyRevenuePerUserStarCount = dailyRevenuePerUserAmount.starCount, + dailyRevenuePerUserNanostarCount = dailyRevenuePerUserAmount.nanostarCount + ) +} 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 df356557..64848ef5 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -20,6 +20,7 @@ import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.ConnectionManager import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.infra.SynchronizedLruMap import org.monogram.data.mapper.ChatMapper import org.monogram.data.mapper.MessageMapper import org.monogram.domain.models.ChatModel @@ -194,7 +195,7 @@ class ChatsListRepositoryImpl( private val initialChatListLimit = 50 private var currentLimit = initialChatListLimit - private val modelCache = ConcurrentHashMap() + private val modelCache = SynchronizedLruMap(MODEL_CACHE_SIZE) private val invalidatedModels = ConcurrentHashMap.newKeySet() private var lastList: List? = null private var lastListFolderId: Int = -1 @@ -701,6 +702,23 @@ class ChatsListRepositoryImpl( } } + fun clearMemoryCaches() { + modelCache.clear() + invalidatedModels.clear() + } + + fun memoryCacheSnapshot(): MemoryCacheSnapshot { + return MemoryCacheSnapshot( + modelCacheSize = modelCache.size(), + invalidatedModelsSize = invalidatedModels.size + ) + } + + data class MemoryCacheSnapshot( + val modelCacheSize: Int, + val invalidatedModelsSize: Int + ) + private fun fetchUser(userId: Long) { if (userId == 0L) return if (cache.pendingUsers.add(userId)) { @@ -731,5 +749,6 @@ class ChatsListRepositoryImpl( companion object { private const val TAG = "ChatsListRepository" private const val REBUILD_THROTTLE_MS = 250L + private const val MODEL_CACHE_SIZE = 256 } } diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt index 6e579c42..6a46f51b 100644 --- a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt @@ -4,7 +4,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching @@ -58,22 +59,22 @@ class ProfilePhotoRepositoryImpl( return listOfNotNull(currentPath) } - override fun getUserProfilePhotosFlow(userId: Long): Flow> = flow { + override fun getUserProfilePhotosFlow(userId: Long): Flow> = channelFlow { if (userId <= 0) { - emit(emptyList()) - return@flow + send(emptyList()) + return@channelFlow } - emit(getUserProfilePhotos(userId)) - updates.file.collect { emit(getUserProfilePhotos(userId)) } + send(getUserProfilePhotos(userId)) + updates.file.collectLatest { send(getUserProfilePhotos(userId)) } } - override fun getChatProfilePhotosFlow(chatId: Long): Flow> = flow { + override fun getChatProfilePhotosFlow(chatId: Long): Flow> = channelFlow { if (chatId == 0L) { - emit(emptyList()) - return@flow + send(emptyList()) + return@channelFlow } - emit(getChatProfilePhotos(chatId)) - updates.file.collect { emit(getChatProfilePhotos(chatId)) } + send(getChatProfilePhotos(chatId)) + updates.file.collectLatest { send(getChatProfilePhotos(chatId)) } } private suspend fun loadChatPhotoHistoryPaths( @@ -266,4 +267,4 @@ class ProfilePhotoRepositoryImpl( private const val FULL_RES_DOWNLOAD_PRIORITY = 32 private const val FILE_DOWNLOAD_TIMEOUT_MS = 15_000L } -} \ No newline at end of file +} diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt index 853c34b6..f8a51347 100644 --- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -1,6 +1,7 @@ package org.monogram.data.repository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -27,13 +28,16 @@ class WallpaperRepositoryImpl( private val scope: CoroutineScope ) : WallpaperRepository { - private val wallpaperUpdates = MutableSharedFlow(extraBufferCapacity = 1) + private val wallpaperUpdates = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val wallpapers = MutableStateFlow>(emptyList()) init { scope.launch { - updates.file.collect { - wallpaperUpdates.emit(Unit) + updates.file.collectLatest { + wallpaperUpdates.tryEmit(Unit) } } diff --git a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt index a513fb19..bf4a7f72 100644 --- a/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/BotCommandModel.kt @@ -13,7 +13,30 @@ sealed interface BotMenuButtonModel { data class BotInfoModel( val commands: List, - val menuButton: BotMenuButtonModel + val menuButton: BotMenuButtonModel, + val shortDescription: String? = null, + val description: String? = null, + val photoFileId: Int = 0, + val photoPath: String? = null, + val animationFileId: Int = 0, + val animationPath: String? = null, + val managerBotUserId: Long = 0L, + val privacyPolicyUrl: String? = null, + val defaultGroupAdministratorRights: ChatAdministratorRightsModel? = null, + val defaultChannelAdministratorRights: ChatAdministratorRightsModel? = null, + val affiliateProgram: AffiliateProgramInfoModel? = null, + val webAppBackgroundLightColor: Int = -1, + val webAppBackgroundDarkColor: Int = -1, + val webAppHeaderLightColor: Int = -1, + val webAppHeaderDarkColor: Int = -1, + val verificationParameters: BotVerificationParametersModel? = null, + val canGetRevenueStatistics: Boolean = false, + val canManageEmojiStatus: Boolean = false, + val hasMediaPreviews: Boolean = false, + val editCommandsLinkType: String? = null, + val editDescriptionLinkType: String? = null, + val editDescriptionMediaLinkType: String? = null, + val editSettingsLinkType: String? = null ) data class InlineQueryResultModel( diff --git a/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt index f863f9a4..18d502ba 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatFullInfoModel.kt @@ -13,16 +13,41 @@ data class ChatFullInfoModel( val giftCount: Int = 0, val isBlocked: Boolean = false, val botInfo: String? = null, + val botInfoModel: BotInfoModel? = null, + val blockListType: String? = null, val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, val locationAddress: String? = null, + val directMessagesChatId: Long = 0L, val canSetStickerSet: Boolean = false, val canSetLocation: Boolean = false, val canGetMembers: Boolean = false, val canGetStatistics: Boolean = false, val canGetRevenueStatistics: Boolean = false, + val canGetStarRevenueStatistics: Boolean = false, + val canEnablePaidMessages: Boolean = false, + val canEnablePaidReaction: Boolean = false, + val hasHiddenMembers: Boolean = false, + val canHideMembers: Boolean = false, + val canToggleAggressiveAntiSpam: Boolean = false, + val isAllHistoryAvailable: Boolean = false, + val canHaveSponsoredMessages: Boolean = false, + val hasAggressiveAntiSpamEnabled: Boolean = false, + val hasPaidMediaAllowed: Boolean = false, + val hasPinnedStories: Boolean = false, val linkedChatId: Long = 0L, val businessInfo: BusinessInfoModel? = null, + val publicPhotoPath: String? = null, val note: String? = null, + val usesUnofficialApp: Boolean = false, + val hasSponsoredMessagesEnabled: Boolean = false, + val needPhoneNumberPrivacyException: Boolean = false, + val botVerification: BotVerificationModel? = null, + val mainProfileTab: ProfileTabType? = null, + val firstProfileAudio: ProfileAudioModel? = null, + val rating: UserRatingModel? = null, + val pendingRating: UserRatingModel? = null, + val pendingRatingDate: Int = 0, val canBeCalled: Boolean = false, val supportsVideoCalls: Boolean = false, val hasPrivateCalls: Boolean = false, @@ -30,6 +55,13 @@ data class ChatFullInfoModel( val hasRestrictedVoiceAndVideoNoteMessages: Boolean = false, val hasPostedToProfileStories: Boolean = false, val setChatBackground: Boolean = false, + val myBoostCount: Int = 0, + val unrestrictBoostCount: Int = 0, + val stickerSetId: Long = 0L, + val customEmojiStickerSetId: Long = 0L, + val botCommands: List = emptyList(), + val upgradedFromBasicGroupId: Long = 0L, + val upgradedFromMaxMessageId: Long = 0L, val incomingPaidMessageStarCount: Long = 0L, val outgoingPaidMessageStarCount: Long = 0L, ) \ No newline at end of file diff --git a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt index 63dcc4a9..b03eec09 100644 --- a/domain/src/main/java/org/monogram/domain/models/ChatModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/ChatModel.kt @@ -63,6 +63,17 @@ data class ChatModel( val isBot: Boolean = false, val isMember: Boolean = true, val isArchived: Boolean = false, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false, + val activeStoryStateType: String? = null, + val activeStoryId: Int = 0, + val boostLevel: Int = 0, + val hasForumTabs: Boolean = false, + val isAdministeredDirectMessagesGroup: Boolean = false, + val paidMessageStarCount: Long = 0L, ) @Serializable diff --git a/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt b/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt new file mode 100644 index 00000000..ef4652fd --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ProfileSecurityModels.kt @@ -0,0 +1,110 @@ +package org.monogram.domain.models + +data class RestrictionInfoModel( + val restrictionReason: String? = null, + val hasSensitiveContent: Boolean = false +) + +enum class ActiveStoryStateType { + LIVE, + UNREAD, + READ, + UNKNOWN +} + +data class ActiveStoryStateModel( + val type: ActiveStoryStateType = ActiveStoryStateType.UNKNOWN, + val storyId: Int = 0 +) + +data class BotVerificationModel( + val botUserId: Long = 0L, + val iconCustomEmojiId: Long = 0L, + val customDescription: String? = null +) + +data class BotVerificationParametersModel( + val iconCustomEmojiId: Long = 0L, + val organizationName: String? = null, + val defaultCustomDescription: String? = null, + val canSetCustomDescription: Boolean = false +) + +enum class ProfileTabType { + POSTS, + GIFTS, + MEDIA, + FILES, + LINKS, + MUSIC, + VOICE, + GIFS, + UNKNOWN +} + +data class ProfileAudioModel( + val duration: Int = 0, + val title: String? = null, + val performer: String? = null, + val fileName: String? = null, + val mimeType: String? = null, + val fileId: Int = 0, + val filePath: String? = null +) + +data class UserRatingModel( + val level: Int = 0, + val isMaximumLevelReached: Boolean = false, + val rating: Long = 0L, + val currentLevelRating: Long = 0L, + val nextLevelRating: Long = 0L +) + +data class ChatAdministratorRightsModel( + val canManageChat: Boolean = false, + val canChangeInfo: Boolean = false, + val canPostMessages: Boolean = false, + val canEditMessages: Boolean = false, + val canDeleteMessages: Boolean = false, + val canInviteUsers: Boolean = false, + val canRestrictMembers: Boolean = false, + val canPinMessages: Boolean = false, + val canManageTopics: Boolean = false, + val canPromoteMembers: Boolean = false, + val canManageVideoChats: Boolean = false, + val canPostStories: Boolean = false, + val canEditStories: Boolean = false, + val canDeleteStories: Boolean = false, + val canManageDirectMessages: Boolean = false, + val canManageTags: Boolean = false, + val isAnonymous: Boolean = false +) + +data class AffiliateProgramInfoModel( + val commissionPerMille: Int = 0, + val monthCount: Int = 0, + val endDate: Int = 0, + val dailyRevenuePerUserStarCount: Long = 0L, + val dailyRevenuePerUserNanostarCount: Int = 0 +) + +data class UserTypeBotInfoModel( + val canBeEdited: Boolean = false, + val canJoinGroups: Boolean = false, + val canReadAllGroupMessages: Boolean = false, + val hasMainWebApp: Boolean = false, + val hasTopics: Boolean = false, + val allowsUsersToCreateTopics: Boolean = false, + val canManageBots: Boolean = false, + val isInline: Boolean = false, + val inlineQueryPlaceholder: String? = null, + val needLocation: Boolean = false, + val canConnectToBusiness: Boolean = false, + val canBeAddedToAttachmentMenu: Boolean = false, + val activeUserCount: Int = 0 +) + +data class SupergroupBotCommandsModel( + val botUserId: Long = 0L, + val commands: List = emptyList() +) diff --git a/domain/src/main/java/org/monogram/domain/models/UserModel.kt b/domain/src/main/java/org/monogram/domain/models/UserModel.kt index 019c302b..f312e9ea 100644 --- a/domain/src/main/java/org/monogram/domain/models/UserModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/UserModel.kt @@ -14,6 +14,9 @@ data class UserModel( val lastSeen: Long = 0L, val isPremium: Boolean = false, val isVerified: Boolean = false, + val isScam: Boolean = false, + val isFake: Boolean = false, + val botVerificationIconCustomEmojiId: Long = 0L, val isSponsor: Boolean = false, val isSupport: Boolean = false, val userStatus: UserStatusType = UserStatusType.OFFLINE, @@ -23,8 +26,16 @@ data class UserModel( val isMutualContact: Boolean = false, val isCloseFriend: Boolean = false, val type: UserTypeEnum = UserTypeEnum.REGULAR, + val botTypeInfo: UserTypeBotInfoModel? = null, + val restrictionInfo: RestrictionInfoModel? = null, + val activeStoryState: ActiveStoryStateModel? = null, + val restrictsNewChats: Boolean = false, + val paidMessageStarCount: Long = 0L, val haveAccess: Boolean = true, - val languageCode: String? = null + val languageCode: String? = null, + val backgroundCustomEmojiId: Long = 0L, + val profileBackgroundCustomEmojiId: Long = 0L, + val addedToAttachmentMenu: Boolean = false ) enum class UserStatusType { diff --git a/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt b/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt index e9962e58..35901c01 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/coil/coilModule.kt @@ -1,13 +1,21 @@ package org.monogram.presentation.di.coil +import android.content.Context import coil3.ImageLoader +import coil3.memory.MemoryCache import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.svg.SvgDecoder import org.koin.dsl.module val coilModule = module { single { - ImageLoader.Builder(get()) + val context = get() + ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.15) + .build() + } .components { add(LottieDecoder.Factory()) add(SvgDecoder.Factory()) diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index ef0340f4..a4858512 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -110,6 +110,9 @@ fun ProfileContent(component: ProfileComponent) { } val isOnline = user?.type != UserTypeEnum.BOT && user?.userStatus == UserStatusType.ONLINE + val isBot = user?.type == UserTypeEnum.BOT || chat?.isBot == true + val isScam = user?.isScam == true || chat?.isScam == true + val isFake = user?.isFake == true || chat?.isFake == true val collapsedColor = MaterialTheme.colorScheme.surface val expandedColor = MaterialTheme.colorScheme.background @@ -170,6 +173,9 @@ fun ProfileContent(component: ProfileComponent) { chatModel = chat, isVerified = user?.isVerified == true || chat?.isVerified == true, isSponsor = user?.isSponsor == true, + isBot = isBot, + isScam = isScam, + isFake = isFake, canSearch = false, canShare = canShareTopBar, canEdit = canEditTopBar, @@ -268,6 +274,9 @@ fun ProfileContent(component: ProfileComponent) { isOnline = isOnline, isVerified = user?.isVerified == true || chat?.isVerified == true, isSponsor = user?.isSponsor == true, + isBot = isBot, + isScam = isScam, + isFake = isFake, statusEmojiPath = user?.statusEmojiPath, progress = progress, contentPadding = PaddingValues( diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt index df5c1436..1149f819 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeader.kt @@ -37,6 +37,8 @@ fun ProfileHeader( isSponsor: Boolean, statusEmojiPath: String?, isBot: Boolean, + isScam: Boolean, + isFake: Boolean, onAvatarClick: () -> Unit ) { val displayPath = profilePhotos.firstOrNull() ?: avatarPath @@ -120,17 +122,27 @@ fun ProfileHeader( } if (isBot) { Spacer(Modifier.width(6.dp)) - Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = stringResource(R.string.label_bot_badge), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } + ProfileStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + if (isScam) { + Spacer(Modifier.width(6.dp)) + ProfileStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + if (isFake) { + Spacer(Modifier.width(6.dp)) + ProfileStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) } } @@ -143,3 +155,22 @@ fun ProfileHeader( ) } } + +@Composable +private fun ProfileStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + color = contentColor + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt index e96d4e2b..440cf845 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.Verified import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -23,6 +24,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -30,6 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UserModel +import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarHeader import org.monogram.presentation.features.stickers.ui.view.StickerImage @@ -45,6 +48,9 @@ fun ProfileHeaderTransformed( isOnline: Boolean, isVerified: Boolean, isSponsor: Boolean, + isBot: Boolean, + isScam: Boolean, + isFake: Boolean, statusEmojiPath: String?, progress: Float, contentPadding: PaddingValues, @@ -149,23 +155,49 @@ fun ProfileHeaderTransformed( ) } - userModel?.let { user -> - if (!user.statusEmojiPath.isNullOrEmpty()) { - Spacer(modifier = Modifier.width(6.dp)) - StickerImage( - path = user.statusEmojiPath, - modifier = Modifier.size(26.dp), - animate = false - ) - } else if (user.isPremium) { - Spacer(modifier = Modifier.width(6.dp)) - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = Color(0xFF31A6FD) - ) - } + val displayedStatusEmojiPath = statusEmojiPath ?: userModel?.statusEmojiPath + if (!displayedStatusEmojiPath.isNullOrEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + StickerImage( + path = displayedStatusEmojiPath, + modifier = Modifier.size(26.dp), + animate = false + ) + } else if (userModel?.isPremium == true) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = Color(0xFF31A6FD) + ) + } + + if (isBot) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + if (isScam) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + + if (isFake) { + Spacer(modifier = Modifier.width(6.dp)) + HeaderStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) } } @@ -202,3 +234,22 @@ fun ProfileHeaderTransformed( ) } } + +@Composable +private fun HeaderStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt index a122cd6d..121f0f9f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt @@ -1,31 +1,13 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.profile.components import android.content.ClipData import android.content.Intent -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith +import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -34,55 +16,9 @@ import androidx.compose.material.icons.automirrored.rounded.Login import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.filled.QrCode -import androidx.compose.material.icons.rounded.AlternateEmail -import androidx.compose.material.icons.rounded.AssignmentTurnedIn -import androidx.compose.material.icons.rounded.BarChart -import androidx.compose.material.icons.rounded.Cake -import androidx.compose.material.icons.rounded.Collections -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Favorite -import androidx.compose.material.icons.rounded.ForwardToInbox -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.LocationOn -import androidx.compose.material.icons.rounded.MicOff -import androidx.compose.material.icons.rounded.Notifications -import androidx.compose.material.icons.rounded.Numbers -import androidx.compose.material.icons.rounded.Palette -import androidx.compose.material.icons.rounded.Payments -import androidx.compose.material.icons.rounded.PersonAdd -import androidx.compose.material.icons.rounded.Phone -import androidx.compose.material.icons.rounded.Portrait -import androidx.compose.material.icons.rounded.RocketLaunch -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Security -import androidx.compose.material.icons.rounded.Shield -import androidx.compose.material.icons.rounded.Timer -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -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.LoadingIndicator -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.ShapeDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material.icons.rounded.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -104,7 +40,7 @@ import org.monogram.presentation.core.ui.* import org.monogram.presentation.core.util.CountryManager import org.monogram.presentation.core.util.OperatorManager import org.monogram.presentation.features.profile.ProfileComponent -import java.util.Calendar +import java.util.* @Composable fun ProfileInfoSectionSkeleton( @@ -589,6 +525,33 @@ fun ProfileInfoSection( } } + if (!isGroupOrChannel && fullInfo?.usesUnofficialApp == true) { + items.add { pos -> + SettingsTile( + icon = Icons.Rounded.Security, + title = stringResource(R.string.unofficial_app_title), + subtitle = stringResource(R.string.unofficial_app_subtitle), + iconColor = Color(0xFFFF9800), + position = pos, + onClick = { } + ) + } + } + + fullInfo?.botVerification?.let { botVerification -> + items.add { pos -> + SettingsTile( + icon = Icons.Rounded.Verified, + title = stringResource(R.string.bot_verification_title), + subtitle = botVerification.customDescription + ?: stringResource(R.string.bot_verification_subtitle), + iconColor = MaterialTheme.colorScheme.primary, + position = pos, + onClick = { } + ) + } + } + user?.phoneNumber?.takeIf { it.isNotEmpty() }?.let { phone -> val formattedPhone = remember(phone) { CountryManager.formatPhoneNumber(phone) diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt index 71e4c6a7..774b8f8d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -42,6 +41,9 @@ fun ProfileTopBar( chatModel: ChatModel?, isVerified: Boolean, isSponsor: Boolean, + isBot: Boolean, + isScam: Boolean, + isFake: Boolean, canSearch: Boolean = false, canShare: Boolean = false, canEdit: Boolean = false, @@ -109,6 +111,33 @@ fun ProfileTopBar( ) } + if (isBot) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_bot_badge), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + + if (isScam) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_scam_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + + if (isFake) { + Spacer(modifier = Modifier.width(4.dp)) + TopBarStatusBadge( + text = stringResource(R.string.label_fake_badge), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } + userModel?.let { user -> if (!user.statusEmojiPath.isNullOrEmpty()) { Spacer(modifier = Modifier.width(4.dp)) @@ -287,3 +316,22 @@ fun ProfileTopBar( } } } + +@Composable +private fun TopBarStatusBadge( + text: String, + containerColor: Color, + contentColor: Color +) { + Surface( + color = containerColor, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp) + ) + } +} diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 47711205..672f2ce0 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -1298,6 +1298,12 @@ No se encontraron enlaces No se encontraron GIFs BOT + ESTAFA + FALSO + Usa app no oficial + Esta cuenta está usando un cliente no oficial de Telegram + Verificación del bot + Verificado por un bot de terceros Cerrado ID diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 4ad42ab9..85b22b7c 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -1175,6 +1175,12 @@ Հղումներ չկան GIF-եր չկան ԲՈՏ + SCAM + FAKE + Uses unofficial app + This account is using an unofficial Telegram client + Bot verification + Verified by a third-party bot Փակ է ID diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 437a089c..105cf8e4 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -1326,6 +1326,12 @@ Nenhum link encontrado Nenhum GIF encontrado BOT + GOLPE + FALSO + Usa app não oficial + Esta conta está usando um cliente não oficial do Telegram + Verificação do bot + Verificado por um bot de terceiros Fechado ID diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 4973ed2d..5f326f41 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -1260,6 +1260,12 @@ Ссылки не найдены GIF не найдены БОТ + СКАМ + ФЕЙК + Использует неофициальное приложение + Этот аккаунт использует неофициальный клиент Telegram + Проверка бота + Подтверждено сторонним ботом Закрыто ID diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 674b1ef8..e8071d05 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -1324,6 +1324,12 @@ Žiadne odkazy sa nenašli Žiadne GIFy sa nenašli BOT + SCAM + FAKE + Používa neoficiálnu aplikáciu + Tento účet používa neoficiálneho klienta Telegramu + Overenie bota + Overené botom tretej strany Zatvorené ID diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 6bca0472..acf87fba 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -1260,6 +1260,12 @@ Посилань не знайдено GIF не знайдено БОТ + ШАХРАЙСТВО + ФЕЙК + Використовує неофіційний застосунок + Цей акаунт використовує неофіційний клієнт Telegram + Перевірка бота + Підтверджено стороннім ботом Зачинено ID diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 7075eaaf..957ddd7b 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -1247,6 +1247,12 @@ 未找到链接 未找到 GIF 机器人 + 诈骗 + 冒充 + 使用非官方应用 + 该账号正在使用非官方 Telegram 客户端 + 机器人验证 + 由第三方机器人验证 已关闭 ID diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 030d3135..369d7463 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1339,6 +1339,12 @@ No links found No GIFs found BOT + SCAM + FAKE + Uses unofficial app + This account is using an unofficial Telegram client + Bot verification + Verified by a third-party bot Closed ID From c3bd4703636e816638dde0f561af56624b951406 Mon Sep 17 00:00:00 2001 From: Artemiy <53598473+aliveoutside@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:36:30 +0300 Subject: [PATCH 04/83] photo: better crop (#188) better crop in pic editor (no casino) --- .../editor/photo/PhotoEditorScreen.kt | 479 ++++++++++++------ .../editor/photo/PhotoEditorUtils.kt | 121 +++-- .../editor/photo/components/CropOverlay.kt | 102 ---- .../photo/components/TransformControls.kt | 174 +++++-- .../editor/photo/crop/CropEditorState.kt | 145 ++++++ .../editor/photo/crop/CropGeometry.kt | 339 +++++++++++++ .../editor/photo/crop/CropOverlay.kt | 257 ++++++++++ .../editor/photo/crop/CropGeometryTest.kt | 125 +++++ 8 files changed, 1390 insertions(+), 352 deletions(-) delete mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt index 69606a65..ffa71781 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt @@ -1,7 +1,8 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.editor.photo +import android.graphics.BitmapFactory import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -23,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector @@ -36,9 +38,12 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.editor.photo.components.* +import org.monogram.presentation.features.chats.currentChat.editor.photo.crop.* import java.io.File enum class EditorTool(val labelRes: Int, val icon: ImageVector) { @@ -50,6 +55,9 @@ enum class EditorTool(val labelRes: Int, val icon: ImageVector) { ERASER(R.string.photo_editor_tool_eraser, Icons.Rounded.CleaningServices) } +private const val MinImageScale = 0.5f +private const val MaxImageScale = 10f + @OptIn(ExperimentalMaterial3Api::class) @Composable fun PhotoEditorScreen( @@ -59,8 +67,9 @@ fun PhotoEditorScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val density = LocalDensity.current - var currentTool by remember { mutableStateOf(EditorTool.NONE) } + var currentTool by remember { mutableStateOf(EditorTool.TRANSFORM) } val paths = remember { mutableStateListOf() } val pathsRedo = remember { mutableStateListOf() } @@ -70,6 +79,7 @@ fun PhotoEditorScreen( var brushSize by remember { mutableFloatStateOf(15f) } var currentFilter by remember { mutableStateOf(null) } + var imageRotation by remember { mutableFloatStateOf(0f) } var imageScale by remember { mutableFloatStateOf(1f) } var imageOffset by remember { mutableStateOf(Offset.Zero) } @@ -81,12 +91,171 @@ fun PhotoEditorScreen( var isSaving by remember { mutableStateOf(false) } var showDiscardDialog by remember { mutableStateOf(false) } + val imageSize by produceState(initialValue = IntSize.Zero, key1 = imagePath) { + value = withContext(Dispatchers.IO) { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(imagePath, options) + IntSize(options.outWidth.coerceAtLeast(0), options.outHeight.coerceAtLeast(0)) + } + } + + + val pivot by remember(canvasSize) { + derivedStateOf { Offset(canvasSize.width / 2f, canvasSize.height / 2f) } + } + + val cropState = rememberCropEditorState( + canvasSize = canvasSize, + imageSize = imageSize, + transformPivot = pivot, + imageScale = imageScale, + imageRotation = imageRotation, + imageOffset = imageOffset + ) + + + fun fillAreaAfterResize() { + val crop = cropState.cropRect + if (crop.width <= 0f || crop.height <= 0f || canvasSize.width <= 0 || canvasSize.height <= 0) return + + val currentAspect = crop.width / crop.height + val targetCropRect = calculateTargetFillRect(canvasSize, currentAspect) + if (targetCropRect == Rect.Zero) return + + + val scaleFactor = maxOf( + targetCropRect.width / crop.width, + targetCropRect.height / crop.height + ) + val targetScale = (imageScale * scaleFactor).coerceIn(MinImageScale, MaxImageScale) + val z = if (imageScale != 0f) targetScale / imageScale else 1f + + + + + + val targetOffset = Offset( + x = (targetCropRect.center.x - pivot.x) - z * (crop.center.x - pivot.x - imageOffset.x), + y = (targetCropRect.center.y - pivot.y) - z * (crop.center.y - pivot.y - imageOffset.y) + ) + + val targetImageBounds = calculateScalarTransformedBounds( + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + val safeTargetCropRect = constrainCropRectToImage( + currentCropRect = crop, + candidateRect = targetCropRect, + visibleBounds = targetImageBounds, + minCropSizePx = cropState.minCropSizePx, + baseBounds = cropState.imageBounds, + scale = targetScale, + rotationDegrees = imageRotation, + offset = targetOffset, + pivot = pivot + ) + + + scope.launch { + val startCrop = crop + val startScale = imageScale + val startOffset = imageOffset + val anim = androidx.compose.animation.core.Animatable(0f) + anim.animateTo(1f, androidx.compose.animation.core.tween(200)) { + val t = value + cropState.setCropRect( + Rect( + left = startCrop.left + (safeTargetCropRect.left - startCrop.left) * t, + top = startCrop.top + (safeTargetCropRect.top - startCrop.top) * t, + right = startCrop.right + (safeTargetCropRect.right - startCrop.right) * t, + bottom = startCrop.bottom + (safeTargetCropRect.bottom - startCrop.bottom) * t + ) + ) + imageScale = startScale + (targetScale - startScale) * t + imageOffset = Offset( + x = startOffset.x + (targetOffset.x - startOffset.x) * t, + y = startOffset.y + (targetOffset.y - startOffset.y) * t + ) + } + } + } + + val shouldConstrain by remember(currentTool) { + derivedStateOf { currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE } + } + + fun applyTransform(centroid: Offset, pan: Offset, zoom: Float) { + val effectiveMinScale = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + minimumScaleToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + currentScale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = pivot + ).coerceAtLeast(MinImageScale) + } else { + MinImageScale + } + + val newScale = (imageScale * zoom).coerceIn(effectiveMinScale, MaxImageScale) + val actualZoom = if (imageScale != 0f) newScale / imageScale else 1f + + val offsetAfterZoom = offsetForZoomAroundAnchor(imageOffset, pivot, centroid, actualZoom) + val newOffset = offsetAfterZoom + pan + + imageScale = newScale + imageOffset = if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + clampOffsetToCoverCrop( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = newScale, + rotationDegrees = imageRotation, + offset = newOffset, + pivot = pivot + ) + } else { + newOffset + } + } + + fun applyRotation(newRotation: Float) { + val deltaAngle = newRotation - imageRotation + + val anchor = if (cropState.cropRect != Rect.Zero) cropState.cropRect.center else pivot + val newOffset = offsetForRotationAroundAnchor(imageOffset, pivot, anchor, deltaAngle) + + imageRotation = newRotation + + + if (shouldConstrain && cropState.cropRect != Rect.Zero && cropState.imageBounds != Rect.Zero) { + val (fittedScale, fittedOffset) = fitContentInBounds( + baseBounds = cropState.imageBounds, + cropRect = cropState.cropRect, + scale = imageScale, + rotationDegrees = newRotation, + offset = newOffset, + pivot = pivot, + minScale = MinImageScale, + maxScale = MaxImageScale + ) + imageScale = fittedScale + imageOffset = fittedOffset + } else { + imageOffset = newOffset + } + } + val hasChanges by remember { derivedStateOf { paths.isNotEmpty() || textElements.isNotEmpty() || currentFilter != null || - imageRotation != 0f || + (cropState.cropRect != Rect.Zero && cropState.cropRect != cropState.defaultCropRect) || + normalizeRotationDegrees(imageRotation) != 0f || imageScale != 1f || imageOffset != Offset.Zero } @@ -120,7 +289,9 @@ fun PhotoEditorScreen( textElements, currentFilter, canvasSize, - imageRotation, + cropState.cropRect, + pivot, + normalizeRotationDegrees(imageRotation), imageScale, imageOffset ) @@ -151,9 +322,7 @@ fun PhotoEditorScreen( tonalElevation = 3.dp, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) { - Column( - modifier = Modifier.navigationBarsPadding() - ) { + Column(modifier = Modifier.navigationBarsPadding()) { AnimatedContent( targetState = currentTool, label = "ToolOptions", @@ -162,24 +331,22 @@ fun PhotoEditorScreen( Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 100.dp), + .heightIn(min = 84.dp), contentAlignment = Alignment.Center ) { when (tool) { EditorTool.TRANSFORM -> { TransformControls( rotation = imageRotation, - scale = imageScale, - onRotationChange = { imageRotation = it }, - onScaleChange = { imageScale = it }, + onRotationChange = { newRotation -> applyRotation(newRotation) }, onReset = { imageRotation = 0f imageScale = 1f imageOffset = Offset.Zero + cropState.reset() } ) } - EditorTool.DRAW, EditorTool.ERASER -> { DrawControls( isEraser = tool == EditorTool.ERASER, @@ -189,7 +356,6 @@ fun PhotoEditorScreen( onSizeChange = { brushSize = it } ) } - EditorTool.FILTER -> { FilterControls( imagePath = imagePath, @@ -197,7 +363,6 @@ fun PhotoEditorScreen( onFilterSelect = { currentFilter = it } ) } - EditorTool.TEXT -> { Button( onClick = { @@ -211,7 +376,6 @@ fun PhotoEditorScreen( Text(stringResource(R.string.photo_editor_action_add_text)) } } - else -> { Text( stringResource(R.string.photo_editor_label_select_tool), @@ -223,10 +387,7 @@ fun PhotoEditorScreen( } } - NavigationBar( - containerColor = Color.Transparent, - tonalElevation = 0.dp - ) { + NavigationBar(containerColor = Color.Transparent, tonalElevation = 0.dp) { EditorTool.entries.forEach { tool -> val label = stringResource(tool.labelRes) NavigationBarItem( @@ -255,180 +416,168 @@ fun PhotoEditorScreen( .background(Color.Black) .onGloballyPositioned { canvasSize = it.size } ) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = imageScale, - scaleY = imageScale, - rotationZ = imageRotation, - translationX = imageOffset.x, - translationY = imageOffset.y - ) - .pointerInput(currentTool) { - if (currentTool == EditorTool.TRANSFORM || currentTool == EditorTool.NONE) { - detectTransformGestures { _, pan, zoom, rotation -> - imageScale *= zoom - imageRotation += rotation - imageOffset += pan - } - } - } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(File(imagePath)) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } - ) - - Canvas( + Box(modifier = Modifier.fillMaxSize()) { + + Box( modifier = Modifier .fillMaxSize() + .graphicsLayer( + scaleX = imageScale, + scaleY = imageScale, + rotationZ = imageRotation, + translationX = imageOffset.x, + translationY = imageOffset.y, + transformOrigin = TransformOrigin.Center + ) .pointerInput(currentTool) { - if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { - detectDragGestures( - onDragStart = { offset -> - val path = Path().apply { moveTo(offset.x, offset.y) } - paths.add( - DrawnPath( - path = path, - color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, - strokeWidth = brushSize, - isEraser = currentTool == EditorTool.ERASER - ) - ) - pathsRedo.clear() - }, - onDrag = { change, _ -> - change.consume() - val index = paths.lastIndex - if (index == -1) return@detectDragGestures - - val currentPathData = paths[index] - val x1 = change.previousPosition.x - val y1 = change.previousPosition.y - val x2 = change.position.x - val y2 = change.position.y - - currentPathData.path.quadraticTo( - x1, y1, (x1 + x2) / 2, (y1 + y2) / 2 - ) - - paths.add(paths.removeAt(index)) - } - ) + if (currentTool == EditorTool.NONE) { + detectTransformGestures { centroid, pan, zoom, _ -> + applyTransform(centroid, pan, zoom) + } } } ) { - paths.forEach { pathData -> - drawPath( - path = pathData.path, - color = pathData.color, - alpha = pathData.alpha, - style = Stroke( - width = pathData.strokeWidth, - cap = StrokeCap.Round, - join = StrokeJoin.Round - ), - blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver - ) - } - } - - textElements.forEach { element -> - val density = LocalDensity.current - - var currentOffset by remember(element.id) { - mutableStateOf( - if (element.offset == Offset.Zero) Offset( - canvasSize.width / 2f, - canvasSize.height / 2f - ) else element.offset - ) - } - var currentScale by remember(element.id) { mutableStateOf(element.scale) } - var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + AsyncImage( + model = ImageRequest.Builder(context).data(File(imagePath)).build(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + colorFilter = currentFilter?.let { ColorFilter.colorMatrix(it.colorMatrix) } + ) - Box( + Canvas( modifier = Modifier - .offset( - x = with(density) { currentOffset.x.toDp() }, - y = with(density) { currentOffset.y.toDp() } + .fillMaxSize() + .pointerInput(currentTool) { + if (currentTool == EditorTool.DRAW || currentTool == EditorTool.ERASER) { + detectDragGestures( + onDragStart = { offset -> + val path = Path().apply { moveTo(offset.x, offset.y) } + paths.add( + DrawnPath( + path = path, + color = if (currentTool == EditorTool.ERASER) Color.Transparent else selectedColor, + strokeWidth = brushSize, + isEraser = currentTool == EditorTool.ERASER + ) + ) + pathsRedo.clear() + }, + onDrag = { change, _ -> + change.consume() + val index = paths.lastIndex + if (index == -1) return@detectDragGestures + val cur = paths[index] + val x1 = change.previousPosition.x + val y1 = change.previousPosition.y + val x2 = change.position.x + val y2 = change.position.y + cur.path.quadraticTo(x1, y1, (x1 + x2) / 2, (y1 + y2) / 2) + paths.add(paths.removeAt(index)) + } + ) + } + } + ) { + paths.forEach { pathData -> + drawPath( + path = pathData.path, + color = pathData.color, + alpha = pathData.alpha, + style = Stroke(width = pathData.strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round), + blendMode = if (pathData.isEraser) BlendMode.Clear else BlendMode.SrcOver ) - .graphicsLayer( - scaleX = currentScale, - scaleY = currentScale, - rotationZ = currentRotation, - translationX = -with(density) { 100.dp.toPx() }, - translationY = -with(density) { 25.dp.toPx() } + } + } + + textElements.forEach { element -> + var currentOffset by remember(element.id) { + mutableStateOf( + if (element.offset == Offset.Zero) Offset(canvasSize.width / 2f, canvasSize.height / 2f) + else element.offset ) - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTransformGestures( - onGesture = { _, pan, zoom, rotation -> + } + var currentScale by remember(element.id) { mutableStateOf(element.scale) } + var currentRotation by remember(element.id) { mutableStateOf(element.rotation) } + + Box( + modifier = Modifier + .offset( + x = with(density) { currentOffset.x.toDp() }, + y = with(density) { currentOffset.y.toDp() } + ) + .graphicsLayer( + scaleX = currentScale, + scaleY = currentScale, + rotationZ = currentRotation, + translationX = -with(density) { 100.dp.toPx() }, + translationY = -with(density) { 25.dp.toPx() } + ) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTransformGestures { _, pan, zoom, rotation -> currentOffset += pan currentScale *= zoom currentRotation += rotation } - ) + } } - } - .pointerInput(currentTool, element.id) { - if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { - detectTapGestures(onTap = { - if (currentTool == EditorTool.TEXT) { - editingTextElement = element - selectedColor = element.color - showTextDialog = true - } - }) + .pointerInput(currentTool, element.id) { + if (currentTool == EditorTool.TEXT || currentTool == EditorTool.NONE) { + detectTapGestures(onTap = { + if (currentTool == EditorTool.TEXT) { + editingTextElement = element + selectedColor = element.color + showTextDialog = true + } + }) + } + } + ) { + LaunchedEffect(currentOffset, currentScale, currentRotation) { + val idx = textElements.indexOfFirst { it.id == element.id } + if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { + textElements[idx] = textElements[idx].copy( + offset = currentOffset, scale = currentScale, rotation = currentRotation + ) } } - ) { - LaunchedEffect(currentOffset, currentScale, currentRotation) { - val idx = textElements.indexOfFirst { it.id == element.id } - if (idx != -1 && (textElements[idx].offset != currentOffset || textElements[idx].rotation != currentRotation)) { - textElements[idx] = textElements[idx].copy( - offset = currentOffset, - scale = currentScale, - rotation = currentRotation - ) - } - } - - Text( - text = element.text, - color = element.color, - style = MaterialTheme.typography.headlineLarge.copy( - shadow = Shadow( - color = Color.Black, - offset = Offset(2f, 2f), - blurRadius = 4f + Text( + text = element.text, + color = element.color, + style = MaterialTheme.typography.headlineLarge.copy( + shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f) ) ) - ) - - if (currentTool == EditorTool.TEXT) { - Box( - modifier = Modifier - .matchParentSize() - .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) - ) + if (currentTool == EditorTool.TEXT) { + Box( + modifier = Modifier + .matchParentSize() + .border(1.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(4.dp)) + ) + } } } } } + if (currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero) { + CropScrim(cropRect = cropState.cropRect) + } + AnimatedVisibility( - visible = currentTool == EditorTool.TRANSFORM, + visible = currentTool == EditorTool.TRANSFORM && cropState.cropRect != Rect.Zero, enter = fadeIn(), exit = fadeOut() ) { - CropOverlay() + CropOverlay( + cropRect = cropState.cropRect, + bounds = cropState.currentImageBounds, + minCropSizePx = cropState.minCropSizePx, + onCropRectChange = cropState.updateCropRect, + onContentTransform = { centroid, pan, zoom -> applyTransform(centroid, pan, zoom) }, + onResizeEnded = { fillAreaAfterResize() } + ) } if (isSaving) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt index 344bb578..1b534410 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.* import androidx.annotation.StringRes import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorMatrix @@ -17,6 +18,8 @@ import java.io.FileOutputStream import java.util.* import android.graphics.Canvas as AndroidCanvas import android.graphics.Paint as AndroidPaint +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withTranslation data class DrawnPath( val path: Path, @@ -114,11 +117,17 @@ suspend fun saveImage( textElements: List, filter: ImageFilter?, canvasSize: IntSize, + cropRect: Rect, + transformPivot: Offset = cropRect.center, imageRotation: Float = 0f, imageScale: Float = 1f, imageOffset: Offset = Offset.Zero ): String? = withContext(Dispatchers.IO) { try { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || cropRect.width <= 0f || cropRect.height <= 0f) { + return@withContext null + } + val options = BitmapFactory.Options().apply { inMutable = true } var bitmap = BitmapFactory.decodeFile(originalPath, options) ?: return@withContext null @@ -150,66 +159,84 @@ suspend fun saveImage( dx = (screenW - (bitmapW * baseScale)) / 2f } - val resultBitmap = Bitmap.createBitmap(canvasSize.width, canvasSize.height, Bitmap.Config.ARGB_8888) + val exportScale = if (baseScale > 0f) 1f / baseScale else 1f + val resultBitmap = createBitmap( + (cropRect.width * exportScale).toInt().coerceAtLeast(1), + (cropRect.height * exportScale).toInt().coerceAtLeast(1) + ) val canvas = AndroidCanvas(resultBitmap) + val transformPivotX = transformPivot.x * exportScale + val transformPivotY = transformPivot.y * exportScale + val scaledCropLeft = cropRect.left * exportScale + val scaledCropTop = cropRect.top * exportScale + val scaledImageOffset = Offset(imageOffset.x * exportScale, imageOffset.y * exportScale) + + canvas.translate(-scaledCropLeft, -scaledCropTop) + + canvas.withTranslation(scaledImageOffset.x + transformPivotX, scaledImageOffset.y + transformPivotY) { + rotate(imageRotation) + scale(imageScale, imageScale) + translate(-transformPivotX, -transformPivotY) + + val imagePaint = AndroidPaint().apply { + isAntiAlias = true + if (filter != null) { + colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + } + } + val destRect = RectF( + dx * exportScale, + dy * exportScale, + dx * exportScale + bitmapW, + dy * exportScale + bitmapH + ) + drawBitmap(bitmap, null, destRect, imagePaint) - canvas.save() - canvas.translate(imageOffset.x + screenW / 2f, imageOffset.y + screenH / 2f) - canvas.rotate(imageRotation) - canvas.scale(imageScale, imageScale) - canvas.translate(-screenW / 2f, -screenH / 2f) + val pathPaint = AndroidPaint().apply { + isAntiAlias = true + style = AndroidPaint.Style.STROKE + strokeCap = AndroidPaint.Cap.ROUND + strokeJoin = AndroidPaint.Join.ROUND + } - val imagePaint = AndroidPaint().apply { - isAntiAlias = true - if (filter != null) { - colorFilter = android.graphics.ColorMatrixColorFilter(filter.colorMatrix.values) + paths.forEach { pathData -> + if (pathData.isEraser) { + pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } else { + pathPaint.xfermode = null + pathPaint.color = pathData.color.toArgb() + pathPaint.alpha = (pathData.alpha * 255).toInt() + } + pathPaint.strokeWidth = pathData.strokeWidth * exportScale + val scaledPath = android.graphics.Path(pathData.path.asAndroidPath()) + scaledPath.transform( + android.graphics.Matrix().apply { setScale(exportScale, exportScale) } + ) + drawPath(scaledPath, pathPaint) } - } - val destRect = RectF(dx, dy, dx + bitmapW * baseScale, dy + bitmapH * baseScale) - canvas.drawBitmap(bitmap, null, destRect, imagePaint) - - val pathPaint = AndroidPaint().apply { - isAntiAlias = true - style = AndroidPaint.Style.STROKE - strokeCap = AndroidPaint.Cap.ROUND - strokeJoin = AndroidPaint.Join.ROUND - } - paths.forEach { pathData -> - if (pathData.isEraser) { - pathPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) - } else { - pathPaint.xfermode = null - pathPaint.color = pathData.color.toArgb() - pathPaint.alpha = (pathData.alpha * 255).toInt() + val textPaint = AndroidPaint().apply { + isAntiAlias = true + typeface = Typeface.DEFAULT_BOLD } - pathPaint.strokeWidth = pathData.strokeWidth - canvas.drawPath(pathData.path.asAndroidPath(), pathPaint) - } - val textPaint = AndroidPaint().apply { - isAntiAlias = true - typeface = Typeface.DEFAULT_BOLD - } + textElements.forEach { element -> + textPaint.color = element.color.toArgb() + textPaint.textSize = 64f * element.scale * exportScale - textElements.forEach { element -> - textPaint.color = element.color.toArgb() - textPaint.textSize = 64f * element.scale + withTranslation(element.offset.x * exportScale, element.offset.y * exportScale) { + rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) - canvas.save() - canvas.translate(element.offset.x, element.offset.y) - canvas.rotate(Math.toDegrees(element.rotation.toDouble()).toFloat()) + val textWidth = textPaint.measureText(element.text) + val fontMetrics = textPaint.fontMetrics + val textHeight = fontMetrics.descent - fontMetrics.ascent - val textWidth = textPaint.measureText(element.text) - val fontMetrics = textPaint.fontMetrics - val textHeight = fontMetrics.descent - fontMetrics.ascent + drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) + } + } - canvas.drawText(element.text, -textWidth / 2f, textHeight / 4f, textPaint) - canvas.restore() } - canvas.restore() - val file = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg") FileOutputStream(file).use { out -> resultBitmap.compress(Bitmap.CompressFormat.JPEG, 95, out) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt deleted file mode 100644 index e9841bfd..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/CropOverlay.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.monogram.presentation.features.chats.currentChat.editor.photo.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -@Composable -fun CropOverlay( - modifier: Modifier = Modifier -) { - Canvas( - modifier = modifier - .fillMaxSize() - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - ) { - val width = size.width - val height = size.height - - val padding = 32.dp.toPx() - val cropWidth = width - padding * 2 - val cropHeight = height - padding * 2 - - val rect = Rect( - offset = Offset(padding, padding), - size = Size(cropWidth, cropHeight) - ) - - drawRect( - color = Color.Black.copy(alpha = 0.7f), - size = size - ) - - drawRect( - color = Color.Transparent, - topLeft = rect.topLeft, - size = rect.size, - blendMode = BlendMode.Clear - ) - - val strokeWidth = 1.dp.toPx() - val gridColor = Color.White.copy(alpha = 0.5f) - - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width / 3, rect.top), - end = Offset(rect.left + rect.width / 3, rect.bottom), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left + rect.width * 2 / 3, rect.top), - end = Offset(rect.left + rect.width * 2 / 3, rect.bottom), - strokeWidth = strokeWidth - ) - - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height / 3), - end = Offset(rect.right, rect.top + rect.height / 3), - strokeWidth = strokeWidth - ) - drawLine( - color = gridColor, - start = Offset(rect.left, rect.top + rect.height * 2 / 3), - end = Offset(rect.right, rect.top + rect.height * 2 / 3), - strokeWidth = strokeWidth - ) - - val cornerLen = 24.dp.toPx() - val cornerStroke = 3.dp.toPx() - val cornerColor = Color.White - - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.topLeft, rect.topLeft.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.topRight, rect.topRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.topRight, rect.topRight.copy(y = rect.top + cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(x = rect.left + cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomLeft, rect.bottomLeft.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(x = rect.right - cornerLen), cornerStroke) - drawLine(cornerColor, rect.bottomRight, rect.bottomRight.copy(y = rect.bottom - cornerLen), cornerStroke) - - drawRect( - color = Color.White.copy(alpha = 0.8f), - topLeft = rect.topLeft, - size = rect.size, - style = Stroke(width = 1.dp.toPx()) - ) - } -} \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt index 99f663fe..553ee2b4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt @@ -1,79 +1,177 @@ package org.monogram.presentation.features.chats.currentChat.editor.photo.components +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.RotateLeft -import androidx.compose.material.icons.rounded.RotateRight import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.monogram.presentation.R +import kotlin.math.absoluteValue +import kotlin.math.floor +import kotlin.math.roundToInt @Composable fun TransformControls( rotation: Float, - scale: Float, onRotationChange: (Float) -> Unit, - onScaleChange: (Float) -> Unit, onReset: () -> Unit ) { + val normalizedRotation = normalizeRotationDegrees(rotation) + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly + verticalAlignment = Alignment.Bottom ) { - FilledTonalIconButton(onClick = { onRotationChange(rotation - 90f) }) { - Icon( - Icons.Rounded.RotateLeft, - contentDescription = stringResource(R.string.photo_editor_action_rotate_left) + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${normalizedRotation.roundToInt()}°", + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RotationWheel( + angle = rotation, + onAngleChange = onRotationChange, + modifier = Modifier.fillMaxWidth() ) } - OutlinedButton( + Spacer(modifier = Modifier.width(12.dp)) + + OutlinedIconButton( onClick = onReset, - contentPadding = PaddingValues(horizontal = 16.dp) + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) ) { - Icon(Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(stringResource(R.string.photo_editor_action_reset)) - } - - FilledTonalIconButton(onClick = { onRotationChange(rotation + 90f) }) { Icon( - Icons.Rounded.RotateRight, - contentDescription = stringResource(R.string.photo_editor_action_rotate_right) + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.photo_editor_action_reset) ) } } + } +} - Spacer(modifier = Modifier.height(12.dp)) +@Composable +private fun RotationWheel( + angle: Float, + onAngleChange: (Float) -> Unit, + modifier: Modifier = Modifier +) { + val onSurface = MaterialTheme.colorScheme.onSurface + val primary = MaterialTheme.colorScheme.primary + var visualAngle by remember { mutableFloatStateOf(angle) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + LaunchedEffect(angle) { + visualAngle = closestEquivalentAngle(angle, visualAngle) + } + + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + shape = MaterialTheme.shapes.large + ) + .pointerInput(Unit) { + var dragAngle = visualAngle + detectDragGestures( + onDragStart = { + dragAngle = visualAngle + } + ) { change, dragAmount -> + change.consume() + dragAngle -= dragAmount.x * 0.1f + visualAngle = dragAngle + onAngleChange(dragAngle) + } + } + .padding(horizontal = 12.dp, vertical = 8.dp) ) { - Text( - stringResource(R.string.photo_editor_label_zoom), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(48.dp) - ) - Slider( - value = scale, - onValueChange = onScaleChange, - valueRange = 0.5f..3f, - modifier = Modifier.weight(1f) - ) + Canvas(modifier = Modifier.fillMaxWidth().height(42.dp)) { + val centerX = size.width / 2f + val bottom = size.height + val tickSpacing = 6f + val minorStep = 5 + val majorStep = 45 + val currentTick = visualAngle / minorStep + val centerTick = floor(currentTick).toInt() + val visibleTickCount = (size.width / tickSpacing / 2f).roundToInt() + 4 + + for (relativeTick in -visibleTickCount..visibleTickCount) { + val tickIndex = centerTick + relativeTick + val tickAngle = tickIndex * minorStep + val x = centerX + (tickIndex - currentTick) * tickSpacing + if (x < 0f || x > size.width) continue + + val distanceRatio = ((x - centerX).absoluteValue / centerX).coerceIn(0f, 1f) + val alpha = 1f - distanceRatio * 0.8f + val isMajor = tickAngle % majorStep == 0 + val isMedium = tickAngle % 15 == 0 + val tickHeight = when { + isMajor -> size.height * 0.62f + isMedium -> size.height * 0.45f + else -> size.height * 0.28f + } + val strokeWidth = when { + isMajor -> 3f + isMedium -> 2.5f + else -> 1.5f + } + + drawLine( + color = onSurface.copy(alpha = alpha), + start = Offset(x, bottom - tickHeight), + end = Offset(x, bottom), + strokeWidth = strokeWidth + ) + } + + drawLine( + color = primary, + start = Offset(centerX, 0f), + end = Offset(centerX, bottom), + strokeWidth = 4f + ) + } } } } + +internal fun normalizeRotationDegrees(value: Float): Float { + var normalized = value % 360f + if (normalized > 180f) normalized -= 360f + if (normalized < -180f) normalized += 360f + return normalized +} + +private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { + val turns = ((referenceAngle - normalizedAngle) / 360f).roundToInt() + return normalizedAngle + turns * 360f +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt new file mode 100644 index 00000000..ad405297 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropEditorState.kt @@ -0,0 +1,145 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +@Stable +class CropEditorState internal constructor( + val minCropSizePx: Float, + val imageBounds: Rect, + val currentImageBounds: Rect, + val defaultCropRect: Rect, + val cropRect: Rect, + val updateCropRect: (Rect) -> Unit, + val setCropRect: (Rect) -> Unit, + val reset: () -> Unit +) + +fun calculateTargetFillRect(canvasSize: IntSize, aspectRatio: Float): Rect { + if (canvasSize.width <= 0 || canvasSize.height <= 0 || aspectRatio <= 0f) return Rect.Zero + val cw = canvasSize.width.toFloat() + val ch = canvasSize.height.toFloat() + val centerX = cw / 2f + val centerY = ch / 2f + + val w: Float + val h: Float + if (ch * aspectRatio > cw) { + w = cw + h = cw / aspectRatio + } else { + h = ch + w = ch * aspectRatio + } + return Rect(centerX - w / 2f, centerY - h / 2f, centerX + w / 2f, centerY + h / 2f) +} + +@Composable +fun rememberCropEditorState( + canvasSize: IntSize, + imageSize: IntSize, + transformPivot: Offset, + imageScale: Float, + imageRotation: Float, + imageOffset: Offset +): CropEditorState { + val density = LocalDensity.current + val minCropSizePx = remember(density) { with(density) { 96.dp.toPx() } } + + val imageBounds by remember(canvasSize, imageSize) { + derivedStateOf { calculateCropRect(canvasSize, imageSize) } + } + val defaultCropRect by remember(imageBounds) { + derivedStateOf { imageBounds } + } + + var cropRect by remember { mutableStateOf(Rect.Zero) } + var previousImageBounds by remember { mutableStateOf(Rect.Zero) } + + val currentImageBounds by remember(imageBounds, imageScale, imageRotation, imageOffset, transformPivot) { + derivedStateOf { + if (imageBounds != Rect.Zero) { + calculateScalarTransformedBounds( + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + } else { + imageBounds + } + } + } + + LaunchedEffect(imageBounds) { + if (imageBounds == Rect.Zero) { + cropRect = Rect.Zero + previousImageBounds = Rect.Zero + } else if (cropRect == Rect.Zero || previousImageBounds == Rect.Zero) { + cropRect = imageBounds + previousImageBounds = imageBounds + } else if (previousImageBounds != imageBounds) { + cropRect = constrainCropRect( + cropRect = remapRectToBounds(cropRect, previousImageBounds, imageBounds), + bounds = imageBounds, + minCropSizePx = minCropSizePx + ) + previousImageBounds = imageBounds + } + } + + return CropEditorState( + minCropSizePx = minCropSizePx, + imageBounds = imageBounds, + currentImageBounds = currentImageBounds, + defaultCropRect = defaultCropRect, + cropRect = cropRect, + updateCropRect = { candidate -> + cropRect = constrainCropRectToImage( + currentCropRect = cropRect, + candidateRect = candidate, + visibleBounds = currentImageBounds, + minCropSizePx = minCropSizePx, + baseBounds = imageBounds, + scale = imageScale, + rotationDegrees = imageRotation, + offset = imageOffset, + pivot = transformPivot + ) + }, + setCropRect = { rect -> + cropRect = rect + }, + reset = { + cropRect = defaultCropRect + } + ) +} + +private fun remapRectToBounds(rect: Rect, fromBounds: Rect, toBounds: Rect): Rect { + if (rect == Rect.Zero || fromBounds.width <= 0f || fromBounds.height <= 0f) return toBounds + + val leftFraction = (rect.left - fromBounds.left) / fromBounds.width + val topFraction = (rect.top - fromBounds.top) / fromBounds.height + val rightFraction = (rect.right - fromBounds.left) / fromBounds.width + val bottomFraction = (rect.bottom - fromBounds.top) / fromBounds.height + + return Rect( + left = toBounds.left + toBounds.width * leftFraction, + top = toBounds.top + toBounds.height * topFraction, + right = toBounds.left + toBounds.width * rightFraction, + bottom = toBounds.top + toBounds.height * bottomFraction + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt new file mode 100644 index 00000000..d742f860 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometry.kt @@ -0,0 +1,339 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +private const val GeometryEpsilon = 0.001f + +private fun contentToScreen( + p: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val dx = p.x - pivot.x + val dy = p.y - pivot.y + return Offset( + x = pivot.x + scale * (dx * cosR - dy * sinR) + offset.x, + y = pivot.y + scale * (dx * sinR + dy * cosR) + offset.y + ) +} + +private fun screenToContent( + screen: Offset, scale: Float, cosR: Float, sinR: Float, offset: Offset, pivot: Offset +): Offset { + val sx = (screen.x - pivot.x - offset.x) / scale + val sy = (screen.y - pivot.y - offset.y) / scale + // R(-rotation) = transpose of R(rotation) + return Offset( + x = pivot.x + sx * cosR + sy * sinR, + y = pivot.y - sx * sinR + sy * cosR + ) +} + +private fun projectRectCornersToContentBounds( + rect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + if (rect.isEmpty || scale <= 0f) return Rect.Zero + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (corner in corners) { + val contentPoint = screenToContent(corner, scale, cosR, sinR, offset, pivot) + minX = min(minX, contentPoint.x) + minY = min(minY, contentPoint.y) + maxX = max(maxX, contentPoint.x) + maxY = max(maxY, contentPoint.y) + } + + return Rect(minX, minY, maxX, maxY) +} + +private fun rectCorners(rect: Rect): Array = arrayOf( + rect.topLeft, + rect.topRight, + rect.bottomRight, + rect.bottomLeft +) + +private fun Rect.containsWithTolerance(point: Offset, epsilon: Float = GeometryEpsilon): Boolean { + return point.x >= left - epsilon && point.x <= right + epsilon && + point.y >= top - epsilon && point.y <= bottom + epsilon +} + +private fun lerpRect(start: Rect, end: Rect, fraction: Float): Rect { + return Rect( + left = start.left + (end.left - start.left) * fraction, + top = start.top + (end.top - start.top) * fraction, + right = start.right + (end.right - start.right) * fraction, + bottom = start.bottom + (end.bottom - start.bottom) * fraction + ) +} + +fun calculateScalarTransformedBounds( + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + val corners = arrayOf( + baseBounds.topLeft, baseBounds.topRight, + baseBounds.bottomRight, baseBounds.bottomLeft + ) + + var minX = Float.POSITIVE_INFINITY + var minY = Float.POSITIVE_INFINITY + var maxX = Float.NEGATIVE_INFINITY + var maxY = Float.NEGATIVE_INFINITY + + for (p in corners) { + val s = contentToScreen(p, scale, cosR, sinR, offset, pivot) + minX = min(minX, s.x); minY = min(minY, s.y) + maxX = max(maxX, s.x); maxY = max(maxY, s.y) + } + return Rect(minX, minY, maxX, maxY) +} + +internal fun isCropRectCoveredByImage( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Boolean { + if (baseBounds.isEmpty || cropRect.isEmpty || scale <= 0f) return false + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + return rectCorners(cropRect).all { corner -> + baseBounds.containsWithTolerance( + point = screenToContent( + screen = corner, + scale = scale, + cosR = cosR, + sinR = sinR, + offset = offset, + pivot = pivot + ) + ) + } +} + +internal fun constrainCropRectToImage( + currentCropRect: Rect, + candidateRect: Rect, + visibleBounds: Rect, + minCropSizePx: Float, + baseBounds: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Rect { + val constrainedCandidate = constrainCropRect( + cropRect = candidateRect, + bounds = visibleBounds, + minCropSizePx = minCropSizePx + ) + + if (baseBounds.isEmpty || constrainedCandidate.isEmpty || scale <= 0f) return constrainedCandidate + if (isCropRectCoveredByImage(baseBounds, constrainedCandidate, scale, rotationDegrees, offset, pivot)) { + return constrainedCandidate + } + if (!isCropRectCoveredByImage(baseBounds, currentCropRect, scale, rotationDegrees, offset, pivot)) { + return currentCropRect + } + + var low = 0f + var high = 1f + repeat(20) { + val mid = (low + high) / 2f + val interpolated = lerpRect(currentCropRect, constrainedCandidate, mid) + if (isCropRectCoveredByImage(baseBounds, interpolated, scale, rotationDegrees, offset, pivot)) { + low = mid + } else { + high = mid + } + } + + return lerpRect(currentCropRect, constrainedCandidate, low) +} + +fun offsetForZoomAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + zoom: Float +): Offset { + return Offset( + x = (1f - zoom) * (anchor.x - pivot.x) + zoom * currentOffset.x, + y = (1f - zoom) * (anchor.y - pivot.y) + zoom * currentOffset.y + ) +} + +fun offsetForRotationAroundAnchor( + currentOffset: Offset, + pivot: Offset, + anchor: Offset, + deltaAngleDegrees: Float +): Offset { + val rad = Math.toRadians(deltaAngleDegrees.toDouble()) + val cosD = cos(rad).toFloat() + val sinD = sin(rad).toFloat() + + val vx = anchor.x - pivot.x - currentOffset.x + val vy = anchor.y - pivot.y - currentOffset.y + val rvx = vx * cosD - vy * sinD + val rvy = vx * sinD + vy * cosD + + return Offset( + x = anchor.x - pivot.x - rvx, + y = anchor.y - pivot.y - rvy + ) +} + +fun clampOffsetToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Offset { + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return offset + + val rad = Math.toRadians(rotationDegrees.toDouble()) + val cosR = cos(rad).toFloat() + val sinR = sin(rad).toFloat() + + var cdx = 0f + var cdy = 0f + + if (cropContentBounds.left < baseBounds.left) { + cdx = baseBounds.left - cropContentBounds.left + } else if (cropContentBounds.right > baseBounds.right) { + cdx = baseBounds.right - cropContentBounds.right + } + + if (cropContentBounds.top < baseBounds.top) { + cdy = baseBounds.top - cropContentBounds.top + } else if (cropContentBounds.bottom > baseBounds.bottom) { + cdy = baseBounds.bottom - cropContentBounds.bottom + } + + if (cdx == 0f && cdy == 0f) return offset + + val screenDx = -scale * (cdx * cosR - cdy * sinR) + val screenDy = -scale * (cdx * sinR + cdy * cosR) + + return Offset(offset.x + screenDx, offset.y + screenDy) +} + +fun fitContentInBounds( + baseBounds: Rect, + cropRect: Rect, + scale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset, + minScale: Float = 0.5f, + maxScale: Float = 30f +): Pair { + if (baseBounds.isEmpty || cropRect.isEmpty) return Pair(scale, offset) + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = scale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return Pair(scale, offset) + + val cropContentW = cropContentBounds.width + val cropContentH = cropContentBounds.height + var newScale = scale + + if (cropContentW > baseBounds.width || cropContentH > baseBounds.height) { + val scaleX = if (baseBounds.width > 0f) cropContentW / baseBounds.width else 1f + val scaleY = if (baseBounds.height > 0f) cropContentH / baseBounds.height else 1f + val correction = max(scaleX, scaleY) + newScale = (scale * correction).coerceIn(minScale, maxScale) + } + + val newOffset = if (newScale != scale) { + val zoomFactor = newScale / scale + offsetForZoomAroundAnchor(offset, pivot, cropRect.center, zoomFactor) + } else { + offset + } + + val clampedOffset = clampOffsetToCoverCrop(baseBounds, cropRect, newScale, rotationDegrees, newOffset, pivot) + return Pair(newScale, clampedOffset) +} + +fun minimumScaleToCoverCrop( + baseBounds: Rect, + cropRect: Rect, + currentScale: Float, + rotationDegrees: Float, + offset: Offset, + pivot: Offset +): Float { + if (baseBounds.isEmpty || cropRect.isEmpty || currentScale <= 0f) return currentScale + + val cropContentBounds = projectRectCornersToContentBounds( + rect = cropRect, + scale = currentScale, + rotationDegrees = rotationDegrees, + offset = offset, + pivot = pivot + ) + if (cropContentBounds == Rect.Zero) return currentScale + + val contentSpanX = cropContentBounds.width + val contentSpanY = cropContentBounds.height + + var minScale = 0f + if (baseBounds.width > 0f && contentSpanX > 0f) { + minScale = max(minScale, currentScale * contentSpanX / baseBounds.width) + } + if (baseBounds.height > 0f && contentSpanY > 0f) { + minScale = max(minScale, currentScale * contentSpanY / baseBounds.height) + } + return minScale +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt new file mode 100644 index 00000000..1a884fd2 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropOverlay.kt @@ -0,0 +1,257 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +fun calculateCropRect(bounds: IntSize, imageSize: IntSize): Rect { + if (bounds.width <= 0 || bounds.height <= 0 || imageSize.width <= 0 || imageSize.height <= 0) { + return Rect.Zero + } + val imageAspect = imageSize.width.toFloat() / imageSize.height.toFloat() + val canvasAspect = bounds.width.toFloat() / bounds.height.toFloat() + return if (imageAspect > canvasAspect) { + val fittedHeight = bounds.width / imageAspect + val top = (bounds.height - fittedHeight) / 2f + Rect(0f, top, bounds.width.toFloat(), top + fittedHeight) + } else { + val fittedWidth = bounds.height * imageAspect + val left = (bounds.width - fittedWidth) / 2f + Rect(left, 0f, left + fittedWidth, bounds.height.toFloat()) + } +} + +fun constrainCropRect(cropRect: Rect, bounds: Rect, minCropSizePx: Float): Rect { + val b = Rect( + left = minOf(bounds.left, bounds.right), + top = minOf(bounds.top, bounds.bottom), + right = maxOf(bounds.left, bounds.right), + bottom = maxOf(bounds.top, bounds.bottom) + ) + if (b.width <= 0f || b.height <= 0f) return cropRect + val minW = minCropSizePx.coerceAtMost(b.width) + val minH = minCropSizePx.coerceAtMost(b.height) + val w = cropRect.width.coerceIn(minW, b.width) + val h = cropRect.height.coerceIn(minH, b.height) + val l = cropRect.left.coerceIn(b.left, (b.right - w).coerceAtLeast(b.left)) + val t = cropRect.top.coerceIn(b.top, (b.bottom - h).coerceAtLeast(b.top)) + return Rect(l, t, l + w, t + h) +} + + + +private enum class CropHandle { + NONE, MOVE, + TOP_LEFT, TOP, TOP_RIGHT, RIGHT, + BOTTOM_RIGHT, BOTTOM, BOTTOM_LEFT, LEFT +} + + + +@Composable +fun CropScrim(cropRect: Rect, modifier: Modifier = Modifier) { + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + ) { + drawRect(Color.Black.copy(alpha = 0.7f), size = size) + drawRect(Color.Transparent, topLeft = cropRect.topLeft, size = cropRect.size, blendMode = BlendMode.Clear) + } +} + + + +@Composable +fun CropOverlay( + cropRect: Rect, + bounds: Rect, + minCropSizePx: Float, + onCropRectChange: (Rect) -> Unit, + onContentTransform: (centroid: Offset, pan: Offset, zoom: Float) -> Unit = { _, _, _ -> }, + onResizeEnded: () -> Unit = {}, + onDragStateChange: (Boolean) -> Unit = {}, + modifier: Modifier = Modifier +) { + val currentCropRect by rememberUpdatedState(cropRect) + val currentOnCropRectChange by rememberUpdatedState(onCropRectChange) + val currentOnContentTransform by rememberUpdatedState(onContentTransform) + val currentOnResizeEnded by rememberUpdatedState(onResizeEnded) + val currentOnDragStateChange by rememberUpdatedState(onDragStateChange) + val handleTouchRadiusPx = 28.dp + val cornerHandleZonePx = 44.dp + val sideHandleLengthPx = 36.dp + val sideTouchInsetPx = 24.dp + + + var isResizing by remember { mutableStateOf(false) } + + Canvas( + modifier = modifier + .fillMaxSize() + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + + .pointerInput(bounds, minCropSizePx) { + val handleTouchRadius = handleTouchRadiusPx.toPx() + val cornerHandleZone = cornerHandleZonePx.toPx() + val sideTouchInset = sideTouchInsetPx.toPx() + + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + val activeHandle = pickCropHandle( + down.position, currentCropRect, + handleTouchRadius, cornerHandleZone, sideTouchInset + ) + + if (activeHandle == CropHandle.NONE || activeHandle == CropHandle.MOVE) { + return@awaitEachGesture + } + + var dragRect = currentCropRect + down.consume() + isResizing = true + currentOnDragStateChange(true) + + try { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val primary = event.changes.firstOrNull { it.id == down.id } + ?: event.changes.firstOrNull { it.pressed } + if (primary == null || !primary.pressed) break + + val drag = primary.position - primary.previousPosition + if (drag == Offset.Zero) continue + primary.consume() + + dragRect = resizeCropRect(dragRect, activeHandle, drag, bounds, minCropSizePx) + currentOnCropRectChange(dragRect) + } + } finally { + isResizing = false + currentOnResizeEnded() + currentOnDragStateChange(false) + } + } + } + + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + if (!isResizing) { + currentOnContentTransform(centroid, pan, zoom) + } + } + } + ) { + + val strokeWidth = 1.dp.toPx() + val gridColor = Color.White.copy(alpha = 0.5f) + drawLine(gridColor, Offset(cropRect.left + cropRect.width / 3, cropRect.top), Offset(cropRect.left + cropRect.width / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.top), Offset(cropRect.left + cropRect.width * 2 / 3, cropRect.bottom), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height / 3), Offset(cropRect.right, cropRect.top + cropRect.height / 3), strokeWidth) + drawLine(gridColor, Offset(cropRect.left, cropRect.top + cropRect.height * 2 / 3), Offset(cropRect.right, cropRect.top + cropRect.height * 2 / 3), strokeWidth) + + + val cornerLen = 24.dp.toPx() + val cornerStroke = 3.dp.toPx() + val white = Color.White + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.topLeft, cropRect.topLeft.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.topRight, cropRect.topRight.copy(y = cropRect.top + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(x = cropRect.left + cornerLen), cornerStroke) + drawLine(white, cropRect.bottomLeft, cropRect.bottomLeft.copy(y = cropRect.bottom - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(x = cropRect.right - cornerLen), cornerStroke) + drawLine(white, cropRect.bottomRight, cropRect.bottomRight.copy(y = cropRect.bottom - cornerLen), cornerStroke) + + + val sideLen = sideHandleLengthPx.toPx() + val sideStroke = 4.dp.toPx() + val cx = cropRect.left + cropRect.width / 2f + val cy = cropRect.top + cropRect.height / 2f + val half = sideLen / 2f + drawLine(white, Offset(cx - half, cropRect.top), Offset(cx + half, cropRect.top), sideStroke) + drawLine(white, Offset(cx - half, cropRect.bottom), Offset(cx + half, cropRect.bottom), sideStroke) + drawLine(white, Offset(cropRect.left, cy - half), Offset(cropRect.left, cy + half), sideStroke) + drawLine(white, Offset(cropRect.right, cy - half), Offset(cropRect.right, cy + half), sideStroke) + + + drawRect(Color.White.copy(alpha = 0.8f), cropRect.topLeft, cropRect.size, style = Stroke(1.dp.toPx())) + } +} + + + +private fun pickCropHandle( + point: Offset, crop: Rect, + touchRadius: Float, cornerZone: Float, sideInset: Float +): CropHandle { + val topBand = (crop.top - sideInset)..(crop.top + sideInset) + val bottomBand = (crop.bottom - sideInset)..(crop.bottom + sideInset) + val leftBand = (crop.left - sideInset)..(crop.left + sideInset) + val rightBand = (crop.right - sideInset)..(crop.right + sideInset) + val inH = point.x in crop.left..crop.right + val inV = point.y in crop.top..crop.bottom + return when { + inCorner(point, crop, CropHandle.TOP_LEFT, touchRadius, cornerZone) -> CropHandle.TOP_LEFT + inCorner(point, crop, CropHandle.TOP_RIGHT, touchRadius, cornerZone) -> CropHandle.TOP_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_RIGHT, touchRadius, cornerZone) -> CropHandle.BOTTOM_RIGHT + inCorner(point, crop, CropHandle.BOTTOM_LEFT, touchRadius, cornerZone) -> CropHandle.BOTTOM_LEFT + inH && point.y in topBand -> CropHandle.TOP + inV && point.x in rightBand -> CropHandle.RIGHT + inH && point.y in bottomBand -> CropHandle.BOTTOM + inV && point.x in leftBand -> CropHandle.LEFT + inH && inV -> CropHandle.MOVE + else -> CropHandle.NONE + } +} + +private fun resizeCropRect(crop: Rect, handle: CropHandle, drag: Offset, bounds: Rect, minSize: Float): Rect { + if (bounds.width <= 0f || bounds.height <= 0f) return crop + val minW = minSize.coerceAtMost(bounds.width) + val minH = minSize.coerceAtMost(bounds.height) + var l = crop.left; var t = crop.top; var r = crop.right; var b = crop.bottom + when (handle) { + CropHandle.MOVE -> { /* not used here */ } + CropHandle.TOP_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP -> { t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.TOP_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); t = (t + drag.y).coerceIn(bounds.top, b - minH) } + CropHandle.RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right) } + CropHandle.BOTTOM_RIGHT -> { r = (r + drag.x).coerceIn(l + minW, bounds.right); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM -> { b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.BOTTOM_LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW); b = (b + drag.y).coerceIn(t + minH, bounds.bottom) } + CropHandle.LEFT -> { l = (l + drag.x).coerceIn(bounds.left, r - minW) } + CropHandle.NONE -> {} + } + return Rect(l, t, r, b) +} + +private fun inCorner(point: Offset, crop: Rect, handle: CropHandle, radius: Float, zone: Float): Boolean { + val r = when (handle) { + CropHandle.TOP_LEFT -> Rect(crop.left - radius, crop.top - radius, crop.left + zone, crop.top + zone) + CropHandle.TOP_RIGHT -> Rect(crop.right - zone, crop.top - radius, crop.right + radius, crop.top + zone) + CropHandle.BOTTOM_RIGHT -> Rect(crop.right - zone, crop.bottom - zone, crop.right + radius, crop.bottom + radius) + CropHandle.BOTTOM_LEFT -> Rect(crop.left - radius, crop.bottom - zone, crop.left + zone, crop.bottom + radius) + else -> return false + } + return point.x in r.left..r.right && point.y in r.top..r.bottom +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt new file mode 100644 index 00000000..954317e3 --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt @@ -0,0 +1,125 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.crop + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CropGeometryTest { + @Test + fun `fitContentInBounds restores crop coverage for rotated image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val cropRect = Rect(left = 15f, top = 15f, right = 85f, bottom = 85f) + val initialScale = 1.2f + val initialOffset = Offset(50f, -40f) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center + ) + ) + + val (newScale, newOffset) = fitContentInBounds( + baseBounds = baseBounds, + cropRect = cropRect, + scale = initialScale, + rotationDegrees = 35f, + offset = initialOffset, + pivot = baseBounds.center, + minScale = 0.5f, + maxScale = 10f + ) + + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = newScale, + rotationDegrees = 35f, + offset = newOffset, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `isCropRectCoveredByImage rejects crop in rotated bounding box corner`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val cropRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = 65f, + bottom = 65f + ) + + assertFalse( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + @Test + fun `constrainCropRectToImage pulls invalid rotated crop back inside image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 100f, bottom = 100f) + val visibleBounds = rotatedVisibleBounds(baseBounds) + val currentCropRect = Rect(left = 35f, top = 35f, right = 65f, bottom = 65f) + val candidateRect = Rect( + left = visibleBounds.left, + top = visibleBounds.top, + right = currentCropRect.right, + bottom = currentCropRect.bottom + ) + + val constrained = constrainCropRectToImage( + currentCropRect = currentCropRect, + candidateRect = candidateRect, + visibleBounds = visibleBounds, + minCropSizePx = 16f, + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + + assertTrue(constrained.left > candidateRect.left + EPSILON) + assertTrue(constrained.top > candidateRect.top + EPSILON) + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = constrained, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + ) + } + + private fun rotatedVisibleBounds(baseBounds: Rect): Rect { + return calculateScalarTransformedBounds( + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 45f, + offset = Offset.Zero, + pivot = baseBounds.center + ) + } + + private companion object { + const val EPSILON = 0.001f + } +} From be8d206150e2047f48093b59a8545bcb2166bfda Mon Sep 17 00:00:00 2001 From: aliveoutside <53598473+aliveoutside@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:24:38 +0400 Subject: [PATCH 05/83] feat(photo-editor): add rotate to crop --- .../editor/photo/PhotoEditorScreen.kt | 23 ++++++++++ .../photo/components/TransformControls.kt | 44 ++++++++++++++----- .../photo/components/TransformControlsTest.kt | 21 +++++++++ .../editor/photo/crop/CropGeometryTest.kt | 24 ++++++++++ 4 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt index ffa71781..390d8370 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt @@ -249,6 +249,28 @@ fun PhotoEditorScreen( } } + fun rotateClockwise() { + val targetRotation = rotateClockwiseToNextRightAngle(imageRotation) + + imageRotation = targetRotation + imageScale = 1f + imageOffset = Offset.Zero + + cropState.setCropRect( + if (cropState.imageBounds == Rect.Zero) { + Rect.Zero + } else { + calculateScalarTransformedBounds( + baseBounds = cropState.imageBounds, + scale = 1f, + rotationDegrees = targetRotation, + offset = Offset.Zero, + pivot = pivot + ) + } + ) + } + val hasChanges by remember { derivedStateOf { paths.isNotEmpty() || @@ -339,6 +361,7 @@ fun PhotoEditorScreen( TransformControls( rotation = imageRotation, onRotationChange = { newRotation -> applyRotation(newRotation) }, + onRotateClockwise = { rotateClockwise() }, onReset = { imageRotation = 0f imageScale = 1f diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt index 553ee2b4..b9c1fd21 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Rotate90DegreesCw import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -23,6 +24,7 @@ import kotlin.math.roundToInt fun TransformControls( rotation: Float, onRotationChange: (Float) -> Unit, + onRotateClockwise: () -> Unit, onReset: () -> Unit ) { val normalizedRotation = normalizeRotationDegrees(rotation) @@ -57,17 +59,34 @@ fun TransformControls( Spacer(modifier = Modifier.width(12.dp)) - OutlinedIconButton( - onClick = onReset, - colors = IconButtonDefaults.outlinedIconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), - contentColor = MaterialTheme.colorScheme.onSurface - ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.photo_editor_action_reset) - ) + OutlinedIconButton( + onClick = onRotateClockwise, + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + Icons.Rounded.Rotate90DegreesCw, + contentDescription = stringResource(R.string.photo_editor_action_rotate_right) + ) + } + + OutlinedIconButton( + onClick = onReset, + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.photo_editor_action_reset) + ) + } } } } @@ -171,6 +190,11 @@ internal fun normalizeRotationDegrees(value: Float): Float { return normalized } +internal fun rotateClockwiseToNextRightAngle(value: Float): Float { + val snappedRotation = (value / 90f).roundToInt() * 90f + return normalizeRotationDegrees(snappedRotation + 90f) +} + private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { val turns = ((referenceAngle - normalizedAngle) / 360f).roundToInt() return normalizedAngle + turns * 360f diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt new file mode 100644 index 00000000..aad6e58a --- /dev/null +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt @@ -0,0 +1,21 @@ +package org.monogram.presentation.features.chats.currentChat.editor.photo.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +class TransformControlsTest { + @Test + fun `rotateClockwiseToNextRightAngle advances exact quarter turn`() { + assertEquals(90f, rotateClockwiseToNextRightAngle(0f), 0.001f) + assertEquals(180f, rotateClockwiseToNextRightAngle(90f), 0.001f) + assertEquals(-90f, rotateClockwiseToNextRightAngle(180f), 0.001f) + } + + @Test + fun `rotateClockwiseToNextRightAngle snaps tilted image before rotating`() { + assertEquals(90f, rotateClockwiseToNextRightAngle(10f), 0.001f) + assertEquals(90f, rotateClockwiseToNextRightAngle(-10f), 0.001f) + assertEquals(180f, rotateClockwiseToNextRightAngle(100f), 0.001f) + assertEquals(-90f, rotateClockwiseToNextRightAngle(179f), 0.001f) + } +} diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt index 954317e3..1aa60c9f 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/crop/CropGeometryTest.kt @@ -109,6 +109,30 @@ class CropGeometryTest { ) } + @Test + fun `quarter turn transformed bounds stay covered by image`() { + val baseBounds = Rect(left = 0f, top = 0f, right = 200f, bottom = 100f) + val pivot = baseBounds.center + val cropRect = calculateScalarTransformedBounds( + baseBounds = baseBounds, + scale = 1f, + rotationDegrees = 90f, + offset = Offset.Zero, + pivot = pivot + ) + + assertTrue( + isCropRectCoveredByImage( + baseBounds = baseBounds, + cropRect = cropRect, + scale = 1f, + rotationDegrees = 90f, + offset = Offset.Zero, + pivot = pivot + ) + ) + } + private fun rotatedVisibleBounds(baseBounds: Rect): Rect { return calculateScalarTransformedBounds( baseBounds = baseBounds, From aa9d1b882dac582b4cbe08bf75e29dd1c4af9a6d Mon Sep 17 00:00:00 2001 From: aliveoutside <53598473+aliveoutside@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:38:38 +0400 Subject: [PATCH 06/83] feat(photo-editor): smoother rotate animation --- .../editor/photo/PhotoEditorScreen.kt | 94 +++++++++++++------ .../photo/components/TransformControls.kt | 8 +- .../photo/components/TransformControlsTest.kt | 8 ++ 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt index 390d8370..01c133b8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/PhotoEditorScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import coil3.request.ImageRequest import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.presentation.R @@ -112,6 +113,55 @@ fun PhotoEditorScreen( imageRotation = imageRotation, imageOffset = imageOffset ) + var transformAnimationJob by remember { mutableStateOf(null) } + + fun animateTransformTo( + targetCropRect: Rect, + targetRotation: Float, + targetScale: Float, + targetOffset: Offset, + durationMillis: Int = 180 + ) { + val startCrop = cropState.cropRect + val startRotation = imageRotation + val startScale = imageScale + val startOffset = imageOffset + + if ( + startCrop == targetCropRect && + startRotation == targetRotation && + startScale == targetScale && + startOffset == targetOffset + ) { + cropState.setCropRect(targetCropRect) + imageRotation = targetRotation + imageScale = targetScale + imageOffset = targetOffset + return + } + + transformAnimationJob?.cancel() + transformAnimationJob = scope.launch { + val anim = androidx.compose.animation.core.Animatable(0f) + anim.animateTo(1f, androidx.compose.animation.core.tween(durationMillis)) { + val t = value + cropState.setCropRect( + Rect( + left = startCrop.left + (targetCropRect.left - startCrop.left) * t, + top = startCrop.top + (targetCropRect.top - startCrop.top) * t, + right = startCrop.right + (targetCropRect.right - startCrop.right) * t, + bottom = startCrop.bottom + (targetCropRect.bottom - startCrop.bottom) * t + ) + ) + imageRotation = startRotation + (targetRotation - startRotation) * t + imageScale = startScale + (targetScale - startScale) * t + imageOffset = Offset( + x = startOffset.x + (targetOffset.x - startOffset.x) * t, + y = startOffset.y + (targetOffset.y - startOffset.y) * t + ) + } + } + } fun fillAreaAfterResize() { @@ -159,28 +209,13 @@ fun PhotoEditorScreen( ) - scope.launch { - val startCrop = crop - val startScale = imageScale - val startOffset = imageOffset - val anim = androidx.compose.animation.core.Animatable(0f) - anim.animateTo(1f, androidx.compose.animation.core.tween(200)) { - val t = value - cropState.setCropRect( - Rect( - left = startCrop.left + (safeTargetCropRect.left - startCrop.left) * t, - top = startCrop.top + (safeTargetCropRect.top - startCrop.top) * t, - right = startCrop.right + (safeTargetCropRect.right - startCrop.right) * t, - bottom = startCrop.bottom + (safeTargetCropRect.bottom - startCrop.bottom) * t - ) - ) - imageScale = startScale + (targetScale - startScale) * t - imageOffset = Offset( - x = startOffset.x + (targetOffset.x - startOffset.x) * t, - y = startOffset.y + (targetOffset.y - startOffset.y) * t - ) - } - } + animateTransformTo( + targetCropRect = safeTargetCropRect, + targetRotation = imageRotation, + targetScale = targetScale, + targetOffset = targetOffset, + durationMillis = 200 + ) } val shouldConstrain by remember(currentTool) { @@ -250,14 +285,10 @@ fun PhotoEditorScreen( } fun rotateClockwise() { - val targetRotation = rotateClockwiseToNextRightAngle(imageRotation) - - imageRotation = targetRotation - imageScale = 1f - imageOffset = Offset.Zero + val targetRotation = rotateClockwiseAnimationTarget(imageRotation) - cropState.setCropRect( - if (cropState.imageBounds == Rect.Zero) { + animateTransformTo( + targetCropRect = if (cropState.imageBounds == Rect.Zero) { Rect.Zero } else { calculateScalarTransformedBounds( @@ -267,7 +298,10 @@ fun PhotoEditorScreen( offset = Offset.Zero, pivot = pivot ) - } + }, + targetRotation = targetRotation, + targetScale = 1f, + targetOffset = Offset.Zero ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt index b9c1fd21..c4fa9966 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControls.kt @@ -190,9 +190,13 @@ internal fun normalizeRotationDegrees(value: Float): Float { return normalized } -internal fun rotateClockwiseToNextRightAngle(value: Float): Float { +internal fun rotateClockwiseAnimationTarget(value: Float): Float { val snappedRotation = (value / 90f).roundToInt() * 90f - return normalizeRotationDegrees(snappedRotation + 90f) + return snappedRotation + 90f +} + +internal fun rotateClockwiseToNextRightAngle(value: Float): Float { + return normalizeRotationDegrees(rotateClockwiseAnimationTarget(value)) } private fun closestEquivalentAngle(normalizedAngle: Float, referenceAngle: Float): Float { diff --git a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt index aad6e58a..04df4f1c 100644 --- a/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/features/chats/currentChat/editor/photo/components/TransformControlsTest.kt @@ -4,6 +4,14 @@ import org.junit.Assert.assertEquals import org.junit.Test class TransformControlsTest { + @Test + fun `rotateClockwiseAnimationTarget always moves clockwise to next quarter turn`() { + assertEquals(90f, rotateClockwiseAnimationTarget(10f), 0.001f) + assertEquals(90f, rotateClockwiseAnimationTarget(-10f), 0.001f) + assertEquals(270f, rotateClockwiseAnimationTarget(179f), 0.001f) + assertEquals(450f, rotateClockwiseAnimationTarget(350f), 0.001f) + } + @Test fun `rotateClockwiseToNextRightAngle advances exact quarter turn`() { assertEquals(90f, rotateClockwiseToNextRightAngle(0f), 0.001f) From 18c39acc3f710c80d71204155c9120484db2cc32 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:43:24 +0300 Subject: [PATCH 07/83] better notifications info caching --- .../monogram/data/chats/ChatModelFactory.kt | 13 +- .../org/monogram/data/db/MonogramDatabase.kt | 4 +- .../monogram/data/db/MonogramMigrations.kt | 25 +++ .../data/db/dao/NotificationExceptionDao.kt | 40 ++++ .../db/model/NotificationExceptionEntity.kt | 22 ++ .../java/org/monogram/data/di/TdLibClient.kt | 6 +- .../monogram/data/di/TdNotificationManager.kt | 24 ++- .../java/org/monogram/data/di/dataModule.kt | 5 +- .../org/monogram/data/infra/OfflineWarmup.kt | 4 +- .../NotificationSettingsRepositoryImpl.kt | 199 +++++++++++++++++- .../notifications/NotificationsComponent.kt | 31 ++- 11 files changed, 348 insertions(+), 25 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt create mode 100644 data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt 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 4c495535..8bf1710a 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -2,6 +2,8 @@ package org.monogram.data.chats import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching @@ -29,6 +31,7 @@ class ChatModelFactory( private val fetchUser: (Long) -> Unit ) { private val missingUserFullInfoUntilMs = ConcurrentHashMap() + private val userFullInfoSemaphore = Semaphore(permits = 3) fun mapChatToModel( chat: TdApi.Chat, @@ -180,6 +183,10 @@ class ChatModelFactory( if (!isUserFullInfoTemporarilyMissing(type.userId)) { lazyLoad(cache.pendingUserFullInfo, type.userId) { if (type.userId == 0L) return@lazyLoad + cache.userFullInfoCache[type.userId]?.let { + triggerUpdate(chat.id) + return@lazyLoad + } val cachedInfo = coRunCatching { userFullInfoDao.getUserFullInfo(type.userId)?.toTdApi() }.getOrNull() @@ -189,7 +196,11 @@ class ChatModelFactory( triggerUpdate(chat.id) return@lazyLoad } - val result = coRunCatching { gateway.execute(TdApi.GetUserFullInfo(type.userId)) }.getOrNull() + val result = userFullInfoSemaphore.withPermit { + cache.userFullInfoCache[type.userId] ?: coRunCatching { + gateway.execute(TdApi.GetUserFullInfo(type.userId)) + }.getOrNull() + } if (result != null) { cache.putUserFullInfo(type.userId, result) coRunCatching { userFullInfoDao.insertUserFullInfo(result.toEntity(type.userId)) } diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index 1df2fac1..7dbbe36c 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -20,12 +20,13 @@ import org.monogram.data.db.model.* AttachBotEntity::class, KeyValueEntity::class, NotificationSettingEntity::class, + NotificationExceptionEntity::class, WallpaperEntity::class, StickerPathEntity::class, SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 28, + version = 29, exportSchema = false ) abstract class MonogramDatabase : RoomDatabase() { @@ -42,6 +43,7 @@ abstract class MonogramDatabase : RoomDatabase() { abstract fun attachBotDao(): AttachBotDao abstract fun keyValueDao(): KeyValueDao abstract fun notificationSettingDao(): NotificationSettingDao + abstract fun notificationExceptionDao(): NotificationExceptionDao abstract fun wallpaperDao(): WallpaperDao abstract fun stickerPathDao(): StickerPathDao abstract fun sponsorDao(): SponsorDao diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index fe7a272e..fa64968f 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -161,6 +161,31 @@ object MonogramMigrations { } } + val MIGRATION_28_29 = object : Migration(28, 29) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `notification_exceptions` ( + `chatId` INTEGER NOT NULL, + `scope` TEXT NOT NULL, + `title` TEXT NOT NULL, + `avatarPath` TEXT, + `personalAvatarPath` TEXT, + `isMuted` INTEGER NOT NULL, + `isGroup` INTEGER NOT NULL, + `isChannel` INTEGER NOT NULL, + `type` TEXT NOT NULL, + `updatedAt` INTEGER NOT NULL, + PRIMARY KEY(`chatId`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_notification_exceptions_scope` ON `notification_exceptions` (`scope`)" + ) + } + } + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") } diff --git a/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt b/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt new file mode 100644 index 00000000..4f7826dd --- /dev/null +++ b/data/src/main/java/org/monogram/data/db/dao/NotificationExceptionDao.kt @@ -0,0 +1,40 @@ +package org.monogram.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.monogram.data.db.model.NotificationExceptionEntity + +@Dao +interface NotificationExceptionDao { + @Query("SELECT * FROM notification_exceptions WHERE scope = :scope ORDER BY updatedAt DESC") + suspend fun getByScope(scope: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(entity: NotificationExceptionEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) + + @Query("DELETE FROM notification_exceptions WHERE scope = :scope") + suspend fun deleteByScope(scope: String) + + @Query("DELETE FROM notification_exceptions WHERE chatId = :chatId") + suspend fun deleteByChatId(chatId: Long) + + @Query("UPDATE notification_exceptions SET isMuted = :isMuted, updatedAt = :updatedAt WHERE chatId = :chatId") + suspend fun updateMute(chatId: Long, isMuted: Boolean, updatedAt: Long = System.currentTimeMillis()) + + @Transaction + suspend fun replaceForScope(scope: String, entities: List) { + deleteByScope(scope) + if (entities.isNotEmpty()) { + insertAll(entities) + } + } + + @Query("DELETE FROM notification_exceptions") + suspend fun clearAll() +} diff --git a/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt b/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt new file mode 100644 index 00000000..4fdd3f5b --- /dev/null +++ b/data/src/main/java/org/monogram/data/db/model/NotificationExceptionEntity.kt @@ -0,0 +1,22 @@ +package org.monogram.data.db.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "notification_exceptions", + indices = [Index(value = ["scope"])] +) +data class NotificationExceptionEntity( + @PrimaryKey val chatId: Long, + val scope: String, + val title: String, + val avatarPath: String?, + val personalAvatarPath: String?, + val isMuted: Boolean, + val isGroup: Boolean, + val isChannel: Boolean, + val type: String, + val updatedAt: Long = System.currentTimeMillis() +) diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index 2dad8845..c31624c2 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -81,8 +81,12 @@ internal class TdLibClient { if (result.code == 429 && retries < 3) { retries++ val retryAfterMs = parseRetryAfterMs(result.message) - updateGlobalRetryWindow(retryAfterMs) Log.w(TAG, "Rate limited for $function, retrying in ${retryAfterMs}ms (attempt $retries)") + if (function is TdApi.GetUserFullInfo) { + delay(retryAfterMs) + } else { + updateGlobalRetryWindow(retryAfterMs) + } continue } 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 31b46b38..4c036272 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -222,10 +222,9 @@ class TdNotificationManager( coRunCatching { val result = gateway.execute(TdApi.GetChatNotificationSettingsExceptions(scope, true)) if (result is TdApi.Chats) { - result.chatIds.forEach { chatId -> - getChat(chatId) { chat -> - updateChatNotificationSettings(chat.id, chat.notificationSettings) - } + for (chatId in result.chatIds.distinct()) { + val chat = getChatSuspend(chatId) ?: continue + updateChatNotificationSettings(chat.id, chat.notificationSettings) } } }.onFailure { @@ -760,12 +759,19 @@ class TdNotificationManager( return } scope.launch { - try { - val result = gateway.execute(TdApi.GetChat(chatId)) - chatCache[chatId] = result - callback(result) - } catch (_: Exception) { + getChatSuspend(chatId)?.let(callback) + } + } + + private suspend fun getChatSuspend(chatId: Long): TdApi.Chat? { + chatCache[chatId]?.let { return it } + + return try { + gateway.execute(TdApi.GetChat(chatId)).also { chat -> + chatCache[chat.id] = chat } + } catch (_: Exception) { + null } } diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 057656f1..74b47f39 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -127,7 +127,8 @@ val dataModule = module { .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .addMigrations( MonogramMigrations.MIGRATION_26_27, - MonogramMigrations.MIGRATION_27_28 + MonogramMigrations.MIGRATION_27_28, + MonogramMigrations.MIGRATION_28_29 ) .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -145,6 +146,7 @@ val dataModule = module { single { get().attachBotDao() } single { get().keyValueDao() } single { get().notificationSettingDao() } + single { get().notificationExceptionDao() } single { get().wallpaperDao() } single { get().stickerPathDao() } single { get().sponsorDao() } @@ -408,6 +410,7 @@ val dataModule = module { remote = get(), cache = get(), chatsRemote = get(), + notificationExceptionDao = get(), updates = get(), scope = get(), dispatchers = get() 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 b1382243..0830bb81 100644 --- a/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt +++ b/data/src/main/java/org/monogram/data/infra/OfflineWarmup.kt @@ -340,8 +340,8 @@ class OfflineWarmup( } private companion object { - private const val USER_WARMUP_LIMIT = 30 - private const val USER_WARMUP_DELAY_MS = 75L + private const val USER_WARMUP_LIMIT = 15 + private const val USER_WARMUP_DELAY_MS = 150L private const val ONE_DAY_MS = 24L * 60 * 60 * 1000 private const val SEVEN_DAYS_MS = 7L * ONE_DAY_MS } diff --git a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt index 923b0eef..c3ff6166 100644 --- a/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/NotificationSettingsRepositoryImpl.kt @@ -1,31 +1,44 @@ package org.monogram.data.repository -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.datasource.cache.SettingsCacheDataSource import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.db.dao.NotificationExceptionDao +import org.monogram.data.db.model.NotificationExceptionEntity import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.mapper.toApi import org.monogram.data.mapper.user.toDomain import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.ChatType import org.monogram.domain.repository.NotificationSettingsRepository import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope +import java.util.concurrent.ConcurrentHashMap class NotificationSettingsRepositoryImpl( private val remote: SettingsRemoteDataSource, private val cache: SettingsCacheDataSource, private val chatsRemote: ChatsRemoteDataSource, + private val notificationExceptionDao: NotificationExceptionDao, private val updates: UpdateDispatcher, private val scope: CoroutineScope, private val dispatchers: DispatcherProvider ) : NotificationSettingsRepository { + private val exceptionsCache = ConcurrentHashMap>() + private val exceptionsCacheMutex = Mutex() + init { scope.launch { updates.newChat.collect { update -> cache.putChat(update.chat) + syncChatWithExceptionsCache(update.chat) } } @@ -35,6 +48,7 @@ class NotificationSettingsRepositoryImpl( synchronized(chat) { chat.title = update.title } + syncChatWithExceptionsCache(chat) } } } @@ -45,6 +59,7 @@ class NotificationSettingsRepositoryImpl( synchronized(chat) { chat.photo = update.photo } + syncChatWithExceptionsCache(chat) } } } @@ -55,6 +70,13 @@ class NotificationSettingsRepositoryImpl( synchronized(chat) { chat.notificationSettings = update.notificationSettings } + syncChatWithExceptionsCache(chat) + } ?: run { + if (update.notificationSettings.isException(compareSound = true)) { + invalidateExceptionsCache() + } else { + removeFromExceptionsCache(update.chatId) + } } } } @@ -73,14 +95,22 @@ class NotificationSettingsRepositoryImpl( remote.setScopeNotificationSettings(scope.toApi(), settings) } - override suspend fun getExceptions(scope: TdNotificationScope): List = coroutineScope { - val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true) - chats?.chatIds?.map { chatId -> - async(dispatchers.io) { - cache.getChat(chatId)?.toDomain() - ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) }?.toDomain() + override suspend fun getExceptions(scope: TdNotificationScope): List { + exceptionsCache[scope]?.let { return it } + + return exceptionsCacheMutex.withLock { + exceptionsCache[scope]?.let { return@withLock it } + + loadExceptionsFromRoom(scope)?.let { roomCached -> + exceptionsCache[scope] = roomCached + return@withLock roomCached } - }?.awaitAll()?.filterNotNull() ?: emptyList() + + val remoteLoaded = loadExceptionsFromApi(scope) + exceptionsCache[scope] = remoteLoaded + persistScopeToRoom(scope, remoteLoaded) + remoteLoaded + } } override suspend fun setChatNotificationSettings(chatId: Long, enabled: Boolean) { @@ -92,6 +122,7 @@ class NotificationSettingsRepositoryImpl( useDefaultMuteStories = true } remote.setChatNotificationSettings(chatId, settings) + updateCachedChatMute(chatId, isMuted = !enabled) } override suspend fun resetChatNotificationSettings(chatId: Long) { @@ -102,5 +133,157 @@ class NotificationSettingsRepositoryImpl( useDefaultMuteStories = true } remote.setChatNotificationSettings(chatId, settings) + removeFromExceptionsCache(chatId) + } + + private suspend fun loadExceptionsFromRoom(scope: TdNotificationScope): List? = + withContext(dispatchers.io) { + val cached = notificationExceptionDao.getByScope(scope.name) + if (cached.isEmpty()) null else cached.map { it.toDomainChatModel() } + } + + private suspend fun loadExceptionsFromApi(scope: TdNotificationScope): List = + withContext(dispatchers.io) { + val chats = remote.getChatNotificationSettingsExceptions(scope.toApi(), true) + val result = mutableListOf() + + chats?.chatIds?.distinct()?.forEach { chatId -> + val chat = cache.getChat(chatId) + ?: chatsRemote.getChat(chatId)?.also { cache.putChat(it) } + + chat?.toDomain()?.let(result::add) + } + + result + } + + private suspend fun persistScopeToRoom(scope: TdNotificationScope, chats: List) { + withContext(dispatchers.io) { + notificationExceptionDao.replaceForScope( + scope = scope.name, + entities = chats.map { it.toExceptionEntity(scope) } + ) + } + } + + private fun syncChatWithExceptionsCache(chat: TdApi.Chat) { + val notificationScope = chat.toNotificationScope() ?: return + val isException = chat.notificationSettings.isException(compareSound = true) + val mappedChat = if (isException) chat.toDomain() else null + + exceptionsCache[notificationScope]?.let { existing -> + val updated = if (isException && mappedChat != null) { + if (existing.any { it.id == chat.id }) { + existing.map { cached -> + if (cached.id == chat.id) mappedChat else cached + } + } else { + existing + mappedChat + } + } else { + existing.filterNot { it.id == chat.id } + } + + exceptionsCache[notificationScope] = updated + } + + scope.launch(dispatchers.io) { + if (isException && mappedChat != null) { + notificationExceptionDao.insert(mappedChat.toExceptionEntity(notificationScope)) + } else { + notificationExceptionDao.deleteByChatId(chat.id) + } + } + } + + private fun updateCachedChatMute(chatId: Long, isMuted: Boolean) { + if (exceptionsCache.isNotEmpty()) { + exceptionsCache.keys.forEach { notificationScope -> + val existing = exceptionsCache[notificationScope] ?: return@forEach + exceptionsCache[notificationScope] = existing.map { chat -> + if (chat.id == chatId) chat.copy(isMuted = isMuted) else chat + } + } + } + + scope.launch(dispatchers.io) { + notificationExceptionDao.updateMute(chatId = chatId, isMuted = isMuted) + } + } + + private fun removeFromExceptionsCache(chatId: Long) { + if (exceptionsCache.isNotEmpty()) { + exceptionsCache.keys.forEach { notificationScope -> + val existing = exceptionsCache[notificationScope] ?: return@forEach + exceptionsCache[notificationScope] = existing.filterNot { it.id == chatId } + } + } + + scope.launch(dispatchers.io) { + notificationExceptionDao.deleteByChatId(chatId) + } + } + + private fun invalidateExceptionsCache() { + exceptionsCache.clear() + scope.launch(dispatchers.io) { + notificationExceptionDao.clearAll() + } + } + + private fun TdApi.Chat.toNotificationScope(): TdNotificationScope? = when (val chatType = type) { + is TdApi.ChatTypePrivate -> TdNotificationScope.PRIVATE_CHATS + is TdApi.ChatTypeBasicGroup -> TdNotificationScope.GROUPS + is TdApi.ChatTypeSupergroup -> if (chatType.isChannel) TdNotificationScope.CHANNELS else TdNotificationScope.GROUPS + else -> null + } + + private fun TdApi.ChatNotificationSettings.isException(compareSound: Boolean): Boolean { + if (!useDefaultMuteFor) return true + if (!useDefaultShowPreview) return true + if (!useDefaultMuteStories) return true + if (!useDefaultShowStoryPoster) return true + if (!useDefaultDisablePinnedMessageNotifications) return true + if (!useDefaultDisableMentionNotifications) return true + + if (compareSound) { + if (!useDefaultSound) return true + if (!useDefaultStorySound) return true + } + + return false + } + + private fun ChatModel.toExceptionEntity(scope: TdNotificationScope): NotificationExceptionEntity { + return NotificationExceptionEntity( + chatId = id, + scope = scope.name, + title = title, + avatarPath = avatarPath, + personalAvatarPath = personalAvatarPath, + isMuted = isMuted, + isGroup = isGroup, + isChannel = isChannel, + type = type.name + ) + } + + private fun NotificationExceptionEntity.toDomainChatModel(): ChatModel { + return ChatModel( + id = chatId, + title = title, + unreadCount = 0, + avatarPath = avatarPath, + personalAvatarPath = personalAvatarPath, + isMuted = isMuted, + isGroup = isGroup, + isChannel = isChannel, + type = type.toDomainChatType() + ) + } + + private fun String.toDomainChatType(): ChatType { + return runCatching { ChatType.valueOf(this) } + .getOrDefault(ChatType.PRIVATE) } } \ No newline at end of file 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 643c32de..075ba5bf 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 @@ -309,7 +309,11 @@ class DefaultNotificationsComponent( notificationSettingsRepository.setChatNotificationSettings(chatId, enabled) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { - loadExceptions(currentChild.scope) + updateExceptionsState(currentChild.scope) { exceptions -> + exceptions.map { chat -> + if (chat.id == chatId) chat.copy(isMuted = !enabled) else chat + } + } } } } @@ -319,7 +323,30 @@ class DefaultNotificationsComponent( notificationSettingsRepository.resetChatNotificationSettings(chatId) val currentChild = childStack.value.active.instance if (currentChild is NotificationsComponent.Child.Exceptions) { - loadExceptions(currentChild.scope) + updateExceptionsState(currentChild.scope) { exceptions -> + exceptions.filterNot { it.id == chatId } + } + } + } + } + + private fun updateExceptionsState( + scope: TdNotificationScope, + transform: (List) -> List + ) { + _state.update { state -> + when (scope) { + TdNotificationScope.PRIVATE_CHATS -> state.copy( + privateExceptions = state.privateExceptions?.let(transform) + ) + + TdNotificationScope.GROUPS -> state.copy( + groupExceptions = state.groupExceptions?.let(transform) + ) + + TdNotificationScope.CHANNELS -> state.copy( + channelExceptions = state.channelExceptions?.let(transform) + ) } } } From 8cedbad51074c22ad83fb5b0add47500d6655af5 Mon Sep 17 00:00:00 2001 From: aliveoutside <53598473+aliveoutside@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:27:15 +0400 Subject: [PATCH 08/83] feat(chat): smoother mic/send button animation --- .../inputbar/ChatInputBarComposerSection.kt | 4 +- .../components/inputbar/InputBarSendButton.kt | 106 ++++++++++++------ 2 files changed, 74 insertions(+), 36 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt index 327658fd..ce1b8000 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt @@ -166,8 +166,8 @@ fun ChatInputBarComposerSection( ) { AnimatedVisibility( visible = !voiceRecorder.isRecording, - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally() + enter = fadeIn(tween(250)) + expandHorizontally(tween(250)), + exit = fadeOut(tween(200)) + shrinkHorizontally(tween(200)) ) { InputBarLeadingIcons( editingMessage = editingMessage, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt index 4b1e6a77..52f668b5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.Crossfade +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -59,6 +60,17 @@ fun InputBarSendButton( var isVoiceRecordingActive by remember { mutableStateOf(false) } val isRecordingMode = isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canSendVoice + val backgroundColor by animateColorAsState( + targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + animationSpec = tween(250), + label = "BackgroundColor" + ) + val contentColor by animateColorAsState( + targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = tween(250), + label = "ContentColor" + ) + if (canWriteText || canSendVoice) { val sendIcon = when { pendingMediaPaths.isNotEmpty() -> Icons.AutoMirrored.Filled.Send @@ -73,16 +85,12 @@ fun InputBarSendButton( Box( modifier = Modifier + .size(48.dp) + .background(color = backgroundColor, shape = CircleShape) + .clip(CircleShape) .then( if (isRecordingMode) { - Modifier - .size(48.dp) - .background( - color = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .clip(CircleShape) - .pointerInput(isVideoMessageMode) { + Modifier.pointerInput(isVideoMessageMode) { awaitEachGesture { try { awaitFirstDown() @@ -147,39 +155,69 @@ fun InputBarSendButton( } } } else { - Modifier - .size(48.dp) - .background( - color = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .clip(CircleShape) - .combinedClickable( - onClick = { - if (isSendEnabled) { - onSendWithOptions(MessageSendOptions()) - } - }, - onLongClick = { - if (canShowOptions) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onShowSendOptionsMenu() - } + Modifier.combinedClickable( + onClick = { + if (isSendEnabled) { + onSendWithOptions(MessageSendOptions()) } - ) + }, + onLongClick = { + if (canShowOptions) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onShowSendOptionsMenu() + } + } + ) } ), contentAlignment = Alignment.Center ) { - Crossfade(targetState = sendIcon, label = "IconAnimation") { icon -> + AnimatedContent( + targetState = sendIcon, + transitionSpec = { + val enteringSend = targetState == Icons.AutoMirrored.Filled.Send || targetState == Icons.Default.Check + val leavingSend = initialState == Icons.AutoMirrored.Filled.Send || initialState == Icons.Default.Check + + when { + enteringSend && !leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut(targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { -it / 2 }) + } + + !enteringSend && leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { -it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { it / 2 }) + } + + else -> { + (fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f)).togetherWith( + fadeOut( + animationSpec = tween(200) + ) + scaleOut(targetScale = 0.8f) + ) + } + }.using(SizeTransform(clip = false)) + }, + label = "IconAnimation" + ) { icon -> Icon( imageVector = icon, contentDescription = null, - tint = if (isSendEnabled || isVoiceRecordingActive) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + tint = contentColor, + modifier = Modifier.size(24.dp) ) } } From 926514bebf0ef310e52a8dd0406776ee1b9b232a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:31:40 +0300 Subject: [PATCH 09/83] fixed runtime update user info in chat --- .../repository/user/UserRepositoryImpl.kt | 4 ++ .../chats/currentChat/DefaultChatComponent.kt | 1 + .../chats/currentChat/impl/MessageLoading.kt | 50 +++++++++++++++++-- .../currentChat/impl/MessageOperations.kt | 6 +++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt index e0b958c6..7f50e2f8 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt @@ -126,6 +126,7 @@ class UserRepositoryImpl( } return try { deferred.await()?.let { user -> + handleUserIdUpdated(user.id) mapUserModel(user, userLocal.getUserFullInfo(userId)) } } finally { @@ -164,6 +165,9 @@ class UserRepositoryImpl( } return try { val fullInfo = deferred.await() + if (fullInfo != null) { + handleUserIdUpdated(userId) + } mapUserModel(user, fullInfo) } finally { fullInfoRequests.remove(userId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index d4ab0c21..ef64f106 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -69,6 +69,7 @@ class DefaultChatComponent( internal val reactionUpdateSuppressedUntil = ConcurrentHashMap() internal val remappedMessageIds = ConcurrentHashMap() internal val mediaDownloadRetryCount = ConcurrentHashMap() + internal val pendingSenderRefreshes = ConcurrentHashMap.newKeySet() internal var lastLoadedOlderId: Long = 0L internal var lastLoadedNewerId: Long = 0L diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt index c3bed7b9..301d7f2c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt @@ -15,6 +15,7 @@ import java.io.File private const val PAGE_SIZE = 50 private const val MAX_DOWNLOAD_RETRIES = 3 +private const val DEFAULT_SENDER_NAME = "User" private fun isUsableAvatarPath(path: String?): Boolean { if (path.isNullOrBlank()) return false @@ -57,6 +58,33 @@ private fun mergeSenderVisuals(previous: MessageModel, incoming: MessageModel): ) } +private fun MessageModel.needsSenderRefresh(): Boolean { + if (senderId <= 0L) return false + val hasPlaceholderName = senderName.isBlank() || senderName == DEFAULT_SENDER_NAME + val hasNoAvatar = senderAvatar.isNullOrBlank() && senderPersonalAvatar.isNullOrBlank() + return hasPlaceholderName || hasNoAvatar +} + +internal fun DefaultChatComponent.requestSenderRefreshIfNeeded(message: MessageModel) { + if (!message.needsSenderRefresh()) return + requestSenderRefresh(message.senderId) +} + +internal fun DefaultChatComponent.requestSenderRefresh(senderId: Long) { + if (senderId <= 0L) return + if (!pendingSenderRefreshes.add(senderId)) return + + scope.launch { + try { + repositoryMessage.invalidateSenderCache(senderId) + val user = userRepository.getUser(senderId) ?: return@launch + refreshMessagesForSender(senderId, user) + } finally { + pendingSenderRefreshes.remove(senderId) + } + } +} + private fun reactionsSemanticEqual( current: List, incoming: List @@ -247,6 +275,7 @@ internal suspend fun DefaultChatComponent.loadComments(threadId: Long) { ) } updateMessages(messages, replace = true) + refreshCachedSenderProfiles(messages) } private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { @@ -291,6 +320,7 @@ private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { } val shouldReplaceCachedPreview = !hasCachedPreview || messages.isNotEmpty() updateMessages(messages, replace = shouldReplaceCachedPreview) + refreshCachedSenderProfiles(messages) if (!isOldestLoaded) { delay(100) loadMoreMessages() @@ -316,6 +346,7 @@ private suspend fun DefaultChatComponent.loadAroundMessage( ) } updateMessages(messages, replace = true) + refreshCachedSenderProfiles(messages) delay(100) loadMoreMessages() loadNewerMessages() @@ -389,6 +420,7 @@ internal fun DefaultChatComponent.loadMoreMessages() { if (olderMessages.isNotEmpty()) { updateMessages(olderMessages) + refreshCachedSenderProfiles(olderMessages) } val afterSize = _state.value.messages.size @@ -461,6 +493,7 @@ internal fun DefaultChatComponent.loadNewerMessages() { if (newerMessages.isNotEmpty()) { updateMessages(newerMessages) + refreshCachedSenderProfiles(newerMessages) lastLoadedNewerId = anchorId } @@ -535,6 +568,7 @@ internal fun DefaultChatComponent.setupMessageCollectors() { _state.value.currentTopicId == null || message.threadId == _state.value.currentTopicId if (isCorrectThread) { updateMessages(listOf(message)) + requestSenderRefreshIfNeeded(message) _state.update { state -> state.copy( isLatestLoaded = if (message.isOutgoing || state.isAtBottom) true else state.isLatestLoaded @@ -592,6 +626,11 @@ internal fun DefaultChatComponent.setupMessageCollectors() { ) } } + + val isCurrentThread = _state.value.currentTopicId == null || newMessage.threadId == _state.value.currentTopicId + if (isCurrentThread) { + requestSenderRefreshIfNeeded(newMessage) + } } } .launchIn(scope) @@ -855,6 +894,10 @@ internal fun DefaultChatComponent.setupMessageCollectors() { updateInlineResultsWithFile(messageId.toInt(), path) } + if (path.isNotEmpty() && messageId == downloadedFileId.toLong()) { + refreshCachedSenderProfiles(_state.value.messages) + } + fileIdToRetry?.let { if (it != 0) { val suppressed = AutoDownloadSuppression.isSuppressed(it) @@ -1006,17 +1049,16 @@ private fun DefaultChatComponent.observeSenderUpdates() { .launchIn(scope) } -private suspend fun DefaultChatComponent.refreshCachedSenderProfiles(messages: List) { +private fun DefaultChatComponent.refreshCachedSenderProfiles(messages: List) { val senderIds = messages.asSequence() + .filter { it.needsSenderRefresh() } .map { it.senderId } .filter { it > 0L } .distinct() .toList() senderIds.forEach { senderId -> - repositoryMessage.invalidateSenderCache(senderId) - val user = userRepository.getUser(senderId) ?: return@forEach - refreshMessagesForSender(senderId, user) + requestSenderRefresh(senderId) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt index 98795df8..1eeea807 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageOperations.kt @@ -18,6 +18,12 @@ internal fun DefaultChatComponent.handleMessageVisible(messageId: Long) { repositoryMessage.markAllMentionsAsRead(chatId) repositoryMessage.markAllReactionsAsRead(chatId) } + + _state.value.messages + .firstOrNull { it.id == messageId } + ?.let { visibleMessage -> + requestSenderRefreshIfNeeded(visibleMessage) + } } } From dec286e4d61e77cd476259dfd8de46371effd87a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:52:12 +0300 Subject: [PATCH 10/83] thread send message fix + caption message fix --- .../remote/TdMessageRemoteDataSource.kt | 32 ++++++++++------- .../data/repository/MessageRepositoryImpl.kt | 34 ++++++++++++++++--- 2 files changed, 49 insertions(+), 17 deletions(-) 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 a22ee018..4bdde980 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 @@ -482,7 +482,7 @@ class TdMessageRemoteDataSource( this.clearDraft = true } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -507,7 +507,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -540,7 +540,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -571,7 +571,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -595,7 +595,7 @@ class TdMessageRemoteDataSource( this.height = 512 } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -620,7 +620,7 @@ class TdMessageRemoteDataSource( this.animation = TdApi.InputFileId(gifId.toInt()) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -645,7 +645,7 @@ class TdMessageRemoteDataSource( this.caption = TdApi.FormattedText(caption, captionEntities.toTdTextEntities(caption)) } val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessage().apply { this.chatId = chatId this.topicId = topicId @@ -684,7 +684,7 @@ class TdMessageRemoteDataSource( } }.toTypedArray() val replyTo = if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null && threadId != 0L) TdApi.MessageTopicThread(threadId) else null + val topicId = resolveTopicId(chatId, threadId) val req = TdApi.SendMessageAlbum().apply { this.chatId = chatId this.topicId = topicId @@ -828,6 +828,16 @@ class TdMessageRemoteDataSource( return TdApi.TextEntity(start, safeLength, tdType) } + private suspend fun resolveTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? { + if (threadId == null || threadId == 0L) return null + val chat = cache.getChat(chatId) ?: getChat(chatId) + return if (chat?.viewAsTopics == true) { + TdApi.MessageTopicForum(threadId.toInt()) + } else { + TdApi.MessageTopicThread(threadId) + } + } + private fun MessageSendOptions.toTdMessageSendOptions(): TdApi.MessageSendOptions { return TdApi.MessageSendOptions().apply { this.disableNotification = silent @@ -928,7 +938,7 @@ class TdMessageRemoteDataSource( val req = TdApi.SendChatAction().apply { this.chatId = chatId this.action = action - this.topicId = if (messageThreadId != 0L) TdApi.MessageTopicThread(messageThreadId) else null + this.topicId = resolveTopicId(chatId, messageThreadId.takeIf { it != 0L }) } return safeExecute(req) } @@ -1087,9 +1097,7 @@ class TdMessageRemoteDataSource( val request = TdApi.SetChatDraftMessage().apply { this.chatId = chatId this.draftMessage = draft - if (threadId != null && threadId != 0L) { - this.topicId = TdApi.MessageTopicThread(threadId) - } + this.topicId = resolveTopicId(chatId, threadId) } safeExecute(request) } 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 a3b263e1..5f03d3e0 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -113,6 +113,20 @@ class MessageRepositoryImpl( is TdApi.UpdateMessageContent -> { val extracted = messageMapper.extractCachedContent(update.newContent) + + if (update.newContent is TdApi.MessagePhoto && extracted.text.isBlank()) { + val refreshed = messageRemoteDataSource.getMessage(update.chatId, update.messageId) + if (refreshed != null) { + chatLocalDataSource.insertMessage( + messageMapper.mapToEntity( + refreshed, + ::resolveSenderName + ) + ) + return + } + } + chatLocalDataSource.updateMessageContent( messageId = update.messageId, content = extracted.text, @@ -1073,11 +1087,7 @@ class MessageRepositoryImpl( TdApi.InputMessageReplyToMessage(replyToMsgId, null, 0, "") else null - val topicId = if (threadId != null) { - TdApi.MessageTopicForum(threadId.toInt()) - } else { - null - } + val topicId = resolveTopicId(chatId, threadId) gateway.execute( TdApi.SendInlineQueryResultMessage( @@ -1092,6 +1102,20 @@ class MessageRepositoryImpl( ) } + private suspend fun resolveTopicId(chatId: Long, threadId: Long?): TdApi.MessageTopic? { + if (threadId == null || threadId == 0L) return null + + val chat = cache.getChat(chatId) + ?: coRunCatching { gateway.execute(TdApi.GetChat(chatId)) }.getOrNull() + ?.also { cache.putChat(it) } + + return if (chat?.viewAsTopics == true) { + TdApi.MessageTopicForum(threadId.toInt()) + } else { + TdApi.MessageTopicThread(threadId) + } + } + override suspend fun getChatEventLog( chatId: Long, query: String, From 4a8842c56fef8a1b55d571abacf3d3cb258be957 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:56:59 +0300 Subject: [PATCH 11/83] fix #174 --- .../features/chats/currentChat/ChatContent.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 2df3cd4a..5814bae9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -490,7 +490,14 @@ fun ChatContent( state = state, component = component, contentAlpha = contentAlpha, - onBack = { keyboardController?.hide(); component.onBackClicked() }, + onBack = { + keyboardController?.hide() + if (state.currentTopicId != null) { + component.onTopicClick(0) + } else { + component.onBackClicked() + } + }, onOpenMenu = { keyboardController?.hide() focusManager.clearFocus(force = true) @@ -1162,7 +1169,7 @@ fun ChatContent( else if (state.youtubeUrl != null) component.onDismissYouTube() else if (state.miniAppUrl != null) component.onDismissMiniApp() else if (state.webViewUrl != null) component.onDismissWebView() - else if (state.currentTopicId != null) component.onBackClicked() + else if (state.currentTopicId != null) component.onTopicClick(0) } } } From 162928f709fcfb13b865b71884d728079d2e8554 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:37:44 +0300 Subject: [PATCH 12/83] slow mode, mute etc. support --- .../chats/currentChat/ChatComponent.kt | 4 + .../features/chats/currentChat/ChatContent.kt | 4 + .../currentChat/components/ChatInputBar.kt | 620 +++++++++++++----- .../inputbar/ChatInputBarComposerSection.kt | 7 + .../inputbar/InputBarLeadingIcons.kt | 31 +- .../components/inputbar/InputBarSendButton.kt | 156 +++-- .../inputbar/InputTextFieldContainer.kt | 56 +- .../chats/currentChat/impl/ChatInfo.kt | 36 +- .../stickers/ui/menu/StickerEmojiMenu.kt | 22 +- 9 files changed, 687 insertions(+), 249 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index ef86b211..c4ed1475 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -196,6 +196,10 @@ interface ChatComponent { val canWrite: Boolean = false, val isAdmin: Boolean = false, val permissions: ChatPermissionsModel = ChatPermissionsModel(), + val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, + val isCurrentUserRestricted: Boolean = false, + val restrictedUntilDate: Int = 0, val memberCount: Int = 0, val onlineCount: Int = 0, val unreadCount: Int = 0, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 5814bae9..dc40d055 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -517,6 +517,10 @@ fun ChatContent( isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed ?: false, permissions = state.permissions, + slowModeDelay = state.slowModeDelay, + slowModeDelayExpiresIn = state.slowModeDelayExpiresIn, + isCurrentUserRestricted = state.isCurrentUserRestricted, + restrictedUntilDate = state.restrictedUntilDate, isAdmin = state.isAdmin, isChannel = state.isChannel, isBot = state.isBot, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt index bb8d8b91..81ed4653 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt @@ -6,9 +6,14 @@ import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.* @@ -19,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import kotlinx.coroutines.delay import org.monogram.domain.models.* import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.StickerRepository @@ -28,7 +34,9 @@ import org.monogram.presentation.features.camera.CameraScreen import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily import org.monogram.presentation.features.chats.currentChat.components.inputbar.* import org.monogram.presentation.features.gallery.GalleryScreen +import java.text.DateFormat import java.util.* +import kotlin.math.ceil @Immutable data class ChatInputBarState( @@ -38,6 +46,10 @@ data class ChatInputBarState( val pendingMediaPaths: List = emptyList(), val isClosed: Boolean = false, val permissions: ChatPermissionsModel = ChatPermissionsModel(), + val slowModeDelay: Int = 0, + val slowModeDelayExpiresIn: Double = 0.0, + val isCurrentUserRestricted: Boolean = false, + val restrictedUntilDate: Int = 0, val isAdmin: Boolean = false, val isChannel: Boolean = false, val isBot: Boolean = false, @@ -88,6 +100,12 @@ data class ChatInputBarActions( val onSendScheduledNow: (MessageModel) -> Unit = {}, ) +private enum class InputBarMode { + Composer, + SlowMode, + Restricted +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatInputBar( @@ -101,10 +119,6 @@ fun ChatInputBar( return } - val context = LocalContext.current - val emojiStyle by appPreferences.emojiStyle.collectAsState() - val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - val canWriteText = remember(state.isChannel, state.isAdmin, state.permissions.canSendBasicMessages) { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendBasicMessages) } @@ -113,9 +127,18 @@ fun ChatInputBar( state.isAdmin, state.permissions.canSendPhotos, state.permissions.canSendVideos, - state.permissions.canSendDocuments + state.permissions.canSendDocuments, + state.permissions.canSendAudios ) { - if (state.isChannel) true else (state.isAdmin || (state.permissions.canSendPhotos || state.permissions.canSendVideos || state.permissions.canSendDocuments)) + if (state.isChannel) { + true + } else { + state.isAdmin || + state.permissions.canSendPhotos || + state.permissions.canSendVideos || + state.permissions.canSendDocuments || + state.permissions.canSendAudios + } } val canSendStickers = remember(state.isChannel, state.isAdmin, state.permissions.canSendOtherMessages) { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendOtherMessages) @@ -123,6 +146,16 @@ fun ChatInputBar( val canSendVoice = remember(state.isChannel, state.isAdmin, state.permissions.canSendVoiceNotes) { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVoiceNotes) } + val canSendVideoNotes = remember(state.isChannel, state.isAdmin, state.permissions.canSendVideoNotes) { + if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVideoNotes) + } + val canSendAnything = remember(canWriteText, canSendMedia, canSendStickers, canSendVoice, canSendVideoNotes) { + canWriteText || canSendMedia || canSendStickers || canSendVoice || canSendVideoNotes + } + + val context = LocalContext.current + val emojiStyle by appPreferences.emojiStyle.collectAsState() + val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } var textValue by remember { mutableStateOf(TextFieldValue(state.draftText)) } var isStickerMenuVisible by remember { mutableStateOf(false) } @@ -196,9 +229,54 @@ fun ChatInputBar( } } + LaunchedEffect(canSendStickers) { + if (!canSendStickers && isStickerMenuVisible) { + isStickerMenuVisible = false + } + } + + LaunchedEffect(canSendVideoNotes, canSendVoice) { + if (!canSendVideoNotes && isVideoMessageMode) { + isVideoMessageMode = false + } + if (!canSendVoice && canSendVideoNotes) { + isVideoMessageMode = true + } + } + var lastEditingMessageId by remember { mutableStateOf(null) } - val voiceRecorder = rememberVoiceRecorder(onRecordingFinished = actions.onSendVoice) + var slowModeRemainingSeconds by remember { + mutableIntStateOf(0) + } + LaunchedEffect(state.slowModeDelay, state.slowModeDelayExpiresIn, state.isAdmin) { + slowModeRemainingSeconds = if (!state.isAdmin && state.slowModeDelay > 0) { + ceil(state.slowModeDelayExpiresIn).toInt().coerceAtLeast(0) + } else { + 0 + } + } + LaunchedEffect(slowModeRemainingSeconds, state.slowModeDelay, state.isAdmin) { + if (!state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0) { + delay(1000) + slowModeRemainingSeconds = (slowModeRemainingSeconds - 1).coerceAtLeast(0) + } + } + val isSlowModeActive = remember(state.isAdmin, state.slowModeDelay, slowModeRemainingSeconds) { + !state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0 + } + + fun activateSlowModeCooldown() { + if (!state.isAdmin && state.slowModeDelay > 0) { + slowModeRemainingSeconds = state.slowModeDelay + } + } + + val voiceRecorder = rememberVoiceRecorder { path, duration, waveform -> + if (!canSendVoice || isSlowModeActive) return@rememberVoiceRecorder + actions.onSendVoice(path, duration, waveform) + activateSlowModeCooldown() + } val maxMessageLength = remember(state.pendingMediaPaths, state.isPremiumUser) { if (state.pendingMediaPaths.isNotEmpty() && !state.isPremiumUser) 1024 else 4096 } @@ -209,11 +287,25 @@ fun ChatInputBar( if (isOverMessageLimit) return@sendWithOptions val isTextEmpty = textValue.text.isBlank() val captionEntities = extractEntities(textValue.annotatedString, knownCustomEmojis) + val isScheduling = it.scheduleDate != null + var sentInstantMessage = false + + val canSendNow = when { + state.pendingMediaPaths.isNotEmpty() && canSendMedia -> true + state.editingMessage != null -> false + canWriteText && !isTextEmpty -> true + else -> false + } + + if (isSlowModeActive && canSendNow && !isScheduling) { + return@sendWithOptions + } if (state.pendingMediaPaths.isNotEmpty() && canSendMedia) { actions.onSendMedia(state.pendingMediaPaths, textValue.text, captionEntities, it) textValue = TextFieldValue("") knownCustomEmojis.clear() + sentInstantMessage = !isScheduling } else if (state.editingMessage != null && canWriteText) { if (!isTextEmpty) { actions.onSaveEdit(textValue.text, captionEntities) @@ -222,6 +314,11 @@ fun ChatInputBar( actions.onSend(textValue.text, captionEntities, it) textValue = TextFieldValue("") knownCustomEmojis.clear() + sentInstantMessage = !isScheduling + } + + if (sentInstantMessage) { + activateSlowModeCooldown() } if (it.scheduleDate != null) { @@ -421,6 +518,26 @@ fun ChatInputBar( if (granted) showCamera = true } + val inputBarMode = remember( + canSendAnything, + isSlowModeActive, + textValue.text, + state.pendingMediaPaths, + state.editingMessage, + voiceRecorder.isRecording + ) { + when { + !canSendAnything -> InputBarMode.Restricted + isSlowModeActive && + textValue.text.isBlank() && + state.pendingMediaPaths.isEmpty() && + state.editingMessage == null && + !voiceRecorder.isRecording -> InputBarMode.SlowMode + + else -> InputBarMode.Composer + } + } + if (showCamera) { CameraScreen( onImageCaptured = { uri -> @@ -434,157 +551,203 @@ fun ChatInputBar( ) } else { Box { - ChatInputBarComposerSection( - editingMessage = state.editingMessage, - replyMessage = state.replyMessage, - pendingMediaPaths = state.pendingMediaPaths, - mentionSuggestions = state.mentionSuggestions, - filteredCommands = filteredCommands, - currentInlineBotUsername = state.currentInlineBotUsername, - isInlineBotLoading = state.isInlineBotLoading, - inlineBotResults = state.inlineBotResults, - isBot = state.isBot, - botMenuButton = state.botMenuButton, - botCommands = state.botCommands, - scheduledMessagesCount = state.scheduledMessages.size, - textValue = textValue, - onTextValueChange = { textValue = it }, - knownCustomEmojis = knownCustomEmojis, - emojiFontFamily = emojiFontFamily, - focusRequester = focusRequester, - canWriteText = canWriteText, - canSendMedia = canSendMedia, - canSendStickers = canSendStickers, - canSendVoice = canSendVoice, - isStickerMenuVisible = isStickerMenuVisible, - closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, - isKeyboardVisible = isKeyboardVisible, - transitionHoldBottomInset = transitionHoldBottomInset, - stickerMenuHeight = stickerMenuHeight, - voiceRecorder = voiceRecorder, - isGifSearchFocused = isGifSearchFocused, - showFullScreenEditor = showFullScreenEditor, - currentMessageLength = currentMessageLength, - maxMessageLength = maxMessageLength, - isOverMessageLimit = isOverMessageLimit, - isVideoMessageMode = isVideoMessageMode, - replyMarkup = state.replyMarkup, - showSendOptionsSheet = showSendOptionsSheet, - stickerRepository = stickerRepository, - onCancelEdit = actions.onCancelEdit, - onCancelReply = actions.onCancelReply, - onCancelMedia = actions.onCancelMedia, - onMediaOrderChange = actions.onMediaOrderChange, - onMediaClick = actions.onMediaClick, - onMentionClick = { user -> - textValue = applyMentionSuggestion(textValue, user) - }, - onMentionQueryClear = { actions.onMentionQueryChange(null) }, - onInlineResultClick = { resultId -> - actions.onSendInlineResult(resultId) - textValue = TextFieldValue("") + AnimatedContent( + targetState = inputBarMode, + transitionSpec = { + (fadeIn(animationSpec = tween(220)) + slideInVertically(animationSpec = tween(220)) { it / 4 }) + .togetherWith( + fadeOut(animationSpec = tween(150)) + slideOutVertically(animationSpec = tween(150)) { it / 4 } + ) }, - onInlineSwitchPmClick = { text -> - state.currentInlineBotUsername?.let { username -> - actions.onInlineSwitchPm(username, text) - } - }, - onLoadMoreInlineResults = actions.onLoadMoreInlineResults, - onCommandClick = { command -> - actions.onSend("/$command", emptyList(), MessageSendOptions()) - textValue = TextFieldValue("") - }, - onAttachClick = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - showGallery = true - }, - onStickerMenuToggle = { - if (isStickerMenuVisible) { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = true - closeStickerMenuWithoutSlide = true - isStickerMenuVisible = false - focusRequester.requestFocus() - } else { - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - if (isKeyboardVisible) { - openStickerMenuAfterKeyboardClosed = true + label = "InputBarModeTransition" + ) { mode -> + when (mode) { + InputBarMode.Composer -> ChatInputBarComposerSection( + editingMessage = state.editingMessage, + replyMessage = state.replyMessage, + pendingMediaPaths = state.pendingMediaPaths, + mentionSuggestions = state.mentionSuggestions, + filteredCommands = filteredCommands, + currentInlineBotUsername = state.currentInlineBotUsername, + isInlineBotLoading = state.isInlineBotLoading, + inlineBotResults = state.inlineBotResults, + isBot = state.isBot, + botMenuButton = state.botMenuButton, + botCommands = state.botCommands, + scheduledMessagesCount = state.scheduledMessages.size, + textValue = textValue, + onTextValueChange = { textValue = it }, + knownCustomEmojis = knownCustomEmojis, + emojiFontFamily = emojiFontFamily, + focusRequester = focusRequester, + canWriteText = canWriteText, + canSendMedia = canSendMedia, + canSendStickers = canSendStickers, + canSendVoice = canSendVoice, + canSendVideoNotes = canSendVideoNotes, + isStickerMenuVisible = isStickerMenuVisible, + closeStickerMenuWithoutSlide = closeStickerMenuWithoutSlide, + isKeyboardVisible = isKeyboardVisible, + transitionHoldBottomInset = transitionHoldBottomInset, + stickerMenuHeight = stickerMenuHeight, + voiceRecorder = voiceRecorder, + isGifSearchFocused = isGifSearchFocused, + showFullScreenEditor = showFullScreenEditor, + currentMessageLength = currentMessageLength, + maxMessageLength = maxMessageLength, + isOverMessageLimit = isOverMessageLimit, + isVideoMessageMode = isVideoMessageMode, + isSlowModeActive = isSlowModeActive, + slowModeRemainingSeconds = slowModeRemainingSeconds, + replyMarkup = state.replyMarkup, + showSendOptionsSheet = showSendOptionsSheet, + stickerRepository = stickerRepository, + onCancelEdit = actions.onCancelEdit, + onCancelReply = actions.onCancelReply, + onCancelMedia = actions.onCancelMedia, + onMediaOrderChange = actions.onMediaOrderChange, + onMediaClick = actions.onMediaClick, + onMentionClick = { user -> + textValue = applyMentionSuggestion(textValue, user) + }, + onMentionQueryClear = { actions.onMentionQueryChange(null) }, + onInlineResultClick = { resultId -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onSendInlineResult(resultId) + textValue = TextFieldValue("") + activateSlowModeCooldown() + }, + onInlineSwitchPmClick = { text -> + state.currentInlineBotUsername?.let { username -> + actions.onInlineSwitchPm(username, text) + } + }, + onLoadMoreInlineResults = actions.onLoadMoreInlineResults, + onCommandClick = { command -> + if (isSlowModeActive || !canWriteText) return@ChatInputBarComposerSection + actions.onSend("/$command", emptyList(), MessageSendOptions()) + textValue = TextFieldValue("") + activateSlowModeCooldown() + }, + onAttachClick = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false + hideKeyboardAndClearFocus() + showGallery = true + }, + onStickerMenuToggle = { + if (isStickerMenuVisible) { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = true + closeStickerMenuWithoutSlide = true + isStickerMenuVisible = false + focusRequester.requestFocus() + } else { + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + if (isKeyboardVisible) { + openStickerMenuAfterKeyboardClosed = true + hideKeyboardAndClearFocus() + } else { + openStickerMenuAfterKeyboardClosed = false + isStickerMenuVisible = true + focusManager.clearFocus() + } + } + }, + onShowBotCommands = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false hideKeyboardAndClearFocus() - } else { + actions.onShowBotCommands() + }, + onOpenMiniApp = actions.onOpenMiniApp, + onInputFocus = { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + if (isStickerMenuVisible) { + closeStickerMenuWithoutSlide = true + } + isStickerMenuVisible = false + }, + onOpenFullScreenEditor = { showFullScreenEditor = true }, + onOpenScheduledMessages = { + actions.onRefreshScheduledMessages() + showScheduledMessagesSheet = true + }, + onSendWithOptions = sendWithOptions, + onShowSendOptionsMenu = { openStickerMenuAfterKeyboardClosed = false - isStickerMenuVisible = true - focusManager.clearFocus() + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false + hideKeyboardAndClearFocus() + showSendOptionsSheet = true + actions.onRefreshScheduledMessages() + }, + onCameraClick = { + hideKeyboardAndClearFocus() + actions.onCameraClick() + }, + onVideoModeToggle = { + if (canSendVideoNotes) { + isVideoMessageMode = !isVideoMessageMode + } + }, + onVoiceStart = { + hideKeyboardAndClearFocus() + voiceRecorder.startRecording() + }, + onVoiceStop = { cancel -> voiceRecorder.stopRecording(cancel) }, + onVoiceLock = { voiceRecorder.lockRecording() }, + onSendSilent = { + showSendOptionsSheet = false + sendWithOptions(MessageSendOptions(silent = true)) + }, + onScheduleMessage = { + showSendOptionsSheet = false + pendingScheduleDateMillis = null + showScheduleDatePicker = true + }, + onOpenScheduledMessagesFromPopup = { + showSendOptionsSheet = false + showScheduledMessagesSheet = true + actions.onRefreshScheduledMessages() + }, + onDismissSendOptions = { showSendOptionsSheet = false }, + onStickerClick = { stickerPath -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onStickerClick(stickerPath) + activateSlowModeCooldown() + }, + onGifClick = { gif -> + if (!canSendStickers || isSlowModeActive) return@ChatInputBarComposerSection + actions.onGifClick(gif) + activateSlowModeCooldown() + }, + onGifSearchFocusedChange = { isGifSearchFocused = it }, + onReplyMarkupButtonClick = actions.onReplyMarkupButtonClick + ) + + InputBarMode.SlowMode -> SlowModeInputBar( + remainingSeconds = slowModeRemainingSeconds, + scheduledMessagesCount = state.scheduledMessages.size, + onOpenScheduledMessages = { + actions.onRefreshScheduledMessages() + showScheduledMessagesSheet = true } - } - }, - onShowBotCommands = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - actions.onShowBotCommands() - }, - onOpenMiniApp = actions.onOpenMiniApp, - onInputFocus = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - if (isStickerMenuVisible) { - closeStickerMenuWithoutSlide = true - } - isStickerMenuVisible = false - }, - onOpenFullScreenEditor = { showFullScreenEditor = true }, - onOpenScheduledMessages = { - actions.onRefreshScheduledMessages() - showScheduledMessagesSheet = true - }, - onSendWithOptions = sendWithOptions, - onShowSendOptionsMenu = { - openStickerMenuAfterKeyboardClosed = false - openKeyboardAfterStickerMenuClosed = false - closeStickerMenuWithoutSlide = false - isStickerMenuVisible = false - hideKeyboardAndClearFocus() - showSendOptionsSheet = true - actions.onRefreshScheduledMessages() - }, - onCameraClick = { - hideKeyboardAndClearFocus() - actions.onCameraClick() - }, - onVideoModeToggle = { isVideoMessageMode = !isVideoMessageMode }, - onVoiceStart = { - hideKeyboardAndClearFocus() - voiceRecorder.startRecording() - }, - onVoiceStop = { cancel -> voiceRecorder.stopRecording(cancel) }, - onVoiceLock = { voiceRecorder.lockRecording() }, - onSendSilent = { - showSendOptionsSheet = false - sendWithOptions(MessageSendOptions(silent = true)) - }, - onScheduleMessage = { - showSendOptionsSheet = false - pendingScheduleDateMillis = null - showScheduleDatePicker = true - }, - onOpenScheduledMessagesFromPopup = { - showSendOptionsSheet = false - showScheduledMessagesSheet = true - actions.onRefreshScheduledMessages() - }, - onDismissSendOptions = { showSendOptionsSheet = false }, - onStickerClick = actions.onStickerClick, - onGifClick = actions.onGifClick, - onGifSearchFocusedChange = { isGifSearchFocused = it }, - onReplyMarkupButtonClick = actions.onReplyMarkupButtonClick - ) + ) + + InputBarMode.Restricted -> RestrictedInputBar( + isCurrentUserRestricted = state.isCurrentUserRestricted, + restrictedUntilDate = state.restrictedUntilDate + ) + } + } FullScreenEditorSheet( visible = showFullScreenEditor, @@ -746,3 +909,152 @@ private fun ClosedTopicBar() { ) } } + +@Composable +private fun SlowModeInputBar( + remainingSeconds: Int, + scheduledMessagesCount: Int, + onOpenScheduledMessages: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.slow_mode_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + AnimatedContent( + targetState = remainingSeconds.coerceAtLeast(0), + transitionSpec = { + (fadeIn(animationSpec = tween(200)) + slideInVertically(animationSpec = tween(200)) { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(120)) + slideOutVertically(animationSpec = tween(120)) { -it / 2 } + ) + }, + label = "SlowModeRemaining" + ) { seconds -> + Text( + text = formatSlowModeDuration(seconds), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (scheduledMessagesCount > 0) { + IconButton(onClick = onOpenScheduledMessages) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = stringResource(R.string.action_scheduled_messages), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun RestrictedInputBar( + isCurrentUserRestricted: Boolean, + restrictedUntilDate: Int +) { + val restrictionDetails = remember(isCurrentUserRestricted, restrictedUntilDate) { + if (!isCurrentUserRestricted) { + null + } else if (restrictedUntilDate <= 0) { + RestrictionDetails.Permanent + } else { + RestrictionDetails.Until(formatRestrictedUntilDate(restrictedUntilDate)) + } + } + + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .windowInsetsPadding(WindowInsets.navigationBars), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.input_error_not_allowed), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + AnimatedVisibility( + visible = restrictionDetails != null, + enter = fadeIn(animationSpec = tween(220)) + expandVertically(animationSpec = tween(220)), + exit = fadeOut(animationSpec = tween(140)) + shrinkVertically(animationSpec = tween(140)) + ) { + val detailsText = when (restrictionDetails) { + is RestrictionDetails.Until -> stringResource( + R.string.logs_restricted_until, + restrictionDetails.value + ) + + RestrictionDetails.Permanent -> stringResource(R.string.logs_restricted_permanently) + null -> "" + } + + Text( + text = detailsText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +private sealed interface RestrictionDetails { + data class Until(val value: String) : RestrictionDetails + data object Permanent : RestrictionDetails +} + +private fun formatRestrictedUntilDate(epochSeconds: Int): String { + val formatter = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) + return formatter.format(Date(epochSeconds.toLong() * 1000L)) +} + +private fun formatSlowModeDuration(totalSeconds: Int): String { + val clamped = totalSeconds.coerceAtLeast(0) + val hours = clamped / 3600 + val minutes = (clamped % 3600) / 60 + val seconds = clamped % 60 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt index ce1b8000..d3982133 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt @@ -45,6 +45,7 @@ fun ChatInputBarComposerSection( canSendMedia: Boolean, canSendStickers: Boolean, canSendVoice: Boolean, + canSendVideoNotes: Boolean, isStickerMenuVisible: Boolean, closeStickerMenuWithoutSlide: Boolean, isKeyboardVisible: Boolean, @@ -57,6 +58,8 @@ fun ChatInputBarComposerSection( maxMessageLength: Int, isOverMessageLimit: Boolean, isVideoMessageMode: Boolean, + isSlowModeActive: Boolean, + slowModeRemainingSeconds: Int, replyMarkup: ReplyMarkupModel?, showSendOptionsSheet: Boolean, stickerRepository: StickerRepository, @@ -248,8 +251,11 @@ fun ChatInputBarComposerSection( isOverCharLimit = isOverMessageLimit, canWriteText = canWriteText, canSendVoice = canSendVoice, + canSendVideoNotes = canSendVideoNotes, canSendMedia = canSendMedia, isVideoMessageMode = isVideoMessageMode, + isSlowModeActive = isSlowModeActive, + slowModeRemainingSeconds = slowModeRemainingSeconds, onSendWithOptions = onSendWithOptions, onShowSendOptionsMenu = onShowSendOptionsMenu, onCameraClick = onCameraClick, @@ -329,6 +335,7 @@ fun ChatInputBarComposerSection( onGifSelected = onGifClick, onSearchFocused = onGifSearchFocusedChange, panelHeight = stickerMenuHeight, + canSendStickers = canSendStickers, stickerRepository = stickerRepository ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt index e1f548fc..9179d9d8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar +import androidx.compose.animation.* import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -21,15 +22,27 @@ fun InputBarLeadingIcons( canSendMedia: Boolean, onAttachClick: () -> Unit ) { - if (editingMessage == null && pendingMediaPaths.isEmpty() && canSendMedia) { - IconButton(onClick = onAttachClick) { - Icon( - imageVector = Icons.Outlined.AddCircleOutline, - contentDescription = stringResource(R.string.cd_attach), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + val canAttachMedia = editingMessage == null && pendingMediaPaths.isEmpty() && canSendMedia + + AnimatedContent( + targetState = canAttachMedia, + transitionSpec = { + (fadeIn() + scaleIn(initialScale = 0.85f)).togetherWith( + fadeOut() + scaleOut(targetScale = 0.85f) + ).using(SizeTransform(clip = false)) + }, + label = "AttachIconVisibility" + ) { showAttach -> + if (showAttach) { + IconButton(onClick = onAttachClick) { + Icon( + imageVector = Icons.Outlined.AddCircleOutline, + contentDescription = stringResource(R.string.cd_attach), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Spacer(modifier = Modifier.width(12.dp)) } - } else if (!canSendMedia) { - Spacer(modifier = Modifier.width(12.dp)) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt index 52f668b5..09459e4b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.outlined.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,8 +43,11 @@ fun InputBarSendButton( isOverCharLimit: Boolean, canWriteText: Boolean, canSendVoice: Boolean, + canSendVideoNotes: Boolean, canSendMedia: Boolean, isVideoMessageMode: Boolean, + isSlowModeActive: Boolean, + slowModeRemainingSeconds: Int, onSendWithOptions: (MessageSendOptions) -> Unit, onShowSendOptionsMenu: () -> Unit, onCameraClick: () -> Unit, @@ -54,34 +58,48 @@ fun InputBarSendButton( ) { val haptic = LocalHapticFeedback.current val isTextEmpty = textValue.text.isBlank() + val canSendContent = canWriteText || (pendingMediaPaths.isNotEmpty() && canSendMedia) + val isSlowModeBlocked = isSlowModeActive && editingMessage == null val isSendEnabled = - (!isTextEmpty || editingMessage != null || pendingMediaPaths.isNotEmpty()) && canWriteText && !isOverCharLimit + (!isTextEmpty || editingMessage != null || pendingMediaPaths.isNotEmpty()) && + canSendContent && + !isOverCharLimit && + !isSlowModeBlocked var isVoiceRecordingActive by remember { mutableStateOf(false) } - val isRecordingMode = isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canSendVoice + val effectiveVideoMode = when { + !canSendVideoNotes -> false + !canSendVoice -> true + else -> isVideoMessageMode + } + val canUseRecording = canSendVoice || canSendVideoNotes + val canToggleRecordingMode = canSendVoice && canSendVideoNotes + val isRecordingMode = + isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canUseRecording && !isSlowModeBlocked val backgroundColor by animateColorAsState( - targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, animationSpec = tween(250), label = "BackgroundColor" ) val contentColor by animateColorAsState( - targetValue = if (isSendEnabled || isVoiceRecordingActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, animationSpec = tween(250), label = "ContentColor" ) - if (canWriteText || canSendVoice) { + if (canWriteText || canSendVoice || canSendVideoNotes) { val sendIcon = when { pendingMediaPaths.isNotEmpty() -> Icons.AutoMirrored.Filled.Send editingMessage != null -> Icons.Default.Check !isTextEmpty -> Icons.AutoMirrored.Filled.Send - isVideoMessageMode -> Icons.Default.Videocam + effectiveVideoMode -> Icons.Default.Videocam else -> Icons.Outlined.Mic } val canShowOptions = editingMessage == null && canWriteText && (!isTextEmpty || (pendingMediaPaths.isNotEmpty() && canSendMedia)) && - !isOverCharLimit + !isOverCharLimit && + !isSlowModeBlocked Box( modifier = Modifier @@ -90,7 +108,7 @@ fun InputBarSendButton( .clip(CircleShape) .then( if (isRecordingMode) { - Modifier.pointerInput(isVideoMessageMode) { + Modifier.pointerInput(effectiveVideoMode, canToggleRecordingMode) { awaitEachGesture { try { awaitFirstDown() @@ -101,7 +119,7 @@ fun InputBarSendButton( } if (up == null) { - if (isVideoMessageMode) { + if (effectiveVideoMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) onCameraClick() waitForUpOrCancellation() @@ -143,8 +161,10 @@ fun InputBarSendButton( } } } else { - onVideoModeToggle() - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + if (canToggleRecordingMode) { + onVideoModeToggle() + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } } } finally { if (isVoiceRecordingActive) { @@ -172,54 +192,78 @@ fun InputBarSendButton( ), contentAlignment = Alignment.Center ) { - AnimatedContent( - targetState = sendIcon, - transitionSpec = { - val enteringSend = targetState == Icons.AutoMirrored.Filled.Send || targetState == Icons.Default.Check - val leavingSend = initialState == Icons.AutoMirrored.Filled.Send || initialState == Icons.Default.Check - - when { - enteringSend && !leavingSend -> { - (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( - initialScale = 0.4f, - animationSpec = tween(220, delayMillis = 50) - ) + slideInVertically { it / 2 }) - .togetherWith( - fadeOut(animationSpec = tween(180)) + scaleOut(targetScale = 0.4f, - animationSpec = tween(180) - ) + slideOutVertically { -it / 2 }) - } + if (isSlowModeBlocked) { + Text( + text = formatSlowModeCountdown(slowModeRemainingSeconds), + style = MaterialTheme.typography.labelSmall, + color = contentColor + ) + } else { + AnimatedContent( + targetState = sendIcon, + transitionSpec = { + val enteringSend = + targetState == Icons.AutoMirrored.Filled.Send || targetState == Icons.Default.Check + val leavingSend = + initialState == Icons.AutoMirrored.Filled.Send || initialState == Icons.Default.Check - !enteringSend && leavingSend -> { - (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( - initialScale = 0.4f, - animationSpec = tween(220, delayMillis = 50) - ) + slideInVertically { -it / 2 }) - .togetherWith( - fadeOut(animationSpec = tween(180)) + scaleOut( - targetScale = 0.4f, - animationSpec = tween(180) - ) + slideOutVertically { it / 2 }) - } + when { + enteringSend && !leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { -it / 2 }) + } - else -> { - (fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f)).togetherWith( - fadeOut( - animationSpec = tween(200) - ) + scaleOut(targetScale = 0.8f) - ) - } - }.using(SizeTransform(clip = false)) - }, - label = "IconAnimation" - ) { icon -> - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(24.dp) - ) + !enteringSend && leavingSend -> { + (fadeIn(animationSpec = tween(220, delayMillis = 50)) + scaleIn( + initialScale = 0.4f, + animationSpec = tween(220, delayMillis = 50) + ) + slideInVertically { -it / 2 }) + .togetherWith( + fadeOut(animationSpec = tween(180)) + scaleOut( + targetScale = 0.4f, + animationSpec = tween(180) + ) + slideOutVertically { it / 2 }) + } + + else -> { + (fadeIn(animationSpec = tween(200)) + scaleIn(initialScale = 0.8f)).togetherWith( + fadeOut( + animationSpec = tween(200) + ) + scaleOut(targetScale = 0.8f) + ) + } + }.using(SizeTransform(clip = false)) + }, + label = "IconAnimation" + ) { icon -> + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(24.dp) + ) + } } } } } + +private fun formatSlowModeCountdown(totalSeconds: Int): String { + val clamped = totalSeconds.coerceAtLeast(0) + val hours = clamped / 3600 + val minutes = (clamped % 3600) / 60 + val seconds = clamped % 60 + + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt index 2fbab909..1a1fb50d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt @@ -62,29 +62,41 @@ fun InputTextFieldContainer( modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) ) { val showBotActions = remember(isBot, textValue.text) { isBot && textValue.text.isEmpty() } - if (showBotActions) { - BotInputActions( - botMenuButton = botMenuButton, - botCommands = botCommands, - canSendStickers = canSendStickers, - isStickerMenuVisible = isStickerMenuVisible, - onStickerMenuToggle = onStickerMenuToggle, - onShowBotCommands = onShowBotCommands, - onOpenMiniApp = onOpenMiniApp - ) - } else if (canSendStickers) { - IconButton( - onClick = onStickerMenuToggle, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = if (isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + AnimatedContent( + targetState = showBotActions to canSendStickers, + transitionSpec = { + (fadeIn() + expandHorizontally()).togetherWith(fadeOut() + shrinkHorizontally()) + }, + label = "InputActionsVisibility" + ) { (showBotActionsState, canSendStickersState) -> + when { + showBotActionsState -> { + BotInputActions( + botMenuButton = botMenuButton, + botCommands = botCommands, + canSendStickers = canSendStickersState, + isStickerMenuVisible = isStickerMenuVisible, + onStickerMenuToggle = onStickerMenuToggle, + onShowBotCommands = onShowBotCommands, + onOpenMiniApp = onOpenMiniApp + ) + } + + canSendStickersState -> { + IconButton( + onClick = onStickerMenuToggle, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = if (isStickerMenuVisible) Icons.Default.Keyboard else Icons.Outlined.EmojiEmotions, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> Spacer(modifier = Modifier.width(8.dp)) } - } else { - Spacer(modifier = Modifier.width(8.dp)) } InputTextField( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt index b2b01b23..3bc2b5fa 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/ChatInfo.kt @@ -32,6 +32,19 @@ internal fun DefaultChatComponent.loadChatInfo() { } } } + + runCatching { chatInfoRepository.getChatFullInfo(chatId) } + .getOrNull() + ?.let { fullInfo -> + _state.update { + it.copy( + slowModeDelay = fullInfo.slowModeDelay, + slowModeDelayExpiresIn = fullInfo.slowModeDelayExpiresIn + ) + } + } + + refreshCurrentUserRestrictionState() } chatListRepository.chatListFlow @@ -65,6 +78,14 @@ internal fun DefaultChatComponent.loadChatInfo() { } .launchIn(scope) + chatListRepository.chatListFlow + .map { chats -> chats.find { it.id == chatId } } + .filterNotNull() + .map { chat -> chat.permissions to chat.isMember } + .distinctUntilChanged() + .onEach { refreshCurrentUserRestrictionState() } + .launchIn(scope) + forumTopicsRepository.forumTopicsFlow .filter { it.first == chatId } .onEach { (_, topics) -> @@ -203,4 +224,17 @@ internal fun DefaultChatComponent.handleConfirmRestrict( ) _state.update { it.copy(restrictUserId = null) } } -} \ No newline at end of file +} + +private suspend fun DefaultChatComponent.refreshCurrentUserRestrictionState() { + val me = runCatching { userRepository.getMe() }.getOrNull() ?: return + val status = runCatching { chatInfoRepository.getChatMember(chatId, me.id)?.status }.getOrNull() + val restrictedStatus = status as? ChatMemberStatus.Restricted + + _state.update { + it.copy( + isCurrentUserRestricted = restrictedStatus != null, + restrictedUntilDate = restrictedStatus?.restrictedUntilDate ?: 0 + ) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt index 1a8e3c47..58b6ba96 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/StickerEmojiMenu.kt @@ -31,12 +31,14 @@ fun StickerEmojiMenu( onGifSelected: (GifModel) -> Unit, panelHeight: Dp = 400.dp, emojiOnlyMode: Boolean = false, + canSendStickers: Boolean = true, onSearchFocused: (Boolean) -> Unit = {}, stickerRepository: StickerRepository ) { - var selectedTab by remember(emojiOnlyMode) { mutableIntStateOf(if (emojiOnlyMode) 1 else 0) } + val stickersAndGifsAllowed = !emojiOnlyMode && canSendStickers + var selectedTab by remember(stickersAndGifsAllowed) { mutableIntStateOf(if (stickersAndGifsAllowed) 0 else 1) } var isSearchMode by remember { mutableStateOf(false) } - val tabs = if (emojiOnlyMode) { + val tabs = if (!stickersAndGifsAllowed) { listOf(Triple(stringResource(R.string.sticker_menu_tab_emojis), Icons.Outlined.EmojiEmotions, 1)) } else { listOf( @@ -46,6 +48,12 @@ fun StickerEmojiMenu( ) } + LaunchedEffect(stickersAndGifsAllowed) { + if (!stickersAndGifsAllowed && selectedTab != 1) { + selectedTab = 1 + } + } + Surface( modifier = if (isSearchMode) { Modifier @@ -63,14 +71,14 @@ fun StickerEmojiMenu( Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) { when (selectedTab) { - 0 -> StickersView( + 0 -> if (stickersAndGifsAllowed) StickersView( onStickerSelected = onStickerSelected, onSearchFocused = { focused -> isSearchMode = focused onSearchFocused(focused) }, contentPadding = PaddingValues(bottom = 76.dp) - ) + ) else Unit 1 -> EmojisGrid( onEmojiSelected = onEmojiSelected, @@ -82,7 +90,7 @@ fun StickerEmojiMenu( contentPadding = PaddingValues(bottom = 76.dp) ) - 2 -> GifsView( + 2 -> if (stickersAndGifsAllowed) GifsView( onGifSelected = onGifSelected, onSearchFocused = { focused -> isSearchMode = focused @@ -90,12 +98,12 @@ fun StickerEmojiMenu( }, contentPadding = PaddingValues(bottom = 76.dp), stickerRepository = stickerRepository - ) + ) else Unit } } AnimatedVisibility( - visible = !isSearchMode && !emojiOnlyMode, + visible = !isSearchMode && tabs.size > 1, enter = fadeIn(animationSpec = tween(180)) + slideInVertically(animationSpec = tween(220)) { it / 4 }, exit = fadeOut(animationSpec = tween(130)) + From 7b42fa05e79bb08e63e1de1f781ab0a13c8ca16c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:43:48 +0300 Subject: [PATCH 13/83] fix disable drag-to-back in chats --- .../org/monogram/app/components/MobileLayout.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/monogram/app/components/MobileLayout.kt b/app/src/main/java/org/monogram/app/components/MobileLayout.kt index 02fa8480..40f04f0d 100644 --- a/app/src/main/java/org/monogram/app/components/MobileLayout.kt +++ b/app/src/main/java/org/monogram/app/components/MobileLayout.kt @@ -29,11 +29,20 @@ import org.monogram.presentation.root.RootComponent @Composable fun MobileLayout(root: RootComponent) { val stack by root.childStack.subscribeAsState() + val isDragToBackEnabled by root.appPreferences.isDragToBackEnabled.collectAsState() val coroutineScope = rememberCoroutineScope() val dragOffsetX = remember { Animatable(0f) } val previous = stack.items.dropLast(1).lastOrNull()?.instance var swipeBackInProgress by remember { mutableStateOf(false) } var widthPx by remember { mutableFloatStateOf(0f) } + val canUseDragToBack = + isDragToBackEnabled && stack.active.instance is RootComponent.Child.ChatDetailChild + + LaunchedEffect(canUseDragToBack) { + if (!canUseDragToBack && dragOffsetX.value > 0f) { + dragOffsetX.snapTo(0f) + } + } if (dragOffsetX.value > 0 && previous != null) { Box(modifier = Modifier.fillMaxSize()) { @@ -57,8 +66,8 @@ fun MobileLayout(root: RootComponent) { widthPx = it.width.toFloat() } .then( - if (stack.active.instance is RootComponent.Child.ChatDetailChild) { - Modifier.pointerInput(Unit) { + if (canUseDragToBack) { + Modifier.pointerInput(canUseDragToBack) { var isDragging = false detectHorizontalDragGestures( onDragStart = { offset -> From c8469e72c992d66b918882b1c91c72b79c57f276 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:20:53 +0300 Subject: [PATCH 14/83] fix chat settings ui --- .../chatSettings/ChatSettingsContent.kt | 25 +++++++++++++------ .../src/main/res/values-es/string.xml | 1 - .../src/main/res/values-hy/string.xml | 1 - .../src/main/res/values-pt-rBR/string.xml | 1 - .../src/main/res/values-ru-rRU/string.xml | 5 ++-- .../src/main/res/values-sk/string.xml | 1 - .../src/main/res/values-uk/string.xml | 1 - .../src/main/res/values-zh-rCN/string.xml | 1 - presentation/src/main/res/values/string.xml | 1 - 9 files changed, 19 insertions(+), 18 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt index 9364f2e7..ecd2f415 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt @@ -1094,12 +1094,20 @@ private fun AppearanceSliderItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { Text( text = title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) Spacer(modifier = Modifier.width(8.dp)) Surface( @@ -1117,13 +1125,14 @@ private fun AppearanceSliderItem( } TextButton( onClick = onReset, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - modifier = Modifier.height(32.dp) + contentPadding = PaddingValues(0.dp), + modifier = Modifier.size(32.dp) ) { - Text( - stringResource(R.string.reset_button), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold + Icon( + imageVector = Icons.Rounded.RestartAlt, + contentDescription = stringResource(R.string.photo_editor_action_reset), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) ) } } diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 672f2ce0..88f570c5 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -570,7 +570,6 @@ Espaciado de letras del mensaje Redondeo de burbuja Tamaño del sticker - Restablecer Fondo de Pantalla del Chat Restablecer Fondo de Pantalla Subir fondo de pantalla diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 85b22b7c..6d8d6d3a 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -541,7 +541,6 @@ Տեքստի չափը Տառերի հեռավորությունը Հաղորդագրության կլորացումը - Վերակայել Չատի պաստառ Վերակայել պաստառը Վերբեռնել պաստառ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 105cf8e4..072a4c06 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -571,7 +571,6 @@ Espaçamento entre letras Arredondamento das bolhas Tamanho das figurinhas - Redefinir Papel de parede do chat Redefinir papel de parede Carregar papel de parede diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 5f326f41..2e24ee89 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -553,11 +553,10 @@ Настройки чатов Внешний вид - Размер текста сообщений - Межбуквенный интервал сообщений + Размер текста + Межбуквенный интервал Скругление блоков Размер стикеров - Сбросить Обои чата Сбросить обои Загрузить обои diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index e8071d05..16488a7a 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -587,7 +587,6 @@ Veľkosť textu správy Zaoblenie bublín Veľkosť nálepiek - Obnoviť Tapeta chatu Obnoviť tapetu Nahrať tapetu diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index acf87fba..9567abc3 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -557,7 +557,6 @@ Міжлітерний інтервал повідомлень Скруглення блоків Розмір стікерів - Скинути Шпалери чату Скинути шпалери Завантажити шпалери diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 957ddd7b..62c0e7d8 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -557,7 +557,6 @@ 字元間距 气泡圆角 贴纸大小 - 重置 会话壁纸 重置壁纸 上传壁纸 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 369d7463..47e880d4 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -577,7 +577,6 @@ Message letter spacing Bubble rounding Sticker size - Reset Chat Wallpaper Reset Wallpaper Upload Wallpaper From 6614dd0b719a9e1e9a4b897d959fd3c10c8a5fd6 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:58:31 +0300 Subject: [PATCH 15/83] pins redesign + fix #73 --- .../chats/currentChat/ChatComponent.kt | 1 + .../features/chats/currentChat/ChatContent.kt | 4 +- .../chats/currentChat/ChatStoreFactory.kt | 82 ++------- .../pins/PinnedMessagesListSheet.kt | 164 +++++++++++++++--- .../chats/currentChat/impl/PinnedMessages.kt | 15 +- 5 files changed, 166 insertions(+), 100 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index c4ed1475..2087a697 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -218,6 +218,7 @@ interface ChatComponent { val pinnedMessage: MessageModel? = null, val allPinnedMessages: List = emptyList(), val showPinnedMessagesList: Boolean = false, + val isLoadingPinnedMessages: Boolean = false, val pinnedMessageCount: Int = 0, val pinnedMessageIndex: Int = 0, val scrollToMessageId: Long? = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index dc40d055..192224bf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -1014,9 +1014,9 @@ fun ChatContent( PinnedMessagesListSheet( state = state, onDismiss = { component.onDismissPinnedMessages() }, - onMessageClick = { scrollToMessageState.value(it); component.onDismissPinnedMessages() }, + onMessageClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, onUnpin = { component.onUnpinMessage(it) }, - onReplyClick = { scrollToMessageState.value(it); component.onDismissPinnedMessages() }, + onReplyClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, onReactionClick = { id, r -> component.onSendReaction(id, r) }, downloadUtils = component.downloadUtils ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt index 3597411e..a338601e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt @@ -7,70 +7,7 @@ import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor import kotlinx.coroutines.flow.update import org.monogram.presentation.features.chats.currentChat.ChatStore.Intent import org.monogram.presentation.features.chats.currentChat.ChatStore.Label -import org.monogram.presentation.features.chats.currentChat.impl.handleAcceptMiniAppTOS -import org.monogram.presentation.features.chats.currentChat.impl.handleAddToAdBlockWhitelist -import org.monogram.presentation.features.chats.currentChat.impl.handleAddToGifs -import org.monogram.presentation.features.chats.currentChat.impl.handleBlockUser -import org.monogram.presentation.features.chats.currentChat.impl.handleBotCommandClick -import org.monogram.presentation.features.chats.currentChat.impl.handleCancelDownloadFile -import org.monogram.presentation.features.chats.currentChat.impl.handleClearHistory -import org.monogram.presentation.features.chats.currentChat.impl.handleClearMessages -import org.monogram.presentation.features.chats.currentChat.impl.handleClearSelection -import org.monogram.presentation.features.chats.currentChat.impl.handleClosePoll -import org.monogram.presentation.features.chats.currentChat.impl.handleCommentsClick -import org.monogram.presentation.features.chats.currentChat.impl.handleConfirmRestrict -import org.monogram.presentation.features.chats.currentChat.impl.handleCopyLink -import org.monogram.presentation.features.chats.currentChat.impl.handleCopySelectedMessages -import org.monogram.presentation.features.chats.currentChat.impl.handleDeleteChat -import org.monogram.presentation.features.chats.currentChat.impl.handleDeleteMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleDeleteSelectedMessages -import org.monogram.presentation.features.chats.currentChat.impl.handleDismissInvoice -import org.monogram.presentation.features.chats.currentChat.impl.handleDismissMiniAppTOS -import org.monogram.presentation.features.chats.currentChat.impl.handleDownloadFile -import org.monogram.presentation.features.chats.currentChat.impl.handleDownloadHighRes -import org.monogram.presentation.features.chats.currentChat.impl.handleDraftChange -import org.monogram.presentation.features.chats.currentChat.impl.handleInlineQueryChange -import org.monogram.presentation.features.chats.currentChat.impl.handleJoinChat -import org.monogram.presentation.features.chats.currentChat.impl.handleKeyboardButtonClick -import org.monogram.presentation.features.chats.currentChat.impl.handleLoadMoreInlineResults -import org.monogram.presentation.features.chats.currentChat.impl.handleMentionQueryChange -import org.monogram.presentation.features.chats.currentChat.impl.handleMessageVisible -import org.monogram.presentation.features.chats.currentChat.impl.handleOpenInvoice -import org.monogram.presentation.features.chats.currentChat.impl.handleOpenMiniApp -import org.monogram.presentation.features.chats.currentChat.impl.handlePinMessage -import org.monogram.presentation.features.chats.currentChat.impl.handlePinnedMessageClick -import org.monogram.presentation.features.chats.currentChat.impl.handlePollOptionClick -import org.monogram.presentation.features.chats.currentChat.impl.handleRemoveFromAdBlockWhitelist -import org.monogram.presentation.features.chats.currentChat.impl.handleReplyMarkupButtonClick -import org.monogram.presentation.features.chats.currentChat.impl.handleReportMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleReportReasonSelected -import org.monogram.presentation.features.chats.currentChat.impl.handleRetractVote -import org.monogram.presentation.features.chats.currentChat.impl.handleSaveEditedMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleSendAlbum -import org.monogram.presentation.features.chats.currentChat.impl.handleSendGif -import org.monogram.presentation.features.chats.currentChat.impl.handleSendGifFile -import org.monogram.presentation.features.chats.currentChat.impl.handleSendInlineResult -import org.monogram.presentation.features.chats.currentChat.impl.handleSendMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleSendPhoto -import org.monogram.presentation.features.chats.currentChat.impl.handleSendReaction -import org.monogram.presentation.features.chats.currentChat.impl.handleSendScheduledNow -import org.monogram.presentation.features.chats.currentChat.impl.handleSendSticker -import org.monogram.presentation.features.chats.currentChat.impl.handleSendVideo -import org.monogram.presentation.features.chats.currentChat.impl.handleSendVoice -import org.monogram.presentation.features.chats.currentChat.impl.handleShowVoters -import org.monogram.presentation.features.chats.currentChat.impl.handleStickerClick -import org.monogram.presentation.features.chats.currentChat.impl.handleToggleMessageSelection -import org.monogram.presentation.features.chats.currentChat.impl.handleToggleMute -import org.monogram.presentation.features.chats.currentChat.impl.handleTopicClick -import org.monogram.presentation.features.chats.currentChat.impl.handleUnblockUser -import org.monogram.presentation.features.chats.currentChat.impl.handleUnpinMessage -import org.monogram.presentation.features.chats.currentChat.impl.handleVideoRecorded -import org.monogram.presentation.features.chats.currentChat.impl.loadAllPinnedMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadMoreMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadNewerMessages -import org.monogram.presentation.features.chats.currentChat.impl.loadScheduledMessages -import org.monogram.presentation.features.chats.currentChat.impl.scrollToBottomInternal -import org.monogram.presentation.features.chats.currentChat.impl.scrollToMessageInternal +import org.monogram.presentation.features.chats.currentChat.impl.* class ChatStoreFactory( private val storeFactory: StoreFactory, @@ -167,10 +104,23 @@ class ChatStoreFactory( is Intent.PinnedMessageClick -> component.handlePinnedMessageClick(intent.message) is Intent.ShowAllPinnedMessages -> { - component._state.update { it.copy(showPinnedMessagesList = true) } + component._state.update { + it.copy( + showPinnedMessagesList = true, + isLoadingPinnedMessages = true + ) + } component.loadAllPinnedMessages() } - is Intent.DismissPinnedMessages -> component._state.update { it.copy(showPinnedMessagesList = false) } + + is Intent.DismissPinnedMessages -> { + component._state.update { + it.copy( + showPinnedMessagesList = false, + isLoadingPinnedMessages = false + ) + } + } is Intent.ScrollToMessageConsumed -> component._state.update { it.copy(scrollToMessageId = null) } is Intent.ScrollToBottom -> component.scrollToBottomInternal() is Intent.DownloadFile -> component.handleDownloadFile(intent.fileId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt index d7d4dd4f..3ab1b3ed 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -10,19 +10,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.monogram.domain.models.MessageModel import org.monogram.presentation.R +import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.ChatComponent import org.monogram.presentation.features.chats.currentChat.chatContent.GroupedMessageItem import org.monogram.presentation.features.chats.currentChat.chatContent.groupMessagesByAlbum import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.* +import org.monogram.presentation.features.chats.currentChat.components.AlbumMessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.ChannelMessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.DateSeparator +import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,6 +44,9 @@ fun PinnedMessagesListSheet( ) { val messages = state.allPinnedMessages val groupedMessages = remember(messages) { groupMessagesByAlbum(messages.distinctBy { it.id }) } + val isLoadingPinnedMessages = state.isLoadingPinnedMessages && messages.isEmpty() + val displayedPinnedCount = maxOf(state.pinnedMessageCount, messages.size) + val shimmerBrush = rememberShimmerBrush() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( @@ -53,33 +62,65 @@ fun PinnedMessagesListSheet( .fillMaxSize() .windowInsetsPadding(WindowInsets.navigationBars) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.pinned_messages), + modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - text = pluralStringResource(R.plurals.pinned_count, messages.size, messages.size), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = FontWeight.Bold, + maxLines = 2, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis ) + Spacer(modifier = Modifier.height(2.dp)) + if (isLoadingPinnedMessages) { + Box( + modifier = Modifier + .padding(top = 2.dp) + .width(108.dp) + .height(18.dp) + .background( + brush = shimmerBrush, + shape = RoundedCornerShape(9.dp) + ) + ) + } else { + Text( + text = pluralStringResource( + R.plurals.pinned_count, + displayedPinnedCount, + displayedPinnedCount + ), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + HorizontalDivider(modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)) - LazyColumn( - modifier = Modifier - .weight(1f) - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), - contentPadding = PaddingValues(8.dp) - ) { + if (isLoadingPinnedMessages) { + PinnedMessagesLoadingSkeleton( + brush = shimmerBrush, + isChannel = state.isChannel, + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)) + ) + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), + contentPadding = PaddingValues(8.dp) + ) { itemsIndexed(groupedMessages, key = { _, item -> when (item) { is GroupedMessageItem.Single -> "pin_${item.message.id}" @@ -198,15 +239,86 @@ fun PinnedMessagesListSheet( } } - Box(modifier = Modifier.padding(16.dp)) { - Button( - onClick = onDismiss, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(16.dp) + } + } + } +} + +private data class PinnedSkeletonConfig( + val isOutgoing: Boolean, + val bubbleWidth: Float, + val lineWidths: List +) + +@Composable +private fun PinnedMessagesLoadingSkeleton( + brush: Brush, + isChannel: Boolean, + modifier: Modifier = Modifier +) { + val items = listOf( + PinnedSkeletonConfig(false, 0.82f, listOf(0.92f, 0.64f)), + PinnedSkeletonConfig(true, 0.58f, listOf(0.8f)), + PinnedSkeletonConfig(false, 0.74f, listOf(0.88f, 0.7f)), + PinnedSkeletonConfig(true, 0.62f, listOf(0.86f, 0.6f)), + PinnedSkeletonConfig(false, 0.68f, listOf(0.76f)), + PinnedSkeletonConfig(true, 0.8f, listOf(0.9f, 0.62f)), + PinnedSkeletonConfig(false, 0.56f, listOf(0.72f)) + ) + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(items) { _, item -> + val outgoing = !isChannel && item.isOutgoing + val bubbleColor = if (outgoing) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.65f) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (outgoing) Arrangement.End else Arrangement.Start + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = bubbleColor, + modifier = Modifier.fillMaxWidth(item.bubbleWidth) ) { - Text(text = stringResource(R.string.pinned_close), fontSize = 16.sp, fontWeight = FontWeight.Bold) + Column( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 9.dp, bottom = 7.dp) + ) { + item.lineWidths.forEachIndexed { index, width -> + Box( + modifier = Modifier + .fillMaxWidth(width) + .height(14.dp) + .background( + brush = brush, + shape = RoundedCornerShape(5.dp) + ) + ) + if (index != item.lineWidths.lastIndex) { + Spacer(modifier = Modifier.height(6.dp)) + } + } + + Spacer(modifier = Modifier.height(7.dp)) + + Box( + modifier = Modifier + .align(Alignment.End) + .width(if (outgoing) 44.dp else 32.dp) + .height(10.dp) + .background( + brush = brush, + shape = RoundedCornerShape(3.dp) + ) + ) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt index 1ae23a2b..223b501d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt @@ -35,13 +35,18 @@ internal fun DefaultChatComponent.loadPinnedMessage() { internal fun DefaultChatComponent.loadAllPinnedMessages() { scope.launch { val threadId = _state.value.currentTopicId + _state.update { it.copy(isLoadingPinnedMessages = true) } try { val pinnedMessages = repositoryMessage.getAllPinnedMessages(chatId, threadId) _state.update { - it.copy(allPinnedMessages = pinnedMessages) + it.copy( + allPinnedMessages = pinnedMessages, + isLoadingPinnedMessages = false + ) } } catch (e: Exception) { Log.e("DefaultChatComponent", "Error loading all pinned messages", e) + _state.update { it.copy(isLoadingPinnedMessages = false) } } } } @@ -109,6 +114,9 @@ private fun DefaultChatComponent.jumpToMessage(message: MessageModel) { val threadId = _state.value.currentTopicId val messages = repositoryMessage.getMessagesAround(chatId, message.id, 50, threadId) if (messages.isNotEmpty()) { + updateMessages(messages, replace = true) + lastLoadedOlderId = 0L + lastLoadedNewerId = 0L _state.update { it.copy( scrollToMessageId = message.id, @@ -118,11 +126,6 @@ private fun DefaultChatComponent.jumpToMessage(message: MessageModel) { isOldestLoaded = false ) } - updateMessages(messages, replace = true) - lastLoadedOlderId = 0L - lastLoadedNewerId = 0L - loadMoreMessages() - loadNewerMessages() } } catch (e: Exception) { Log.e("DefaultChatComponent", "Error jumping to message", e) From 8f1937a52193a8b61041428ae8b8ca916c4e0d49 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:02:52 +0300 Subject: [PATCH 16/83] rewritten pinned sheet --- .../features/chats/currentChat/ChatContent.kt | 22 +- .../chats/currentChat/ChatStoreFactory.kt | 3 +- .../pins/PinnedMessagesListSheet.kt | 465 +++++++++++------- 3 files changed, 308 insertions(+), 182 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 192224bf..18db097b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -435,7 +435,7 @@ fun ChatContent( var containerSize by remember { mutableStateOf(IntSize.Zero) } val isCustomBackHandlingEnabled = - (editingPhotoPath != null || editingVideoPath != null || selectedMessageId != null || state.selectedMessageIds.isNotEmpty() || state.currentTopicId != null || state.showBotCommands || state.restrictUserId != null || state.fullScreenImages != null || state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null || state.miniAppUrl != null || state.webViewUrl != null || state.instantViewUrl != null || state.youtubeUrl != null) + (editingPhotoPath != null || editingVideoPath != null || selectedMessageId != null || state.selectedMessageIds.isNotEmpty() || state.currentTopicId != null || state.showBotCommands || state.restrictUserId != null || state.showPinnedMessagesList || state.fullScreenImages != null || state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null || state.miniAppUrl != null || state.webViewUrl != null || state.instantViewUrl != null || state.youtubeUrl != null) CompositionLocalProvider(LocalLinkHandler provides { component.onLinkClick(it) }) { Box( @@ -1012,13 +1012,28 @@ fun ChatContent( // Modals & Overlays if (state.showPinnedMessagesList) { PinnedMessagesListSheet( - state = state, + allPinnedMessages = state.allPinnedMessages, + pinnedMessageCount = state.pinnedMessageCount, + isLoadingPinnedMessages = state.isLoadingPinnedMessages, + isGroup = state.isGroup, + isChannel = state.isChannel, + fontSize = state.fontSize, + letterSpacing = state.letterSpacing, + bubbleRadius = state.bubbleRadius, + stickerSize = state.stickerSize, + autoDownloadMobile = state.autoDownloadMobile, + autoDownloadWifi = state.autoDownloadWifi, + autoDownloadRoaming = state.autoDownloadRoaming, + autoDownloadFiles = state.autoDownloadFiles, + autoplayGifs = state.autoplayGifs, + autoplayVideos = state.autoplayVideos, onDismiss = { component.onDismissPinnedMessages() }, onMessageClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, onUnpin = { component.onUnpinMessage(it) }, onReplyClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, onReactionClick = { id, r -> component.onSendReaction(id, r) }, - downloadUtils = component.downloadUtils + downloadUtils = component.downloadUtils, + isAnyViewerOpen = isAnyViewerOpen ) } @@ -1167,6 +1182,7 @@ fun ChatContent( else if (selectedMessageId != null) selectedMessageId = null else if (state.showBotCommands) component.onDismissBotCommands() else if (state.restrictUserId != null) component.onDismissRestrictDialog() + else if (state.showPinnedMessagesList && !isAnyViewerOpen) component.onDismissPinnedMessages() else if (state.fullScreenImages != null) component.onDismissImages() else if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) component.onDismissVideo() else if (state.instantViewUrl != null) component.onDismissInstantView() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt index a338601e..3cbe6bf5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt @@ -107,7 +107,8 @@ class ChatStoreFactory( component._state.update { it.copy( showPinnedMessagesList = true, - isLoadingPinnedMessages = true + isLoadingPinnedMessages = true, + allPinnedMessages = emptyList() ) } component.loadAllPinnedMessages() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt index 3ab1b3ed..85dd1454 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -1,27 +1,37 @@ package org.monogram.presentation.features.chats.currentChat.components.pins +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import org.monogram.domain.models.MessageModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.IDownloadUtils -import org.monogram.presentation.features.chats.currentChat.ChatComponent import org.monogram.presentation.features.chats.currentChat.chatContent.GroupedMessageItem import org.monogram.presentation.features.chats.currentChat.chatContent.groupMessagesByAlbum import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate @@ -29,11 +39,26 @@ import org.monogram.presentation.features.chats.currentChat.components.AlbumMess import org.monogram.presentation.features.chats.currentChat.components.ChannelMessageBubbleContainer import org.monogram.presentation.features.chats.currentChat.components.DateSeparator import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun PinnedMessagesListSheet( - state: ChatComponent.State, + allPinnedMessages: List, + pinnedMessageCount: Int, + isLoadingPinnedMessages: Boolean, + isGroup: Boolean, + isChannel: Boolean, + fontSize: Float, + letterSpacing: Float, + bubbleRadius: Float, + stickerSize: Float, + autoDownloadMobile: Boolean, + autoDownloadWifi: Boolean, + autoDownloadRoaming: Boolean, + autoDownloadFiles: Boolean, + autoplayGifs: Boolean, + autoplayVideos: Boolean, onDismiss: () -> Unit, onMessageClick: (MessageModel) -> Unit, onUnpin: (MessageModel) -> Unit, @@ -42,204 +67,288 @@ fun PinnedMessagesListSheet( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { - val messages = state.allPinnedMessages + val messages = allPinnedMessages val groupedMessages = remember(messages) { groupMessagesByAlbum(messages.distinctBy { it.id }) } - val isLoadingPinnedMessages = state.isLoadingPinnedMessages && messages.isEmpty() - val displayedPinnedCount = maxOf(state.pinnedMessageCount, messages.size) + val showLoadingSkeleton = isLoadingPinnedMessages && messages.isEmpty() + val displayedPinnedCount = maxOf(pinnedMessageCount, messages.size) + val density = LocalDensity.current + val scope = rememberCoroutineScope() val shimmerBrush = rememberShimmerBrush() - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var dismissOffsetY by remember { mutableFloatStateOf(0f) } + var sheetHeightPx by remember { mutableFloatStateOf(0f) } + val dismissDistanceThresholdPx = with(density) { 104.dp.toPx() } + val dismissVelocityThresholdPx = with(density) { 360.dp.toPx() } + val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val dismissProgress = (dismissOffsetY / (sheetHeightPx.takeIf { it > 0f } ?: 1f)).coerceIn(0f, 1f) + val dividerColor = MaterialTheme.colorScheme.outlineVariant + val surfaceColor = MaterialTheme.colorScheme.background + val contentColor = MaterialTheme.colorScheme.onSurface + val scrimColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.48f * (1f - dismissProgress)) + val scrimInteractionSource = remember { MutableInteractionSource() } + val dragState = rememberDraggableState { delta -> + if (isAnyViewerOpen) return@rememberDraggableState + dismissOffsetY = (dismissOffsetY + delta).coerceAtLeast(0f) + } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - contentColor = MaterialTheme.colorScheme.onSurface, - dragHandle = { BottomSheetDefaults.DragHandle() } - ) { - Column( + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(scrimColor) + .clickable( + interactionSource = scrimInteractionSource, + indication = null + ) { + if (!isAnyViewerOpen) onDismiss() + } + ) + + Surface( modifier = Modifier + .align(Alignment.BottomCenter) .fillMaxSize() - .windowInsetsPadding(WindowInsets.navigationBars) + .padding(top = statusBarTopPadding) + .offset { IntOffset(x = 0, y = dismissOffsetY.roundToInt()) } + .onSizeChanged { sheetHeightPx = it.height.toFloat() }, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = surfaceColor, + contentColor = contentColor ) { Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars) ) { - Text( - text = stringResource(R.string.pinned_messages), - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - maxLines = 2, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(2.dp)) - if (isLoadingPinnedMessages) { - Box( - modifier = Modifier - .padding(top = 2.dp) - .width(108.dp) - .height(18.dp) - .background( - brush = shimmerBrush, - shape = RoundedCornerShape(9.dp) - ) - ) - } else { + Column( + modifier = Modifier + .fillMaxWidth() + .draggable( + state = dragState, + orientation = Orientation.Vertical, + enabled = !isAnyViewerOpen, + onDragStopped = { velocity -> + if (isAnyViewerOpen) { + scope.launch { + animate( + initialValue = dismissOffsetY, + targetValue = 0f, + animationSpec = spring() + ) { value, _ -> dismissOffsetY = value } + } + return@draggable + } + + val shouldDismiss = + dismissOffsetY > dismissDistanceThresholdPx || + velocity > dismissVelocityThresholdPx + + if (shouldDismiss) { + scope.launch { + val target = if (sheetHeightPx > 0f) { + sheetHeightPx + } else { + with(density) { 640.dp.toPx() } + } + animate( + initialValue = dismissOffsetY, + targetValue = target, + animationSpec = tween(durationMillis = 220) + ) { value, _ -> dismissOffsetY = value } + onDismiss() + } + } else { + scope.launch { + animate( + initialValue = dismissOffsetY, + targetValue = 0f, + animationSpec = spring() + ) { value, _ -> dismissOffsetY = value } + } + } + } + ) + .padding(horizontal = 24.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BottomSheetDefaults.DragHandle() + Text( - text = pluralStringResource( - R.plurals.pinned_count, - displayedPinnedCount, - displayedPinnedCount - ), + text = stringResource(R.string.pinned_messages), modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.labelLarge, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant + overflow = TextOverflow.Ellipsis ) - } - } - - HorizontalDivider(modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)) - - if (isLoadingPinnedMessages) { - PinnedMessagesLoadingSkeleton( - brush = shimmerBrush, - isChannel = state.isChannel, - modifier = Modifier - .weight(1f) - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)) - ) - } else { - LazyColumn( - modifier = Modifier - .weight(1f) - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), - contentPadding = PaddingValues(8.dp) - ) { - itemsIndexed(groupedMessages, key = { _, item -> - when (item) { - is GroupedMessageItem.Single -> "pin_${item.message.id}" - is GroupedMessageItem.Album -> "pin_album_${item.albumId}" - } - }) { index, item -> - val msg = when (item) { - is GroupedMessageItem.Single -> item.message - is GroupedMessageItem.Album -> item.messages.last() + Spacer(modifier = Modifier.height(2.dp)) + if (showLoadingSkeleton) { + Box( + modifier = Modifier + .padding(top = 2.dp) + .width(108.dp) + .height(18.dp) + .background( + brush = shimmerBrush, + shape = RoundedCornerShape(9.dp) + ) + ) + } else { + Text( + text = pluralStringResource( + R.plurals.pinned_count, + displayedPinnedCount, + displayedPinnedCount + ), + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - val olderMsg = when (val olderItem = groupedMessages.getOrNull(index + 1)) { - is GroupedMessageItem.Single -> olderItem.message - is GroupedMessageItem.Album -> olderItem.messages.last() - null -> null - } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + color = dividerColor + ) + } - val newerMsg = when (val newerItem = groupedMessages.getOrNull(index - 1)) { - is GroupedMessageItem.Single -> newerItem.message - is GroupedMessageItem.Album -> newerItem.messages.first() - null -> null - } + if (showLoadingSkeleton) { + PinnedMessagesLoadingSkeleton( + brush = shimmerBrush, + isChannel = isChannel, + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)) + ) + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.05f)), + contentPadding = PaddingValues(8.dp) + ) { + itemsIndexed(groupedMessages, key = { _, item -> + when (item) { + is GroupedMessageItem.Single -> "pin_${item.message.id}" + is GroupedMessageItem.Album -> "pin_album_${item.albumId}" + } + }) { index, item -> + val msg = when (item) { + is GroupedMessageItem.Single -> item.message + is GroupedMessageItem.Album -> item.messages.last() + } - if (shouldShowDate(msg, olderMsg)) { - DateSeparator(msg.date) - Spacer(modifier = Modifier.height(8.dp)) - } + val olderMsg = when (val olderItem = groupedMessages.getOrNull(index + 1)) { + is GroupedMessageItem.Single -> olderItem.message + is GroupedMessageItem.Album -> olderItem.messages.last() + null -> null + } - Box(modifier = Modifier.animateItem()) { - if (state.isChannel) { - if (item is GroupedMessageItem.Single) { - ChannelMessageBubbleContainer( - msg = item.message, - olderMsg = olderMsg, - newerMsg = newerMsg, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - autoDownloadFiles = state.autoDownloadFiles, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onDocumentClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.message) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stickerSize = state.stickerSize, - downloadUtils = downloadUtils - ) - } else if (item is GroupedMessageItem.Album) { - AlbumMessageBubbleContainer( - messages = item.messages, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = false, - isChannel = true, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - downloadUtils = downloadUtils - ) + val newerMsg = when (val newerItem = groupedMessages.getOrNull(index - 1)) { + is GroupedMessageItem.Single -> newerItem.message + is GroupedMessageItem.Album -> newerItem.messages.first() + null -> null } - } else { - if (item is GroupedMessageItem.Single) { - MessageBubbleContainer( - msg = item.message, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = state.isGroup, - fontSize = state.fontSize, - letterSpacing = state.letterSpacing, - bubbleRadius = state.bubbleRadius, - stSize = state.stickerSize, - autoDownloadMobile = state.autoDownloadMobile, - autoDownloadWifi = state.autoDownloadWifi, - autoDownloadRoaming = state.autoDownloadRoaming, - autoDownloadFiles = state.autoDownloadFiles, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onDocumentClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.message) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - downloadUtils = downloadUtils - ) - } else if (item is GroupedMessageItem.Album) { - AlbumMessageBubbleContainer( - messages = item.messages, - olderMsg = olderMsg, - newerMsg = newerMsg, - isGroup = state.isGroup, - isChannel = false, - autoplayGifs = state.autoplayGifs, - autoplayVideos = state.autoplayVideos, - onPhotoClick = { onMessageClick(it) }, - onVideoClick = { onMessageClick(it) }, - onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, - onGoToReply = { onReplyClick(it) }, - onReactionClick = onReactionClick, - toProfile = {}, - downloadUtils = downloadUtils - ) + + if (shouldShowDate(msg, olderMsg)) { + DateSeparator(msg.date) + Spacer(modifier = Modifier.height(8.dp)) } + + Box(modifier = Modifier.animateItem()) { + if (isChannel) { + if (item is GroupedMessageItem.Single) { + ChannelMessageBubbleContainer( + msg = item.message, + olderMsg = olderMsg, + newerMsg = newerMsg, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadFiles = autoDownloadFiles, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onDocumentClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.message) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stickerSize = stickerSize, + downloadUtils = downloadUtils + ) + } else if (item is GroupedMessageItem.Album) { + AlbumMessageBubbleContainer( + messages = item.messages, + olderMsg = olderMsg, + newerMsg = newerMsg, + isGroup = false, + isChannel = true, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + toProfile = {}, + downloadUtils = downloadUtils + ) + } + } else { + if (item is GroupedMessageItem.Single) { + MessageBubbleContainer( + msg = item.message, + olderMsg = olderMsg, + newerMsg = newerMsg, + isGroup = isGroup, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stSize = stickerSize, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoDownloadFiles = autoDownloadFiles, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onDocumentClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.message) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + toProfile = {}, + downloadUtils = downloadUtils + ) + } else if (item is GroupedMessageItem.Album) { + AlbumMessageBubbleContainer( + messages = item.messages, + olderMsg = olderMsg, + newerMsg = newerMsg, + isGroup = isGroup, + isChannel = false, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + onPhotoClick = { onMessageClick(it) }, + onVideoClick = { onMessageClick(it) }, + onReplyClick = { _, _, _ -> onMessageClick(item.messages.last()) }, + onGoToReply = { onReplyClick(it) }, + onReactionClick = onReactionClick, + toProfile = {}, + downloadUtils = downloadUtils + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) } } - Spacer(modifier = Modifier.height(4.dp)) } } - - } } } } From 803460e97c0212743fe7d5bf4046f884a9c7d2f9 Mon Sep 17 00:00:00 2001 From: Fimkov <123477029+fimkov@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:28:29 +0700 Subject: [PATCH 17/83] remove password length limit (#210) --- .../features/auth/components/PasswordInputScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt index 09ae0b4a..694177a2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PasswordInputScreen.kt @@ -41,7 +41,6 @@ import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.rounded.VpnKey import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -190,8 +189,7 @@ fun PasswordInputScreen( PasswordContent( password = password, onPasswordChange = { - val filtered = it.filter { c -> c != ' ' } - password = filtered.take(64) + password = it.filter { c -> c != ' ' } }, passwordVisible = passwordVisible, onPasswordVisibleChange = { passwordVisible = it }, From 6db2fc1954413bcf37e71bbe562f4538e7e2d4e6 Mon Sep 17 00:00:00 2001 From: Fimkov <123477029+fimkov@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:31:05 +0700 Subject: [PATCH 18/83] phone number input screen improvements (#209) - add phone number placeholder using libphonenumber example numbers - add SIM card country auto detection via TelephonyManager - now using getCountryForPhone for searching country by code - add getExampleNumber tests --- .../presentation/core/util/CountryUtil.kt | 27 ++++++++++++++++++- .../auth/components/PhoneInputScreen.kt | 26 ++++++++++++++---- .../core/util/CountryManagerTest.kt | 4 +++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt index 63102a16..b26c413c 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CountryUtil.kt @@ -1,6 +1,5 @@ package org.monogram.presentation.core.util -import android.util.Log import com.google.i18n.phonenumbers.PhoneNumberUtil import org.monogram.presentation.core.util.Country.Companion.FALLBACK_LENGTH @@ -285,6 +284,32 @@ object CountryManager { return result } + /** + * Get example phone number for a country with digits masked as zeros + * + * @param iso country ISO code + * @return formatted example number with body digits replaced by zeros, + * or throws if no example number is available for the given ISO + **/ + fun getExampleNumber(iso: String): String = + phoneUtil.format( + phoneUtil.getExampleNumberForType(iso, PhoneNumberUtil.PhoneNumberType.MOBILE), + PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL + ).let { it.substringBefore(" ") + " " + it.substringAfter(" ").replace(Regex("\\d"), "0") } + + /** + * Get country ISO code from the device's SIM card + * + * @param context Android context + * @return uppercase ISO code of the SIM card's country, + * or 'null' if no SIM is present or the country cannot be determined + **/ + fun getSimIso(context: android.content.Context): String? { + val tm = context.getSystemService(android.content.Context.TELEPHONY_SERVICE) + as android.telephony.TelephonyManager + return tm.simCountryIso?.uppercase()?.takeIf { it.isNotEmpty() } + } + private fun countryCodeToEmoji(countryCode: String): String { if (countryCode == "FT") return "⭐" if (countryCode == "YL") return "✈️" diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt index 8fcd30bd..9bd08a58 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/components/PhoneInputScreen.kt @@ -80,6 +80,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalInspectionMode @@ -125,10 +126,11 @@ fun PhoneInputScreen( } } + val context = LocalContext.current val defaultCountry = remember { - val currentIso = Locale.getDefault().country - countries.find { it.iso == currentIso } ?: countries.find { it.code == "380" } - ?: countries.first() + val iso = if (isPreview) "US" else + (CountryManager.getSimIso(context) ?: Locale.getDefault().country) + countries.find { it.iso == iso } ?: countries.find { it.code == "380" } ?: countries.first() } var phoneBody by remember { mutableStateOf("") } @@ -208,6 +210,18 @@ fun PhoneInputScreen( } } + val phonePlaceholder = remember(selectedCountry) { + selectedCountry?.let { country -> + try { + val example = CountryManager.getExampleNumber(country.iso) + val prefix = "+${country.code}" + if (example.startsWith(prefix)) example.removePrefix(prefix).trim() else example + } catch (_: Exception) { + "" + } + } ?: "" + } + val fullNumber = "+$codeInput$phoneBody" val isFormValid = remember(fullNumber, selectedCountry?.iso) { val iso = selectedCountry?.iso @@ -385,7 +399,7 @@ fun PhoneInputScreen( codeInput = digits codeFieldValue = newValue.copy(text = digits) - val newCountry = countries.find { it.code == digits } + val newCountry = CountryManager.getCountryForPhone(digits) selectedCountry = newCountry phoneDisplay = newCountry?.let { @@ -483,7 +497,9 @@ fun PhoneInputScreen( ) { Column { Text( - text = if (phoneBody.isEmpty()) stringResource(R.string.phone_number_placeholder) else phoneDisplay, + text = if (phoneBody.isEmpty()) + phonePlaceholder.ifEmpty { stringResource(R.string.phone_number_placeholder) } + else phoneDisplay, style = MaterialTheme.typography.titleMedium, color = if (phoneBody.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.5f diff --git a/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt b/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt index 0a3b8cb6..8126848f 100644 --- a/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/core/util/CountryManagerTest.kt @@ -176,5 +176,9 @@ class CountryManagerTest { @Test fun `Y-land number mask`() = assertEquals("42777", CountryManager.maskPhoneNumber("42777")) + + @Test + fun `getExampleNumber returns valid example`() = + assertEquals("+7 000 000-00-00", CountryManager.getExampleNumber("RU")) } From a0720915210761f7e11a3d7e26432d6eeac6432d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:58:10 +0300 Subject: [PATCH 19/83] better animations of pins --- .../features/chats/currentChat/ChatContent.kt | 36 ++++++++++-- .../pins/PinnedMessagesListSheet.kt | 58 +++++++++++-------- 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 18db097b..e8cbf1f4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -433,6 +433,20 @@ fun ChatContent( (!state.viewAsTopics || state.currentTopicId != null) var containerSize by remember { mutableStateOf(IntSize.Zero) } + var renderPinnedMessagesList by remember { mutableStateOf(state.showPinnedMessagesList) } + var pendingPinnedSheetAction by remember { mutableStateOf<(() -> Unit)?>(null) } + + LaunchedEffect(state.showPinnedMessagesList) { + if (state.showPinnedMessagesList) { + renderPinnedMessagesList = true + } + } + + val requestPinnedMessagesListDismiss = { + if (state.showPinnedMessagesList) { + component.onDismissPinnedMessages() + } + } val isCustomBackHandlingEnabled = (editingPhotoPath != null || editingVideoPath != null || selectedMessageId != null || state.selectedMessageIds.isNotEmpty() || state.currentTopicId != null || state.showBotCommands || state.restrictUserId != null || state.showPinnedMessagesList || state.fullScreenImages != null || state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null || state.miniAppUrl != null || state.webViewUrl != null || state.instantViewUrl != null || state.youtubeUrl != null) @@ -1010,8 +1024,9 @@ fun ChatContent( // Modals & Overlays - if (state.showPinnedMessagesList) { + if (renderPinnedMessagesList) { PinnedMessagesListSheet( + isVisible = state.showPinnedMessagesList, allPinnedMessages = state.allPinnedMessages, pinnedMessageCount = state.pinnedMessageCount, isLoadingPinnedMessages = state.isLoadingPinnedMessages, @@ -1027,10 +1042,21 @@ fun ChatContent( autoDownloadFiles = state.autoDownloadFiles, autoplayGifs = state.autoplayGifs, autoplayVideos = state.autoplayVideos, - onDismiss = { component.onDismissPinnedMessages() }, - onMessageClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, + onDismissRequest = requestPinnedMessagesListDismiss, + onHidden = { + renderPinnedMessagesList = false + pendingPinnedSheetAction?.invoke() + pendingPinnedSheetAction = null + }, + onMessageClick = { + pendingPinnedSheetAction = { scrollToMessageState.value(it) } + requestPinnedMessagesListDismiss() + }, onUnpin = { component.onUnpinMessage(it) }, - onReplyClick = { component.onDismissPinnedMessages(); scrollToMessageState.value(it) }, + onReplyClick = { + pendingPinnedSheetAction = { scrollToMessageState.value(it) } + requestPinnedMessagesListDismiss() + }, onReactionClick = { id, r -> component.onSendReaction(id, r) }, downloadUtils = component.downloadUtils, isAnyViewerOpen = isAnyViewerOpen @@ -1182,7 +1208,7 @@ fun ChatContent( else if (selectedMessageId != null) selectedMessageId = null else if (state.showBotCommands) component.onDismissBotCommands() else if (state.restrictUserId != null) component.onDismissRestrictDialog() - else if (state.showPinnedMessagesList && !isAnyViewerOpen) component.onDismissPinnedMessages() + else if (state.showPinnedMessagesList && !isAnyViewerOpen) requestPinnedMessagesListDismiss() else if (state.fullScreenImages != null) component.onDismissImages() else if (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) component.onDismissVideo() else if (state.instantViewUrl != null) component.onDismissInstantView() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt index 85dd1454..96c4634e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -44,6 +44,7 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun PinnedMessagesListSheet( + isVisible: Boolean, allPinnedMessages: List, pinnedMessageCount: Int, isLoadingPinnedMessages: Boolean, @@ -59,7 +60,8 @@ fun PinnedMessagesListSheet( autoDownloadFiles: Boolean, autoplayGifs: Boolean, autoplayVideos: Boolean, - onDismiss: () -> Unit, + onDismissRequest: () -> Unit, + onHidden: () -> Unit, onMessageClick: (MessageModel) -> Unit, onUnpin: (MessageModel) -> Unit, onReplyClick: (MessageModel) -> Unit, @@ -76,10 +78,12 @@ fun PinnedMessagesListSheet( val shimmerBrush = rememberShimmerBrush() var dismissOffsetY by remember { mutableFloatStateOf(0f) } var sheetHeightPx by remember { mutableFloatStateOf(0f) } + var isAnimationReady by remember { mutableStateOf(false) } val dismissDistanceThresholdPx = with(density) { 104.dp.toPx() } val dismissVelocityThresholdPx = with(density) { 360.dp.toPx() } val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val dismissProgress = (dismissOffsetY / (sheetHeightPx.takeIf { it > 0f } ?: 1f)).coerceIn(0f, 1f) + val hiddenOffset = sheetHeightPx.takeIf { it > 0f } ?: with(density) { 640.dp.toPx() } + val dismissProgress = (dismissOffsetY / hiddenOffset).coerceIn(0f, 1f) val dividerColor = MaterialTheme.colorScheme.outlineVariant val surfaceColor = MaterialTheme.colorScheme.background val contentColor = MaterialTheme.colorScheme.onSurface @@ -90,6 +94,26 @@ fun PinnedMessagesListSheet( dismissOffsetY = (dismissOffsetY + delta).coerceAtLeast(0f) } + LaunchedEffect(sheetHeightPx) { + if (sheetHeightPx > 0f && !isAnimationReady) { + dismissOffsetY = hiddenOffset + isAnimationReady = true + } + } + + LaunchedEffect(isVisible, isAnimationReady, hiddenOffset) { + if (!isAnimationReady) return@LaunchedEffect + val target = if (isVisible) 0f else hiddenOffset + animate( + initialValue = dismissOffsetY, + targetValue = target, + animationSpec = if (isVisible) spring() else tween(durationMillis = 220) + ) { value, _ -> dismissOffsetY = value } + if (!isVisible) { + onHidden() + } + } + Box(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier @@ -99,7 +123,7 @@ fun PinnedMessagesListSheet( interactionSource = scrimInteractionSource, indication = null ) { - if (!isAnyViewerOpen) onDismiss() + if (!isAnyViewerOpen) onDismissRequest() } ) @@ -138,27 +162,15 @@ fun PinnedMessagesListSheet( return@draggable } - val shouldDismiss = - dismissOffsetY > dismissDistanceThresholdPx || - velocity > dismissVelocityThresholdPx + val shouldDismiss = + dismissOffsetY > dismissDistanceThresholdPx || + velocity > dismissVelocityThresholdPx - if (shouldDismiss) { - scope.launch { - val target = if (sheetHeightPx > 0f) { - sheetHeightPx - } else { - with(density) { 640.dp.toPx() } - } - animate( - initialValue = dismissOffsetY, - targetValue = target, - animationSpec = tween(durationMillis = 220) - ) { value, _ -> dismissOffsetY = value } - onDismiss() - } - } else { - scope.launch { - animate( + if (shouldDismiss) { + onDismissRequest() + } else { + scope.launch { + animate( initialValue = dismissOffsetY, targetValue = 0f, animationSpec = spring() From f05cab950534df452325020cbeb2d782e05b4e20 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:28:57 +0300 Subject: [PATCH 20/83] chore: update project dependencies --- baselineprofile/build.gradle.kts | 1 - build.gradle.kts | 1 - data/build.gradle.kts | 1 - gradle.properties | 7 ++++-- gradle/libs.versions.toml | 31 ++++++++++++------------ gradle/wrapper/gradle-wrapper.properties | 2 +- presentation/build.gradle.kts | 1 - 7 files changed, 21 insertions(+), 23 deletions(-) diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts index 86766edd..bdb81339 100644 --- a/baselineprofile/build.gradle.kts +++ b/baselineprofile/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id("com.android.test") - alias(libs.plugins.kotlin.android) alias(libs.plugins.androidx.baselineprofile) } diff --git a/build.gradle.kts b/build.gradle.kts index c0dbc224..17faf427 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,6 @@ import java.util.Properties // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.android.lint) apply false alias(libs.plugins.android.library) apply false diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 4672bba9..7300a4a2 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -2,7 +2,6 @@ import java.util.* plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } diff --git a/gradle.properties b/gradle.properties index 40628e35..6cc0856c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8 --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.ref=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.locks=ALL-UNNAMED # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -21,4 +21,7 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableR8.fullMode=true \ No newline at end of file +android.enableR8.fullMode=true +android.uniquePackageNames=false +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31566b6d..38dacb48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,21 @@ [versions] # Plugins -agp = "8.12.0" -kotlin = "2.3.10" +agp = "9.1.0" +kotlin = "2.3.20" google-services = "4.4.4" -ossLicensesPlugin = "0.10.10" +ossLicensesPlugin = "0.11.0" ksp = "2.3.6" -baselineprofile = "1.4.1" +baselineprofile = "1.5.0-alpha05" # AndroidX -androidx-activityCompose = "1.12.4" -androidx-biometric = "1.4.0-alpha05" -androidx-camera = "1.5.3" -androidx-compose-bom = "2026.02.01" -androidx-compose-runtime = "1.10.4" +androidx-activityCompose = "1.13.0" +androidx-biometric = "1.4.0-alpha06" +androidx-camera = "1.6.0" +androidx-compose-bom = "2026.03.01" +androidx-compose-runtime = "1.10.6" androidx-material3 = "1.4.0" androidx-adaptive = "1.2.0" -androidx-media3 = "1.9.2" +androidx-media3 = "1.10.0" androidx-securityCrypto = "1.1.0" androidx-room = "2.8.4" androidx-uiautomator = "2.3.0" @@ -27,16 +27,16 @@ kotlinx-coroutines = "1.10.2" kotlinx-serialization = "1.10.0" # Architecture & DI -decompose = "3.4.0" +decompose = "3.5.0" mvikotlin = "4.3.0" -koin = "4.1.1" +koin = "4.2.0" # UI & Media coil = "3.4.0" -maplibre = "1.4.1" +maplibre = "1.7.0" # Google & Firebase -firebase-bom = "34.10.0" +firebase-bom = "34.11.0" playServices-location = "21.3.0" playServices-mlkit-barcode = "18.3.1" playServices-ossLicenses = "17.4.0" @@ -44,7 +44,7 @@ playServices-ossLicenses = "17.4.0" # Others zxing = "3.5.4" junit = "4.13.2" -libphonenumber = "8.13.55" +libphonenumber = "9.0.27" [libraries] # AndroidX Activity @@ -186,7 +186,6 @@ koin = [ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } android-lint = { id = "com.android.lint", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } google-oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b1..37f78a6a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index e836a24a..af285278 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) id("kotlin-parcelize") From 11eeca9cc6f2854c7be7804f87b8d3125656f65a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:29:10 +0300 Subject: [PATCH 21/83] fix: rename release APK outputs --- app/build.gradle.kts | 57 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8fcdcc0a..220e7207 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,11 +1,11 @@ import com.android.build.api.artifact.SingleArtifact import com.android.build.api.variant.FilterConfiguration +import com.android.build.api.variant.impl.VariantOutputImpl import com.google.android.gms.oss.licenses.plugin.DependencyTask import com.google.gms.googleservices.GoogleServicesPlugin plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.google.oss.licenses) alias(libs.plugins.google.services) @@ -45,10 +45,16 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + buildFeatures { + resValues = true + } signingConfig = signingConfigs.getByName("debug") resValue("string", "app_name", "MonoGram") } debug { + buildFeatures { + resValues = true + } applicationIdSuffix = ".debug" isMinifyEnabled = false resValue("string", "app_name", "MonoGram Debug") @@ -58,9 +64,6 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - kotlin { - jvmToolchain(21) - } buildFeatures { compose = true } @@ -68,36 +71,70 @@ android { androidComponents { onVariants { variant -> + if (variant.buildType != "release") return@onVariants + + variant.outputs.forEach { output -> + val variantOutput = output as? VariantOutputImpl ?: return@forEach + val abi = variantOutput.filters.find { + it.filterType == FilterConfiguration.FilterType.ABI + }?.identifier ?: "universal" + val versionName = variantOutput.versionName.orNull ?: "unknown" + + variantOutput.outputFileName.set( + "monogram-$abi-$versionName-${variant.buildType}.apk" + ) + } + val apkDirProvider = variant.artifacts.get(SingleArtifact.APK) val artifactsLoader = variant.artifacts.getBuiltArtifactsLoader() - val renameTask = tasks.register("rename${variant.name.capitalize()}Apk") { + val capitalizedVariantName = variant.name.replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() + } + + val renameTask = tasks.register("rename${capitalizedVariantName}Apk") { inputs.dir(apkDirProvider) doLast { - val builtArtifacts = artifactsLoader.load(apkDirProvider.get())!! - val targetDir = apkDirProvider.get().asFile + val sourceDir = apkDirProvider.get().asFile + + val builtArtifacts = artifactsLoader.load(apkDirProvider.get()) ?: return@doLast + + val targetDir = project.layout.projectDirectory.dir("releases").asFile.apply { + mkdirs() + } + + targetDir.listFiles() + ?.filter { it.isFile && it.extension == "apk" && it.name.startsWith("monogram-") } + ?.forEach(File::delete) builtArtifacts.elements.forEach { artifact -> val abi = artifact.filters.find { it.filterType == FilterConfiguration.FilterType.ABI }?.identifier ?: "universal" + val versionName = artifact.versionName - val versionCode = artifact.versionCode val buildType = variant.buildType val originalApk = File(artifact.outputFile) + val targetFile = File( targetDir, - "monogram-$abi-${versionName}(${versionCode})-${buildType}.apk" + "monogram-$abi-$versionName-${buildType}.apk" ) originalApk.copyTo(targetFile, overwrite = true) } + + if (sourceDir == targetDir) { + builtArtifacts.elements + .map { File(it.outputFile) } + .forEach(File::delete) + } } } - project.tasks.matching { it.name == "assemble${variant.name.capitalize()}" }.configureEach { + project.tasks.matching { it.name == "assemble${capitalizedVariantName}" }.configureEach { finalizedBy(renameTask) } } From ca9c306b8db242147a174198956ff5a4538e8b5c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:55:45 +0300 Subject: [PATCH 22/83] fix media download event routing and message cache collisions --- .../datasource/cache/ChatLocalDataSource.kt | 12 +- .../cache/InMemoryChatLocalDataSource.kt | 49 +- .../cache/RoomChatLocalDataSource.kt | 27 +- .../remote/MessageRemoteDataSource.kt | 22 +- .../remote/TdMessageRemoteDataSource.kt | 156 ++++-- .../org/monogram/data/db/MonogramDatabase.kt | 40 +- .../monogram/data/db/MonogramMigrations.kt | 79 +++ .../org/monogram/data/db/dao/MessageDao.kt | 17 +- .../monogram/data/db/model/MessageEntity.kt | 4 +- .../java/org/monogram/data/di/dataModule.kt | 136 ++++- .../data/repository/MessageRepositoryImpl.kt | 41 +- .../monogram/domain/models/TransferEvents.kt | 59 ++ .../domain/repository/FileRepository.kt | 7 +- .../domain/repository/MessageRepository.kt | 16 +- .../presentation/core/ui/SettingsItem.kt | 27 +- .../chats/currentChat/impl/MessageLoading.kt | 526 ++++++++++-------- .../components/InstantViewComponents.kt | 44 +- .../profile/DefaultProfileComponent.kt | 43 +- .../webapp/components/InvoiceDialog.kt | 50 +- 19 files changed, 984 insertions(+), 371 deletions(-) create mode 100644 domain/src/main/java/org/monogram/domain/models/TransferEvents.kt diff --git a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt index 43f8fffe..17f66247 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt @@ -23,6 +23,7 @@ interface ChatLocalDataSource { suspend fun insertMessages(messages: List) suspend fun markAsRead(chatId: Long, upToMessageId: Long) suspend fun updateMessageContent( + chatId: Long, messageId: Long, content: String, contentType: String, @@ -34,8 +35,15 @@ interface ChatLocalDataSource { suspend fun updateMediaPath(fileId: Int, path: String) - suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int) - suspend fun deleteMessage(messageId: Long) + suspend fun updateInteractionInfo( + chatId: Long, + messageId: Long, + viewCount: Int, + forwardCount: Int, + replyCount: Int + ) + + suspend fun deleteMessage(chatId: Long, messageId: Long) suspend fun clearMessagesForChat(chatId: Long) suspend fun getChatFullInfo(chatId: Long): ChatFullInfoEntity? diff --git a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt index c591c47c..62bfda77 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt @@ -90,6 +90,7 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { } override suspend fun updateMessageContent( + chatId: Long, messageId: Long, content: String, contentType: String, @@ -98,18 +99,17 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { mediaPath: String?, editDate: Int ) { - messages.values.forEach { flow -> - val current = flow.value[messageId] ?: return@forEach - flow.update { - it + (messageId to current.copy( - content = content, - contentType = contentType, - contentMeta = contentMeta, - mediaFileId = mediaFileId, - mediaPath = mediaPath, - editDate = editDate - )) - } + val flow = messages[chatId] ?: return + val current = flow.value[messageId] ?: return + flow.update { + it + (messageId to current.copy( + content = content, + contentType = contentType, + contentMeta = contentMeta, + mediaFileId = mediaFileId, + mediaPath = mediaPath, + editDate = editDate + )) } } @@ -127,17 +127,26 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { } } - override suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int) { - messages.values.forEach { flow -> - val current = flow.value[messageId] ?: return@forEach - flow.update { - it + (messageId to current.copy(viewCount = viewCount, forwardCount = forwardCount, replyCount = replyCount)) - } + override suspend fun updateInteractionInfo( + chatId: Long, + messageId: Long, + viewCount: Int, + forwardCount: Int, + replyCount: Int + ) { + val flow = messages[chatId] ?: return + val current = flow.value[messageId] ?: return + flow.update { + it + (messageId to current.copy( + viewCount = viewCount, + forwardCount = forwardCount, + replyCount = replyCount + )) } } - override suspend fun deleteMessage(messageId: Long) { - messages.values.forEach { flow -> + override suspend fun deleteMessage(chatId: Long, messageId: Long) { + messages[chatId]?.let { flow -> if (flow.value.containsKey(messageId)) { flow.update { it - messageId } } diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt index 8f810893..d1fe7c99 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt @@ -55,6 +55,7 @@ class RoomChatLocalDataSource( override suspend fun markAsRead(chatId: Long, upToMessageId: Long) = messageDao.markAsRead(chatId, upToMessageId) override suspend fun updateMessageContent( + chatId: Long, messageId: Long, content: String, contentType: String, @@ -62,14 +63,30 @@ class RoomChatLocalDataSource( mediaFileId: Int, mediaPath: String?, editDate: Int - ) = messageDao.updateContent(messageId, content, contentType, contentMeta, mediaFileId, mediaPath, editDate) + ) = messageDao.updateContent( + chatId, + messageId, + content, + contentType, + contentMeta, + mediaFileId, + mediaPath, + editDate + ) override suspend fun updateMediaPath(fileId: Int, path: String) = messageDao.updateMediaPath(fileId, path) - override suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int) = - messageDao.updateInteractionInfo(messageId, viewCount, forwardCount, replyCount) - - override suspend fun deleteMessage(messageId: Long) = messageDao.deleteMessage(messageId) + override suspend fun updateInteractionInfo( + chatId: Long, + messageId: Long, + viewCount: Int, + forwardCount: Int, + replyCount: Int + ) = + messageDao.updateInteractionInfo(chatId, messageId, viewCount, forwardCount, replyCount) + + override suspend fun deleteMessage(chatId: Long, messageId: Long) = + messageDao.deleteMessage(chatId, messageId) override suspend fun clearMessagesForChat(chatId: Long) = messageDao.clearMessagesForChat(chatId) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt index 74100ccf..896ecf28 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt @@ -3,7 +3,16 @@ package org.monogram.data.datasource.remote import kotlinx.coroutines.flow.Flow import org.drinkless.tdlib.TdApi import org.monogram.data.datasource.remote.TdMessageRemoteDataSource.DownloadType -import org.monogram.domain.models.* +import org.monogram.domain.models.FileDownloadEvent +import org.monogram.domain.models.MessageDeletedEvent +import org.monogram.domain.models.MessageDownloadEvent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageIdUpdatedEvent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageUploadProgressEvent +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel import org.monogram.domain.repository.OlderMessagesPage @@ -11,15 +20,14 @@ import org.monogram.domain.repository.ReadUpdate import org.monogram.domain.repository.SearchChatMessagesResult interface MessageRemoteDataSource { + val fileDownloadFlow: Flow val newMessageFlow: Flow val messageEditedFlow: Flow val messageReadFlow: Flow - val messageUploadProgressFlow: Flow> - val messageDownloadProgressFlow: Flow> - val messageDownloadCancelledFlow: Flow - val messageDeletedFlow: Flow>> - val messageIdUpdateFlow: Flow> - val messageDownloadCompletedFlow: Flow> + val messageUploadProgressFlow: Flow + val messageDownloadFlow: Flow + val messageDeletedFlow: Flow + val messageIdUpdateFlow: Flow val pinnedMessageFlow: Flow val mediaUpdateFlow: Flow fun registerFileForMessage(fileId: Int, chatId: Long, messageId: Long) 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 4bdde980..6a2e37b9 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 @@ -2,10 +2,20 @@ package org.monogram.data.datasource.remote import android.os.Build import android.util.Log -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.chats.ChatCache @@ -15,10 +25,26 @@ import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.toApi -import org.monogram.domain.models.* +import org.monogram.domain.models.FileDownloadEvent +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageDeletedEvent +import org.monogram.domain.models.MessageDownloadEvent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.MessageIdUpdatedEvent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageUploadProgressEvent +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel -import org.monogram.domain.repository.* +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.OlderMessagesPage +import org.monogram.domain.repository.PollRepository +import org.monogram.domain.repository.ReadUpdate +import org.monogram.domain.repository.SearchChatMessagesResult +import org.monogram.domain.repository.UserRepository import java.util.concurrent.ConcurrentHashMap class TdMessageRemoteDataSource( @@ -45,18 +71,20 @@ class TdMessageRemoteDataSource( extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - override val messageUploadProgressFlow = MutableSharedFlow>() - override val messageDownloadProgressFlow = MutableSharedFlow>() - override val messageDownloadCancelledFlow = MutableSharedFlow() - override val messageDeletedFlow = MutableSharedFlow>>( + override val messageUploadProgressFlow = MutableSharedFlow() + override val fileDownloadFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val messageDownloadFlow = MutableSharedFlow() + override val messageDeletedFlow = MutableSharedFlow( extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - override val messageIdUpdateFlow = MutableSharedFlow>( + override val messageIdUpdateFlow = MutableSharedFlow( extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - override val messageDownloadCompletedFlow = MutableSharedFlow>() override val pinnedMessageFlow = MutableSharedFlow( extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST @@ -1181,7 +1209,13 @@ class TdMessageRemoteDataSource( scope.launch(dispatcherProvider.io) { try { val model = mapMessageToModel(message) - messageIdUpdateFlow.emit(Triple(message.chatId, update.oldMessageId, model)) + messageIdUpdateFlow.emit( + MessageIdUpdatedEvent( + chatId = message.chatId, + oldMessageId = update.oldMessageId, + message = model + ) + ) } catch (e: Exception) { Log.e("TdMessageRemote", "Error handling SendSucceeded", e) } } } @@ -1248,7 +1282,12 @@ class TdMessageRemoteDataSource( scope.launch(dispatcherProvider.io) { messageIds.forEach { cache.removeMessage(update.chatId, it) } removeMessagesFromCache(update.chatId, messageIds) - messageDeletedFlow.emit(update.chatId to messageIds) + messageDeletedFlow.emit( + MessageDeletedEvent( + chatId = update.chatId, + messageIds = messageIds + ) + ) } } } @@ -1364,6 +1403,20 @@ class TdMessageRemoteDataSource( if (isDC) { fileDownloadQueue.notifyDownloadComplete(file.id) lastProgressMap.remove(file.id) + scope.launch { + fileDownloadFlow.emit( + FileDownloadEvent.Completed( + fileId = file.id, + path = file.local?.path ?: "" + ) + ) + fileDownloadFlow.emit( + FileDownloadEvent.Progress( + fileId = file.id, + progress = 1.0f + ) + ) + } fileUpdateHandler.fileIdToCustomEmojiId[file.id]?.let { customEmojiId -> fileUpdateHandler.customEmojiPaths[customEmojiId] = file.local?.path ?: "" } @@ -1371,20 +1424,26 @@ class TdMessageRemoteDataSource( val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { scope.launch { - entries.forEach { (_, messageId) -> - messageDownloadCompletedFlow.emit( - Triple(messageId, file.id, file.local?.path ?: "") + entries.forEach { (chatId, messageId) -> + messageDownloadFlow.emit( + MessageDownloadEvent.Completed( + chatId = chatId, + messageId = messageId, + fileId = file.id, + path = file.local?.path ?: "" + ) + ) + messageDownloadFlow.emit( + MessageDownloadEvent.Progress( + chatId = chatId, + messageId = messageId, + fileId = file.id, + progress = 1.0f + ) ) - messageDownloadProgressFlow.emit(messageId to 1.0f) } } } else if (fileDownloadQueue.registry.standaloneFileIds.contains(file.id)) { - scope.launch { - messageDownloadCompletedFlow.emit( - Triple(file.id.toLong(), file.id, file.local?.path ?: "") - ) - messageDownloadProgressFlow.emit(file.id.toLong() to 1.0f) - } fileDownloadQueue.registry.standaloneFileIds.remove(file.id) } updateMessageWithFile(file.id) @@ -1394,15 +1453,28 @@ class TdMessageRemoteDataSource( val pInt = (p * 100).toInt() if (lastProgressMap[file.id] != pInt) { lastProgressMap[file.id] = pInt + scope.launch { + fileDownloadFlow.emit( + FileDownloadEvent.Progress( + fileId = file.id, + progress = p + ) + ) + } val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { scope.launch { - entries.forEach { (_, messageId) -> - messageDownloadProgressFlow.emit(messageId to p) + entries.forEach { (chatId, messageId) -> + messageDownloadFlow.emit( + MessageDownloadEvent.Progress( + chatId = chatId, + messageId = messageId, + fileId = file.id, + progress = p + ) + ) } } - } else if (fileDownloadQueue.registry.standaloneFileIds.contains(file.id)) { - scope.launch { messageDownloadProgressFlow.emit(file.id.toLong() to p) } } } } else if (isCancelled) { @@ -1411,12 +1483,16 @@ class TdMessageRemoteDataSource( val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { scope.launch { - entries.forEach { (_, messageId) -> - messageDownloadCancelledFlow.emit(messageId) + entries.forEach { (chatId, messageId) -> + messageDownloadFlow.emit( + MessageDownloadEvent.Cancelled( + chatId = chatId, + messageId = messageId, + fileId = file.id + ) + ) } } - } else if (fileDownloadQueue.registry.standaloneFileIds.contains(file.id)) { - scope.launch { messageDownloadCancelledFlow.emit(file.id.toLong()) } } } @@ -1426,8 +1502,15 @@ class TdMessageRemoteDataSource( val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { scope.launch { - entries.forEach { (_, messageId) -> - messageUploadProgressFlow.emit(messageId to 1.0f) + entries.forEach { (chatId, messageId) -> + messageUploadProgressFlow.emit( + MessageUploadProgressEvent( + chatId = chatId, + messageId = messageId, + fileId = file.id, + progress = 1.0f + ) + ) } } } @@ -1441,8 +1524,15 @@ class TdMessageRemoteDataSource( val entries = fileIdToMessageMap[file.id] if (!entries.isNullOrEmpty()) { scope.launch { - entries.forEach { (_, messageId) -> - messageUploadProgressFlow.emit(messageId to p) + entries.forEach { (chatId, messageId) -> + messageUploadProgressFlow.emit( + MessageUploadProgressEvent( + chatId = chatId, + messageId = messageId, + fileId = file.id, + progress = p + ) + ) } } } diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index 7dbbe36c..bb6ec3bd 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -2,8 +2,42 @@ package org.monogram.data.db import androidx.room.Database import androidx.room.RoomDatabase -import org.monogram.data.db.dao.* -import org.monogram.data.db.model.* +import org.monogram.data.db.dao.AttachBotDao +import org.monogram.data.db.dao.ChatDao +import org.monogram.data.db.dao.ChatFolderDao +import org.monogram.data.db.dao.ChatFullInfoDao +import org.monogram.data.db.dao.KeyValueDao +import org.monogram.data.db.dao.MessageDao +import org.monogram.data.db.dao.NotificationExceptionDao +import org.monogram.data.db.dao.NotificationSettingDao +import org.monogram.data.db.dao.RecentEmojiDao +import org.monogram.data.db.dao.SearchHistoryDao +import org.monogram.data.db.dao.SponsorDao +import org.monogram.data.db.dao.StickerPathDao +import org.monogram.data.db.dao.StickerSetDao +import org.monogram.data.db.dao.TextCompositionStyleDao +import org.monogram.data.db.dao.TopicDao +import org.monogram.data.db.dao.UserDao +import org.monogram.data.db.dao.UserFullInfoDao +import org.monogram.data.db.dao.WallpaperDao +import org.monogram.data.db.model.AttachBotEntity +import org.monogram.data.db.model.ChatEntity +import org.monogram.data.db.model.ChatFolderEntity +import org.monogram.data.db.model.ChatFullInfoEntity +import org.monogram.data.db.model.KeyValueEntity +import org.monogram.data.db.model.MessageEntity +import org.monogram.data.db.model.NotificationExceptionEntity +import org.monogram.data.db.model.NotificationSettingEntity +import org.monogram.data.db.model.RecentEmojiEntity +import org.monogram.data.db.model.SearchHistoryEntity +import org.monogram.data.db.model.SponsorEntity +import org.monogram.data.db.model.StickerPathEntity +import org.monogram.data.db.model.StickerSetEntity +import org.monogram.data.db.model.TextCompositionStyleEntity +import org.monogram.data.db.model.TopicEntity +import org.monogram.data.db.model.UserEntity +import org.monogram.data.db.model.UserFullInfoEntity +import org.monogram.data.db.model.WallpaperEntity @Database( entities = [ @@ -26,7 +60,7 @@ import org.monogram.data.db.model.* SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 29, + version = 30, exportSchema = false ) abstract class MonogramDatabase : RoomDatabase() { diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index fa64968f..0accd23f 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -186,6 +186,85 @@ object MonogramMigrations { } } + val MIGRATION_29_30 = object : Migration(29, 30) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `messages_new` ( + `id` INTEGER NOT NULL, + `chatId` INTEGER NOT NULL, + `senderId` INTEGER NOT NULL, + `senderName` TEXT NOT NULL, + `content` TEXT NOT NULL, + `contentType` TEXT NOT NULL, + `contentMeta` TEXT, + `mediaFileId` INTEGER NOT NULL, + `mediaPath` TEXT, + `mediaThumbnailPath` TEXT, + `minithumbnail` BLOB, + `date` INTEGER NOT NULL, + `isOutgoing` INTEGER NOT NULL, + `isRead` INTEGER NOT NULL, + `replyToMessageId` INTEGER NOT NULL, + `replyToPreview` TEXT, + `replyToPreviewType` TEXT, + `replyToPreviewText` TEXT, + `replyToPreviewSenderName` TEXT, + `replyCount` INTEGER NOT NULL, + `forwardFromName` TEXT, + `forwardFromId` INTEGER NOT NULL, + `forwardOriginChatId` INTEGER, + `forwardOriginMessageId` INTEGER, + `forwardDate` INTEGER NOT NULL, + `editDate` INTEGER NOT NULL, + `mediaAlbumId` INTEGER NOT NULL, + `entities` TEXT, + `viewCount` INTEGER NOT NULL, + `forwardCount` INTEGER NOT NULL, + `createdAt` INTEGER NOT NULL, + PRIMARY KEY(`chatId`, `id`) + ) + """.trimIndent() + ) + + db.execSQL( + """ + INSERT INTO `messages_new` ( + `id`, `chatId`, `senderId`, `senderName`, `content`, `contentType`, `contentMeta`, + `mediaFileId`, `mediaPath`, `mediaThumbnailPath`, `minithumbnail`, `date`, + `isOutgoing`, `isRead`, `replyToMessageId`, `replyToPreview`, `replyToPreviewType`, + `replyToPreviewText`, `replyToPreviewSenderName`, `replyCount`, `forwardFromName`, + `forwardFromId`, `forwardOriginChatId`, `forwardOriginMessageId`, `forwardDate`, + `editDate`, `mediaAlbumId`, `entities`, `viewCount`, `forwardCount`, `createdAt` + ) + SELECT + `id`, `chatId`, `senderId`, `senderName`, `content`, `contentType`, `contentMeta`, + `mediaFileId`, `mediaPath`, `mediaThumbnailPath`, `minithumbnail`, `date`, + `isOutgoing`, `isRead`, `replyToMessageId`, `replyToPreview`, `replyToPreviewType`, + `replyToPreviewText`, `replyToPreviewSenderName`, `replyCount`, `forwardFromName`, + `forwardFromId`, `forwardOriginChatId`, `forwardOriginMessageId`, `forwardDate`, + `editDate`, `mediaAlbumId`, `entities`, `viewCount`, `forwardCount`, `createdAt` + FROM `messages` + """.trimIndent() + ) + + db.execSQL( + """ + UPDATE `messages_new` + SET `mediaPath` = NULL + WHERE `mediaPath` IS NOT NULL + AND `contentType` IN ('photo', 'video', 'video_note', 'document', 'gif', 'voice', 'sticker', 'audio') + """.trimIndent() + ) + + db.execSQL("DROP TABLE `messages`") + db.execSQL("ALTER TABLE `messages_new` RENAME TO `messages`") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_messages_chatId_date` ON `messages` (`chatId`, `date`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_messages_chatId_id` ON `messages` (`chatId`, `id`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_messages_createdAt` ON `messages` (`createdAt`)") + } + } + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") } diff --git a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt index 520e3da3..df8a231f 100644 --- a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt @@ -25,9 +25,10 @@ interface MessageDao { suspend fun markAsRead(chatId: Long, upToMessageId: Long) @Query( - "UPDATE messages SET content = :content, contentType = :contentType, contentMeta = :contentMeta, mediaFileId = :mediaFileId, mediaPath = :mediaPath, editDate = :editDate WHERE id = :messageId" + "UPDATE messages SET content = :content, contentType = :contentType, contentMeta = :contentMeta, mediaFileId = :mediaFileId, mediaPath = :mediaPath, editDate = :editDate WHERE chatId = :chatId AND id = :messageId" ) suspend fun updateContent( + chatId: Long, messageId: Long, content: String, contentType: String, @@ -40,8 +41,14 @@ interface MessageDao { @Query("UPDATE messages SET mediaPath = :path WHERE mediaFileId = :fileId AND mediaFileId != 0") suspend fun updateMediaPath(fileId: Int, path: String) - @Query("UPDATE messages SET viewCount = :viewCount, forwardCount = :forwardCount, replyCount = :replyCount WHERE id = :messageId") - suspend fun updateInteractionInfo(messageId: Long, viewCount: Int, forwardCount: Int, replyCount: Int) + @Query("UPDATE messages SET viewCount = :viewCount, forwardCount = :forwardCount, replyCount = :replyCount WHERE chatId = :chatId AND id = :messageId") + suspend fun updateInteractionInfo( + chatId: Long, + messageId: Long, + viewCount: Int, + forwardCount: Int, + replyCount: Int + ) @Query( """ @@ -61,8 +68,8 @@ interface MessageDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMessages(messages: List) - @Query("DELETE FROM messages WHERE id = :messageId") - suspend fun deleteMessage(messageId: Long) + @Query("DELETE FROM messages WHERE chatId = :chatId AND id = :messageId") + suspend fun deleteMessage(chatId: Long, messageId: Long) @Query("DELETE FROM messages WHERE chatId = :chatId") suspend fun clearMessagesForChat(chatId: Long) diff --git a/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt b/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt index 8cf3fb5c..a1b5cf25 100644 --- a/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/MessageEntity.kt @@ -2,10 +2,10 @@ package org.monogram.data.db.model import androidx.room.Entity import androidx.room.Index -import androidx.room.PrimaryKey @Entity( tableName = "messages", + primaryKeys = ["chatId", "id"], indices = [ Index(value = ["chatId", "date"]), Index(value = ["chatId", "id"]), @@ -13,7 +13,7 @@ import androidx.room.PrimaryKey ] ) data class MessageEntity( - @PrimaryKey val id: Long, + val id: Long, val chatId: Long, val senderId: Long, val senderName: String = "", 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 74b47f39..74729457 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -14,23 +14,144 @@ import org.monogram.data.chats.ChatCache import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.PlayerDataSourceFactoryImpl import org.monogram.data.datasource.TdFileDataSource -import org.monogram.data.datasource.cache.* -import org.monogram.data.datasource.remote.* +import org.monogram.data.datasource.cache.ChatLocalDataSource +import org.monogram.data.datasource.cache.ChatsCacheDataSource +import org.monogram.data.datasource.cache.InMemorySettingsCacheDataSource +import org.monogram.data.datasource.cache.RoomChatLocalDataSource +import org.monogram.data.datasource.cache.RoomStickerLocalDataSource +import org.monogram.data.datasource.cache.RoomUserLocalDataSource +import org.monogram.data.datasource.cache.SettingsCacheDataSource +import org.monogram.data.datasource.cache.StickerLocalDataSource +import org.monogram.data.datasource.cache.UserCacheDataSource +import org.monogram.data.datasource.cache.UserLocalDataSource +import org.monogram.data.datasource.remote.AuthRemoteDataSource +import org.monogram.data.datasource.remote.ChatRemoteSource +import org.monogram.data.datasource.remote.ChatsRemoteDataSource +import org.monogram.data.datasource.remote.EmojiRemoteSource +import org.monogram.data.datasource.remote.ExternalProxyDataSource +import org.monogram.data.datasource.remote.GifRemoteSource +import org.monogram.data.datasource.remote.HttpExternalProxyDataSource +import org.monogram.data.datasource.remote.LinkRemoteDataSource +import org.monogram.data.datasource.remote.MessageFileApi +import org.monogram.data.datasource.remote.MessageFileCoordinator +import org.monogram.data.datasource.remote.MessageRemoteDataSource +import org.monogram.data.datasource.remote.NominatimRemoteDataSource +import org.monogram.data.datasource.remote.PrivacyRemoteDataSource +import org.monogram.data.datasource.remote.ProxyRemoteDataSource +import org.monogram.data.datasource.remote.SettingsRemoteDataSource +import org.monogram.data.datasource.remote.StickerRemoteSource +import org.monogram.data.datasource.remote.TdAuthRemoteDataSource +import org.monogram.data.datasource.remote.TdChatRemoteSource +import org.monogram.data.datasource.remote.TdChatsRemoteDataSource +import org.monogram.data.datasource.remote.TdEmojiRemoteSource +import org.monogram.data.datasource.remote.TdGifRemoteSource +import org.monogram.data.datasource.remote.TdLinkRemoteDataSource +import org.monogram.data.datasource.remote.TdMessageRemoteDataSource +import org.monogram.data.datasource.remote.TdPrivacyRemoteDataSource +import org.monogram.data.datasource.remote.TdProxyRemoteDataSource +import org.monogram.data.datasource.remote.TdSettingsRemoteDataSource +import org.monogram.data.datasource.remote.TdStickerRemoteSource +import org.monogram.data.datasource.remote.TdUpdateRemoteDataSource +import org.monogram.data.datasource.remote.TdUserRemoteDataSource +import org.monogram.data.datasource.remote.UpdateRemoteDateSource +import org.monogram.data.datasource.remote.UserRemoteDataSource import org.monogram.data.db.MonogramDatabase import org.monogram.data.db.MonogramMigrations import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.TelegramGatewayImpl import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.gateway.UpdateDispatcherImpl -import org.monogram.data.infra.* -import org.monogram.data.mapper.* +import org.monogram.data.infra.AndroidStringProvider +import org.monogram.data.infra.ConnectionManager +import org.monogram.data.infra.DataMemoryDiagnostics +import org.monogram.data.infra.DataMemoryPressureHandler +import org.monogram.data.infra.DefaultDispatcherProvider +import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileMessageRegistry +import org.monogram.data.infra.FileUpdateHandler +import org.monogram.data.infra.OfflineWarmup +import org.monogram.data.infra.SponsorSyncManager +import org.monogram.data.infra.TdLibParametersProvider +import org.monogram.data.mapper.ChatMapper +import org.monogram.data.mapper.CustomEmojiLoader +import org.monogram.data.mapper.MessageMapper +import org.monogram.data.mapper.NetworkMapper +import org.monogram.data.mapper.StorageMapper +import org.monogram.data.mapper.TdFileHelper +import org.monogram.data.mapper.WebPageMapper import org.monogram.data.mapper.message.MessageContentMapper import org.monogram.data.mapper.message.MessagePersistenceMapper import org.monogram.data.mapper.message.MessageSenderResolver -import org.monogram.data.repository.* +import org.monogram.data.repository.AttachMenuBotRepositoryImpl +import org.monogram.data.repository.AuthRepositoryImpl +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.EmojiRepositoryImpl +import org.monogram.data.repository.ExternalProxyRepositoryImpl +import org.monogram.data.repository.GifRepositoryImpl +import org.monogram.data.repository.LinkHandlerRepositoryImpl +import org.monogram.data.repository.LinkParser +import org.monogram.data.repository.LocationRepositoryImpl +import org.monogram.data.repository.MessageRepositoryImpl +import org.monogram.data.repository.NetworkStatisticsRepositoryImpl +import org.monogram.data.repository.NotificationSettingsRepositoryImpl +import org.monogram.data.repository.PollRepositoryImpl +import org.monogram.data.repository.PremiumRepositoryImpl +import org.monogram.data.repository.PrivacyRepositoryImpl +import org.monogram.data.repository.ProfilePhotoRepositoryImpl +import org.monogram.data.repository.SessionRepositoryImpl +import org.monogram.data.repository.SponsorRepositoryImpl +import org.monogram.data.repository.StickerRepositoryImpl +import org.monogram.data.repository.StorageRepositoryImpl +import org.monogram.data.repository.StreamingRepositoryImpl +import org.monogram.data.repository.UpdateRepositoryImpl +import org.monogram.data.repository.UserProfileEditRepositoryImpl +import org.monogram.data.repository.WallpaperRepositoryImpl import org.monogram.data.repository.user.UserRepositoryImpl import org.monogram.data.stickers.StickerFileManager -import org.monogram.domain.repository.* +import org.monogram.domain.repository.AttachMenuBotRepository +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.ChatCreationRepository +import org.monogram.domain.repository.ChatEventLogRepository +import org.monogram.domain.repository.ChatFolderRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +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.EmojiRepository +import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.FileRepository +import org.monogram.domain.repository.ForumTopicsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.InlineBotRepository +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.MessageAiRepository +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.NetworkStatisticsRepository +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PlayerDataSourceFactory +import org.monogram.domain.repository.PollRepository +import org.monogram.domain.repository.PremiumRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.SessionRepository +import org.monogram.domain.repository.SponsorRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StreamingRepository +import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.UpdateRepository +import org.monogram.domain.repository.UserProfileEditRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WallpaperRepository +import org.monogram.domain.repository.WebAppRepository val dataModule = module { single { CoroutineScope(SupervisorJob() + get().default) } @@ -128,7 +249,8 @@ val dataModule = module { .addMigrations( MonogramMigrations.MIGRATION_26_27, MonogramMigrations.MIGRATION_27_28, - MonogramMigrations.MIGRATION_28_29 + MonogramMigrations.MIGRATION_28_29, + MonogramMigrations.MIGRATION_29_30 ) .fallbackToDestructiveMigration(dropAllTables = true) .build() 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 5f03d3e0..5a552b51 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -3,7 +3,12 @@ package org.monogram.data.repository import android.content.Context import android.util.Log import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.drinkless.tdlib.TdApi @@ -23,12 +28,31 @@ import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.map import org.monogram.data.mapper.toDomain -import org.monogram.domain.models.* +import org.monogram.domain.models.ChatEventActionModel +import org.monogram.domain.models.ChatEventLogFiltersModel +import org.monogram.domain.models.ChatEventModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.FileModel +import org.monogram.domain.models.InlineQueryResultModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageSenderModel +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.InstantViewModel import org.monogram.domain.models.webapp.InvoiceModel import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel -import org.monogram.domain.repository.* +import org.monogram.domain.repository.FixedTextResult +import org.monogram.domain.repository.FormattedTextResult +import org.monogram.domain.repository.InlineBotResultsModel +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.OlderMessagesPage +import org.monogram.domain.repository.ProfileMediaFilter +import org.monogram.domain.repository.SearchChatMessagesResult +import org.monogram.domain.repository.TextCompositionStyleModel import java.io.File class MessageRepositoryImpl( @@ -53,10 +77,9 @@ class MessageRepositoryImpl( override val senderUpdateFlow = messageMapper.senderUpdateFlow override val messageEditedFlow = messageRemoteDataSource.messageEditedFlow override val messageUploadProgressFlow = messageRemoteDataSource.messageUploadProgressFlow - override val messageDownloadProgressFlow = messageRemoteDataSource.messageDownloadProgressFlow - override val messageDownloadCancelledFlow = messageRemoteDataSource.messageDownloadCancelledFlow + override val fileDownloadFlow = messageRemoteDataSource.fileDownloadFlow + override val messageDownloadFlow = messageRemoteDataSource.messageDownloadFlow override val messageReadFlow = messageRemoteDataSource.messageReadFlow - override val messageDownloadCompletedFlow = messageRemoteDataSource.messageDownloadCompletedFlow override val messageDeletedFlow = messageRemoteDataSource.messageDeletedFlow override val messageIdUpdateFlow = messageRemoteDataSource.messageIdUpdateFlow override val pinnedMessageFlow = messageRemoteDataSource.pinnedMessageFlow @@ -128,6 +151,7 @@ class MessageRepositoryImpl( } chatLocalDataSource.updateMessageContent( + chatId = update.chatId, messageId = update.messageId, content = extracted.text, contentType = extracted.type, @@ -152,6 +176,7 @@ class MessageRepositoryImpl( is TdApi.UpdateMessageInteractionInfo -> { chatLocalDataSource.updateInteractionInfo( + chatId = update.chatId, messageId = update.messageId, viewCount = update.interactionInfo?.viewCount ?: 0, forwardCount = update.interactionInfo?.forwardCount ?: 0, @@ -166,7 +191,7 @@ class MessageRepositoryImpl( is TdApi.UpdateDeleteMessages -> { if (update.isPermanent) { update.messageIds.forEach { messageId -> - chatLocalDataSource.deleteMessage(messageId) + chatLocalDataSource.deleteMessage(update.chatId, messageId) } } } @@ -350,7 +375,7 @@ class MessageRepositoryImpl( override suspend fun deleteMessage(chatId: Long, messageIds: List, revoke: Boolean) { messageRemoteDataSource.deleteMessages(chatId, messageIds.toLongArray(), revoke) - messageIds.forEach { chatLocalDataSource.deleteMessage(it) } + messageIds.forEach { chatLocalDataSource.deleteMessage(chatId, it) } } override suspend fun editMessage(chatId: Long, messageId: Long, newText: String, entities: List) { diff --git a/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt b/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt new file mode 100644 index 00000000..effcdc27 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/TransferEvents.kt @@ -0,0 +1,59 @@ +package org.monogram.domain.models + +sealed interface FileDownloadEvent { + val fileId: Int + + data class Progress( + override val fileId: Int, + val progress: Float + ) : FileDownloadEvent + + data class Completed( + override val fileId: Int, + val path: String + ) : FileDownloadEvent +} + +sealed interface MessageDownloadEvent { + val chatId: Long + val messageId: Long + val fileId: Int + + data class Progress( + override val chatId: Long, + override val messageId: Long, + override val fileId: Int, + val progress: Float + ) : MessageDownloadEvent + + data class Completed( + override val chatId: Long, + override val messageId: Long, + override val fileId: Int, + val path: String + ) : MessageDownloadEvent + + data class Cancelled( + override val chatId: Long, + override val messageId: Long, + override val fileId: Int + ) : MessageDownloadEvent +} + +data class MessageUploadProgressEvent( + val chatId: Long, + val messageId: Long, + val fileId: Int, + val progress: Float +) + +data class MessageDeletedEvent( + val chatId: Long, + val messageIds: List +) + +data class MessageIdUpdatedEvent( + val chatId: Long, + val oldMessageId: Long, + val message: MessageModel +) diff --git a/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt index dc00f8a4..4de949c8 100644 --- a/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/FileRepository.kt @@ -1,12 +1,13 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.FileModel +import org.monogram.domain.models.MessageDownloadEvent interface FileRepository { - val messageDownloadProgressFlow: Flow> - val messageDownloadCancelledFlow: Flow - val messageDownloadCompletedFlow: Flow> + val fileDownloadFlow: Flow + val messageDownloadFlow: Flow fun downloadFile( fileId: Int, diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt index e4667b98..dae5b713 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -1,7 +1,15 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow -import org.monogram.domain.models.* +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.MessageDeletedEvent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageIdUpdatedEvent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageUploadProgressEvent +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.InstantViewModel sealed interface ReadUpdate { @@ -40,10 +48,10 @@ interface MessageRepository : val newMessageFlow: Flow val senderUpdateFlow: Flow val messageReadFlow: Flow - val messageUploadProgressFlow: Flow> - val messageDeletedFlow: Flow>> + val messageUploadProgressFlow: Flow + val messageDeletedFlow: Flow val messageEditedFlow: Flow - val messageIdUpdateFlow: Flow> + val messageIdUpdateFlow: Flow val pinnedMessageFlow: Flow val mediaUpdateFlow: Flow suspend fun getHighResFileId(chatId: Long, messageId: Long): Int? diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt index 085f12e4..8004a0cb 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/SettingsItem.kt @@ -3,14 +3,28 @@ package org.monogram.presentation.core.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -23,7 +37,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import org.koin.compose.koinInject +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.FileModel import org.monogram.domain.repository.FileRepository import java.io.File @@ -95,9 +111,10 @@ fun SettingsItem( LaunchedEffect(icon.id) { if (localPath.isEmpty() || !File(localPath).exists()) { fileRepository.downloadFile(icon.id, 32) - fileRepository.messageDownloadCompletedFlow - .filter { it.first == icon.id.toLong() } - .collect { (_, _, completedPath) -> localPath = completedPath } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == icon.id } + .collect { completed -> localPath = completed.path } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt index 301d7f2c..8bdc5c11 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt @@ -1,12 +1,21 @@ package org.monogram.presentation.features.chats.currentChat.impl import android.util.Log -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock -import org.monogram.domain.models.* +import kotlinx.coroutines.withContext +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageDownloadEvent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageReactionModel +import org.monogram.domain.models.MessageSendingState +import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ReadUpdate import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent @@ -580,7 +589,10 @@ internal fun DefaultChatComponent.setupMessageCollectors() { .launchIn(scope) repositoryMessage.messageIdUpdateFlow - .onEach { (cId, oldId, newMessage) -> + .onEach { event -> + val cId = event.chatId + val oldId = event.oldMessageId + val newMessage = event.message if (cId == chatId) { if (oldId != newMessage.id) { remappedMessageIds[oldId] = newMessage.id @@ -636,7 +648,10 @@ internal fun DefaultChatComponent.setupMessageCollectors() { .launchIn(scope) repositoryMessage.messageUploadProgressFlow - .onEach { (messageId, progress) -> + .onEach { event -> + if (event.chatId != chatId) return@onEach + val messageId = event.messageId + val progress = event.progress updateMessageContent(messageId) { message -> val isUploading = progress < 1f && message.sendingState is MessageSendingState.Pending val newSendingState = if (progress >= 1f) null else message.sendingState @@ -655,266 +670,303 @@ internal fun DefaultChatComponent.setupMessageCollectors() { } .launchIn(scope) - repositoryMessage.messageDownloadProgressFlow - .onEach { (messageId, progress) -> - updateMessageContent(messageId) { message -> - val isDownloading = progress < 1f - val newContent = when (val content = message.content) { - is MessageContent.Photo -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) - - is MessageContent.Video -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) - - is MessageContent.VideoNote -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) - - is MessageContent.Document -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) - - is MessageContent.Gif -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) - - is MessageContent.Voice -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) - - is MessageContent.Sticker -> content.copy( - isDownloading = isDownloading, - downloadProgress = progress, - downloadError = false - ) + repositoryMessage.messageDownloadFlow + .onEach { event -> + when (event) { + is MessageDownloadEvent.Progress -> { + if (event.chatId != chatId) return@onEach + updateMessageContent(event.messageId) { message -> + val isDownloading = event.progress < 1f + val newContent = when (val content = message.content) { + is MessageContent.Photo -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - else -> content - } - message.copy(content = newContent) - } - } - .launchIn(scope) + is MessageContent.Video -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - repositoryMessage.messageDownloadCancelledFlow - .onEach { messageId -> - var cancelledFileId = 0 - updateMessageContent(messageId) { message -> - val newContent = when (val content = message.content) { - is MessageContent.Photo -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.VideoNote -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Video -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Document -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.VideoNote -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Gif -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Document -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Voice -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Gif -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageContent.Sticker -> content.copy( + isDownloading = isDownloading, + downloadProgress = event.progress, + downloadError = false + ) - is MessageContent.Voice -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) + else -> content + } + message.copy(content = newContent) } + } - is MessageContent.Sticker -> { - cancelledFileId = content.fileId - content.copy( - isDownloading = false, - downloadProgress = 0f, - downloadError = false - ) - } + is MessageDownloadEvent.Cancelled -> { + if (event.chatId != chatId) return@onEach + var cancelledFileId = 0 + updateMessageContent(event.messageId) { message -> + val newContent = when (val content = message.content) { + is MessageContent.Photo -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - else -> content - } - message.copy(content = newContent) - } - AutoDownloadSuppression.suppress(cancelledFileId) - if (cancelledFileId != 0) { - mediaDownloadRetryCount.remove(cancelledFileId) - } - } - .launchIn(scope) + is MessageContent.Video -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - repositoryMessage.messageDownloadCompletedFlow - .onEach { (messageId, downloadedFileId, path) -> - var fileIdToRetry: Int? = null - var mainFileId = 0 - var mainPathUpdated = false + is MessageContent.VideoNote -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - updateMessageContent(messageId) { message -> - val isError = path.isEmpty() - val finalPath = path.ifEmpty { null } + is MessageContent.Document -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - val newContent = when (val content = message.content) { - is MessageContent.Photo -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - if (finalPath != null) content.copy(thumbnailPath = finalPath) else content - } - } + is MessageContent.Gif -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.Video -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - if (finalPath != null) content.copy(thumbnailPath = finalPath) else content - } - } + is MessageContent.Voice -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.VideoNote -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content - } - } + is MessageContent.Sticker -> { + cancelledFileId = content.fileId + content.copy( + isDownloading = false, + downloadProgress = 0f, + downloadError = false + ) + } - is MessageContent.Document -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content + else -> content } + message.copy(content = newContent) } - - is MessageContent.Gif -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content - } + AutoDownloadSuppression.suppress(cancelledFileId) + if (cancelledFileId != 0) { + mediaDownloadRetryCount.remove(cancelledFileId) } + } - is MessageContent.Voice -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content - } - } + is MessageDownloadEvent.Completed -> { + if (event.chatId != chatId) return@onEach + val messageId = event.messageId + val downloadedFileId = event.fileId + val path = event.path + var fileIdToRetry: Int? = null + var mainFileId = 0 + var mainPathUpdated = false + + updateMessageContent(messageId) { message -> + val isError = path.isEmpty() + val finalPath = path.ifEmpty { null } + + val newContent = when (val content = message.content) { + is MessageContent.Photo -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + if (finalPath != null) content.copy(thumbnailPath = finalPath) else content + } + } - is MessageContent.Sticker -> { - if (downloadedFileId == content.fileId) { - mainFileId = content.fileId - mainPathUpdated = true - if (isError) fileIdToRetry = content.fileId - content.copy(path = finalPath, isDownloading = false, downloadError = isError) - } else { - content + is MessageContent.Video -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + if (finalPath != null) content.copy(thumbnailPath = finalPath) else content + } + } + + is MessageContent.VideoNote -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } + + is MessageContent.Document -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } + + is MessageContent.Gif -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } + + is MessageContent.Voice -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } + + is MessageContent.Sticker -> { + if (downloadedFileId == content.fileId) { + mainFileId = content.fileId + mainPathUpdated = true + if (isError) fileIdToRetry = content.fileId + content.copy( + path = finalPath, + isDownloading = false, + downloadError = isError + ) + } else { + content + } + } + + else -> content } + message.copy(content = newContent) } - else -> content - } - message.copy(content = newContent) - } - - if (path.isNotEmpty() && mainFileId != 0) { - AutoDownloadSuppression.clear(mainFileId) - mediaDownloadRetryCount.remove(mainFileId) - } + if (path.isNotEmpty() && mainFileId != 0) { + AutoDownloadSuppression.clear(mainFileId) + mediaDownloadRetryCount.remove(mainFileId) + } - if (mainPathUpdated && path.isNotEmpty()) { - updateFullScreenImagePath(messageId, path) - } + if (mainPathUpdated && path.isNotEmpty()) { + updateFullScreenImagePath(messageId, path) + } - if (path.isNotEmpty() && messageId in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { - updateInlineResultsWithFile(messageId.toInt(), path) - } + if (path.isNotEmpty() && messageId in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong()) { + updateInlineResultsWithFile(messageId.toInt(), path) + } - if (path.isNotEmpty() && messageId == downloadedFileId.toLong()) { - refreshCachedSenderProfiles(_state.value.messages) - } + if (path.isNotEmpty() && messageId == downloadedFileId.toLong()) { + refreshCachedSenderProfiles(_state.value.messages) + } - fileIdToRetry?.let { - if (it != 0) { - val suppressed = AutoDownloadSuppression.isSuppressed(it) - if (!suppressed) { - val attempts = (mediaDownloadRetryCount[it] ?: 0) + 1 - mediaDownloadRetryCount[it] = attempts - if (attempts <= MAX_DOWNLOAD_RETRIES) { - onDownloadFile(it) - } else { - AutoDownloadSuppression.suppress(it) - Log.w( - "DownloadDebug", - "retryLimitReached: fileId=$it attempts=$attempts chatId=$chatId" - ) + fileIdToRetry?.let { + if (it != 0) { + val suppressed = AutoDownloadSuppression.isSuppressed(it) + if (!suppressed) { + val attempts = (mediaDownloadRetryCount[it] ?: 0) + 1 + mediaDownloadRetryCount[it] = attempts + if (attempts <= MAX_DOWNLOAD_RETRIES) { + onDownloadFile(it) + } else { + AutoDownloadSuppression.suppress(it) + Log.w( + "DownloadDebug", + "retryLimitReached: fileId=$it attempts=$attempts chatId=$chatId" + ) + } + } else { + Log.d( + "DownloadDebug", + "retrySkippedBySuppression: fileId=$it chatId=$chatId" + ) + } } - } else { - Log.d("DownloadDebug", "retrySkippedBySuppression: fileId=$it chatId=$chatId") } } } @@ -922,7 +974,9 @@ internal fun DefaultChatComponent.setupMessageCollectors() { .launchIn(scope) repositoryMessage.messageDeletedFlow - .onEach { (cId, messageIds) -> + .onEach { event -> + val cId = event.chatId + val messageIds = event.messageIds if (cId == chatId) { messageIds.forEach(reactionUpdateSuppressedUntil::remove) messageIds.forEach(remappedMessageIds::remove) diff --git a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt index 18fae7d5..08666249 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/instantview/components/InstantViewComponents.kt @@ -1,13 +1,23 @@ package org.monogram.presentation.features.instantview.components import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -22,10 +32,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isUnspecified import coil3.compose.AsyncImage import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.webapp.PageBlockCaption import org.monogram.domain.models.webapp.RichText import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer @@ -94,15 +106,17 @@ fun AsyncImageWithDownload( fileRepository.downloadFile(fileId) val progressJob = launch { - fileRepository.messageDownloadProgressFlow - .filter { it.first == fileId.toLong() } - .collect { progress = it.second } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .collect { progress = it.progress } } val completedPath = withTimeoutOrNull(60_000L) { - fileRepository.messageDownloadCompletedFlow - .filter { it.first == fileId.toLong() } - .mapNotNull { (_, _, candidatePath) -> candidatePath.takeIf { it.isNotEmpty() } } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .mapNotNull { event -> event.path.takeIf { it.isNotEmpty() } } .first() } @@ -175,15 +189,17 @@ fun AsyncVideoWithDownload( fileRepository.downloadFile(fileId) val progressJob = launch { - fileRepository.messageDownloadProgressFlow - .filter { it.first == fileId.toLong() } - .collect { progress = it.second } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .collect { progress = it.progress } } val completedPath = withTimeoutOrNull(60_000L) { - fileRepository.messageDownloadCompletedFlow - .filter { it.first == fileId.toLong() } - .mapNotNull { (_, _, candidatePath) -> candidatePath.takeIf { it.isNotEmpty() } } + fileRepository.fileDownloadFlow + .filterIsInstance() + .filter { it.fileId == fileId } + .mapNotNull { event -> event.path.takeIf { it.isNotEmpty() } } .first() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 9a0029b1..5ff42487 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -3,12 +3,41 @@ package org.monogram.presentation.features.profile import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import org.monogram.domain.models.* -import org.monogram.domain.repository.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatInteractionType +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatRevenueStatisticsModel +import org.monogram.domain.models.ChatStatisticsModel +import org.monogram.domain.models.FileDownloadEvent +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.StatisticsGraphModel +import org.monogram.domain.models.UserTypeEnum +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatMemberStatus +import org.monogram.domain.repository.ChatMembersFilter +import org.monogram.domain.repository.ChatOperationsRepository +import org.monogram.domain.repository.ChatSettingsRepository +import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.ProfileMediaFilter +import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope @@ -763,12 +792,12 @@ class DefaultProfileComponent( } val completed = withTimeoutOrNull(timeoutMs) { - messageRepository.messageDownloadCompletedFlow.first { (_, completedFileId, path) -> - completedFileId == fileId && path.isNotEmpty() - } + messageRepository.fileDownloadFlow + .filterIsInstance() + .first { event -> event.fileId == fileId && event.path.isNotEmpty() } } if (completed != null) { - return completed.third + return completed.path } val fallback = messageRepository.getFileInfo(fileId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt index a739101a..fd6c34f3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/InvoiceDialog.kt @@ -1,10 +1,34 @@ package org.monogram.presentation.features.webapp.components import androidx.compose.foundation.background -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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -14,8 +38,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import org.monogram.domain.models.FileDownloadEvent import org.monogram.domain.models.webapp.InvoiceModel import org.monogram.domain.repository.FileRepository import org.monogram.domain.repository.PaymentRepository @@ -50,13 +74,19 @@ fun InvoiceDialog( if (photoPath == null) { fileRepository.downloadFile(fileId) launch { - fileRepository.messageDownloadProgressFlow - .filter { it.first == fileId.toLong() } - .collect { progress = it.second } + fileRepository.fileDownloadFlow + .collect { event -> + if (event is FileDownloadEvent.Progress && event.fileId == fileId) { + progress = event.progress + } + } } - fileRepository.messageDownloadCompletedFlow - .filter { it.first == fileId.toLong() } - .collect { (_, _, completedPath) -> photoPath = completedPath } + fileRepository.fileDownloadFlow + .collect { event -> + if (event is FileDownloadEvent.Completed && event.fileId == fileId) { + photoPath = event.path + } + } } } else { photoPath = fileIdStr From 7467cae02c9c964e1967d37da9918b751e670e05 Mon Sep 17 00:00:00 2001 From: Uladzislau Hramyka Date: Wed, 8 Apr 2026 11:23:54 +0300 Subject: [PATCH 23/83] Remove non-existent `web_app_set_header_text` event and page reload of `bg_color` change (#214) --- .../org/monogram/domain/models/webapp/WebAppEvents.kt | 1 - .../presentation/features/webapp/MiniAppState.kt | 6 ------ .../presentation/features/webapp/MiniAppViewer.kt | 2 +- .../presentation/features/webapp/TelegramWebAppHost.kt | 1 - .../presentation/features/webapp/TelegramWebviewProxy.kt | 2 -- .../features/webapp/components/MiniAppWebView.kt | 9 ++++++++- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt b/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt index a3312a36..ea9a49c2 100644 --- a/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt +++ b/domain/src/main/java/org/monogram/domain/models/webapp/WebAppEvents.kt @@ -8,7 +8,6 @@ sealed class WebAppEvent { data object RequestTheme : WebAppEvent() data class SetBackgroundColor(val color: String) : WebAppEvent() data class SetHeaderColor(val colorKey: String?, val color: String?) : WebAppEvent() - data class SetHeaderText(val text: String) : WebAppEvent() data class SetBottomBarColor(val color: String) : WebAppEvent() data class SetupMainButton( val isVisible: Boolean, diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt index 213261d6..ad2393bb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt @@ -76,8 +76,6 @@ class MiniAppState( var backgroundColor by mutableStateOf(initialThemeParams.backgroundColor?.let { Color(it.toColorInt()) }) var bottomBarColor by mutableStateOf(initialThemeParams.bottomBarBackgroundColor?.let { Color(it.toColorInt()) }) - var headerText by mutableStateOf(botName) - var isExpanded by mutableStateOf(false) var isFullscreen by mutableStateOf(false) @@ -619,10 +617,6 @@ class MiniAppState( topBarColor = themeParams.headerBackgroundColor?.let { Color(it.toColorInt()) } } - override fun onSetHeaderText(text: String) { - headerText = text - } - override fun onSetBottomBarColor(color: Int) { bottomBarColor = Color(color) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt index 85eea84d..7bcf868e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppViewer.kt @@ -362,7 +362,7 @@ fun MiniAppViewer( Column(modifier = Modifier.fillMaxSize()) { if (!state.isFullscreen) { MiniAppTopBar( - headerText = state.headerText, + headerText = botName, isBackButtonVisible = state.isBackButtonVisible, isSettingsButtonVisible = state.isSettingsButtonVisible, isInitializing = state.isInitializing, diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt index 970332f7..c2d4152a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebAppHost.kt @@ -53,7 +53,6 @@ interface TelegramWebAppHost { fun onSetBackgroundColor(color: Int) fun onSetHeaderColor(colorKey: String?, customColor: Int?) fun onResetHeaderColor() - fun onSetHeaderText(text: String) fun onSetBottomBarColor(color: Int) fun onResetBottomBarColor() fun onSetupClosingBehavior(needConfirmation: Boolean) diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt index 067abf4e..3029ab6c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt @@ -118,7 +118,6 @@ class TelegramWebviewProxy( data.optString("color_key").takeIf { it.isNotEmpty() }, data.optString("color").takeIf { it.isNotEmpty() }) - "web_app_set_header_text" -> WebAppEvent.SetHeaderText(data.optString("text")) "web_app_set_bottom_bar_color" -> WebAppEvent.SetBottomBarColor(data.optString("color")) "web_app_setup_main_button" -> WebAppEvent.SetupMainButton( data.optBoolean("is_visible"), data.optBoolean("is_active"), @@ -305,7 +304,6 @@ class TelegramWebviewProxy( is WebAppEvent.SetBackgroundColor -> host.onSetBackgroundColor(parseColor(event.color)) is WebAppEvent.SetHeaderColor -> host.onSetHeaderColor(event.colorKey, event.color?.let { parseColor(it) }) - is WebAppEvent.SetHeaderText -> host.onSetHeaderText(event.text) is WebAppEvent.SetBottomBarColor -> host.onSetBottomBarColor(parseColor(event.color)) is WebAppEvent.SetupMainButton -> host.onSetupMainButton( event.isVisible, diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt index 12754742..6794b4b1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/components/MiniAppWebView.kt @@ -11,6 +11,10 @@ import android.view.ViewGroup import android.webkit.* import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.viewinterop.AndroidView @@ -34,6 +38,8 @@ fun MiniAppWebView( onLoadingChanged: (Boolean) -> Unit, modifier: Modifier = Modifier ) { + var lastLoadedUrl by remember { mutableStateOf(url) } + AndroidView( factory = { ctx -> WebView(ctx).apply { @@ -130,8 +136,9 @@ fun MiniAppWebView( view.setBackgroundColor( backgroundColor?.toArgb() ?: themeParams.backgroundColor?.toColorInt() ?: Color.TRANSPARENT ) - if (url.isNotEmpty() && view.url != url) { + if (url.isNotEmpty() && url != lastLoadedUrl) { view.loadUrl(url, mapOf("Accept-Language" to acceptLanguage)) + lastLoadedUrl = url } } ) From eab7b5bd7188abbdd7b346261a481bbda2de44f4 Mon Sep 17 00:00:00 2001 From: Uladzislau Hramyka Date: Wed, 8 Apr 2026 11:24:16 +0300 Subject: [PATCH 24/83] Update sensors handling and move evaluateJavascript to message queue (#211) - Allow sending null instead of empty object when sending event to client - Add custom throttle when sending "changed" event for sensors to respect refresh rate parameter, because passing sampling period doesn't guarantee that it will send update for sensors at specified rate > samplingPeriodUs - The rate sensor events are delivered at. **This is only a hint to the system. Events may be received faster or slower than the specified rate. Usually events are received faster.** - Wrap all evaluateJavascript calls to `webapp.post` to make them asynchronous - Refactor `stopSensor` function to accept 1 or many types as list and remove specific code for device orientation --- .../features/webapp/TelegramWebviewProxy.kt | 232 ++++++++++++++---- 1 file changed, 179 insertions(+), 53 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt index 3029ab6c..2e34a09c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/TelegramWebviewProxy.kt @@ -85,7 +85,14 @@ class TelegramWebviewProxy( document.documentElement.style.setProperty('--tg-content-safe-area-inset-left', '${contentSafeArea.optInt("left")}px'); document.documentElement.style.setProperty('--tg-content-safe-area-inset-right', '${contentSafeArea.optInt("right")}px'); """.trimIndent() - webView.evaluateJavascript(script, null) + + webView.post { + try { + webView.evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for injecting safe area CSS", e) + } + } } @JavascriptInterface @@ -348,11 +355,18 @@ class TelegramWebviewProxy( "accelerometer" ) - is WebAppEvent.StopAccelerometer -> stopSensor(Sensor.TYPE_ACCELEROMETER, "accelerometer") + is WebAppEvent.StopAccelerometer -> stopSensors("accelerometer", Sensor.TYPE_ACCELEROMETER) is WebAppEvent.StartGyroscope -> startSensor(Sensor.TYPE_GYROSCOPE, event.refreshRate, "gyroscope") - is WebAppEvent.StopGyroscope -> stopSensor(Sensor.TYPE_GYROSCOPE, "gyroscope") + is WebAppEvent.StopGyroscope -> stopSensors("gyroscope", Sensor.TYPE_GYROSCOPE) is WebAppEvent.StartDeviceOrientation -> startDeviceOrientation(event.refreshRate, event.needAbsolute) - is WebAppEvent.StopDeviceOrientation -> stopSensor(Sensor.TYPE_ROTATION_VECTOR, "device_orientation") + is WebAppEvent.StopDeviceOrientation -> stopSensors( + "device_orientation", + Sensor.TYPE_ACCELEROMETER, + Sensor.TYPE_MAGNETIC_FIELD, + Sensor.TYPE_GAME_ROTATION_VECTOR, + Sensor.TYPE_ROTATION_VECTOR + ) + is WebAppEvent.ToggleOrientationLock -> host.onToggleOrientationLock(event.locked) is WebAppEvent.RequestFullscreen -> { host.onRequestFullscreen() @@ -407,10 +421,23 @@ class TelegramWebviewProxy( fun dispatchToWebView(eventType: String, eventData: JSONObject?) { Log.d(TAG, "dispatchToWebView: $eventType | Data: $eventData") - val data = eventData?.toString() ?: "{}" - val script = - "if (window.Telegram && window.Telegram.WebView && window.Telegram.WebView.receiveEvent) { window.Telegram.WebView.receiveEvent('$eventType', $data); }" - webView.evaluateJavascript(script, null) + + val data = eventData?.toString() + val quotedEvent = JSONObject.quote(eventType) + + val script = """ + if (window.Telegram?.WebView?.receiveEvent) { + window.Telegram.WebView.receiveEvent($quotedEvent, $data); + } + """.trimIndent() + + webView.post { + try { + webView.evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for event $eventType", e) + } + } } private fun updateViewport() { @@ -427,7 +454,13 @@ class TelegramWebviewProxy( document.documentElement.style.setProperty('--tg-viewport-height', '${height}px'); document.documentElement.style.setProperty('--tg-viewport-stable-height', '${height}px'); """.trimIndent() - webView.evaluateJavascript(script, null) + webView.post { + try { + webView.evaluateJavascript(script, null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for updating viewport", e) + } + } } private fun injectCSSVars(themeParamsJson: String) { @@ -444,28 +477,57 @@ class TelegramWebviewProxy( } } sb.append("})();") - webView.evaluateJavascript(sb.toString(), null) + webView.post { + try { + webView.evaluateJavascript(sb.toString(), null) + } catch (e: Exception) { + Log.e(TAG, "Error evaluating JS for injecting CSS vars", e) + } + } } catch (e: Exception) { Log.e(TAG, "CSS Inject Error", e) } } + private fun getSensorDelay(refreshRate: Long): Int { + if (refreshRate >= 160) return SensorManager.SENSOR_DELAY_NORMAL + if (refreshRate >= 60) return SensorManager.SENSOR_DELAY_UI + return SensorManager.SENSOR_DELAY_GAME + } + private fun startSensor(type: Int, refreshMs: Long, eventName: String) { - val delay = (refreshMs * 1000).toInt().coerceAtLeast(SensorManager.SENSOR_DELAY_GAME) + val clampedRefreshMs = refreshMs.coerceIn(20, 1000) - stopSensor(type, eventName) + stopSensors(eventName, type) val listener = object : SensorEventListener { + private var lastUpdateTimestamp = 0L + override fun onSensorChanged(event: SensorEvent) { + val currentTime = System.currentTimeMillis() + + if (currentTime - lastUpdateTimestamp < clampedRefreshMs) { + return + } + val params = JSONObject() when (type) { - Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE -> { + Sensor.TYPE_ACCELEROMETER -> { + params.put("x", -event.values[0]) + params.put("y", -event.values[1]) + params.put("z", -event.values[2]) + } + + Sensor.TYPE_GYROSCOPE -> { params.put("x", event.values[0]) params.put("y", event.values[1]) params.put("z", event.values[2]) } } dispatchToWebView("${eventName}_changed", params) + + lastUpdateTimestamp = currentTime + } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} @@ -473,72 +535,136 @@ class TelegramWebviewProxy( val sensor = sensorManager.getDefaultSensor(type) if (sensor != null) { - sensorManager.registerListener(listener, sensor, delay) + sensorManager.registerListener(listener, sensor, getSensorDelay(clampedRefreshMs)) activeSensors[type] = listener - dispatchToWebView("${eventName}_started", JSONObject()) + dispatchToWebView("${eventName}_started", null) } else { dispatchToWebView("${eventName}_failed", JSONObject().put("error", "UNSUPPORTED")) } } private fun startDeviceOrientation(refreshMs: Long, needAbsolute: Boolean) { - val type = if (needAbsolute) Sensor.TYPE_ROTATION_VECTOR else Sensor.TYPE_GAME_ROTATION_VECTOR - val delay = (refreshMs * 1000).toInt().coerceAtLeast(SensorManager.SENSOR_DELAY_GAME) + val clampedRefreshMs = refreshMs.coerceIn(20, 1000) - stopSensor(Sensor.TYPE_ROTATION_VECTOR, "") - stopSensor(Sensor.TYPE_GAME_ROTATION_VECTOR, "") + val sensorTypes = if (needAbsolute) { + if (sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null) { + intArrayOf(Sensor.TYPE_ROTATION_VECTOR) + } else { + intArrayOf(Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_MAGNETIC_FIELD) + } + } else { + intArrayOf(Sensor.TYPE_GAME_ROTATION_VECTOR) + } + + stopSensors( + "device_orientation", + Sensor.TYPE_ROTATION_VECTOR, + Sensor.TYPE_GAME_ROTATION_VECTOR, + Sensor.TYPE_ACCELEROMETER, + Sensor.TYPE_MAGNETIC_FIELD + ) val listener = object : SensorEventListener { + private var lastUpdateTimestamp = 0L + + private val rotationMatrix = FloatArray(9) + private val inclinationMatrix = FloatArray(9) + private val orientation = FloatArray(3) + private val truncatedVector = FloatArray(4) + + private var gravityValues: FloatArray? = null + private var magneticValues: FloatArray? = null + override fun onSensorChanged(event: SensorEvent) { - val rotationMatrix = FloatArray(9) - SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) - val orientation = FloatArray(3) - SensorManager.getOrientation(rotationMatrix, orientation) - - var alpha = Math.toDegrees(orientation[0].toDouble()) // Azimuth - if (alpha < 0) alpha += 360.0 - val beta = Math.toDegrees(orientation[1].toDouble()) // Pitch - val gamma = Math.toDegrees(orientation[2].toDouble()) // Roll - - if (alpha.isNaN() || beta.isNaN() || gamma.isNaN() || - alpha.isInfinite() || beta.isInfinite() || gamma.isInfinite() - ) { - return + val currentTime = System.currentTimeMillis() + if (currentTime - lastUpdateTimestamp < clampedRefreshMs) return + + val success = when (event.sensor.type) { + Sensor.TYPE_ROTATION_VECTOR, Sensor.TYPE_GAME_ROTATION_VECTOR -> { + val values = event.values + // Samsung/device-specific safety check for rotation vector length + if (values.size > 4) { + values.copyInto(truncatedVector, 0, 0, 4) + SensorManager.getRotationMatrixFromVector( + rotationMatrix, + truncatedVector + ) + } else { + SensorManager.getRotationMatrixFromVector(rotationMatrix, values) + } + true + } + + Sensor.TYPE_ACCELEROMETER -> { + gravityValues = event.values.clone() + tryComputeMatrix() + } + + Sensor.TYPE_MAGNETIC_FIELD -> { + magneticValues = event.values.clone() + tryComputeMatrix() + } + + else -> false } - val params = JSONObject() - params.put("alpha", alpha) - params.put("beta", beta) - params.put("gamma", gamma) - params.put("absolute", needAbsolute) + if (success) { + SensorManager.getOrientation(rotationMatrix, orientation) + lastUpdateTimestamp = currentTime - dispatchToWebView("device_orientation_changed", params) + val params = JSONObject().apply { + put("absolute", needAbsolute) + put("alpha", -orientation[0].toDouble()) + put("beta", -orientation[1].toDouble()) + put("gamma", orientation[2].toDouble()) + } + dispatchToWebView("device_orientation_changed", params) + } + } + + private fun tryComputeMatrix(): Boolean { + val g = gravityValues ?: return false + val m = magneticValues ?: return false + return SensorManager.getRotationMatrix(rotationMatrix, inclinationMatrix, g, m) } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} } - val sensor = sensorManager.getDefaultSensor(type) - if (sensor != null) { - sensorManager.registerListener(listener, sensor, delay) - activeSensors[type] = listener - dispatchToWebView("device_orientation_started", JSONObject()) + var startedCount = 0 + sensorTypes.forEach { type -> + sensorManager.getDefaultSensor(type)?.let { sensor -> + if (sensorManager.registerListener( + listener, + sensor, + getSensorDelay(clampedRefreshMs) + ) + ) { + activeSensors[type] = listener + startedCount++ + } + } + } + + if (startedCount > 0) { + dispatchToWebView("device_orientation_started", null) } else { dispatchToWebView("device_orientation_failed", JSONObject().put("error", "UNSUPPORTED")) } } - private fun stopSensor(type: Int, eventName: String) { - if (eventName == "device_orientation") { - activeSensors.remove(Sensor.TYPE_ROTATION_VECTOR)?.let { sensorManager.unregisterListener(it) } - activeSensors.remove(Sensor.TYPE_GAME_ROTATION_VECTOR)?.let { sensorManager.unregisterListener(it) } - dispatchToWebView("device_orientation_stopped", JSONObject()) - return + private fun stopSensors(eventName: String, vararg types: Int) { + var shouldSendStoppedEvent = false + + types.forEach { type -> + activeSensors.remove(type)?.let { + sensorManager.unregisterListener(it) + shouldSendStoppedEvent = true + } } - activeSensors.remove(type)?.let { - sensorManager.unregisterListener(it) - dispatchToWebView("${eventName}_stopped", JSONObject()) + if (shouldSendStoppedEvent) { + dispatchToWebView("${eventName}_stopped", null) } } From 6178c234ad14c5c4dd64dfb90dd893aeeabb6077 Mon Sep 17 00:00:00 2001 From: Andro_Dev <87223939+andr0d1v@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:26:57 +0300 Subject: [PATCH 25/83] feat: fast reply gesture (#168) --- .../features/chats/currentChat/ChatContent.kt | 20 - .../chatContent/ChatContentList.kt | 10 +- .../components/AlbumMessageBubbleContainer.kt | 214 +++---- .../components/FastReplyIndicator.kt | 142 +++++ .../components/MessageBubbleContainer.kt | 144 +++-- .../channels/ChannelMessageBubbleContainer.kt | 522 +++++++++--------- .../pins/PinnedMessagesListSheet.kt | 3 + 7 files changed, 629 insertions(+), 426 deletions(-) create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index e8cbf1f4..24d231d5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -458,26 +458,6 @@ fun ChatContent( .background(MaterialTheme.colorScheme.background) .onGloballyPositioned { containerSize = it.size } ) { - /*if (isDragToBackEnabled && !isTablet && !isCustomBackHandlingEnabled && dragOffsetX.value > 0 && previousChild != null) { - Box( - modifier = Modifier.fillMaxSize() - ) { - renderChild(previousChild) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Color.Black.copy( - alpha = 0.3f * (1f - (dragOffsetX.value / containerSize.width.toFloat()).coerceIn( - 0f, - 1f - )) - ) - ) - ) - } - }*/ - Box( modifier = Modifier .fillMaxSize() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index d897f3dd..09182a0c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -494,6 +494,7 @@ private fun MessageBubbleSwitcher( isAnyViewerOpen: Boolean = false ) { val isChannel = state.isChannel && state.currentTopicId == null + val isTopicClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed?: false when (item) { is GroupedMessageItem.Single -> { @@ -591,6 +592,8 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, + canReply = state.canWrite && !isSelectionMode, + onReplySwipe = { component.onReplyMessage(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, onInstantViewClick = { component.onOpenInstantView(it) }, downloadUtils = downloadUtils, @@ -694,7 +697,7 @@ private fun MessageBubbleSwitcher( onPositionChange = { _, pos, size -> onMessagePositionChange(pos, size) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -762,7 +765,7 @@ private fun MessageBubbleSwitcher( onCommentsClick = { component.onCommentsClick(it) }, toProfile = toProfile, onViaBotClick = onViaBotClick, - canReply = state.canWrite && !isSelectionMode, + canReply = state.canWrite && !isSelectionMode && (!isTopicClosed || state.isAdmin), onReplySwipe = { component.onReplyMessage(it) }, swipeEnabled = !isSelectionMode, downloadUtils = downloadUtils, @@ -874,7 +877,8 @@ private fun RootMessageSection( onRetractVote = { component.onRetractVote(it) }, onShowVoters = { id, opt -> component.onShowVoters(id, opt) }, onClosePoll = { component.onClosePoll(it) }, - toProfile = toProfile, swipeEnabled = false, + toProfile = toProfile, + swipeEnabled = false, onViaBotClick = onViaBotClick, onInstantViewClick = { component.onOpenInstantView(it) }, onYouTubeClick = { component.onOpenYouTube(it) }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt index 91555392..a2a0da73 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.currentChat.components import android.content.res.Configuration +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme @@ -14,6 +15,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize @@ -97,11 +99,21 @@ fun AlbumMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val dragOffsetX = remember { Animatable(0f) } + Column( modifier = Modifier .fillMaxWidth() .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing, bottom = 2.dp) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(messages.first()) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -137,109 +149,121 @@ fun AlbumMessageBubbleContainer( Spacer(modifier = Modifier.width(8.dp)) } - Column( - modifier = Modifier - .then(if (isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) - .widthIn(max = maxWidth) - .then(if (isChannel) Modifier.fillMaxWidth() else Modifier) - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(lastMsg.id, bubblePosition, bubbleSize) + Box( + modifier = Modifier.wrapContentSize() + ) { + Column( + modifier = Modifier + .then(if (isChannel) Modifier.padding(horizontal = 8.dp) else Modifier) + .widthIn(max = maxWidth) + .then(if (isChannel) Modifier.fillMaxWidth() else Modifier) + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(lastMsg.id, bubblePosition, bubbleSize) + } } + ) { + if (isGroup && !isOutgoing && !isChannel) { + Text( + text = firstMsg.senderName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, bottom = 4.dp) + ) } - ) { - if (isGroup && !isOutgoing && !isChannel) { - Text( - text = firstMsg.senderName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 12.dp, bottom = 4.dp) - ) - } - if (isChannel) { - ChannelAlbumMessageBubble( - messages = messages, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(lastMsg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - fontSize = fontSize, - bubbleRadius = bubbleRadius, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } else { - ChatAlbumMessageBubble( - messages = messages, - isOutgoing = isOutgoing, - isGroup = isGroup, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(lastMsg.id, it) }, - toProfile = toProfile, - modifier = Modifier, - fontSize = fontSize, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + if (isChannel) { + ChannelAlbumMessageBubble( + messages = messages, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(lastMsg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + fontSize = fontSize, + bubbleRadius = bubbleRadius, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } else { + ChatAlbumMessageBubble( + messages = messages, + isOutgoing = isOutgoing, + isGroup = isGroup, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(lastMsg.id, it) }, + toProfile = toProfile, + modifier = Modifier, + fontSize = fontSize, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - lastMsg.replyMarkup?.let { markup -> - ReplyMarkupView( - replyMarkup = markup, - onButtonClick = { onReplyMarkupButtonClick(lastMsg.id, it) } + lastMsg.replyMarkup?.let { markup -> + ReplyMarkupView( + replyMarkup = markup, + onButtonClick = { onReplyMarkupButtonClick(lastMsg.id, it) } + ) + } + + MessageViaBotAttribution( + msg = lastMsg, + isOutgoing = isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) ) } - MessageViaBotAttribution( - msg = lastMsg, + FastReplyIndicator( + modifier = Modifier + .align(if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart), + dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + maxWidth = maxWidth, ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt new file mode 100644 index 00000000..9cf04035 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/FastReplyIndicator.kt @@ -0,0 +1,142 @@ +package org.monogram.presentation.features.chats.currentChat.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +const val REPLY_TRIGGER_FRACTION = 0.35f +const val MAX_SWIPE_FRACTION = 0.7f +const val ICON_OFFSET_FRACTION = 0.1f + +@Composable +fun FastReplyIndicator( + modifier: Modifier = Modifier, + dragOffsetX: Animatable, + isOutgoing: Boolean = false, + inverseOffset: Boolean = false, + maxWidth: Dp, +) { + val triggerDistance = maxWidth.value * REPLY_TRIGGER_FRACTION + val dragged = (-dragOffsetX.value).coerceAtLeast(0f) + val progress = ((dragged - 48.dp.value) / (triggerDistance - 48.dp.value)) + .coerceIn(0f, 1f) + + val iconAlpha by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 150) + ) + val iconScale by animateFloatAsState( + targetValue = lerp(0.5f, 1f, progress), + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) + ) + val iconOffset = maxWidth * ICON_OFFSET_FRACTION + + if (dragged > 48.dp.value) { + Box( + modifier = modifier + .offset(x = if (isOutgoing) iconOffset else maxWidth) + .size(30.dp) + .graphicsLayer { + translationX = when { + isOutgoing -> (-dragOffsetX.value - iconOffset.value) * 0.5f + inverseOffset -> -iconOffset.value + else -> iconOffset.value + } + scaleX = iconScale + scaleY = iconScale + alpha = iconAlpha + } + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.7f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + +fun Modifier.fastReplyPointer( + canReply: Boolean, + dragOffsetX: Animatable, + scope: CoroutineScope, + onReplySwipe: () -> Unit, + maxWidth: Float +): Modifier = pointerInput(canReply) { + if (!canReply) return@pointerInput + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var isDragging = false + var totalDragX = 0f + + while (down.pressed) { + val event = awaitPointerEvent(pass = PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (change.changedToUp()) break + + val deltaX = change.positionChange().x + totalDragX += deltaX + + if (!isDragging) { + if (totalDragX < -48.dp.toPx()) { + isDragging = true + } else if (totalDragX > 48.dp.toPx()) { + break + } + } + + if (isDragging) { + change.consume() + val newOffset = dragOffsetX.value + deltaX + scope.launch { + dragOffsetX.snapTo(newOffset.coerceIn(-(maxWidth * MAX_SWIPE_FRACTION), 0f)) + } + } + } + + if (isDragging) { + if (-dragOffsetX.value >= maxWidth * REPLY_TRIGGER_FRACTION) { + onReplySwipe() + } + scope.launch { + dragOffsetX.animateTo(0f, spring()) + } + } + } +} \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt index e03b6b36..fa6ef60d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt @@ -2,6 +2,7 @@ package org.monogram.presentation.features.chats.currentChat.components import android.content.res.Configuration import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures @@ -18,6 +19,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize @@ -114,12 +116,22 @@ fun MessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val dragOffsetX = remember { Animatable(0f) } + Column( modifier = Modifier .fillMaxWidth() .background(animatedColor.value, RoundedCornerShape(12.dp)) .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(msg) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -152,70 +164,82 @@ fun MessageBubbleContainer( toProfile = toProfile ) - Column( - modifier = Modifier - .width(IntrinsicSize.Max) - .widthIn(max = maxWidth) - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(msg.id, bubblePosition, bubbleSize) - } - }, - horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + Box( + modifier = Modifier.wrapContentSize() ) { - MessageContentSelector( - msg = msg, - newerMsg = newerMsg, - isOutgoing = isOutgoing, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - isGroup = isGroup, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - stSize = stSize, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoDownloadFiles = autoDownloadFiles, - autoplayGifs = autoplayGifs, - autoplayVideos = autoplayVideos, - showLinkPreviews = showLinkPreviews, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onVideoClick = onVideoClick, - onDocumentClick = onDocumentClick, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onReplyClick = onReplyClick, - onGoToReply = onGoToReply, - onReactionClick = onReactionClick, - onStickerClick = onStickerClick, - onPollOptionClick = onPollOptionClick, - onRetractVote = onRetractVote, - onShowVoters = onShowVoters, - onClosePoll = onClosePoll, - onInstantViewClick = onInstantViewClick, - onYouTubeClick = onYouTubeClick, - toProfile = toProfile, - bubblePosition = bubblePosition, - bubbleSize = bubbleSize, - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) + Column( + modifier = Modifier + .width(IntrinsicSize.Max) + .widthIn(max = maxWidth) + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(msg.id, bubblePosition, bubbleSize) + } + }, + horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start + ) { + MessageContentSelector( + msg = msg, + newerMsg = newerMsg, + isOutgoing = isOutgoing, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + isGroup = isGroup, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + stSize = stSize, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoDownloadFiles = autoDownloadFiles, + autoplayGifs = autoplayGifs, + autoplayVideos = autoplayVideos, + showLinkPreviews = showLinkPreviews, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onVideoClick = onVideoClick, + onDocumentClick = onDocumentClick, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onReplyClick = onReplyClick, + onGoToReply = onGoToReply, + onReactionClick = onReactionClick, + onStickerClick = onStickerClick, + onPollOptionClick = onPollOptionClick, + onRetractVote = onRetractVote, + onShowVoters = onShowVoters, + onClosePoll = onClosePoll, + onInstantViewClick = onInstantViewClick, + onYouTubeClick = onYouTubeClick, + toProfile = toProfile, + bubblePosition = bubblePosition, + bubbleSize = bubbleSize, + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) - MessageReplyMarkup( - msg = msg, - onReplyMarkupButtonClick = onReplyMarkupButtonClick - ) + MessageReplyMarkup( + msg = msg, + onReplyMarkupButtonClick = onReplyMarkupButtonClick + ) - MessageViaBotAttribution( - msg = msg, + MessageViaBotAttribution( + msg = msg, + isOutgoing = isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + ) + } + + FastReplyIndicator( + modifier = Modifier + .align(if (isOutgoing) Alignment.CenterEnd else Alignment.CenterStart), + dragOffsetX = dragOffsetX, isOutgoing = isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(if (isOutgoing) Alignment.End else Alignment.Start) + maxWidth = maxWidth ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt index 1c742a7d..79bae263 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageBubbleContainer.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize @@ -27,7 +28,9 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate +import org.monogram.presentation.features.chats.currentChat.components.FastReplyIndicator import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.fastReplyPointer @Composable fun ChannelMessageBubbleContainer( @@ -70,8 +73,10 @@ fun ChannelMessageBubbleContainer( showComments: Boolean = true, toProfile: (Long) -> Unit = {}, onViaBotClick: (String) -> Unit = {}, + canReply: Boolean = true, + onReplySwipe: (MessageModel) -> Unit = {}, downloadUtils: IDownloadUtils, - isAnyViewerOpen: Boolean = false + isAnyViewerOpen: Boolean = false, ) { val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp @@ -104,12 +109,22 @@ fun ChannelMessageBubbleContainer( var bubblePosition by remember { mutableStateOf(Offset.Zero) } var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + val dragOffsetX = remember { androidx.compose.animation.core.Animatable(0f) } + Column( modifier = Modifier .fillMaxWidth() .background(animatedColor.value, RoundedCornerShape(12.dp)) .onGloballyPositioned { outerColumnPosition = it.positionInWindow() } .padding(top = topSpacing) + .offset { IntOffset(dragOffsetX.value.toInt(), 0) } + .fastReplyPointer( + canReply = canReply, + dragOffsetX = dragOffsetX, + scope = rememberCoroutineScope(), + onReplySwipe = { onReplySwipe(msg) }, + maxWidth = maxWidth.value + ) .pointerInput(Unit) { detectTapGestures( onTap = { offset -> @@ -134,268 +149,279 @@ fun ChannelMessageBubbleContainer( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Bottom ) { - Column( - modifier = Modifier - .padding(horizontal = 8.dp) - .widthIn(max = maxWidth) - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - bubblePosition = coordinates.positionInWindow() - bubbleSize = coordinates.size - if (shouldReportPosition) { - onPositionChange(msg.id, bubblePosition, bubbleSize) - } - } + Box( + modifier = Modifier.wrapContentSize() ) { - when (val content = msg.content) { - is MessageContent.Text -> { - ChannelTextMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - showLinkPreviews = showLinkPreviews, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onInstantViewClick = onInstantViewClick, - onYouTubeClick = onYouTubeClick, - onClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) - }, - onLongClick = { offset -> - onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) - }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth() - ) - } + Column( + modifier = Modifier + .padding(horizontal = 8.dp) + .widthIn(max = maxWidth) + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + bubblePosition = coordinates.positionInWindow() + bubbleSize = coordinates.size + if (shouldReportPosition) { + onPositionChange(msg.id, bubblePosition, bubbleSize) + } + } + ) { + when (val content = msg.content) { + is MessageContent.Text -> { + ChannelTextMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + showLinkPreviews = showLinkPreviews, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onInstantViewClick = onInstantViewClick, + onYouTubeClick = onYouTubeClick, + onClick = { offset -> + onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) + }, + onLongClick = { offset -> + onReplyClick(bubblePosition, bubbleSize, bubblePosition + offset) + }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth() + ) + } - is MessageContent.Photo -> { - ChannelPhotoMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onPhotoClick = onPhotoClick, - onDownloadPhoto = onDownloadPhoto, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils - ) - } + is MessageContent.Photo -> { + ChannelPhotoMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onPhotoClick = onPhotoClick, + onDownloadPhoto = onDownloadPhoto, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils + ) + } - is MessageContent.Video -> { - ChannelVideoMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayVideos = autoplayVideos, - onVideoClick = onVideoClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + is MessageContent.Video -> { + ChannelVideoMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoplayVideos = autoplayVideos, + onVideoClick = onVideoClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - is MessageContent.Document -> { - DocumentMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onDocumentClick = onDocumentClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - downloadUtils = downloadUtils - ) - } + is MessageContent.Document -> { + DocumentMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + autoDownloadFiles = autoDownloadFiles, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onDocumentClick = onDocumentClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + downloadUtils = downloadUtils + ) + } - is MessageContent.Audio -> { - AudioMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - autoDownloadFiles = autoDownloadFiles, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - onAudioClick = onAudioClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - downloadUtils = downloadUtils - ) - } + is MessageContent.Audio -> { + AudioMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + autoDownloadFiles = autoDownloadFiles, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + onAudioClick = onAudioClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + downloadUtils = downloadUtils + ) + } - is MessageContent.Gif -> { - ChannelGifMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - autoDownloadMobile = autoDownloadMobile, - autoDownloadWifi = autoDownloadWifi, - autoDownloadRoaming = autoDownloadRoaming, - autoplayGifs = autoplayGifs, - onGifClick = onVideoClick, - onCancelDownload = onCancelDownload, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile, - modifier = Modifier.fillMaxWidth(), - downloadUtils = downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + is MessageContent.Gif -> { + ChannelGifMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + autoDownloadMobile = autoDownloadMobile, + autoDownloadWifi = autoDownloadWifi, + autoDownloadRoaming = autoDownloadRoaming, + autoplayGifs = autoplayGifs, + onGifClick = onVideoClick, + onCancelDownload = onCancelDownload, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile, + modifier = Modifier.fillMaxWidth(), + downloadUtils = downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) + } - is MessageContent.Sticker -> { - StickerMessageBubble( - content = content, - msg = msg, - isOutgoing = false, - stickerSize = stickerSize, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onStickerClick = { onStickerClick(content.setId) }, - onLongClick = { - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + (bubbleSize.toSize() / 2f).toOffset() - ) - }, - toProfile = toProfile - ) + is MessageContent.Sticker -> { + StickerMessageBubble( + content = content, + msg = msg, + isOutgoing = false, + stickerSize = stickerSize, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onStickerClick = { onStickerClick(content.setId) }, + onLongClick = { + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + (bubbleSize.toSize() / 2f).toOffset() + ) + }, + toProfile = toProfile + ) + } + + is MessageContent.Poll -> { + ChannelPollMessageBubble( + content = content, + msg = msg, + isSameSenderAbove = isSameSenderAbove, + isSameSenderBelow = isSameSenderBelow, + fontSize = fontSize, + letterSpacing = letterSpacing, + bubbleRadius = bubbleRadius, + onOptionClick = { onPollOptionClick(msg.id, it) }, + onRetractVote = { onRetractVote(msg.id) }, + onShowVoters = { onShowVoters(msg.id, it) }, + onClosePoll = { onClosePoll(msg.id) }, + onReplyClick = onGoToReply, + onReactionClick = { onReactionClick(msg.id, it) }, + onLongClick = { offset -> + onReplyClick( + bubblePosition, + bubbleSize, + bubblePosition + offset + ) + }, + onCommentsClick = onCommentsClick, + showComments = showComments, + toProfile = toProfile + ) + } + + else -> {} } - is MessageContent.Poll -> { - ChannelPollMessageBubble( - content = content, - msg = msg, - isSameSenderAbove = isSameSenderAbove, - isSameSenderBelow = isSameSenderBelow, - fontSize = fontSize, - letterSpacing = letterSpacing, - bubbleRadius = bubbleRadius, - onOptionClick = { onPollOptionClick(msg.id, it) }, - onRetractVote = { onRetractVote(msg.id) }, - onShowVoters = { onShowVoters(msg.id, it) }, - onClosePoll = { onClosePoll(msg.id) }, - onReplyClick = onGoToReply, - onReactionClick = { onReactionClick(msg.id, it) }, - onLongClick = { offset -> - onReplyClick( - bubblePosition, - bubbleSize, - bubblePosition + offset - ) - }, - onCommentsClick = onCommentsClick, - showComments = showComments, - toProfile = toProfile + msg.replyMarkup?.let { markup -> + ReplyMarkupView( + replyMarkup = markup, + onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } ) } - else -> {} - } - - msg.replyMarkup?.let { markup -> - ReplyMarkupView( - replyMarkup = markup, - onButtonClick = { onReplyMarkupButtonClick(msg.id, it) } + MessageViaBotAttribution( + msg = msg, + isOutgoing = msg.isOutgoing, + onViaBotClick = onViaBotClick, + modifier = Modifier.align(Alignment.Start) ) } - MessageViaBotAttribution( - msg = msg, - isOutgoing = msg.isOutgoing, - onViaBotClick = onViaBotClick, - modifier = Modifier.align(Alignment.Start) + FastReplyIndicator( + modifier = Modifier.align(Alignment.CenterStart), + dragOffsetX = dragOffsetX, + inverseOffset = isLandscape, + maxWidth = maxWidth, ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt index 96c4634e..2e3a47a5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -307,6 +307,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } @@ -334,6 +335,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } else if (item is GroupedMessageItem.Album) { @@ -351,6 +353,7 @@ fun PinnedMessagesListSheet( onGoToReply = { onReplyClick(it) }, onReactionClick = onReactionClick, toProfile = {}, + canReply = false, downloadUtils = downloadUtils ) } From 33c9da59ad9eb58cc107ee1017d78f4a81e830be Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:43:21 +0300 Subject: [PATCH 26/83] Add androidx-compose-ui version and update refs Introduce androidx-compose-ui = "1.11.0-beta02" and update Compose UI module entries to use version.ref = "androidx-compose-ui" (ui, ui-graphics, ui-tooling, ui-tooling-preview). This centralizes the Compose UI version instead of relying on the runtime version, ensuring consistent UI artifact versions. --- gradle/libs.versions.toml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38dacb48..7220bce0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ androidx-biometric = "1.4.0-alpha06" androidx-camera = "1.6.0" androidx-compose-bom = "2026.03.01" androidx-compose-runtime = "1.10.6" +androidx-compose-ui = "1.11.0-beta02" androidx-material3 = "1.4.0" androidx-adaptive = "1.2.0" androidx-media3 = "1.10.0" @@ -70,10 +71,10 @@ androidx-compose-material3-adaptive = { module = "androidx.compose.material3.ada androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } androidx-compose-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidx-compose-runtime" } -androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-runtime" } -androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-ui" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidx-compose-ui" } # AndroidX Media3 androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "androidx-media3" } @@ -191,4 +192,4 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi google-oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } +androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "baselineprofile" } \ No newline at end of file From 9f8c2b186c3dcd53c62b54d7c3ea2d8276d659e9 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:47:18 +0300 Subject: [PATCH 27/83] Optimize chat UI updates and typing dots Refactor TypingDots to draw with Canvas and a single animated phase for smoother, cheaper animations. Introduce ChatContentTopBarUiState and pass selectedCount/canRevokeSelected to ChatContentTopBar to reduce recompositions and avoid recalculating derived selection data in the top bar. Prevent redundant state updates and store intents in ChatStoreFactory and DefaultChatComponent by guarding against unchanged values. Debounce duplicate bottom-reached reports in ChatContent (track lastReportedBottomState) and use snapshot data for unread/visibility logic. Misc: wire through topBarState fields throughout ChatContentTopBar to centralize UI state. --- .../presentation/core/ui/TypingDots.kt | 63 +++++---- .../features/chats/currentChat/ChatContent.kt | 81 ++++++++++- .../chats/currentChat/ChatStoreFactory.kt | 8 +- .../chats/currentChat/DefaultChatComponent.kt | 8 +- .../chatContent/ChatContentTopBar.kt | 129 +++++++++++------- 5 files changed, 197 insertions(+), 92 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt b/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt index a67e63d6..22f3e3e7 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/ui/TypingDots.kt @@ -1,17 +1,13 @@ package org.monogram.presentation.core.ui import androidx.compose.animation.core.* -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.Canvas import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.State import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -25,34 +21,37 @@ fun TypingDots( ) { val infiniteTransition = rememberInfiniteTransition(label = "TypingDots") val color = if (dotColor == Color.Unspecified) LocalContentColor.current else dotColor + val phase: State = infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 600, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "DotPhase" + ) + val width = dotSize * 3 + spacing * 2 + val minAlpha = 0.2f + val maxAlpha = 1f + + Canvas(modifier = modifier.size(width = width, height = dotSize), onDraw = { + val diameter = size.height + val radius = diameter / 2f + val spacingPx = spacing.toPx() - Row( - modifier = modifier, - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(spacing) - ) { repeat(3) { index -> - val alpha by infiniteTransition.animateFloat( - initialValue = 0.2f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = keyframes { - durationMillis = 600 - 0.2f at 0 - 1f at 300 - 0.2f at 600 - }, - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset(index * 200) - ), - label = "DotAlpha" - ) + val shifted = (phase.value + index / 3f) % 1f + val progress = if (shifted <= 0.5f) shifted * 2f else (1f - shifted) * 2f + val alpha = minAlpha + (maxAlpha - minAlpha) * progress - Box( - modifier = Modifier - .size(dotSize) - .background(color.copy(alpha = alpha), CircleShape) + drawCircle( + color = color.copy(alpha = alpha), + radius = radius, + center = Offset( + x = radius + index * (diameter + spacingPx), + y = radius + ) ) } - } + }) } \ No newline at end of file diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 24d231d5..91b8ebe5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -239,10 +239,9 @@ fun ChatContent( scrollState, isComments, isForumList, - showInitialLoading, - state.unreadCount, - state.isLatestLoaded + showInitialLoading ) { + var lastReportedBottomState: Boolean? = null snapshotFlow { BottomVisibilitySnapshot( isAtBottom = scrollState.isAtBottom( @@ -257,7 +256,10 @@ fun ChatContent( } .distinctUntilChanged() .collectLatest { snapshot -> - component.onBottomReached(snapshot.isAtBottom) + if (lastReportedBottomState != snapshot.isAtBottom) { + component.onBottomReached(snapshot.isAtBottom) + lastReportedBottomState = snapshot.isAtBottom + } val shouldShow = !isForumList && !showInitialLoading && @@ -267,7 +269,7 @@ fun ChatContent( showScrollToBottomButton = true } else { delay(120) - val keepVisible = state.unreadCount > 0 || !snapshot.isNearBottom + val keepVisible = snapshot.unreadCount > 0 || !snapshot.isNearBottom if (!keepVisible) { showScrollToBottomButton = false } @@ -450,6 +452,71 @@ fun ChatContent( val isCustomBackHandlingEnabled = (editingPhotoPath != null || editingVideoPath != null || selectedMessageId != null || state.selectedMessageIds.isNotEmpty() || state.currentTopicId != null || state.showBotCommands || state.restrictUserId != null || state.showPinnedMessagesList || state.fullScreenImages != null || state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null || state.miniAppUrl != null || state.webViewUrl != null || state.instantViewUrl != null || state.youtubeUrl != null) + val selectedCount = state.selectedMessageIds.size + val canRevokeSelected = remember(state.selectedMessageIds, state.messages) { + if (state.selectedMessageIds.isEmpty()) { + false + } else { + state.messages.any { it.id in state.selectedMessageIds && it.canBeDeletedForAllUsers } + } + } + val topBarUiState = remember( + state.currentTopicId, + state.rootMessage, + state.isGroup, + state.isChannel, + state.isAdmin, + state.permissions, + state.otherUser, + state.currentUser, + state.typingAction, + state.memberCount, + state.onlineCount, + state.topics, + state.chatTitle, + state.chatAvatar, + state.chatPersonalAvatar, + state.chatEmojiStatus, + state.isOnline, + state.isVerified, + state.isSponsor, + state.isWhitelistedInAdBlock, + state.isInstalledFromGooglePlay, + state.isMuted, + state.isSearchActive, + state.searchQuery, + state.pinnedMessage, + state.pinnedMessageCount + ) { + ChatContentTopBarUiState( + currentTopicId = state.currentTopicId, + rootMessage = state.rootMessage, + isGroup = state.isGroup, + isChannel = state.isChannel, + isAdmin = state.isAdmin, + permissions = state.permissions, + otherUser = state.otherUser, + currentUser = state.currentUser, + typingAction = state.typingAction, + memberCount = state.memberCount, + onlineCount = state.onlineCount, + topics = state.topics, + chatTitle = state.chatTitle, + chatAvatar = state.chatAvatar, + chatPersonalAvatar = state.chatPersonalAvatar, + chatEmojiStatus = state.chatEmojiStatus, + isOnline = state.isOnline, + isVerified = state.isVerified, + isSponsor = state.isSponsor, + isWhitelistedInAdBlock = state.isWhitelistedInAdBlock, + isInstalledFromGooglePlay = state.isInstalledFromGooglePlay, + isMuted = state.isMuted, + isSearchActive = state.isSearchActive, + searchQuery = state.searchQuery, + pinnedMessage = state.pinnedMessage, + pinnedMessageCount = state.pinnedMessageCount + ) + } CompositionLocalProvider(LocalLinkHandler provides { component.onLinkClick(it) }) { Box( @@ -481,7 +548,9 @@ fun ChatContent( containerColor = Color.Transparent, topBar = { ChatContentTopBar( - state = state, + topBarState = topBarUiState, + selectedCount = selectedCount, + canRevokeSelected = canRevokeSelected, component = component, contentAlpha = contentAlpha, onBack = { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt index 3cbe6bf5..80783ef2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt @@ -129,8 +129,12 @@ class ChatStoreFactory( is Intent.CancelDownloadFile -> component.handleCancelDownloadFile(intent.fileId) - is Intent.UpdateScrollPosition -> component._state.update { it.copy(currentScrollMessageId = intent.messageId) } - is Intent.BottomReached -> component._state.update { it.copy(isAtBottom = intent.isAtBottom) } + is Intent.UpdateScrollPosition -> component._state.update { + if (it.currentScrollMessageId == intent.messageId) it else it.copy(currentScrollMessageId = intent.messageId) + } + is Intent.BottomReached -> component._state.update { + if (it.isAtBottom == intent.isAtBottom) it else it.copy(isAtBottom = intent.isAtBottom) + } is Intent.HighlightConsumed -> component._state.update { it.copy(highlightedMessageId = null) } is Intent.Typing -> { /* Handle typing */ } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index ef64f106..fcc0f1fe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -370,8 +370,12 @@ class DefaultChatComponent( if (_state.value.currentTopicId == null) { cacheProvider.saveChatScrollPosition(chatId, messageId) } - _state.update { it.copy(lastScrollPosition = messageId) } - store.accept(ChatStore.Intent.UpdateScrollPosition(messageId)) + _state.update { + if (it.lastScrollPosition == messageId) it else it.copy(lastScrollPosition = messageId) + } + if (_state.value.currentScrollMessageId != messageId) { + store.accept(ChatStore.Intent.UpdateScrollPosition(messageId)) + } } override fun onBottomReached(isAtBottom: Boolean) = store.accept(ChatStore.Intent.BottomReached(isAtBottom)) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt index ec150579..11fa35af 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -53,7 +54,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import org.monogram.domain.models.ChatPermissionsModel import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.TopicModel +import org.monogram.domain.models.UserModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ExpressiveDefaults @@ -64,10 +68,42 @@ import org.monogram.presentation.features.chats.currentChat.components.pins.Pinn import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown +@Immutable +data class ChatContentTopBarUiState( + val currentTopicId: Long?, + val rootMessage: MessageModel?, + val isGroup: Boolean, + val isChannel: Boolean, + val isAdmin: Boolean, + val permissions: ChatPermissionsModel, + val otherUser: UserModel?, + val currentUser: UserModel?, + val typingAction: String?, + val memberCount: Int, + val onlineCount: Int, + val topics: List, + val chatTitle: String, + val chatAvatar: String?, + val chatPersonalAvatar: String?, + val chatEmojiStatus: String?, + val isOnline: Boolean, + val isVerified: Boolean, + val isSponsor: Boolean, + val isWhitelistedInAdBlock: Boolean, + val isInstalledFromGooglePlay: Boolean, + val isMuted: Boolean, + val isSearchActive: Boolean, + val searchQuery: String, + val pinnedMessage: MessageModel?, + val pinnedMessageCount: Int +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatContentTopBar( - state: ChatComponent.State, + topBarState: ChatContentTopBarUiState, + selectedCount: Int, + canRevokeSelected: Boolean, component: ChatComponent, contentAlpha: Float, onBack: () -> Unit, @@ -77,28 +113,21 @@ fun ChatContentTopBar( ) { val localClipboard = LocalClipboard.current val isAdBlockEnabled by component.appPreferences.isAdBlockEnabled.collectAsState() - val isSelectionMode = state.selectedMessageIds.isNotEmpty() - val isMainChat = state.currentTopicId == null && state.rootMessage == null - val canClearOrDeleteChat = (!state.isGroup && !state.isChannel) || state.isAdmin - val otherUserId = state.otherUser?.id - val canReportChat = state.isGroup || state.isChannel || - (otherUserId != null && state.currentUser?.id != otherUserId) + val isSelectionMode = selectedCount > 0 + val isMainChat = topBarState.currentTopicId == null && topBarState.rootMessage == null + val canClearOrDeleteChat = (!topBarState.isGroup && !topBarState.isChannel) || topBarState.isAdmin + val otherUserId = topBarState.otherUser?.id + val canReportChat = topBarState.isGroup || topBarState.isChannel || + (otherUserId != null && topBarState.currentUser?.id != otherUserId) var showDeleteSheet by remember { mutableStateOf(false) } var pendingUnpinMessage by remember { mutableStateOf(null) } val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() if (showDeleteSheet) { - val selectedMessages = remember(state.messages, state.selectedMessageIds) { - state.messages.filter { it.id in state.selectedMessageIds } - } - val canRevoke = remember(selectedMessages) { - selectedMessages.any { it.canBeDeletedForAllUsers } - } - DeleteMessagesSheet( - count = state.selectedMessageIds.size, - canRevoke = canRevoke, + count = selectedCount, + canRevoke = canRevokeSelected, onDismiss = { showDeleteSheet = false }, onDelete = { revoke -> component.onDeleteSelectedMessages(revoke = revoke) @@ -136,7 +165,7 @@ fun ChatContentTopBar( if (selectionMode) { TopAppBar( title = { - Text(text = "${state.selectedMessageIds.size}") + Text(text = "$selectedCount") }, navigationIcon = { IconButton(onClick = { component.onClearSelection() }, shapes = iconButtonShapes) { @@ -258,98 +287,98 @@ fun ChatContentTopBar( } ) } else { - val formattedUserStatus = rememberUserStatusText(state.otherUser) + val formattedUserStatus = rememberUserStatusText(topBarState.otherUser) val statusText = when { - state.typingAction != null -> state.typingAction - state.isChannel -> stringResource( + topBarState.typingAction != null -> topBarState.typingAction + topBarState.isChannel -> stringResource( R.string.subscribers_count_format, - state.memberCount + topBarState.memberCount ) - state.isGroup -> { - if (state.onlineCount > 0) { + topBarState.isGroup -> { + if (topBarState.onlineCount > 0) { stringResource( R.string.members_online_count_format, - stringResource(R.string.members_count_format, state.memberCount), - state.onlineCount + stringResource(R.string.members_count_format, topBarState.memberCount), + topBarState.onlineCount ) } else { - stringResource(R.string.members_count_format, state.memberCount) + stringResource(R.string.members_count_format, topBarState.memberCount) } } else -> formattedUserStatus } - val currentTopic = remember(state.currentTopicId, state.topics) { - if (state.currentTopicId != null) { - state.topics.find { it.id.toLong() == state.currentTopicId } + val currentTopic = remember(topBarState.currentTopicId, topBarState.topics) { + if (topBarState.currentTopicId != null) { + topBarState.topics.find { it.id.toLong() == topBarState.currentTopicId } } else null } val threadTitle = stringResource(R.string.thread_title) - val title = remember(currentTopic, state.rootMessage, state.chatTitle, threadTitle) { + val title = remember(currentTopic, topBarState.rootMessage, topBarState.chatTitle, threadTitle) { when { currentTopic != null -> currentTopic.name - state.rootMessage != null -> threadTitle - else -> state.chatTitle + topBarState.rootMessage != null -> threadTitle + else -> topBarState.chatTitle } } val topicEmojiPath = currentTopic?.iconCustomEmojiPath ChatTopBar( title = title, - avatarPath = state.chatAvatar, - emojiStatusPath = state.chatEmojiStatus, + avatarPath = topBarState.chatAvatar, + emojiStatusPath = topBarState.chatEmojiStatus, statusText = statusText, - isOnline = state.isOnline, - isVerified = state.isVerified, - isSponsor = state.isSponsor, + isOnline = topBarState.isOnline, + isVerified = topBarState.isVerified, + isSponsor = topBarState.isSponsor, onBack = onBack, onMenu = onOpenMenu, onClick = { component.onProfileClicked() }, topicEmojiPath = topicEmojiPath, - isChannel = state.isChannel, - isWhitelistedInAdBlock = state.isWhitelistedInAdBlock, - onToggleAdBlockWhitelist = if (isMainChat && state.isChannel && isAdBlockEnabled && !state.isInstalledFromGooglePlay) { + isChannel = topBarState.isChannel, + isWhitelistedInAdBlock = topBarState.isWhitelistedInAdBlock, + onToggleAdBlockWhitelist = if (isMainChat && topBarState.isChannel && isAdBlockEnabled && !topBarState.isInstalledFromGooglePlay) { { - if (state.isWhitelistedInAdBlock) { + if (topBarState.isWhitelistedInAdBlock) { component.onRemoveFromAdBlockWhitelist() } else { component.onAddToAdBlockWhitelist() } } } else null, - isMuted = state.isMuted, + isMuted = topBarState.isMuted, onToggleMute = component::onToggleMute, - isSearchActive = state.isSearchActive, - searchQuery = state.searchQuery, + isSearchActive = topBarState.isSearchActive, + searchQuery = topBarState.searchQuery, onSearchToggle = component::onSearchToggle, onSearchQueryChange = component::onSearchQueryChange, onClearHistory = if (isMainChat && canClearOrDeleteChat) component::onClearHistory else null, onDeleteChat = if (isMainChat && canClearOrDeleteChat) component::onDeleteChat else null, onReport = if (isMainChat && canReportChat) component::onReport else null, - onCopyLink = if (isMainChat && (state.isGroup || state.isChannel)) { + onCopyLink = if (isMainChat && (topBarState.isGroup || topBarState.isChannel)) { { component.onCopyLink(localClipboard) } } else null, - onManageMembers = if (isMainChat && state.isGroup && (state.isAdmin || state.permissions.canInviteUsers)) { + onManageMembers = if (isMainChat && topBarState.isGroup && (topBarState.isAdmin || topBarState.permissions.canInviteUsers)) { { component.onProfileClicked() } } else null, showBack = showBack, - personalAvatarPath = state.chatPersonalAvatar + personalAvatarPath = topBarState.chatPersonalAvatar ) } } - val showPinned = state.pinnedMessage != null && !isSelectionMode && state.rootMessage == null + val showPinned = topBarState.pinnedMessage != null && !isSelectionMode && topBarState.rootMessage == null AnimatedVisibility( visible = showPinned, enter = expandVertically(), exit = shrinkVertically() ) { - state.pinnedMessage?.let { pinned -> + topBarState.pinnedMessage?.let { pinned -> PinnedMessageBar( message = pinned, - count = state.pinnedMessageCount, + count = topBarState.pinnedMessageCount, onClose = { pendingUnpinMessage = pinned }, onClick = { onPinnedMessageClick(pinned) }, onShowAll = { component.onShowAllPinnedMessages() } From 028a12b8bbf26934b71b8e4dfcbb6acea6ac3e41 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:03:28 +0300 Subject: [PATCH 28/83] Enable configuration cache; simplify build tasks Enable Gradle configuration cache and simplify APK and OSS dependency handling. - Added org.gradle.configuration-cache=true to gradle.properties. - Replaced a custom APK rename task with a Sync task that copies *.apk into the releases/ directory (imports Sync, removes builtArtifacts-based renaming logic, and cleans existing monogram-*.apk files before copying). The assemble tasks now finalize by the new copy task. - Adjusted DependencyTask handling for OSS licenses to use explicit buildDir generated JSON file paths, depend on the release task by name, ensure destination directory exists, and copy the release dependencies.json to the debug location if present. --- app/build.gradle.kts | 65 ++++++++++++++------------------------------ gradle.properties | 1 + 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 220e7207..5f8fb45b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import com.android.build.api.variant.FilterConfiguration import com.android.build.api.variant.impl.VariantOutputImpl import com.google.android.gms.oss.licenses.plugin.DependencyTask import com.google.gms.googleservices.GoogleServicesPlugin +import org.gradle.api.tasks.Sync plugins { alias(libs.plugins.android.application) @@ -86,56 +87,26 @@ androidComponents { } val apkDirProvider = variant.artifacts.get(SingleArtifact.APK) - val artifactsLoader = variant.artifacts.getBuiltArtifactsLoader() val capitalizedVariantName = variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } - val renameTask = tasks.register("rename${capitalizedVariantName}Apk") { - inputs.dir(apkDirProvider) + val copyTask = tasks.register("copy${capitalizedVariantName}Apk") { + from(apkDirProvider) + include("*.apk") + into(layout.projectDirectory.dir("releases")) - doLast { - val sourceDir = apkDirProvider.get().asFile - - val builtArtifacts = artifactsLoader.load(apkDirProvider.get()) ?: return@doLast - - val targetDir = project.layout.projectDirectory.dir("releases").asFile.apply { - mkdirs() - } - - targetDir.listFiles() + doFirst { + destinationDir.mkdirs() + destinationDir.listFiles() ?.filter { it.isFile && it.extension == "apk" && it.name.startsWith("monogram-") } - ?.forEach(File::delete) - - builtArtifacts.elements.forEach { artifact -> - val abi = artifact.filters.find { - it.filterType == FilterConfiguration.FilterType.ABI - }?.identifier ?: "universal" - - val versionName = artifact.versionName - val buildType = variant.buildType - - val originalApk = File(artifact.outputFile) - - val targetFile = File( - targetDir, - "monogram-$abi-$versionName-${buildType}.apk" - ) - - originalApk.copyTo(targetFile, overwrite = true) - } - - if (sourceDir == targetDir) { - builtArtifacts.elements - .map { File(it.outputFile) } - .forEach(File::delete) - } + ?.forEach { it.delete() } } } project.tasks.matching { it.name == "assemble${capitalizedVariantName}" }.configureEach { - finalizedBy(renameTask) + finalizedBy(copyTask) } } } @@ -168,14 +139,20 @@ dependencies { tasks.withType(DependencyTask::class.java).configureEach { if (name == "debugOssDependencyTask") { - val releaseTaskProvider = project.tasks.named("releaseOssDependencyTask") + val releaseJsonProvider = + layout.buildDirectory.file("generated/third_party_licenses/release/dependencies.json") + val debugJsonProvider = + layout.buildDirectory.file("generated/third_party_licenses/debug/dependencies.json") - dependsOn(releaseTaskProvider) + dependsOn("releaseOssDependencyTask") doLast { - val releaseJson = releaseTaskProvider.get().dependenciesJson.get().asFile - val debugJson = dependenciesJson.get().asFile - if (releaseJson.exists()) releaseJson.copyTo(debugJson, overwrite = true) + val releaseJson = releaseJsonProvider.get().asFile + val debugJson = debugJsonProvider.get().asFile + if (releaseJson.exists()) { + debugJson.parentFile?.mkdirs() + releaseJson.copyTo(debugJson, overwrite = true) + } } } } diff --git a/gradle.properties b/gradle.properties index 6cc0856c..ff9e0897 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8 --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.ref=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED --add-opens=java.base/java.util.concurrent.locks=ALL-UNNAMED +org.gradle.configuration-cache=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects From 984e6bc11b7a485b1c3dee234da7d18277a5eac7 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:34:57 +0300 Subject: [PATCH 29/83] Preserve string/plurals with keep.xml Add res/raw/keep.xml to both app and presentation modules to prevent resource shrinking from removing string and plural resources. The XML declares the tools namespace and uses tools:keep=@string/*,@plurals/* to retain these resources during build (resource shrinker/R8). --- app/src/main/res/raw/keep.xml | 4 ++++ .../main/java/org/monogram/data/mapper/user/PremiumMapper.kt | 1 + presentation/src/main/res/raw/keep.xml | 4 ++++ 3 files changed, 9 insertions(+) create mode 100644 app/src/main/res/raw/keep.xml create mode 100644 presentation/src/main/res/raw/keep.xml diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 00000000..5739f671 --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,4 @@ + + diff --git a/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt b/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt index 6a02a256..80c55722 100644 --- a/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/user/PremiumMapper.kt @@ -31,6 +31,7 @@ fun TdApi.PremiumFeature.toDomain() : PremiumFeatureType = when (this) { is TdApi.PremiumFeatureAdvancedChatManagement -> PremiumFeatureType.ADVANCED_CHAT_MANAGEMENT is TdApi.PremiumFeatureDisabledAds -> PremiumFeatureType.NO_ADS is TdApi.PremiumFeatureUniqueReactions -> PremiumFeatureType.INFINITE_REACTIONS + is TdApi.PremiumFeatureProfileBadge -> PremiumFeatureType.BADGE is TdApi.PremiumFeatureAppIcons -> PremiumFeatureType.APP_ICONS is TdApi.PremiumFeatureEmojiStatus -> PremiumFeatureType.PROFILE_BADGE else -> PremiumFeatureType.UNKNOWN diff --git a/presentation/src/main/res/raw/keep.xml b/presentation/src/main/res/raw/keep.xml new file mode 100644 index 00000000..5739f671 --- /dev/null +++ b/presentation/src/main/res/raw/keep.xml @@ -0,0 +1,4 @@ + + From 37ee6b1e163f50a68d47364465db4c515b93e82d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:03:33 +0300 Subject: [PATCH 30/83] targeted media path updates Introduce targeted media-path updates and one-shot cache reset support. Change updateMediaPath signature to include chatId and messageId and implement per-message update in in-memory and Room data sources; add queries to clear cached media paths and avatar paths in DAOs. Add clearCachedMediaPaths / clearCachedChatAvatarPaths / clearCachedAvatarPaths implementations across in-memory and Room sources. Remove eager chat avatar file preloading from ChatCache and adjust TdFileHelper to prefer valid stored/cache paths and avoid falling back to stale cache. Replace FileUpdateHandler usage in MessageRepository with messageDownloadFlow handling and add a hard cache reset guarded by a KeyValue flag; update DI to provide StickerPathDao and KeyValueDao. --- .../java/org/monogram/data/chats/ChatCache.kt | 12 ------ .../datasource/cache/ChatLocalDataSource.kt | 4 +- .../cache/InMemoryChatLocalDataSource.kt | 28 ++++++++++-- .../cache/InMemoryUserLocalDataSource.kt | 12 +++++- .../cache/RoomChatLocalDataSource.kt | 8 +++- .../cache/RoomUserLocalDataSource.kt | 7 +++ .../datasource/cache/UserLocalDataSource.kt | 1 + .../java/org/monogram/data/db/dao/ChatDao.kt | 5 ++- .../org/monogram/data/db/dao/MessageDao.kt | 11 ++++- .../java/org/monogram/data/db/dao/UserDao.kt | 5 ++- .../java/org/monogram/data/di/dataModule.kt | 3 +- .../org/monogram/data/mapper/TdFileHelper.kt | 9 ++-- .../data/repository/MessageRepositoryImpl.kt | 43 ++++++++++++++++--- 13 files changed, 115 insertions(+), 33 deletions(-) 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 8186256a..2e0c8de5 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -3,7 +3,6 @@ package org.monogram.data.chats import org.drinkless.tdlib.TdApi import org.monogram.data.datasource.cache.ChatsCacheDataSource import org.monogram.data.datasource.cache.UserCacheDataSource -import java.io.File import java.util.concurrent.ConcurrentHashMap class ChatCache : ChatsCacheDataSource, UserCacheDataSource { @@ -433,17 +432,6 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { ) clientData = "mc:${entity.memberCount};oc:${entity.onlineCount}" } - if (entity.photoId != 0 && !entity.avatarPath.isNullOrEmpty()) { - val avatarFile = File(entity.avatarPath) - if (avatarFile.exists()) { - fileCache[entity.photoId] = TdApi.File().apply { - id = entity.photoId - local = TdApi.LocalFile().apply { - this.path = entity.avatarPath - } - } - } - } chatPermissionsCache[entity.id] = chat.permissions onlineMemberCount[entity.id] = entity.onlineCount putChat(chat) diff --git a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt index 17f66247..e917ff5a 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/ChatLocalDataSource.kt @@ -33,7 +33,9 @@ interface ChatLocalDataSource { editDate: Int ) - suspend fun updateMediaPath(fileId: Int, path: String) + suspend fun updateMediaPath(chatId: Long, messageId: Long, fileId: Int, path: String) + suspend fun clearCachedMediaPaths() + suspend fun clearCachedChatAvatarPaths() suspend fun updateInteractionInfo( chatId: Long, diff --git a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt index 62bfda77..1b85e338 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryChatLocalDataSource.kt @@ -113,12 +113,22 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { } } - override suspend fun updateMediaPath(fileId: Int, path: String) { + override suspend fun updateMediaPath(chatId: Long, messageId: Long, fileId: Int, path: String) { + val flow = messages[chatId] ?: return + val current = flow.value[messageId] ?: return + if (current.mediaFileId != fileId || fileId == 0) return + flow.update { + it + (messageId to current.copy(mediaPath = path)) + } + } + + override suspend fun clearCachedMediaPaths() { + val mediaTypes = setOf("photo", "video", "video_note", "document", "gif", "voice", "sticker", "audio") messages.values.forEach { flow -> flow.update { current -> current.mapValues { (_, message) -> - if (message.mediaFileId == fileId) { - message.copy(mediaPath = path) + if (message.contentType in mediaTypes && (message.mediaPath != null || message.mediaThumbnailPath != null)) { + message.copy(mediaPath = null, mediaThumbnailPath = null) } else { message } @@ -127,6 +137,18 @@ class InMemoryChatLocalDataSource : ChatLocalDataSource { } } + override suspend fun clearCachedChatAvatarPaths() { + chats.update { current -> + current.mapValues { (_, chat) -> + if (chat.avatarPath != null) { + chat.copy(avatarPath = null) + } else { + chat + } + } + } + } + override suspend fun updateInteractionInfo( chatId: Long, messageId: Long, diff --git a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt index e201eb3f..bc157fa2 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/InMemoryUserLocalDataSource.kt @@ -38,4 +38,14 @@ class InMemoryUserLocalDataSource : UserLocalDataSource { override suspend fun deleteExpired(timestamp: Long) { fullInfoEntities.values.removeIf { it.createdAt < timestamp } } -} \ No newline at end of file + + override suspend fun clearCachedAvatarPaths() { + users.values.forEach { user -> + user.profilePhoto = null + } + } + + override suspend fun clearDatabase() { + clearAll() + } +} diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt index d1fe7c99..613e8c01 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomChatLocalDataSource.kt @@ -74,7 +74,13 @@ class RoomChatLocalDataSource( editDate ) - override suspend fun updateMediaPath(fileId: Int, path: String) = messageDao.updateMediaPath(fileId, path) + override suspend fun updateMediaPath(chatId: Long, messageId: Long, fileId: Int, path: String) { + messageDao.updateMediaPathForMessage(chatId = chatId, messageId = messageId, fileId = fileId, path = path) + } + + override suspend fun clearCachedMediaPaths() = messageDao.clearCachedMediaPaths() + + override suspend fun clearCachedChatAvatarPaths() = chatDao.clearAvatarPaths() override suspend fun updateInteractionInfo( chatId: Long, diff --git a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt index 2a684860..084a5c8c 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/RoomUserLocalDataSource.kt @@ -85,4 +85,11 @@ class RoomUserLocalDataSource( userDao.clearAll() userFullInfoDao.clearAll() } + + override suspend fun clearCachedAvatarPaths() { + userDao.clearAvatarPaths() + users.values.forEach { user -> + user.profilePhoto = null + } + } } diff --git a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt index c831bfdc..9aef0942 100644 --- a/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/cache/UserLocalDataSource.kt @@ -19,4 +19,5 @@ interface UserLocalDataSource { suspend fun saveUser(user: UserEntity) {} suspend fun loadUser(userId: Long): UserEntity? = null suspend fun clearDatabase() {} + suspend fun clearCachedAvatarPaths() {} } diff --git a/data/src/main/java/org/monogram/data/db/dao/ChatDao.kt b/data/src/main/java/org/monogram/data/db/dao/ChatDao.kt index 3d5ad8ce..c55883ec 100644 --- a/data/src/main/java/org/monogram/data/db/dao/ChatDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/ChatDao.kt @@ -35,4 +35,7 @@ interface ChatDao { @Query("DELETE FROM chats WHERE createdAt < :timestamp") suspend fun deleteExpired(timestamp: Long) -} \ No newline at end of file + + @Query("UPDATE chats SET avatarPath = NULL WHERE avatarPath IS NOT NULL") + suspend fun clearAvatarPaths() +} diff --git a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt index df8a231f..daae21bb 100644 --- a/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/MessageDao.kt @@ -38,8 +38,15 @@ interface MessageDao { editDate: Int ) - @Query("UPDATE messages SET mediaPath = :path WHERE mediaFileId = :fileId AND mediaFileId != 0") - suspend fun updateMediaPath(fileId: Int, path: String) + @Query( + "UPDATE messages SET mediaPath = :path WHERE chatId = :chatId AND id = :messageId AND mediaFileId = :fileId AND mediaFileId != 0" + ) + suspend fun updateMediaPathForMessage(chatId: Long, messageId: Long, fileId: Int, path: String) + + @Query( + "UPDATE messages SET mediaPath = NULL, mediaThumbnailPath = NULL WHERE (mediaPath IS NOT NULL OR mediaThumbnailPath IS NOT NULL) AND contentType IN ('photo', 'video', 'video_note', 'document', 'gif', 'voice', 'sticker', 'audio')" + ) + suspend fun clearCachedMediaPaths() @Query("UPDATE messages SET viewCount = :viewCount, forwardCount = :forwardCount, replyCount = :replyCount WHERE chatId = :chatId AND id = :messageId") suspend fun updateInteractionInfo( diff --git a/data/src/main/java/org/monogram/data/db/dao/UserDao.kt b/data/src/main/java/org/monogram/data/db/dao/UserDao.kt index 9ad1a9f4..bb2af930 100644 --- a/data/src/main/java/org/monogram/data/db/dao/UserDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/UserDao.kt @@ -28,4 +28,7 @@ interface UserDao { @Query("DELETE FROM users WHERE createdAt < :timestamp") suspend fun deleteExpired(timestamp: Long) -} \ No newline at end of file + + @Query("UPDATE users SET avatarPath = NULL, personalAvatarPath = NULL WHERE avatarPath IS NOT NULL OR personalAvatarPath IS NOT NULL") + suspend fun clearAvatarPaths() +} 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 74729457..53f166bd 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -618,7 +618,8 @@ val dataModule = module { fileDataSource = get(), chatLocalDataSource = get(), userLocalDataSource = get(), - fileUpdateHandler = get(), + stickerPathDao = get(), + keyValueDao = get(), textCompositionStyleDao = get() ) } diff --git a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt index d113c63c..8895912e 100644 --- a/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt +++ b/data/src/main/java/org/monogram/data/mapper/TdFileHelper.kt @@ -45,14 +45,17 @@ class TdFileHelper( } fun resolveCachedPath(fileId: Int, storedPath: String?): String? { + val fromCache = fileId.takeIf { it != 0 } + ?.let { cache.fileCache[it]?.local?.path } + ?.takeIf { isValidPath(it) } + if (fromCache != null) return fromCache + val fromStored = storedPath ?.takeIf { it.isNotBlank() } ?.takeIf { isValidPath(it) } if (fromStored != null) return fromStored - return fileId.takeIf { it != 0 } - ?.let { cache.fileCache[it]?.local?.path } - ?.takeIf { isValidPath(it) } + return null } fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) { 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 5a552b51..870949ea 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -19,11 +19,13 @@ import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.cache.ChatLocalDataSource import org.monogram.data.datasource.cache.UserLocalDataSource import org.monogram.data.datasource.remote.MessageRemoteDataSource +import org.monogram.data.db.dao.KeyValueDao +import org.monogram.data.db.dao.StickerPathDao import org.monogram.data.db.dao.TextCompositionStyleDao +import org.monogram.data.db.model.KeyValueEntity import org.monogram.data.db.model.TextCompositionStyleEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher -import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.map @@ -36,6 +38,7 @@ import org.monogram.domain.models.FileModel import org.monogram.domain.models.InlineQueryResultModel import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.MessageDownloadEvent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageSenderModel @@ -68,10 +71,12 @@ class MessageRepositoryImpl( private val scope: CoroutineScope, private val chatLocalDataSource: ChatLocalDataSource, private val userLocalDataSource: UserLocalDataSource, - private val fileUpdateHandler: FileUpdateHandler, + private val stickerPathDao: StickerPathDao, + private val keyValueDao: KeyValueDao, private val textCompositionStyleDao: TextCompositionStyleDao ) : MessageRepository { private val _textCompositionStyles = MutableStateFlow>(emptyList()) + private val hardResetFlagKey = "cache_hard_reset_v2" override val newMessageFlow = messageRemoteDataSource.newMessageFlow override val senderUpdateFlow = messageMapper.senderUpdateFlow @@ -117,16 +122,40 @@ class MessageRepositoryImpl( chatLocalDataSource.deleteExpired(ninetyDaysAgo) } - scope.launch { - fileUpdateHandler.fileDownloadCompleted.collect { (fileIdLong, path) -> - val fileId = fileIdLong.toInt() - if (fileId != 0 && path.isNotBlank()) { - chatLocalDataSource.updateMediaPath(fileId, path) + scope.launch(dispatcherProvider.io) { + performHardCacheResetIfNeeded() + } + + scope.launch(dispatcherProvider.io) { + messageDownloadFlow.collect { event -> + if (event is MessageDownloadEvent.Completed && event.fileId != 0 && event.path.isNotBlank()) { + chatLocalDataSource.updateMediaPath( + chatId = event.chatId, + messageId = event.messageId, + fileId = event.fileId, + path = event.path + ) } } } } + private suspend fun performHardCacheResetIfNeeded() { + val alreadyCleared = keyValueDao.getValue(hardResetFlagKey)?.value == "1" + if (alreadyCleared) return + + coRunCatching { + chatLocalDataSource.clearAll() + userLocalDataSource.clearDatabase() + stickerPathDao.clearAll() + cache.clearAll() + keyValueDao.insertValue(KeyValueEntity(hardResetFlagKey, "1")) + Log.i("MessageRepository", "One-shot hard cache reset completed") + }.onFailure { error -> + Log.e("MessageRepository", "Failed to perform hard cache reset", error) + } + } + private suspend fun processCachedUpdate(update: TdApi.Update) { when (update) { is TdApi.UpdateNewMessage -> { From 714da7494a030fe6ab19adab9ded8cbc977a0849 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:13:02 +0300 Subject: [PATCH 31/83] Add video duration to gallery items and UI --- .../features/gallery/GalleryMediaQueries.kt | 6 +++++- .../features/gallery/GalleryModels.kt | 1 + .../gallery/components/GalleryMediaGrid.kt | 17 ++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt index 6c21143d..af51b7bd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryMediaQueries.kt @@ -34,6 +34,7 @@ fun queryImages(context: Context): List { ), dateAdded = cursor.getLong(dateColumn), isVideo = false, + durationMs = null, bucketName = bucket, relativePath = relative, isCamera = isCameraBucket(bucket, relative), @@ -51,7 +52,8 @@ fun queryVideos(context: Context): List { MediaStore.Video.Media._ID, MediaStore.Video.Media.DATE_ADDED, MediaStore.Video.Media.BUCKET_DISPLAY_NAME, - MediaStore.Video.Media.RELATIVE_PATH + MediaStore.Video.Media.RELATIVE_PATH, + MediaStore.Video.Media.DURATION ) context.contentResolver.query( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, @@ -64,6 +66,7 @@ fun queryVideos(context: Context): List { val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED) val bucketColumn = cursor.getColumnIndex(MediaStore.Video.Media.BUCKET_DISPLAY_NAME) val relColumn = cursor.getColumnIndex(MediaStore.Video.Media.RELATIVE_PATH) + val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION) while (cursor.moveToNext()) { val bucket = if (bucketColumn != -1) cursor.getString(bucketColumn).orEmpty() else "" val relative = if (relColumn != -1) cursor.getString(relColumn).orEmpty() else "" @@ -75,6 +78,7 @@ fun queryVideos(context: Context): List { ), dateAdded = cursor.getLong(dateColumn), isVideo = true, + durationMs = cursor.getLong(durationColumn).takeIf { it > 0L }, bucketName = bucket, relativePath = relative, isCamera = isCameraBucket(bucket, relative), diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt index 7d877dcd..45d80369 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryModels.kt @@ -19,6 +19,7 @@ data class GalleryMediaItem( val uri: Uri, val dateAdded: Long, val isVideo: Boolean, + val durationMs: Long?, val bucketName: String, val relativePath: String, val isCamera: Boolean, diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt index f16b729c..51bac7e2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryMediaGrid.kt @@ -28,6 +28,20 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import org.monogram.presentation.R import org.monogram.presentation.features.gallery.GalleryMediaItem +import java.util.Locale + +private fun formatDuration(durationMs: Long): String { + val totalSeconds = durationMs / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + return if (hours > 0) { + String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } +} @Composable fun GalleryGrid( @@ -97,7 +111,8 @@ fun GalleryGrid( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) ) { Text( - text = stringResource(R.string.media_type_video), + text = item.durationMs?.let(::formatDuration) + ?: stringResource(R.string.media_type_video), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) From e579db9feb2bc6c7e81d62dc67b8c7a8494580c6 Mon Sep 17 00:00:00 2001 From: Andro_Dev <87223939+andr0d1v@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:25:39 +0300 Subject: [PATCH 32/83] feat(files): download progress (#215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image реализовал отображение прогресса загрузки файлов --- .../channels/ChannelAlbumMessageBubble.kt | 4 +-- .../components/chats/AudioMessageBubble.kt | 2 +- .../components/chats/DocumentMessageBubble.kt | 2 +- .../components/chats/MessageUtils.kt | 28 +++++++++++++------ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt index 299902af..3a9470c9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt @@ -415,7 +415,7 @@ fun ChannelDocumentAlbumBubble( overflow = TextOverflow.Ellipsis ) Text( - text = formatFileSize(content.size), + text = formatFileSize(content.size, content.isDownloading, content.downloadProgress), style = MaterialTheme.typography.labelSmall, color = timeColor ) @@ -618,7 +618,7 @@ fun ChannelAudioAlbumBubble( overflow = TextOverflow.Ellipsis ) Text( - text = content.performer.ifEmpty { formatFileSize(content.size) }, + text = content.performer.ifEmpty { formatFileSize(content.size, content.isDownloading, content.downloadProgress) }, style = MaterialTheme.typography.labelSmall, color = timeColor, maxLines = 1, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt index 3d413cce..9bd282de 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt @@ -264,7 +264,7 @@ fun AudioRow( overflow = TextOverflow.Ellipsis ) Text( - text = content.performer.ifEmpty { formatFileSize(content.size) }, + text = content.performer.ifEmpty { formatFileSize(content.size, content.isDownloading, content.downloadProgress) }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), maxLines = 1, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt index c89d6842..1cee123f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt @@ -288,7 +288,7 @@ fun DocumentRow( overflow = TextOverflow.Ellipsis ) Text( - text = formatFileSize(content.size), + text = formatFileSize(content.size, content.isDownloading, content.downloadProgress), style = MaterialTheme.typography.labelSmall, color = timeColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt index 8c40848a..b385b50a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt @@ -50,16 +50,26 @@ fun formatDuration(seconds: Int): String { val s = seconds % 60 return String.format(Locale.getDefault(), "%02d:%02d", m, s) } -fun formatFileSize(size: Long): String { - if (size <= 0) return "0 B" +fun formatFileSize(size: Long, isDownloading: Boolean, downloadProgress: Float): String { val units = arrayOf("B", "KB", "MB", "GB", "TB") - val digitGroups = (log10(size.toDouble()) / log10(1024.0)).toInt() - return String.format( - Locale.getDefault(), - "%.1f %s", - size / 1024.0.pow(digitGroups.toDouble()), - units[digitGroups] - ) + + fun format(value: Double): String { + if (value <= 0) return "0 B" + val digitGroups = (log10(value) / log10(1024.0)).toInt() + return String.format( + Locale.US, + "%.1f %s", + value / 1024.0.pow(digitGroups.toDouble()), + units[digitGroups] + ) + } + + return if (isDownloading) { + val downloaded = size * downloadProgress + "${format(downloaded.toDouble())} / ${format(size.toDouble())}" + } else { + format(size.toDouble()) + } } fun getEmojiFontFileName(style: EmojiStyle): String? = when (style) { From 27f3a22e1e921a1c7dacd0cde95b20519acbf442 Mon Sep 17 00:00:00 2001 From: keimoger Date: Thu, 9 Apr 2026 15:02:02 +0400 Subject: [PATCH 33/83] Pretty folding and unfolding, fullscreen media, no hang on startup and so on (#217) --- .../java/org/monogram/app/MainActivity.kt | 19 +- .../main/java/org/monogram/app/MainContent.kt | 56 ++- .../monogram/app/components/TabletLayout.kt | 104 +++- .../datasource/remote/AuthRemoteDataSource.kt | 1 + .../remote/TdAuthRemoteDataSource.kt | 4 + .../java/org/monogram/data/di/TdLibClient.kt | 18 +- .../data/repository/AuthRepositoryImpl.kt | 63 ++- gradle/libs.versions.toml | 9 +- .../chats/chatList/ChatListContent.kt | 77 ++- .../chats/chatList/components/ChatListItem.kt | 10 +- .../chatList/components/ChatListTopBar.kt | 5 + .../features/chats/currentChat/ChatContent.kt | 106 ++-- .../chatContent/ChatContentViewers.kt | 139 ++++-- .../currentChat/components/ChatTopBar.kt | 14 +- .../inputbar/ChatInputBarComposerSection.kt | 15 +- .../profile/DefaultProfileComponent.kt | 80 +++ .../features/profile/ProfileComponent.kt | 33 ++ .../features/profile/ProfileContent.kt | 185 +------ .../features/profile/ProfileViewers.kt | 470 ++++++++++++++++++ .../components/ProfileHeaderTransformed.kt | 10 +- .../profile/components/ProfileSections.kt | 16 +- .../stickers/ui/menu/MessageOptionsMenu.kt | 11 +- .../features/viewers/MediaViewer.kt | 6 +- .../settings/settings/SettingsContent.kt | 30 +- 24 files changed, 1084 insertions(+), 397 deletions(-) create mode 100644 presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index 4d4ec909..3ed05019 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -6,7 +6,10 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.layout.WindowInfoTracker import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.retainedComponent import org.koin.android.ext.android.inject @@ -41,13 +44,21 @@ class MainActivity : FragmentActivity() { handleIntent(intent) startNotificationService() + val windowInfoTracker = WindowInfoTracker.getOrCreate(this) + setContent { + val windowLayoutInfo by windowInfoTracker.windowLayoutInfo(this) + .collectAsStateWithLifecycle(initialValue = null) + AppThemeContainer(root.appPreferences) { CompositionLocalProvider( - LocalLinkHandler provides root::handleLink, - LocalVideoPlayerPool provides root.videoPlayerPool + LocalLinkHandler provides root::handleLink, + LocalVideoPlayerPool provides root.videoPlayerPool ) { - MainContent(root) + MainContent( + root = root, + windowLayoutInfo = windowLayoutInfo + ) } } } @@ -80,4 +91,4 @@ class MainActivity : FragmentActivity() { startService(intent) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt index f9152c95..d908843c 100644 --- a/app/src/main/java/org/monogram/app/MainContent.kt +++ b/app/src/main/java/org/monogram/app/MainContent.kt @@ -1,36 +1,57 @@ package org.monogram.app -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseInBack import androidx.compose.animation.core.EaseOutBack import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.window.core.layout.WindowSizeClass as WindowSizeClassCore import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.zIndex -import androidx.window.core.layout.WindowWidthSizeClass +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowLayoutInfo import com.arkivanov.decompose.extensions.compose.subscribeAsState -import org.monogram.app.components.* +import org.monogram.app.components.ChatConfirmJoinSheet +import org.monogram.app.components.LockScreen +import org.monogram.app.components.MobileLayout +import org.monogram.app.components.ProxyConfirmSheet +import org.monogram.app.components.TabletLayout +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentViewers import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet +import org.monogram.presentation.features.profile.ProfileViewers import org.monogram.presentation.features.stickers.core.toDomain import org.monogram.presentation.root.RootComponent @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MainContent(root: RootComponent) { +fun MainContent( + root: RootComponent, + windowLayoutInfo: WindowLayoutInfo? +) { val childStack by root.childStack.subscribeAsState() val isLocked by root.isLocked.collectAsState() - val adaptiveInfo = currentWindowAdaptiveInfo() - val isExpanded = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED + val localClipboard = LocalClipboard.current + + val isExpanded = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClassCore.WIDTH_DP_MEDIUM_LOWER_BOUND) || + windowLayoutInfo?.displayFeatures?.filterIsInstance()?.any { + it.orientation == FoldingFeature.Orientation.VERTICAL && it.isSeparating + } == true + val activeChild = childStack.active.instance Box( @@ -56,7 +77,7 @@ fun MainContent(root: RootComponent) { activeChild !is RootComponent.Child.AuthChild && activeChild !is RootComponent.Child.StartupChild ) { - TabletLayout(root, childStack) + TabletLayout(childStack, windowLayoutInfo) } else { MobileLayout(root) } @@ -94,5 +115,24 @@ fun MainContent(root: RootComponent) { ) { LockScreen(root) } + + when (activeChild) { + is RootComponent.Child.ChatDetailChild -> { + val chatState by activeChild.component.state.collectAsState() + ChatContentViewers( + state = chatState, + component = activeChild.component, + localClipboard = localClipboard + ) + } + is RootComponent.Child.ProfileChild -> { + val profileState by activeChild.component.state.subscribeAsState() + ProfileViewers( + state = profileState, + component = activeChild.component + ) + } + else -> {} + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/monogram/app/components/TabletLayout.kt b/app/src/main/java/org/monogram/app/components/TabletLayout.kt index fb5c0491..9f98a745 100644 --- a/app/src/main/java/org/monogram/app/components/TabletLayout.kt +++ b/app/src/main/java/org/monogram/app/components/TabletLayout.kt @@ -1,23 +1,85 @@ package org.monogram.app.components +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowLayoutInfo import com.arkivanov.decompose.router.stack.ChildStack import org.monogram.app.R import org.monogram.presentation.root.RootComponent +import android.os.Build +import android.view.RoundedCorner @Composable -fun TabletLayout(root: RootComponent, childStack: ChildStack<*, RootComponent.Child>) { +fun TabletLayout( + childStack: ChildStack<*, RootComponent.Child>, + windowLayoutInfo: WindowLayoutInfo? +) { val activeChild = childStack.active.instance val isSettings = isSettingsSelected(childStack) + val density = LocalDensity.current + val windowInfo = LocalWindowInfo.current + val screenWidthDp = with(density) { windowInfo.containerSize.width.toDp() } + + val view = LocalView.current + val deviceCornerRadius = remember(view, density, screenWidthDp) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val insets = view.rootWindowInsets + val corners = listOfNotNull( + insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT), + insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT), + insets?.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT), + insets?.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT) + ) + + if (corners.isNotEmpty()) { + // Use the reported radius if available. On sharp devices, this is 0. + val radiusPx = corners.maxOf { it.radius } + with(density) { radiusPx.toDp() } + } else { + // Fallback only if the OS provides no corner information at all. + if (screenWidthDp > 600.dp) 28.dp else 16.dp + } + } else { + if (screenWidthDp > 600.dp) 28.dp else 16.dp + } + } + + val foldingFeature = windowLayoutInfo?.displayFeatures + ?.filterIsInstance() + ?.find { it.orientation == FoldingFeature.Orientation.VERTICAL } + + val targetListWidth = remember(foldingFeature, screenWidthDp) { + if (foldingFeature != null && foldingFeature.isSeparating) { + val hingeBounds = foldingFeature.bounds + if (hingeBounds.left > 0) { + with(density) { hingeBounds.left.toDp() } + } else { + screenWidthDp / 2 + } + } else { + 350.dp + } + } + + val animatedWidth by animateDpAsState( + targetValue = targetListWidth, + animationSpec = tween(durationMillis = 500), + label = "ListPaneWidth" + ) val listChild = remember(childStack) { val settingsChild = childStack.backStack.find { @@ -34,28 +96,38 @@ fun TabletLayout(root: RootComponent, childStack: ChildStack<*, RootComponent.Ch } } - Row(Modifier.fillMaxSize()) { + val paneCornerRadius = remember(deviceCornerRadius) { + val opticalScaling = 1.5f + (deviceCornerRadius * opticalScaling - 6.dp).coerceAtLeast(0.dp) + } + + Row( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerLow) + ) { Box( modifier = Modifier - .width(350.dp) - .fillMaxHeight(), + .width(animatedWidth) + .fillMaxHeight() + .padding(start = 6.dp, end = 6.dp, top = 6.dp, bottom = 6.dp) + .clip(RoundedCornerShape(paneCornerRadius)) + .background(MaterialTheme.colorScheme.surface), ) { if (listChild != null) { RenderChild(listChild) } } - HorizontalDivider( - modifier = Modifier - .fillMaxHeight() - .width(1.dp), - color = MaterialTheme.colorScheme.outlineVariant, - ) + Spacer(modifier = Modifier.width(6.dp)) Box( modifier = Modifier .weight(1f) - .fillMaxHeight(), + .fillMaxHeight() + .padding(end = 6.dp, top = 6.dp, bottom = 6.dp) + .clip(RoundedCornerShape(paneCornerRadius)) + .background(MaterialTheme.colorScheme.surface), ) { val isListOnly = activeChild == listChild diff --git a/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt index bcb6c7c4..f068a3ea 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/AuthRemoteDataSource.kt @@ -4,6 +4,7 @@ import org.drinkless.tdlib.TdApi interface AuthRemoteDataSource { suspend fun setTdlibParameters(parameters: TdApi.SetTdlibParameters) + suspend fun getAuthorizationState(): TdApi.AuthorizationState suspend fun setPhoneNumber(phone: String) suspend fun resendCode() suspend fun setAuthCode(code: String) diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt index e494abf1..bf4350d8 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdAuthRemoteDataSource.kt @@ -11,6 +11,10 @@ class TdAuthRemoteDataSource( gateway.execute(parameters) } + override suspend fun getAuthorizationState(): TdApi.AuthorizationState { + return gateway.execute(TdApi.GetAuthorizationState()) + } + override suspend fun setPhoneNumber(phone: String) { val settings = TdApi.PhoneNumberAuthenticationSettings().apply { isCurrentPhoneNumber = false diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index c31624c2..95395530 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.suspendCancellableCoroutine import org.drinkless.tdlib.Client import org.drinkless.tdlib.TdApi @@ -26,6 +27,9 @@ internal class TdLibClient { private val _isAuthenticated = MutableStateFlow(false) val isAuthenticated = _isAuthenticated.asStateFlow() + private val _isInitialized = MutableStateFlow(false) + val isInitialized = _isInitialized.asStateFlow() + init { try { Client.execute(TdApi.SetLogVerbosityLevel(0)) @@ -41,7 +45,9 @@ internal class TdLibClient { { result -> if (result is TdApi.Update) { if (result is TdApi.UpdateAuthorizationState) { - _isAuthenticated.value = result.authorizationState is TdApi.AuthorizationStateReady + val state = result.authorizationState + _isInitialized.value = state !is TdApi.AuthorizationStateWaitTdlibParameters + _isAuthenticated.value = state is TdApi.AuthorizationStateReady } _updates.tryEmit(result) } @@ -68,6 +74,16 @@ internal class TdLibClient { } suspend fun sendSuspend(function: TdApi.Function): T { + if (function !is TdApi.SetTdlibParameters && + function !is TdApi.SetLogVerbosityLevel && + function !is TdApi.GetOption && + function !is TdApi.GetAuthorizationState) { + if (!_isInitialized.value) { + Log.d(TAG, "Waiting for TDLib initialization before sending $function") + isInitialized.first { it } + } + } + var retries = 0 while (true) { waitForGlobalRetryWindow() diff --git a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt index 7dd3ff63..58525fd0 100644 --- a/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AuthRepositoryImpl.kt @@ -1,8 +1,10 @@ package org.monogram.data.repository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.AuthRemoteDataSource @@ -25,21 +27,66 @@ class AuthRepositoryImpl( private val _errors = MutableSharedFlow(extraBufferCapacity = 1) override val errors = _errors.asSharedFlow() + private val initMutex = Mutex() + init { scope.launch { + // Proactively check current state in case we missed the update + launchAuthAction { + val state = remote.getAuthorizationState() + handleUpdate(state) + } + updates.authorizationState.collect { update -> - if (update.authorizationState is TdApi.AuthorizationStateWaitTdlibParameters) { - sendTdLibParameters() - } - val domainState = update.authorizationState.toDomain() - _authState.update { domainState } + handleUpdate(update.authorizationState) } } } - private suspend fun sendTdLibParameters() { - coRunCatching { remote.setTdlibParameters(parametersProvider.create()) } - .onFailure { emitError(it) } + private fun handleUpdate(state: TdApi.AuthorizationState) { + if (state is TdApi.AuthorizationStateWaitTdlibParameters) { + sendTdLibParameters() + } + val domainState = state.toDomain() + _authState.update { domainState } + } + + private fun sendTdLibParameters() { + if (!initMutex.tryLock()) return + + scope.launch { + try { + var attempts = 0 + while (true) { + // Double check if we still need to send parameters + val currentState = coRunCatching { remote.getAuthorizationState() }.getOrNull() + if (currentState != null && currentState !is TdApi.AuthorizationStateWaitTdlibParameters) { + break + } + + val result = coRunCatching { remote.setTdlibParameters(parametersProvider.create()) } + if (result.isSuccess) { + // After success, immediately re-check state to move past WaitParameters + val nextState = coRunCatching { remote.getAuthorizationState() }.getOrNull() + if (nextState != null) { + handleUpdate(nextState) + } + break + } + + val error = result.exceptionOrNull() + if (error?.message?.contains("Parameters are already set", ignoreCase = true) == true) { + break + } + + attempts++ + val delayMs = (1000L * attempts).coerceAtMost(10_000L) + delay(delayMs) + } + } finally { + initMutex.unlock() + } + } } private fun launchAuthAction(action: suspend () -> Unit) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7220bce0..c1c5d0f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidx-room = "2.8.4" androidx-uiautomator = "2.3.0" androidx-benchmark-macro-junit4 = "1.4.1" androidx-test-ext-junit = "1.3.0" +androidx-window = "1.5.1" # KotlinX kotlinx-coroutines = "1.10.2" @@ -50,6 +51,7 @@ libphonenumber = "9.0.27" [libraries] # AndroidX Activity androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activityCompose" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } # AndroidX Biometric androidx-biometric = { module = "androidx.biometric:biometric-compose", version.ref = "androidx-biometric" } @@ -68,6 +70,8 @@ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", na androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended-android" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-material3" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "androidx-adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "androidx-adaptive" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "androidx-adaptive" } androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } androidx-compose-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidx-compose-runtime" } @@ -149,12 +153,15 @@ androidx-compose = [ "androidx-compose-ui", "androidx-compose-material3", "androidx-compose-material3-adaptive", + "androidx-compose-material3-adaptive-layout", + "androidx-compose-material3-adaptive-navigation", "androidx-compose-material3-adaptive-navigation-suite", "androidx-compose-material3-windowsizeclass", "androidx-compose-foundation-layout", "androidx-compose-ui-graphics", "androidx-compose-runtime", - "androidx-compose-material-icons-extended" + "androidx-compose-material-icons-extended", + "androidx-window" ] androidx-media3 = [ "androidx-media3-common", diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index a08e4df7..2e094599 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -53,7 +53,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.window.core.layout.WindowWidthSizeClass +import androidx.window.core.layout.WindowSizeClass import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -94,7 +94,7 @@ fun ChatListContent(component: ChatListComponent) { var showPermissionRequest by remember { mutableStateOf(!isPermissionRequested) } val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED + val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) val isCustomBackHandlingEnabled = state.isSearchActive || state.selectedChatIds.isNotEmpty() || state.selectedFolderId == -2 || state.isForwarding || state.instantViewUrl != null || state.webAppUrl != null || state.webViewUrl != null || showStatusMenu @@ -218,9 +218,9 @@ fun ChatListContent(component: ChatListComponent) { } val maxHide = if (isArchivePersistent) { - -(archiveItemHeightPx + tabsHeightPx) + -archiveItemHeightPx } else { - -tabsHeightPx + 0f } val oldOffset = headerOffsetPx @@ -311,23 +311,13 @@ fun ChatListContent(component: ChatListComponent) { } } - val maxHide = if (isArchivePersistent) { - -(archiveItemHeightPx + tabsHeightPx) - } else { - -tabsHeightPx - } + val maxHide = if (isArchivePersistent) -archiveItemHeightPx else 0f if (headerOffsetPx < 0f && headerOffsetPx > maxHide) { val target = if (isArchivePersistent) { - if (headerOffsetPx > -archiveItemHeightPx / 2) { - 0f - } else if (headerOffsetPx > -archiveItemHeightPx - tabsHeightPx / 2) { - -archiveItemHeightPx - } else { - maxHide - } + if (headerOffsetPx > -archiveItemHeightPx / 2) 0f else -archiveItemHeightPx } else { - if (headerOffsetPx > maxHide / 2) 0f else maxHide + 0f } headerAnimationJob = scope.launch { animate(initialValue = headerOffsetPx, targetValue = target) { value, _ -> @@ -555,13 +545,7 @@ fun ChatListContent(component: ChatListComponent) { } else { 0f } - val visibleTabsHeight = if (isArchivePersistent) { - (tabsHeightPx + (headerOffsetPx + archiveItemHeightPx).coerceAtMost(0f)).coerceAtLeast( - 0f - ) - } else { - (tabsHeightPx + headerOffsetPx).coerceAtLeast(0f) - } + val visibleTabsHeight = tabsHeightPx (visibleArchiveHeight + visibleTabsHeight).toDp() }) .clip(RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp)), @@ -614,14 +598,7 @@ fun ChatListContent(component: ChatListComponent) { if (state.folders.size > 1) { FolderTabs( - modifier = Modifier.offset { - val yOffset = if (isArchivePersistent) { - (headerOffsetPx + archiveItemHeightPx).coerceAtMost(0f) - } else { - headerOffsetPx - } - IntOffset(0, yOffset.roundToInt()) - }, + modifier = Modifier, folders = state.folders, pagerState = pagerState, onTabClick = { index -> @@ -684,7 +661,7 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .padding(top = padding.calculateTopPadding()) .fillMaxSize(), - shape = if (isTablet) RoundedCornerShape(0.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + shape = if (isTablet) RoundedCornerShape(16.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), color = if (isTablet) Color.Transparent else MaterialTheme.colorScheme.surface ) { Box(modifier = Modifier.fillMaxSize()) { @@ -762,7 +739,12 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .fillMaxSize() .semantics { contentDescription = "ChatList" }, - contentPadding = PaddingValues(top = 12.dp, bottom = 88.dp), + contentPadding = PaddingValues( + top = 12.dp, + bottom = 88.dp, + start = if (isTablet) 12.dp else 0.dp, + end = if (isTablet) 12.dp else 0.dp + ), ) { if (state.isSearchActive) { if (state.searchQuery.isEmpty() && state.searchHistory.isNotEmpty()) { @@ -861,9 +843,11 @@ fun ChatListContent(component: ChatListComponent) { isSelected = false, onClick = { onChatClicked(chat.id) }, onLongClick = { component.onRemoveSearchHistoryItem(chat.id) }, + isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos + showPhotos = showPhotos, + activeChatId = state.activeChatId ) } } @@ -886,9 +870,11 @@ fun ChatListContent(component: ChatListComponent) { isSelected = state.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, + isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos + showPhotos = showPhotos, + activeChatId = state.activeChatId ) } } @@ -914,9 +900,11 @@ fun ChatListContent(component: ChatListComponent) { isSelected = state.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, + isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos + showPhotos = showPhotos, + activeChatId = state.activeChatId ) } @@ -1002,7 +990,8 @@ fun ChatListContent(component: ChatListComponent) { isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos + showPhotos = showPhotos, + activeChatId = state.activeChatId ) } } @@ -1106,7 +1095,12 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .fillMaxSize() .semantics { contentDescription = "ChatList" }, - contentPadding = PaddingValues(top = 12.dp, bottom = 88.dp) + contentPadding = PaddingValues( + top = 12.dp, + bottom = 88.dp, + start = if (isTablet) 4.dp else 0.dp, + end = if (isTablet) 4.dp else 0.dp + ) ) { if (folderChats.isEmpty() && hasFolderLoadState && !isFolderLoading) { item { @@ -1128,7 +1122,8 @@ fun ChatListContent(component: ChatListComponent) { isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos + showPhotos = showPhotos, + activeChatId = state.activeChatId ) } } @@ -1213,7 +1208,7 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .width(menuWidth) .heightIn(max = maxMenuHeightDp) - .clip(ShapeDefaults.LargeIncreased) + .clip(RoundedCornerShape(16.dp)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 090a52ec..49eea301 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.ChatModel import org.monogram.domain.models.MessageEntityType import org.monogram.presentation.R @@ -40,6 +41,7 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.bui import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent import org.monogram.presentation.features.stickers.ui.view.StickerImage import org.monogram.core.date.toDate +import org.monogram.presentation.features.chats.ChatListComponent @OptIn(ExperimentalFoundationApi::class) @Composable @@ -53,15 +55,17 @@ fun ChatListItem( messageLines: Int, showPhotos: Boolean, modifier: Modifier = Modifier, - isTabletSelected: Boolean = false + isTabletSelected: Boolean = false, + activeChatId: Long? ) { val isSavedMessages = chat.id == currentUserId val backgroundColor by animateColorAsState( targetValue = when { + isTabletSelected -> MaterialTheme.colorScheme.primaryContainer isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - isTabletSelected -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) chat.isPinned -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + activeChatId == chat.id -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else -> Color.Transparent }, label = "ItemBg" @@ -71,7 +75,7 @@ fun ChatListItem( modifier = modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(24)) + .clip(RoundedCornerShape(12.dp)) .background(backgroundColor) .combinedClickable(onClick = onClick, onLongClick = onLongClick) .padding(horizontal = 8.dp, vertical = 8.dp) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt index fe443ab2..6d819f9c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt @@ -12,9 +12,11 @@ import androidx.compose.material.icons.rounded.Shield import androidx.compose.material.icons.rounded.ShieldMoon import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.* +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.window.core.layout.WindowSizeClass import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.boundsInRoot @@ -50,6 +52,9 @@ fun ChatListTopBar( val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() val motionScheme = MaterialTheme.motionScheme + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + AnimatedContent( targetState = isSearchActive, transitionSpec = { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 91b8ebe5..bde4127b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -771,43 +771,43 @@ fun ChatContent( val onPhotoClickStable: (MessageModel, List, List, List, Int) -> Unit = remember(component) { { msg: MessageModel, paths: List, captions: List, messageIds: List, index: Int -> - val content = msg.content as? MessageContent.Photo - val clickedPath = paths.getOrNull(index) - ?.takeIf { it.isNotBlank() && File(it).exists() } - ?: content?.path?.takeIf { File(it).exists() } + val content = msg.content as? MessageContent.Photo + val clickedPath = paths.getOrNull(index) + ?.takeIf { it.isNotBlank() && File(it).exists() } + ?: content?.path?.takeIf { File(it).exists() } - if (clickedPath != null) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() + if (clickedPath != null) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() - val validItems = paths.mapIndexedNotNull { itemIndex, path -> - val validPath = path.takeIf { it.isNotBlank() && File(it).exists() } - ?: return@mapIndexedNotNull null - Triple(itemIndex, validPath, captions.getOrNull(itemIndex)) - } + val validItems = paths.mapIndexedNotNull { itemIndex, path -> + val validPath = path.takeIf { it.isNotBlank() && File(it).exists() } + ?: return@mapIndexedNotNull null + Triple(itemIndex, validPath, captions.getOrNull(itemIndex)) + } + + if (validItems.isNotEmpty()) { + val validPaths = validItems.map { it.second } + val validCaptions = validItems.map { it.third } + val validMessageIds = validItems.map { (itemIndex, _, _) -> + messageIds.getOrNull(itemIndex) ?: msg.id + } + val startIndex = validItems.indexOfFirst { (itemIndex, _, _) -> itemIndex == index } + .takeIf { it >= 0 } + ?: validPaths.indexOf(clickedPath).takeIf { it >= 0 } + ?: 0 - if (validItems.isNotEmpty()) { - val validPaths = validItems.map { it.second } - val validCaptions = validItems.map { it.third } - val validMessageIds = validItems.map { (itemIndex, _, _) -> - messageIds.getOrNull(itemIndex) ?: msg.id + component.onOpenImages( + images = validPaths, + captions = validCaptions, + startIndex = startIndex, + messageId = msg.id, + messageIds = validMessageIds + ) } - val startIndex = validItems.indexOfFirst { (itemIndex, _, _) -> itemIndex == index } - .takeIf { it >= 0 } - ?: validPaths.indexOf(clickedPath).takeIf { it >= 0 } - ?: 0 - - component.onOpenImages( - images = validPaths, - captions = validCaptions, - startIndex = startIndex, - messageId = msg.id, - messageIds = validMessageIds - ) + } else { + content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) } - } else { - content?.fileId?.takeIf { it != 0 }?.let(component::onDownloadFile) - } Unit } } @@ -818,25 +818,25 @@ fun ChatContent( if (!currentIsVisible.value || currentShowInitialLoading.value || scrollState.isScrollInProgress) { Unit } else { - val videoContent = msg.content as? MessageContent.Video - val supportsStreaming = videoContent?.supportsStreaming ?: false - val validPath = path?.takeIf { File(it).exists() } + val videoContent = msg.content as? MessageContent.Video + val supportsStreaming = videoContent?.supportsStreaming ?: false + val validPath = path?.takeIf { File(it).exists() } - if (validPath != null || supportsStreaming) { - currentKeyboardController.value?.hide() - currentFocusManager.value.clearFocus() - component.onOpenVideo(path = validPath, messageId = msg.id, caption = caption) - } else { - val fileId = when (val c = msg.content) { - is MessageContent.Video -> c.fileId - is MessageContent.Gif -> c.fileId - else -> 0 - } - if (fileId != 0) { - component.onDownloadFile(fileId) + if (validPath != null || supportsStreaming) { + currentKeyboardController.value?.hide() + currentFocusManager.value.clearFocus() + component.onOpenVideo(path = validPath, messageId = msg.id, caption = caption) + } else { + val fileId = when (val c = msg.content) { + is MessageContent.Video -> c.fileId + is MessageContent.Gif -> c.fileId + else -> 0 + } + if (fileId != 0) { + component.onDownloadFile(fileId) + } } } - } } } @@ -953,9 +953,9 @@ fun ChatContent( onMessagePositionChange = onMessagePositionChangeStable, onViaBotClick = onViaBotClickStable, toProfile = toProfileStable, - downloadUtils = component.downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) + downloadUtils = component.downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) AnimatedVisibility( visible = showScrollToBottomButton, @@ -1140,11 +1140,11 @@ fun ChatContent( ) } - ChatContentViewers( + /*ChatContentViewers( state = state, component = component, localClipboard = localClipboard - ) + )*/ selectedMessage?.let { msg -> ChatMessageOptionsMenu( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt index df669f6a..a345eef7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentViewers.kt @@ -24,6 +24,18 @@ fun ChatContentViewers( component: ChatComponent, localClipboard: Clipboard ) { + InstantViewOverlay(state, component) + YouTubeOverlay(state, component, localClipboard) + MiniAppOverlay(state, component) + WebViewOverlay(state, component) + ImagesOverlay(state, component, localClipboard) + VideoOverlay(state, component, localClipboard) + InvoiceOverlay(state, component) + MiniAppTOSOverlay(state, component) +} + +@Composable +private fun InstantViewOverlay(state: ChatComponent.State, component: ChatComponent) { AnimatedVisibility( visible = state.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -39,7 +51,14 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun YouTubeOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { AnimatedVisibility( visible = state.youtubeUrl != null, enter = fadeIn(), @@ -70,7 +89,10 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun MiniAppOverlay(state: ChatComponent.State, component: ChatComponent) { AnimatedVisibility( visible = state.miniAppUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -88,7 +110,10 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun WebViewOverlay(state: ChatComponent.State, component: ChatComponent) { AnimatedVisibility( visible = state.webViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), @@ -101,7 +126,14 @@ fun ChatContentViewers( ) } } +} +@Composable +private fun ImagesOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { AnimatedVisibility( visible = state.fullScreenImages != null, enter = fadeIn() + scaleIn(initialScale = 0.9f), @@ -189,7 +221,7 @@ fun ChatContentViewers( onDelete = { path -> val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } if (msg?.isOutgoing == true) { - component.onDeleteMessage(msg) + component.onDeleteMessage(msg, true) component.onDismissImages() } }, @@ -204,60 +236,67 @@ fun ChatContentViewers( } else { path } - localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(link)) - ) - }, - onCopyText = { path -> - val msg = state.messages.find { - when (val content = it.content) { - is MessageContent.Photo -> content.path == path - is MessageContent.Video -> content.path == path - is MessageContent.Gif -> content.path == path - else -> false - } - } - val textToCopy = when (val content = msg?.content) { - is MessageContent.Photo -> content.caption - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> "" - } - if (textToCopy.isNotEmpty()) { localClipboard.nativeClipboard.setPrimaryClip( - ClipData.newPlainText("", AnnotatedString(textToCopy)) + ClipData.newPlainText("", AnnotatedString(link)) ) - } - }, - onVideoClick = { path -> - val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } - if (msg != null) { - val mediaPath = msg.displayMediaPathForViewer() ?: path - component.onOpenVideo( - path = mediaPath, - messageId = msg.id, - caption = when (val content = msg.content) { - is MessageContent.Video -> content.caption - is MessageContent.Gif -> content.caption - else -> null + }, + onCopyText = { path -> + val msg = state.messages.find { + when (val content = it.content) { + is MessageContent.Photo -> content.path == path + is MessageContent.Video -> content.path == path + is MessageContent.Gif -> content.path == path + else -> false } - ) - } else { - component.onOpenVideo(path = path) - } - }, - captions = state.fullScreenCaptions, - imageDownloadingStates = imageDownloadingStates, - imageDownloadProgressStates = imageDownloadProgressStates, - downloadUtils = component.downloadUtils - ) + } + val textToCopy = when (val content = msg?.content) { + is MessageContent.Photo -> content.caption + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onVideoClick = { path -> + val msg = currentViewerMessage ?: state.messages.find { it.content.matchesDisplayPath(path) } + if (msg != null) { + val mediaPath = msg.displayMediaPathForViewer() ?: path + component.onOpenVideo( + path = mediaPath, + messageId = msg.id, + caption = when (val content = msg.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> null + } + ) + } else { + component.onOpenVideo(path = path, messageId = null, caption = null) + } + }, + captions = state.fullScreenCaptions, + imageDownloadingStates = imageDownloadingStates, + imageDownloadProgressStates = imageDownloadProgressStates, + downloadUtils = component.downloadUtils + ) } } } +} +@Composable +private fun VideoOverlay( + state: ChatComponent.State, + component: ChatComponent, + localClipboard: Clipboard +) { val videoVisible = (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) && state.fullScreenImages == null - + AnimatedVisibility( visible = videoVisible, enter = fadeIn() + scaleIn(initialScale = 0.9f), @@ -301,7 +340,7 @@ fun ChatContentViewers( it.content.matchesDisplayPath(videoPath) } if (deleteMsg?.isOutgoing == true) { - component.onDeleteMessage(deleteMsg) + component.onDeleteMessage(deleteMsg, true) component.onDismissVideo() } }, @@ -351,7 +390,10 @@ fun ChatContentViewers( } } } +} +@Composable +private fun InvoiceOverlay(state: ChatComponent.State, component: ChatComponent) { if (state.invoiceSlug != null || state.invoiceMessageId != null) { InvoiceDialog( slug = state.invoiceSlug, @@ -362,7 +404,10 @@ fun ChatContentViewers( onDismiss = { status -> component.onDismissInvoice(status) } ) } +} +@Composable +private fun MiniAppTOSOverlay(state: ChatComponent.State, component: ChatComponent) { MiniAppTOSBottomSheet( isVisible = state.showMiniAppTOS, onDismiss = { component.onDismissMiniAppTOS() }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt index 317f96ab..a08f2cf6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt @@ -15,10 +15,11 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.rounded.* import androidx.compose.material3.* +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.window.core.layout.WindowSizeClass import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.res.stringResource @@ -66,12 +67,15 @@ fun ChatTopBar( onCopyLink: (() -> Unit)? = null, onManageMembers: (() -> Unit)? = null, showBack: Boolean = true, - personalAvatarPath: String? = null + personalAvatarPath: String? = null, + isTablet: Boolean = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) ) { var showMenu by remember { mutableStateOf(false) } var showClearHistorySheet by remember { mutableStateOf(false) } var showDeleteChatSheet by remember { mutableStateOf(false) } + val windowInsets = if (isTablet) WindowInsets(0, 0, 0, 0) else WindowInsets.statusBars + Box(modifier = Modifier.fillMaxWidth()) { AnimatedContent( targetState = isSearchActive, @@ -82,7 +86,7 @@ fun ChatTopBar( ) { searching -> if (searching) { TopAppBar( - windowInsets = WindowInsets.statusBars, + windowInsets = windowInsets, title = { TextField( value = searchQuery, @@ -117,7 +121,7 @@ fun ChatTopBar( ) } else { TopAppBar( - windowInsets = WindowInsets.statusBars, + windowInsets = windowInsets, title = { Row( verticalAlignment = Alignment.CenterVertically, @@ -280,7 +284,7 @@ fun ChatTopBar( ), modifier = Modifier .align(Alignment.TopEnd) - .windowInsetsPadding(WindowInsets.statusBars) + .windowInsetsPadding(windowInsets) .padding(top = 56.dp, end = 16.dp) ) { ViewerSettingsDropdown { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt index d3982133..6e846c41 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt @@ -3,6 +3,7 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar import androidx.compose.animation.* import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.* @@ -10,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.TextFieldValue @@ -24,6 +26,7 @@ import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu @Composable fun ChatInputBarComposerSection( + modifier: Modifier = Modifier, editingMessage: MessageModel?, replyMessage: MessageModel?, pendingMediaPaths: List, @@ -63,6 +66,7 @@ fun ChatInputBarComposerSection( replyMarkup: ReplyMarkupModel?, showSendOptionsSheet: Boolean, stickerRepository: StickerRepository, + isTablet: Boolean = false, onCancelEdit: () -> Unit, onCancelReply: () -> Unit, onCancelMedia: () -> Unit, @@ -97,7 +101,12 @@ fun ChatInputBarComposerSection( onGifSearchFocusedChange: (Boolean) -> Unit, onReplyMarkupButtonClick: (KeyboardButtonModel) -> Unit ) { - Surface(color = MaterialTheme.colorScheme.surface, tonalElevation = 2.dp) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + shape = if (isTablet) RoundedCornerShape(16.dp) else RectangleShape + ) { Column( modifier = Modifier .fillMaxWidth() @@ -339,7 +348,9 @@ fun ChatInputBarComposerSection( stickerRepository = stickerRepository ) } - Spacer(Modifier.navigationBarsPadding()) + if (!isTablet) { + Spacer(Modifier.navigationBarsPadding()) + } } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index 5ff42487..c983ad55 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -32,6 +32,7 @@ import org.monogram.domain.repository.ChatMembersFilter import org.monogram.domain.repository.ChatOperationsRepository import org.monogram.domain.repository.ChatSettingsRepository import org.monogram.domain.repository.ChatStatisticsRepository +import org.monogram.domain.repository.GifRepository import org.monogram.domain.repository.LocationRepository import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.PrivacyRepository @@ -68,6 +69,7 @@ class DefaultProfileComponent( private val privacyRepository: PrivacyRepository = container.repositories.privacyRepository override val messageRepository: MessageRepository = container.repositories.messageRepository private val locationRepository: LocationRepository = container.repositories.locationRepository + private val gifRepository: GifRepository = container.repositories.gifRepository private val botPreferences: BotPreferencesProvider = container.preferences.botPreferencesProvider override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() @@ -720,8 +722,10 @@ class DefaultProfileComponent( _state.update { it.copy( fullScreenImages = null, + fullScreenImageMessageIds = emptyList(), fullScreenCaptions = emptyList(), fullScreenVideoPath = null, + fullScreenVideoMessageId = null, fullScreenVideoCaption = null, isViewingProfilePhotos = false, isProfilePhotoHdLoading = false @@ -729,6 +733,82 @@ class DefaultProfileComponent( } } + override fun onDismissImages() { + onDismissViewer() + } + + override fun onDismissVideo() { + onDismissViewer() + } + + override fun onDismissInstantView() { + _state.update { it.copy(instantViewUrl = null) } + } + + override fun onDismissYouTube() { + _state.update { it.copy(youtubeUrl = null) } + } + + override fun onDismissWebView() { + _state.update { it.copy(webViewUrl = null) } + } + + override fun onDismissInvoice(status: String?) { + _state.update { it.copy(invoiceSlug = null, invoiceMessageId = null) } + } + + override fun onForwardMessage(message: MessageModel) { + onMessageLongClicked(message) + } + + override fun onDeleteMessage(message: MessageModel, revoke: Boolean) { + scope.launch { + messageRepository.deleteMessage(chatId, listOf(message.id), revoke) + } + } + + override fun onOpenVideo(path: String, messageId: Long?, caption: String?) { + _state.update { + it.copy( + fullScreenVideoPath = path, + fullScreenVideoMessageId = messageId, + fullScreenVideoCaption = caption, + fullScreenImages = null + ) + } + } + + override fun onDownloadHighRes(messageId: Long) { + scope.launch { + val fileId = messageRepository.getHighResFileId(chatId, messageId) + if (fileId != null && fileId != 0) { + messageRepository.downloadFile(fileId, priority = 32) + } + } + } + + override fun onAddToGifs(path: String) { + scope.launch { + gifRepository.addSavedGif(path) + } + } + + override fun onOpenWebView(url: String) { + _state.update { it.copy(webViewUrl = url) } + } + + override fun onDismissMiniAppTOS() { + _state.update { it.copy(showMiniAppTOS = false) } + } + + override fun onAcceptMiniAppTOS() { + val botId = _state.value.user?.id ?: return + scope.launch { + botPreferences.setWebappPermission(botId, "tos_accepted", true) + _state.update { it.copy(showMiniAppTOS = false, isTOSAccepted = true) } + } + } + override fun onOpenMiniApp(url: String, name: String, chatId: Long) { _state.update { it.copy(miniAppUrl = url, miniAppName = name, chatId = chatId) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt index e48a88a0..c4198cd5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileComponent.kt @@ -17,6 +17,20 @@ interface ProfileComponent { fun onMessageLongClick(message: MessageModel) fun onAvatarClick() fun onDismissViewer() + fun onDismissImages() + fun onDismissVideo() + fun onDismissInstantView() + fun onDismissYouTube() + fun onDismissWebView() + fun onDismissInvoice(status: String?) + fun onForwardMessage(message: MessageModel) + fun onDeleteMessage(message: MessageModel, revoke: Boolean) + fun onOpenVideo(path: String, messageId: Long?, caption: String?) + fun onDownloadHighRes(messageId: Long) + fun onAddToGifs(path: String) + fun onOpenWebView(url: String) + fun onDismissMiniAppTOS() + fun onAcceptMiniAppTOS() fun onLoadMoreMedia() fun onOpenMiniApp(url: String, name: String, chatId: Long) fun onDismissMiniApp() @@ -93,13 +107,31 @@ interface ProfileComponent { val personalAvatarPath: String? = null, val fullScreenImages: List? = null, + val fullScreenImageMessageIds: List = emptyList(), val fullScreenCaptions: List = emptyList(), val fullScreenStartIndex: Int = 0, val fullScreenVideoPath: String? = null, + val fullScreenVideoMessageId: Long? = null, val fullScreenVideoCaption: String? = null, val isViewingProfilePhotos: Boolean = false, val isProfilePhotoHdLoading: Boolean = false, + val instantViewUrl: String? = null, + val youtubeUrl: String? = null, + val webViewUrl: String? = null, + val invoiceSlug: String? = null, + val invoiceMessageId: Long? = null, + + val autoDownloadWifi: Boolean = true, + val autoDownloadRoaming: Boolean = false, + val autoDownloadMobile: Boolean = true, + + val isPlayerGesturesEnabled: Boolean = true, + val isPlayerDoubleTapSeekEnabled: Boolean = true, + val playerSeekDuration: Int = 10, + val isPlayerZoomEnabled: Boolean = true, + val isInstalledFromGooglePlay: Boolean = false, + val miniAppUrl: String? = null, val miniAppName: String? = null, val currentUser: UserModel? = null, @@ -122,6 +154,7 @@ interface ProfileComponent { val botPermissions: Map = emptyMap(), val isTOSVisible: Boolean = false, + val showMiniAppTOS: Boolean = false, val isTOSAccepted: Boolean = false, val isAcceptingTOS: Boolean = false, val pendingMiniAppUrl: String? = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index a4858512..6f214f8d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -5,7 +5,6 @@ package org.monogram.presentation.features.profile import android.content.ClipData import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells @@ -19,22 +18,24 @@ import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Person import androidx.compose.material3.* +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState -import org.monogram.domain.models.MessageContent import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum import org.monogram.presentation.R @@ -43,14 +44,13 @@ import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.chatList.components.SettingsTextField import org.monogram.presentation.features.profile.components.* -import org.monogram.presentation.features.viewers.ImageViewer -import org.monogram.presentation.features.viewers.VideoViewer -import org.monogram.presentation.features.webapp.MiniAppViewer @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileContent(component: ProfileComponent) { val state by component.state.subscribeAsState() + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) val localClipboard = LocalClipboard.current val context = LocalContext.current val collapsingToolbarState = rememberCollapsingToolbarScaffoldState() @@ -153,7 +153,7 @@ fun ProfileContent(component: ProfileComponent) { val canReportTopBar = isGroupOrChannel && !isCurrentUserProfile val canBlockTopBar = !isCurrentUserProfile && !isGroupOrChannel && user?.type != UserTypeEnum.BOT val canEditContactTopBar = !isCurrentUserProfile && !isGroupOrChannel && user?.isContact == true - val canDeleteTopBar = !isCurrentUserProfile && (!isGroupOrChannel || chat?.isMember == true) + val canDeleteTopBar = !isCurrentUserProfile && (!isGroupOrChannel || chat.isMember) var showLeaveSheet by remember { mutableStateOf(false) } var showDeleteChatSheet by remember { mutableStateOf(false) } var showBlockSheet by remember { mutableStateOf(false) } @@ -298,6 +298,8 @@ fun ProfileContent(component: ProfileComponent) { columns = GridCells.Fixed(3), modifier = Modifier .fillMaxSize() + .padding(horizontal = if (isTablet) 12.dp else 16.dp) + .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.background), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -355,163 +357,6 @@ fun ProfileContent(component: ProfileComponent) { } } - val notImplemented = stringResource(R.string.not_implemented) - AnimatedVisibility( - visible = state.fullScreenImages != null, - enter = fadeIn() + scaleIn(initialScale = 0.9f), - exit = fadeOut() + scaleOut(targetScale = 0.9f) - ) { - state.fullScreenImages?.let { images -> - Box(modifier = Modifier.fillMaxSize()) { - ImageViewer( - images = images, - startIndex = state.fullScreenStartIndex, - onDismiss = component::onDismissViewer, - autoDownload = false, - downloadUtils = component.downloadUtils, - onPageChanged = { index -> - - if (!state.isViewingProfilePhotos && state.canLoadMoreMedia && !state.isLoadingMoreMedia && - index >= images.size - 5 - ) { - component.onLoadMoreMedia() - } - - if (!state.isViewingProfilePhotos) { - val photoMessages = state.mediaMessages.filter { it.content is MessageContent.Photo } - - val message = photoMessages.getOrNull(index) - if (message != null) { - component.onDownloadMedia(message) - - val nextMessage = photoMessages.getOrNull(index + 1) - if (nextMessage != null) { - component.onDownloadMedia(nextMessage) - } - } - } - }, - onForward = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onDelete = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyLink = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyText = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - captions = state.fullScreenCaptions.filterNotNull(), - showImageNumber = false - ) - - if (state.isViewingProfilePhotos && state.isProfilePhotoHdLoading) { - Surface( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 56.dp), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f) - ) { - Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - LoadingIndicator( - modifier = Modifier.size(16.dp) - ) - Text( - text = "Loading HD", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - } - } - } - - AnimatedVisibility( - visible = state.fullScreenVideoPath != null, - enter = fadeIn() + scaleIn(initialScale = 0.9f), - exit = fadeOut() + scaleOut(targetScale = 0.9f) - ) { - state.fullScreenVideoPath?.let { path -> - val msg = state.mediaMessages.find { - when (val content = it.content) { - is MessageContent.Video -> content.path == path - is MessageContent.Gif -> content.path == path - is MessageContent.VideoNote -> content.path == path - else -> false - } - } - val videoContent = msg?.content as? MessageContent.Video - val fileId = videoContent?.fileId ?: (msg?.content as? MessageContent.Gif)?.fileId - ?: (msg?.content as? MessageContent.VideoNote)?.fileId ?: 0 - val supportsStreaming = videoContent?.supportsStreaming ?: false - - VideoViewer( - path = path, - onDismiss = component::onDismissViewer, - isGesturesEnabled = true, - isDoubleTapSeekEnabled = true, - seekDuration = 10, - isZoomEnabled = true, - downloadUtils = component.downloadUtils, - onForward = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onDelete = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyLink = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - onCopyText = { Toast.makeText(context, notImplemented, Toast.LENGTH_SHORT).show() }, - caption = state.fullScreenVideoCaption, - fileId = fileId, - supportsStreaming = supportsStreaming - ) - } - } - - AnimatedVisibility( - visible = state.miniAppUrl != null, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - if (state.miniAppUrl != null && state.miniAppName != null) { - MiniAppViewer( - baseUrl = state.miniAppUrl.toString(), - botName = title, - onDismiss = { component.onDismissMiniApp() }, - chatId = state.chatId, - botUserId = state.user!!.id, - webAppRepository = component.messageRepository, - ) - } - } - - AnimatedVisibility( - visible = state.isStatisticsVisible, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - if (state.statistics != null) { - StatisticsViewer( - title = stringResource(R.string.statistics_title), - data = state.statistics!!, - onDismiss = component::onDismissStatistics, - onLoadGraph = component::onLoadStatisticsGraph - ) - } - } - - AnimatedVisibility( - visible = state.isRevenueStatisticsVisible, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - if (state.revenueStatistics != null) { - StatisticsViewer( - title = stringResource(R.string.revenue_title), - data = state.revenueStatistics!!, - onDismiss = component::onDismissStatistics, - onLoadGraph = component::onLoadStatisticsGraph - ) - } - } - ProfileQRDialog( state = state, onDismiss = component::onDismissQRCode @@ -574,7 +419,7 @@ fun ProfileContent(component: ProfileComponent) { dragHandle = { BottomSheetDefaults.DragHandle() }, containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { Column( @@ -666,13 +511,6 @@ fun ProfileContent(component: ProfileComponent) { onAccept = component::onAcceptTOS ) } - - state.selectedLocation?.let { location -> - LocationViewer( - location = location, - onDismiss = component::onDismissLocation - ) - } } } @@ -682,7 +520,8 @@ private fun ProfileHeaderSkeleton( contentPadding: PaddingValues ) { val shimmer = rememberShimmerBrush() - val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val containerSize = LocalWindowInfo.current.containerSize + val screenHeight = with(LocalDensity.current) { containerSize.height.toDp() } val titleWidth = androidx.compose.ui.unit.lerp(220.dp, 124.dp, progress) val subtitleWidth = androidx.compose.ui.unit.lerp(132.dp, 88.dp, progress) val avatarCornerPercent = (100 * (1f - progress)).toInt() diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt new file mode 100644 index 00000000..851a301a --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileViewers.kt @@ -0,0 +1,470 @@ +package org.monogram.presentation.features.profile + +import android.content.ClipData +import android.util.Log +import androidx.compose.animation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.presentation.R +import org.monogram.presentation.features.instantview.InstantViewer +import org.monogram.presentation.features.profile.components.LocationViewer +import org.monogram.presentation.features.profile.components.StatisticsViewer +import org.monogram.presentation.features.viewers.ImageViewer +import org.monogram.presentation.features.viewers.VideoViewer +import org.monogram.presentation.features.viewers.YouTubeViewer +import org.monogram.presentation.features.webapp.MiniAppViewer +import org.monogram.presentation.features.webapp.components.InvoiceDialog +import org.monogram.presentation.features.webapp.components.MiniAppTOSBottomSheet +import org.monogram.presentation.features.webview.InternalWebView + +@Composable +fun ProfileViewers( + state: ProfileComponent.State, + component: ProfileComponent +) { + val localClipboard = LocalClipboard.current + + InstantViewOverlay(state, component) + YouTubeOverlay(state, component, localClipboard) + MiniAppOverlay(state, component) + WebViewOverlay(state, component) + ImagesOverlay(state, component, localClipboard) + VideoOverlay(state, component, localClipboard) + InvoiceOverlay(state, component) + MiniAppTOSOverlay(state, component) +} + +@Composable +private fun InstantViewOverlay(state: ProfileComponent.State, component: ProfileComponent) { + AnimatedVisibility( + visible = state.instantViewUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + state.instantViewUrl?.let { url -> + InstantViewer( + url = url, + messageRepository = component.messageRepository, + fileRepository = component.messageRepository, + onDismiss = { component.onDismissInstantView() }, + onOpenWebView = { component.onOpenWebView(it) } + ) + } + } +} + +@Composable +private fun YouTubeOverlay( + state: ProfileComponent.State, + component: ProfileComponent, + localClipboard: Clipboard +) { + AnimatedVisibility( + visible = state.youtubeUrl != null, + enter = fadeIn(), + exit = fadeOut() + ) { + state.youtubeUrl?.let { url -> + YouTubeViewer( + videoUrl = url, + onDismiss = { component.onDismissYouTube() }, + onForward = { + val msg = state.mediaMessages.find { + (it.content as? MessageContent.Text)?.text?.contains(url) == true + } + if (msg != null) component.onForwardMessage(msg) + }, + onCopyLink = { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(it)) + ) + }, + onCopyText = { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(it)) + ) + }, + isPipEnabled = !state.isInstalledFromGooglePlay + ) + } + } +} + +@Composable +private fun MiniAppOverlay(state: ProfileComponent.State, component: ProfileComponent) { + AnimatedVisibility( + visible = state.miniAppUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + if (state.miniAppUrl != null && state.miniAppName != null) { + val title = state.chat?.title ?: listOfNotNull(state.user?.firstName, state.user?.lastName) + .joinToString(" ") + .ifBlank { "Unknown" } + + MiniAppViewer( + chatId = state.chatId, + botUserId = state.user?.id ?: 0L, + baseUrl = state.miniAppUrl, + botName = title, + botAvatarPath = state.chat?.avatarPath ?: state.user?.avatarPath, + webAppRepository = component.messageRepository, + onDismiss = { component.onDismissMiniApp() } + ) + } + } +} + +@Composable +private fun WebViewOverlay(state: ProfileComponent.State, component: ProfileComponent) { + AnimatedVisibility( + visible = state.webViewUrl != null, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + state.webViewUrl?.let { url -> + InternalWebView( + url = url, + onDismiss = { component.onDismissWebView() } + ) + } + } +} + +@Composable +private fun ImagesOverlay( + state: ProfileComponent.State, + component: ProfileComponent, + localClipboard: Clipboard +) { + AnimatedVisibility( + visible = state.fullScreenImages != null, + enter = fadeIn() + scaleIn(initialScale = 0.9f), + exit = fadeOut() + scaleOut(targetScale = 0.9f) + ) { + state.fullScreenImages?.let { images -> + val autoDownload = remember(state.autoDownloadWifi, state.autoDownloadRoaming, state.autoDownloadMobile) { + when { + component.downloadUtils.isWifiConnected() -> state.autoDownloadWifi + component.downloadUtils.isRoaming() -> state.autoDownloadRoaming + else -> state.autoDownloadMobile + } + } + + val viewerItems = remember(images, state.fullScreenImageMessageIds, state.mediaMessages) { + if (state.fullScreenImageMessageIds.size == images.size) { + state.fullScreenImageMessageIds.mapIndexed { index, messageId -> + val message = state.mediaMessages.firstOrNull { it.id == messageId } + val resolvedPath = message?.displayMediaPathForViewer() ?: images[index] + ViewerMediaItem(messageId = messageId, path = resolvedPath) + } + } else { + images.map { path -> + val message = state.mediaMessages.find { it.content.matchesDisplayPath(path) } + ViewerMediaItem( + messageId = message?.id ?: 0L, + path = message?.displayMediaPathForViewer() ?: path + ) + } + } + } + + val viewerImages = remember(viewerItems) { viewerItems.map { it.path } } + val imageMessageIds = remember(viewerItems) { viewerItems.map { it.messageId } } + var currentImageIndex by remember(viewerImages, state.fullScreenStartIndex) { + mutableIntStateOf( + state.fullScreenStartIndex.coerceIn( + 0, + (viewerImages.lastIndex).coerceAtLeast(0) + ) + ) + } + + val currentViewerMessage = remember(currentImageIndex, imageMessageIds, state.mediaMessages) { + imageMessageIds.getOrNull(currentImageIndex) + ?.takeIf { it != 0L } + ?.let { id -> state.mediaMessages.firstOrNull { it.id == id } } + } + + val imageDownloadingStates = remember(imageMessageIds, state.mediaMessages) { + imageMessageIds.map { id -> + val content = state.mediaMessages.firstOrNull { it.id == id }?.content + when (content) { + is MessageContent.Photo -> content.isDownloading + else -> false + } + } + } + + val imageDownloadProgressStates = remember(imageMessageIds, state.mediaMessages) { + imageMessageIds.map { id -> + val content = state.mediaMessages.firstOrNull { it.id == id }?.content + when (content) { + is MessageContent.Photo -> content.downloadProgress + else -> 0f + } + } + } + + if (viewerImages.isNotEmpty()) { + Box(modifier = Modifier.fillMaxSize()) { + ImageViewer( + images = viewerImages, + startIndex = state.fullScreenStartIndex.coerceIn(0, viewerImages.lastIndex), + onDismiss = component::onDismissImages, + autoDownload = autoDownload, + onPageChanged = { index -> + currentImageIndex = index + if (!state.isViewingProfilePhotos && state.canLoadMoreMedia && !state.isLoadingMoreMedia && + index >= viewerImages.size - 5 + ) { + component.onLoadMoreMedia() + } + + if (!state.isViewingProfilePhotos) { + imageMessageIds.getOrNull(index)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) + imageMessageIds.getOrNull(index + 1)?.takeIf { it != 0L }?.let(component::onDownloadHighRes) + } + }, + onForward = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + msg?.let { component.onForwardMessage(it) } + }, + onDelete = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + if (msg?.isOutgoing == true) { + component.onDeleteMessage(msg, true) + component.onDismissImages() + } + }, + onCopyLink = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + val link = if (msg != null) { + "https://t.me/c/${state.chatId.toString().removePrefix("-100")}/${msg.id shr 20}" + } else { + path + } + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + }, + onCopyText = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + val textToCopy = when (val content = msg?.content) { + is MessageContent.Photo -> content.caption + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onVideoClick = { path -> + val msg = currentViewerMessage ?: state.mediaMessages.find { it.content.matchesDisplayPath(path) } + if (msg != null) { + val mediaPath = msg.displayMediaPathForViewer() ?: path + component.onOpenVideo( + path = mediaPath, + messageId = msg.id, + caption = when (val content = msg.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> null + } + ) + } else { + component.onOpenVideo(path = path, messageId = null, caption = null) + } + }, + captions = state.fullScreenCaptions.filterNotNull(), + imageDownloadingStates = imageDownloadingStates, + imageDownloadProgressStates = imageDownloadProgressStates, + downloadUtils = component.downloadUtils, + showImageNumber = false + ) + + if (state.isViewingProfilePhotos && state.isProfilePhotoHdLoading) { + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 56.dp), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f) + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + text = "Loading HD", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } + } +} + +@Composable +private fun VideoOverlay( + state: ProfileComponent.State, + component: ProfileComponent, + localClipboard: Clipboard +) { + val videoVisible = + (state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null) && state.fullScreenImages == null + + AnimatedVisibility( + visible = videoVisible, + enter = fadeIn() + scaleIn(initialScale = 0.9f), + exit = fadeOut() + scaleOut(targetScale = 0.9f) + ) { + if (videoVisible) { + val messageId = state.fullScreenVideoMessageId + val path = state.fullScreenVideoPath + + val msg = remember(messageId, path, state.mediaMessages) { + state.mediaMessages.find { it.id == messageId } ?: state.mediaMessages.find { + it.content.matchesDisplayPath(path ?: "") + } + } + + val videoContent = msg?.content as? MessageContent.Video + val gifContent = msg?.content as? MessageContent.Gif + + val fileId = videoContent?.fileId ?: gifContent?.fileId ?: 0 + val supportsStreaming = videoContent?.supportsStreaming ?: false + val finalPath = path ?: videoContent?.path ?: gifContent?.path ?: "" + + if (finalPath.isNotBlank() || (supportsStreaming && fileId != 0)) { + key(finalPath, fileId) { + VideoViewer( + path = finalPath, + onDismiss = component::onDismissVideo, + isGesturesEnabled = state.isPlayerGesturesEnabled, + isDoubleTapSeekEnabled = state.isPlayerDoubleTapSeekEnabled, + seekDuration = state.playerSeekDuration, + isZoomEnabled = state.isPlayerZoomEnabled, + onForward = { videoPath -> + val forwardMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + forwardMsg?.let { component.onForwardMessage(it) } + }, + onDelete = { videoPath -> + val deleteMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + if (deleteMsg?.isOutgoing == true) { + component.onDeleteMessage(deleteMsg, true) + component.onDismissVideo() + } + }, + onCopyLink = { videoPath -> + val linkMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + val link = if (linkMsg != null) { + "https://t.me/c/${state.chatId.toString().removePrefix("-100")}/${linkMsg.id shr 20}" + } else { + videoPath + } + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + }, + onCopyText = { videoPath -> + val textMsg = state.mediaMessages.find { + it.content.matchesDisplayPath(videoPath) + } + val textToCopy = when (val content = textMsg?.content) { + is MessageContent.Video -> content.caption + is MessageContent.Gif -> content.caption + else -> "" + } + if (textToCopy.isNotEmpty()) { + localClipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(textToCopy)) + ) + } + }, + onSaveGif = if (state.mediaMessages.any { (it.content as? MessageContent.Gif)?.path == finalPath }) { + { videoPath -> component.onAddToGifs(videoPath) } + } else null, + caption = state.fullScreenVideoCaption, + fileId = fileId, + supportsStreaming = supportsStreaming, + downloadUtils = component.downloadUtils + ) + } + } + } + } +} + +@Composable +private fun InvoiceOverlay(state: ProfileComponent.State, component: ProfileComponent) { + if (state.invoiceSlug != null || state.invoiceMessageId != null) { + InvoiceDialog( + slug = state.invoiceSlug, + chatId = state.chatId, + messageId = state.invoiceMessageId, + paymentRepository = component.messageRepository, + fileRepository = component.messageRepository, + onDismiss = { status -> component.onDismissInvoice(status) } + ) + } +} + +@Composable +private fun MiniAppTOSOverlay(state: ProfileComponent.State, component: ProfileComponent) { + MiniAppTOSBottomSheet( + isVisible = state.showMiniAppTOS, + onDismiss = { component.onDismissMiniAppTOS() }, + onAccept = { component.onAcceptMiniAppTOS() } + ) +} + +private data class ViewerMediaItem( + val messageId: Long, + val path: String +) + +private fun MessageModel.displayMediaPathForViewer(): String? { + return when (val content = content) { + is MessageContent.Photo -> content.path ?: content.thumbnailPath + is MessageContent.Video -> content.path ?: content.thumbnailPath + is MessageContent.Gif -> content.path + else -> null + } +} + +private fun MessageContent.matchesDisplayPath(path: String): Boolean { + return when (this) { + is MessageContent.Photo -> (this.path ?: this.thumbnailPath) == path + is MessageContent.Video -> this.path == path || this.thumbnailPath == path + is MessageContent.Gif -> this.path == path + else -> false + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt index 440cf845..73974023 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileHeaderTransformed.kt @@ -23,7 +23,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -43,7 +44,6 @@ fun ProfileHeaderTransformed( subtitle: String, avatarSize: Dp, userModel: UserModel?, - chatModel: ChatModel?, avatarCornerPercent: Int, isOnline: Boolean, isVerified: Boolean, @@ -55,9 +55,11 @@ fun ProfileHeaderTransformed( progress: Float, contentPadding: PaddingValues, onAvatarClick: () -> Unit, - onActionClick: () -> Unit + chatModel: ChatModel? = null, + onActionClick: () -> Unit = {} ) { - val screenHeight = LocalConfiguration.current.screenHeightDp.dp + val containerSize = LocalWindowInfo.current.containerSize + val screenHeight = with(LocalDensity.current) { containerSize.height.toDp() } BoxWithConstraints( modifier = Modifier diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt index 121f0f9f..684cc7df 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileSections.kt @@ -74,13 +74,13 @@ fun ProfileInfoSectionSkeleton( ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = 24.dp, - bottomEnd = 24.dp, + bottomStart = 16.dp, + bottomEnd = 16.dp, topStart = 4.dp, topEnd = 4.dp ) - ItemPosition.STANDALONE -> RoundedCornerShape(24.dp) + ItemPosition.STANDALONE -> RoundedCornerShape(16.dp) } Surface( @@ -142,7 +142,7 @@ fun ProfileInfoSectionSkeleton( private fun ProfileQuickActionsSkeleton(shimmer: androidx.compose.ui.graphics.Brush) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() .padding(bottom = 2.dp) @@ -188,7 +188,7 @@ private fun ProfileQuickActionsSkeleton(shimmer: androidx.compose.ui.graphics.Br private fun LinkedChatItemSkeleton(shimmer: androidx.compose.ui.graphics.Brush) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(24.dp), + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) @@ -289,7 +289,7 @@ fun ProfileInfoSection( onDismissRequest = { isSponsorSheetVisible = false }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { Column( modifier = Modifier @@ -960,7 +960,7 @@ private fun ProfileQuickActions( ) { Surface( color = MaterialTheme.colorScheme.surfaceContainer, - shape = ShapeDefaults.LargeIncreased, + shape = RoundedCornerShape(16.dp), modifier = Modifier .fillMaxWidth() .padding(bottom = 2.dp) @@ -1413,7 +1413,7 @@ private fun UsernamesTile( else -> MaterialTheme.colorScheme.outline } - val cornerRadius = 24.dp + val cornerRadius = 16.dp val shape = when (position) { ItemPosition.TOP -> RoundedCornerShape( topStart = cornerRadius, diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt index dd3fab13..5debd437 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.automirrored.rounded.Forward import androidx.compose.material.icons.automirrored.rounded.Reply +import androidx.compose.material.icons.automirrored.rounded.Undo import androidx.compose.material.icons.rounded.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -30,10 +31,10 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* @@ -104,12 +105,12 @@ fun MessageOptionsMenu( onDismiss: () -> Unit ) { val density = LocalDensity.current - val configuration = LocalConfiguration.current val haptic = LocalHapticFeedback.current val scope = rememberCoroutineScope() val emojiRepository: EmojiRepository = koinInject() - val screenHeight = with(density) { configuration.screenHeightDp.dp.toPx() }.toInt() + val windowSize = LocalWindowInfo.current.containerSize + val screenHeight = windowSize.height val windowInsets = WindowInsets.systemBars.union(WindowInsets.ime) val topInset = windowInsets.getTop(density) val bottomInset = windowInsets.getBottom(density) @@ -729,7 +730,7 @@ fun MessageOptionsMenu( if (sections.hasRestoreOriginalTextAction) { InternalMenuOptionItem( - icon = Icons.Rounded.Undo, + icon = Icons.AutoMirrored.Rounded.Undo, text = stringResource(R.string.menu_restore_original_text), onClick = { animateOutAndDismiss(onRestoreOriginalText) } ) @@ -851,7 +852,7 @@ private data class MessageMenuSections( } companion object { - val Saver = listSaver( + val Saver = listSaver( save = { listOf( it.hasViewersSection, diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt index 69718d5e..b9d7a048 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/MediaViewer.kt @@ -17,9 +17,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -75,9 +75,9 @@ fun MediaViewer( val context = LocalContext.current - val config = LocalConfiguration.current + val containerSize = LocalWindowInfo.current.containerSize val density = LocalDensity.current - val screenHeightPx = with(density) { config.screenHeightDp.dp.toPx() } + val screenHeightPx = containerSize.height.toFloat() val dismissDistancePx = with(density) { 160.dp.toPx() } val dismissVelocityThreshold = with(density) { 1000.dp.toPx() } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt index 0d40ea8d..ffcda04b 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.filled.MoreVert @@ -47,7 +48,6 @@ import androidx.compose.material.icons.filled.PhoneIphone import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.DataUsage import androidx.compose.material.icons.rounded.Edit @@ -81,6 +81,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -99,10 +100,10 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -115,6 +116,7 @@ import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R import org.monogram.presentation.core.ui.CollapsingToolbarScaffold @@ -135,7 +137,6 @@ import org.monogram.presentation.core.ui.SectionHeader import java.util.Locale import kotlin.math.roundToInt -val QrBackgroundColor = Color(0xFFEFF1E6) val QrDarkGreen = Color(0xFF3E4D36) val QrSurfaceShapeColor = Color(0xFFE3E6D8) @@ -143,6 +144,8 @@ val QrSurfaceShapeColor = Color(0xFFE3E6D8) @Composable fun SettingsContent(component: SettingsComponent) { val state by component.state.subscribeAsState() + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val haptic = LocalHapticFeedback.current @@ -228,7 +231,7 @@ fun SettingsContent(component: SettingsComponent) { onDismissRequest = component::onQrCodeDismissed, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { val username = state.currentUser?.username ?: "user" val qrContent = state.qrContent.ifEmpty { "https://t.me/$username" } @@ -311,7 +314,7 @@ fun SettingsContent(component: SettingsComponent) { onDismissRequest = component::onSupportDismissed, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { Column( modifier = Modifier @@ -367,7 +370,7 @@ fun SettingsContent(component: SettingsComponent) { onDismissRequest = component::onMoreOptionsDismissed, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { Column( modifier = Modifier @@ -514,7 +517,7 @@ fun SettingsContent(component: SettingsComponent) { ) { IconButton(onClick = component::onBackClicked) { Icon( - Icons.Rounded.ArrowBack, + Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.cd_back), tint = iconTint ) @@ -565,16 +568,13 @@ fun SettingsContent(component: SettingsComponent) { ) } ) { padding -> - var safeTopPadding by remember { mutableStateOf(0.dp) } var safeBottomPadding by remember { mutableStateOf(0.dp) } val language = remember { Locale.getDefault().displayLanguage .replaceFirstChar { it.uppercase() } } - val currentTop = padding.calculateTopPadding() val currentBottom = padding.calculateBottomPadding() - if (currentTop > 0.dp) safeTopPadding = currentTop if (currentBottom > 0.dp) safeBottomPadding = currentBottom CollapsingToolbarScaffold( @@ -601,8 +601,8 @@ fun SettingsContent(component: SettingsComponent) { val sidePadding = lerp(24.dp, 0.dp, progress) val topPadding = 0.dp - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp + val containerSize = LocalWindowInfo.current.containerSize + val screenHeight = with(LocalDensity.current) { containerSize.height.toDp() } val headerHeight = maxWidth.coerceAtMost(screenHeight * 0.6f) Box( @@ -640,8 +640,8 @@ fun SettingsContent(component: SettingsComponent) { .fillMaxSize() .semantics { contentDescription = "SettingsList" }, contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, + start = if (isTablet) 12.dp else 16.dp, + end = if (isTablet) 12.dp else 16.dp, top = 0.dp, bottom = safeBottomPadding ), @@ -976,7 +976,7 @@ fun SettingsContent(component: SettingsComponent) { modifier = Modifier .width(menuWidth) .heightIn(max = maxMenuHeightDp) - .clip(RoundedCornerShape(24.dp)) + .clip(RoundedCornerShape(16.dp)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null From 62540fbe0a35d3f17d02460a1425c49f08d3a376 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:16:16 +0300 Subject: [PATCH 34/83] fixed profile paddings --- .../monogram/presentation/features/profile/ProfileContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index 6f214f8d..a8e47b83 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -298,7 +298,7 @@ fun ProfileContent(component: ProfileComponent) { columns = GridCells.Fixed(3), modifier = Modifier .fillMaxSize() - .padding(horizontal = if (isTablet) 12.dp else 16.dp) + .padding(horizontal = if (isTablet) 12.dp else 0.dp) .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.background), horizontalArrangement = Arrangement.spacedBy(2.dp), From 2c17f68bba0e7615e7d350b9da1ed01a7aebfb00 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:19:54 +0300 Subject: [PATCH 35/83] optimize file update pipeline and wallpaper persistence --- .../org/monogram/data/db/MonogramDatabase.kt | 2 +- .../monogram/data/db/MonogramMigrations.kt | 36 ++++ .../org/monogram/data/db/dao/WallpaperDao.kt | 9 +- .../monogram/data/db/model/WallpaperEntity.kt | 23 ++- .../java/org/monogram/data/di/dataModule.kt | 22 ++- .../monogram/data/infra/FileDownloadQueue.kt | 14 +- .../monogram/data/infra/FileObserverHub.kt | 92 ++++++++++ .../monogram/data/infra/FileUpdateHandler.kt | 6 + .../monogram/data/mapper/WallpaperMapper.kt | 69 +++++++ .../repository/AttachMenuBotRepositoryImpl.kt | 68 ++++++- .../repository/ProfilePhotoRepositoryImpl.kt | 122 +++++++++++-- .../repository/StreamingRepositoryImpl.kt | 41 ++--- .../repository/WallpaperRepositoryImpl.kt | 172 ++++++++++++------ .../repository/user/UserRepositoryImpl.kt | 24 ++- .../repository/user/UserUpdateSynchronizer.kt | 68 +++++-- 15 files changed, 629 insertions(+), 139 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/infra/FileObserverHub.kt diff --git a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt index bb6ec3bd..48f61d90 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramDatabase.kt @@ -60,7 +60,7 @@ import org.monogram.data.db.model.WallpaperEntity SponsorEntity::class, TextCompositionStyleEntity::class ], - version = 30, + version = 31, exportSchema = false ) abstract class MonogramDatabase : RoomDatabase() { diff --git a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt index 0accd23f..2cf92556 100644 --- a/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt +++ b/data/src/main/java/org/monogram/data/db/MonogramMigrations.kt @@ -265,6 +265,42 @@ object MonogramMigrations { } } + val MIGRATION_30_31 = object : Migration(30, 31) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS `wallpapers`") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `wallpapers` ( + `id` INTEGER NOT NULL, + `slug` TEXT NOT NULL, + `title` TEXT NOT NULL, + `type` TEXT NOT NULL, + `pattern` INTEGER NOT NULL, + `documentId` INTEGER NOT NULL, + `thumbnailFileId` INTEGER, + `thumbnailWidth` INTEGER, + `thumbnailHeight` INTEGER, + `thumbnailLocalPath` TEXT, + `backgroundColor` INTEGER, + `secondBackgroundColor` INTEGER, + `thirdBackgroundColor` INTEGER, + `fourthBackgroundColor` INTEGER, + `intensity` INTEGER, + `rotation` INTEGER, + `isInverted` INTEGER, + `settingsIsMoving` INTEGER, + `settingsIsBlurred` INTEGER, + `themeName` TEXT, + `isDownloaded` INTEGER NOT NULL, + `localPath` TEXT, + `isDefault` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + } + } + private fun SupportSQLiteDatabase.addColumn(table: String, column: String, definition: String) { execSQL("ALTER TABLE `$table` ADD COLUMN `$column` $definition") } diff --git a/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt b/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt index d3f0f77b..12c32f54 100644 --- a/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt +++ b/data/src/main/java/org/monogram/data/db/dao/WallpaperDao.kt @@ -9,11 +9,14 @@ import org.monogram.data.db.model.WallpaperEntity @Dao interface WallpaperDao { - @Query("SELECT * FROM wallpapers") - fun getWallpapers(): Flow> + @Query("SELECT * FROM wallpapers ORDER BY isDefault DESC, id ASC") + fun observeWallpapers(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertWallpapers(wallpapers: List) + suspend fun upsertWallpapers(wallpapers: List) + + @Query("DELETE FROM wallpapers WHERE id NOT IN (:ids)") + suspend fun deleteNotIn(ids: List) @Query("DELETE FROM wallpapers") suspend fun clearAll() diff --git a/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt b/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt index 77782767..d6476a3a 100644 --- a/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt +++ b/data/src/main/java/org/monogram/data/db/model/WallpaperEntity.kt @@ -6,5 +6,26 @@ import androidx.room.PrimaryKey @Entity(tableName = "wallpapers") data class WallpaperEntity( @PrimaryKey val id: Long, - val data: String + val slug: String, + val title: String, + val type: String, + val pattern: Boolean, + val documentId: Long, + val thumbnailFileId: Int?, + val thumbnailWidth: Int?, + val thumbnailHeight: Int?, + val thumbnailLocalPath: String?, + val backgroundColor: Int?, + val secondBackgroundColor: Int?, + val thirdBackgroundColor: Int?, + val fourthBackgroundColor: Int?, + val intensity: Int?, + val rotation: Int?, + val isInverted: Boolean?, + val settingsIsMoving: Boolean?, + val settingsIsBlurred: Boolean?, + val themeName: String?, + val isDownloaded: Boolean, + val localPath: String?, + val isDefault: Boolean ) 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 53f166bd..056eda4d 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -68,6 +68,7 @@ import org.monogram.data.infra.DataMemoryPressureHandler import org.monogram.data.infra.DefaultDispatcherProvider import org.monogram.data.infra.FileDownloadQueue import org.monogram.data.infra.FileMessageRegistry +import org.monogram.data.infra.FileObserverHub import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.infra.OfflineWarmup import org.monogram.data.infra.SponsorSyncManager @@ -250,7 +251,8 @@ val dataModule = module { MonogramMigrations.MIGRATION_26_27, MonogramMigrations.MIGRATION_27_28, MonogramMigrations.MIGRATION_28_29, - MonogramMigrations.MIGRATION_29_30 + MonogramMigrations.MIGRATION_29_30, + MonogramMigrations.MIGRATION_30_31 ) .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -309,6 +311,7 @@ val dataModule = module { scope = get(), gateway = get(), fileQueue = get(), + fileObserverHub = get(), keyValueDao = get(), cacheProvider = get() ) @@ -325,8 +328,8 @@ val dataModule = module { remote = get(), chatLocal = get(), gateway = get(), - updates = get(), - fileQueue = get() + fileQueue = get(), + fileObserverHub = get() ) } @@ -548,8 +551,8 @@ val dataModule = module { single { WallpaperRepositoryImpl( remote = get(), - updates = get(), wallpaperDao = get(), + fileObserverHub = get(), dispatchers = get(), scope = get() ) @@ -579,6 +582,7 @@ val dataModule = module { cache = get(), cacheProvider = get(), updates = get(), + fileObserverHub = get(), dispatchers = get(), attachBotDao = get(), scope = get() @@ -672,6 +676,13 @@ val dataModule = module { ) } + single { + FileObserverHub( + queue = get(), + fileUpdateHandler = get() + ) + } + single { DataMemoryPressureHandler( chatsListRepository = get(), @@ -753,8 +764,7 @@ val dataModule = module { single { StreamingRepositoryImpl( fileDataSource = get(), - updates = get(), - scope = get() + fileObserverHub = get() ) } diff --git a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt index 567f0acc..5fba84e2 100644 --- a/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt +++ b/data/src/main/java/org/monogram/data/infra/FileDownloadQueue.kt @@ -1,10 +1,17 @@ package org.monogram.data.infra import android.util.Log -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.chats.ChatCache @@ -456,6 +463,11 @@ class FileDownloadQueue( fun isFileQueued(fileId: Int) = pendingRequests.containsKey(fileId) || activeRequests.containsKey(fileId) + fun getCachedFile(fileId: Int): TdApi.File? = cache.fileCache[fileId] + + fun getCachedPath(fileId: Int): String? = + cache.fileCache[fileId]?.local?.path?.takeIf { it.isNotEmpty() } + fun setChatOpened(chatId: Long) { openChatIds.add(chatId) activeChatId = chatId diff --git a/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt b/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt new file mode 100644 index 00000000..d2990c49 --- /dev/null +++ b/data/src/main/java/org/monogram/data/infra/FileObserverHub.kt @@ -0,0 +1,92 @@ +package org.monogram.data.infra + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeoutOrNull +import org.drinkless.tdlib.TdApi + +class FileObserverHub( + private val queue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler +) { + + data class FileState( + val fileId: Int, + val path: String?, + val isDownloading: Boolean, + val isDownloaded: Boolean, + val downloadProgress: Float, + val isUploading: Boolean, + val isUploaded: Boolean, + val uploadProgress: Float + ) + + val fileStates: Flow = fileUpdateHandler.fileUpdates + .map { it.toState() } + .distinctUntilChanged() + + fun observeFile(fileId: Int): Flow = flow { + getCachedFile(fileId)?.let { emit(it.toState()) } + fileStates + .filter { it.fileId == fileId } + .collect { emit(it) } + }.distinctUntilChanged() + + fun observeFiles(fileIds: Set): Flow { + if (fileIds.isEmpty()) return flow { } + return flow { + fileIds.forEach { fileId -> + getCachedFile(fileId)?.let { emit(it.toState()) } + } + fileStates + .filter { it.fileId in fileIds } + .collect { emit(it) } + }.distinctUntilChanged() + } + + fun getCachedFile(fileId: Int): TdApi.File? = queue.getCachedFile(fileId) + + fun getCachedPath(fileId: Int): String? = queue.getCachedPath(fileId) + + suspend fun awaitDownload(fileId: Int, timeoutMs: Long? = null): Boolean { + if (queue.getCachedFile(fileId)?.local?.isDownloadingCompleted == true) { + return true + } + return if (timeoutMs == null) { + runCatching { + queue.waitForDownload(fileId).await() + true + }.getOrDefault(false) + } else { + withTimeoutOrNull(timeoutMs) { + runCatching { + queue.waitForDownload(fileId).await() + true + }.getOrDefault(false) + } ?: false + } + } + + private fun TdApi.File.toState(): FileState { + val localFile = local + val remoteFile = remote + val downloadProgress = + if (size > 0) localFile.downloadedSize.toFloat() / size.toFloat() else 0f + val uploadProgress = + if (size > 0) (remoteFile?.uploadedSize ?: 0L).toFloat() / size.toFloat() else 0f + val path = localFile.path.takeIf { it.isNotEmpty() } + return FileState( + fileId = id, + path = path, + isDownloading = localFile.isDownloadingActive, + isDownloaded = localFile.isDownloadingCompleted, + downloadProgress = downloadProgress, + isUploading = remoteFile?.isUploadingActive == true, + isUploaded = remoteFile?.isUploadingCompleted == true, + uploadProgress = uploadProgress + ) + } +} diff --git a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt index a2d74a63..8b9e5407 100644 --- a/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt +++ b/data/src/main/java/org/monogram/data/infra/FileUpdateHandler.kt @@ -24,12 +24,17 @@ class FileUpdateHandler( private val _fileDownloadCompleted = MutableSharedFlow>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val _uploadProgress = MutableSharedFlow>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val _fileUpdates = MutableSharedFlow( + extraBufferCapacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) val downloadProgress = _downloadProgress.asSharedFlow() val downloadCompleted = _downloadCompleted.asSharedFlow() val fileDownloadProgress = _fileDownloadProgress.asSharedFlow() val fileDownloadCompleted = _fileDownloadCompleted.asSharedFlow() val uploadProgress = _uploadProgress.asSharedFlow() + val fileUpdates = _fileUpdates.asSharedFlow() init { scope.launch { @@ -39,6 +44,7 @@ class FileUpdateHandler( private fun handle(file: TdApi.File) { queue.updateFileCache(file) + _fileUpdates.tryEmit(file) val downloading = file.local?.isDownloadingActive == true val downloadDone = file.local?.isDownloadingCompleted == true diff --git a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt index b671664e..fa2890f1 100644 --- a/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/WallpaperMapper.kt @@ -1,6 +1,7 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.WallpaperEntity import org.monogram.domain.models.ThumbnailModel import org.monogram.domain.models.WallpaperModel import org.monogram.domain.models.WallpaperSettings @@ -55,6 +56,74 @@ fun TdApi.Background.toDomain(): WallpaperModel { ) } +fun WallpaperModel.toEntity(): WallpaperEntity = WallpaperEntity( + id = id, + slug = slug, + title = title, + type = type.name, + pattern = pattern, + documentId = documentId, + thumbnailFileId = thumbnail?.fileId, + thumbnailWidth = thumbnail?.width, + thumbnailHeight = thumbnail?.height, + thumbnailLocalPath = thumbnail?.localPath, + backgroundColor = settings?.backgroundColor, + secondBackgroundColor = settings?.secondBackgroundColor, + thirdBackgroundColor = settings?.thirdBackgroundColor, + fourthBackgroundColor = settings?.fourthBackgroundColor, + intensity = settings?.intensity, + rotation = settings?.rotation, + isInverted = settings?.isInverted, + settingsIsMoving = settings?.isMoving, + settingsIsBlurred = settings?.isBlurred, + themeName = themeName, + isDownloaded = isDownloaded, + localPath = localPath, + isDefault = isDefault +) + +fun WallpaperEntity.toDomain(): WallpaperModel = WallpaperModel( + id = id, + slug = slug, + title = title, + type = runCatching { WallpaperType.valueOf(type) }.getOrDefault(WallpaperType.WALLPAPER), + pattern = pattern, + documentId = documentId, + thumbnail = thumbnailFileId?.let { + ThumbnailModel( + fileId = it, + width = thumbnailWidth ?: 0, + height = thumbnailHeight ?: 0, + localPath = thumbnailLocalPath + ) + }, + settings = WallpaperSettings( + backgroundColor = backgroundColor, + secondBackgroundColor = secondBackgroundColor, + thirdBackgroundColor = thirdBackgroundColor, + fourthBackgroundColor = fourthBackgroundColor, + intensity = intensity, + rotation = rotation, + isInverted = isInverted, + isMoving = settingsIsMoving, + isBlurred = settingsIsBlurred + ).takeIf { + backgroundColor != null || + secondBackgroundColor != null || + thirdBackgroundColor != null || + fourthBackgroundColor != null || + intensity != null || + rotation != null || + isInverted != null || + settingsIsMoving != null || + settingsIsBlurred != null + }, + themeName = themeName, + isDownloaded = isDownloaded, + localPath = localPath, + isDefault = isDefault +) + fun TdApi.Thumbnail.toDomain(): ThumbnailModel = ThumbnailModel( fileId = this.file.id, width = this.width, diff --git a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt index 83c67501..5e8020a4 100644 --- a/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/AttachMenuBotRepositoryImpl.kt @@ -12,21 +12,26 @@ import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.AttachBotDao import org.monogram.data.db.model.AttachBotEntity import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.infra.FileObserverHub import org.monogram.data.mapper.toDomain import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.FileLocalModel import org.monogram.domain.repository.AttachMenuBotRepository import org.monogram.domain.repository.CacheProvider +import java.util.concurrent.ConcurrentHashMap class AttachMenuBotRepositoryImpl( private val remote: SettingsRemoteDataSource, private val cache: SettingsCacheDataSource, private val cacheProvider: CacheProvider, private val updates: UpdateDispatcher, + private val fileObserverHub: FileObserverHub, private val dispatchers: DispatcherProvider, private val attachBotDao: AttachBotDao, private val scope: CoroutineScope ) : AttachMenuBotRepository { private val attachMenuBots = MutableStateFlow>(cacheProvider.attachBots.value) + private val sideMenuIconFileToBotId = ConcurrentHashMap() init { scope.launch { @@ -35,6 +40,7 @@ class AttachMenuBotRepositoryImpl( val bots = update.bots.map { it.toDomain() } attachMenuBots.value = bots cacheProvider.setAttachBots(bots) + rebuildTrackedIcons(bots) saveAttachBotsToDb(bots) @@ -49,16 +55,10 @@ class AttachMenuBotRepositoryImpl( } scope.launch { - updates.file.collect { update -> - val currentBots = attachMenuBots.value - if (currentBots.any { it.icon?.icon?.id == update.file.id }) { - cache.getAttachMenuBots()?.let { bots -> - val domainBots = bots.map { it.toDomain() } - attachMenuBots.value = domainBots - cacheProvider.setAttachBots(domainBots) - saveAttachBotsToDb(domainBots) - } - } + fileObserverHub.fileStates.collect { state -> + if (!state.isDownloaded || state.path.isNullOrBlank()) return@collect + val botId = sideMenuIconFileToBotId[state.fileId] ?: return@collect + applyBotIconPath(botId, state.fileId, state.path) } } @@ -74,6 +74,7 @@ class AttachMenuBotRepositoryImpl( if (bots.isNotEmpty()) { attachMenuBots.value = bots cacheProvider.setAttachBots(bots) + rebuildTrackedIcons(bots) } } } @@ -83,6 +84,53 @@ class AttachMenuBotRepositoryImpl( return attachMenuBots } + private fun rebuildTrackedIcons(bots: List) { + sideMenuIconFileToBotId.clear() + bots.forEach { bot -> + bot.icon?.icon?.id?.takeIf { it != 0 }?.let { fileId -> + sideMenuIconFileToBotId[fileId] = bot.botUserId + } + } + } + + private suspend fun applyBotIconPath(botId: Long, fileId: Int, path: String) { + val current = attachMenuBots.value + if (current.isEmpty()) return + + var changed = false + val updated = current.map { bot -> + if (bot.botUserId != botId) return@map bot + val iconContainer = bot.icon ?: return@map bot + val iconModel = iconContainer.icon ?: return@map bot + if (iconModel.id != fileId) return@map bot + if (iconModel.local.path == path && iconModel.local.isDownloadingCompleted) return@map bot + + changed = true + bot.copy( + icon = iconContainer.copy( + icon = iconModel.copy( + local = FileLocalModel( + path = path, + isDownloadingActive = false, + canBeDownloaded = iconModel.local.canBeDownloaded, + isDownloadingCompleted = true, + canBeDeleted = iconModel.local.canBeDeleted, + downloadOffset = iconModel.local.downloadOffset, + downloadedPrefixSize = iconModel.local.downloadedPrefixSize, + downloadedSize = iconModel.size + ) + ) + ) + ) + } + + if (!changed) return + + attachMenuBots.value = updated + cacheProvider.setAttachBots(updated) + saveAttachBotsToDb(updated) + } + private suspend fun saveAttachBotsToDb(bots: List) { withContext(dispatchers.io) { attachBotDao.clearAll() diff --git a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt index 6a46f51b..6b50a377 100644 --- a/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ProfilePhotoRepositoryImpl.kt @@ -12,8 +12,8 @@ import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.cache.ChatLocalDataSource import org.monogram.data.datasource.remote.UserRemoteDataSource import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileObserverHub import org.monogram.data.mapper.isValidFilePath import org.monogram.data.mapper.toEntity import org.monogram.data.mapper.user.toTdApiChat @@ -23,8 +23,8 @@ class ProfilePhotoRepositoryImpl( private val remote: UserRemoteDataSource, private val chatLocal: ChatLocalDataSource, private val gateway: TelegramGateway, - private val updates: UpdateDispatcher, - private val fileQueue: FileDownloadQueue + private val fileQueue: FileDownloadQueue, + private val fileObserverHub: FileObserverHub ) : ProfilePhotoRepository { private val avatarDownloadPriority = AVATAR_DOWNLOAD_PRIORITY private val avatarHdPrefetchPriority = AVATAR_HD_PREFETCH_PRIORITY @@ -64,8 +64,22 @@ class ProfilePhotoRepositoryImpl( send(emptyList()) return@channelFlow } - send(getUserProfilePhotos(userId)) - updates.file.collectLatest { send(getUserProfilePhotos(userId)) } + + var trackedFileIds = emptySet() + + suspend fun reload() { + val loaded = getUserProfilePhotosWithTracking(userId) + trackedFileIds = loaded.second + send(loaded.first) + } + + reload() + + fileObserverHub.fileStates.collectLatest { state -> + if (state.fileId in trackedFileIds) { + reload() + } + } } override fun getChatProfilePhotosFlow(chatId: Long): Flow> = channelFlow { @@ -73,15 +87,72 @@ class ProfilePhotoRepositoryImpl( send(emptyList()) return@channelFlow } - send(getChatProfilePhotos(chatId)) - updates.file.collectLatest { send(getChatProfilePhotos(chatId)) } + + var trackedFileIds = emptySet() + + suspend fun reload() { + val loaded = getChatProfilePhotosWithTracking(chatId) + trackedFileIds = loaded.second + send(loaded.first) + } + + reload() + + fileObserverHub.fileStates.collectLatest { state -> + if (state.fileId in trackedFileIds) { + reload() + } + } + } + + private suspend fun getUserProfilePhotosWithTracking( + userId: Long, + offset: Int = 0, + limit: Int = 10, + ensureFullRes: Boolean = false + ): Pair, Set> { + if (userId <= 0) return emptyList() to emptySet() + val trackedFileIds = linkedSetOf() + val result = remote.getUserProfilePhotos(userId, offset, limit) + ?: return emptyList() to emptySet() + val paths = coroutineScope { + result.photos + .map { photo -> + async { + resolveUserProfilePhotoPath( + photo, + ensureFullRes, + trackedFileIds + ) + } + } + .awaitAll() + .filterNotNull() + } + return paths to trackedFileIds + } + + private suspend fun getChatProfilePhotosWithTracking( + chatId: Long, + offset: Int = 0, + limit: Int = 10, + ensureFullRes: Boolean = false + ): Pair, Set> { + if (chatId == 0L) return emptyList() to emptySet() + val trackedFileIds = linkedSetOf() + val paths = loadChatPhotoHistoryPaths(chatId, offset, limit, ensureFullRes, trackedFileIds) + if (paths.isNotEmpty()) return paths to trackedFileIds + + val currentPath = resolveCurrentChatPhotoPath(chatId, ensureFullRes, trackedFileIds) + return listOfNotNull(currentPath) to trackedFileIds } private suspend fun loadChatPhotoHistoryPaths( chatId: Long, offset: Int, limit: Int, - ensureFullRes: Boolean + ensureFullRes: Boolean, + trackedFileIds: MutableSet? = null ): List { if (limit <= 0) return emptyList() @@ -110,26 +181,41 @@ class ProfilePhotoRepositoryImpl( return coroutineScope { chatPhotos - .map { photo -> async { resolveUserProfilePhotoPath(photo, ensureFullRes) } } + .map { photo -> + async { + resolveUserProfilePhotoPath( + photo, + ensureFullRes, + trackedFileIds + ) + } + } .awaitAll() .filterNotNull() .distinct() } } - private suspend fun resolveCurrentChatPhotoPath(chatId: Long, ensureFullRes: Boolean): String? { + private suspend fun resolveCurrentChatPhotoPath( + chatId: Long, + ensureFullRes: Boolean, + trackedFileIds: MutableSet? = null + ): String? { val chat = remote.getChat(chatId)?.also { chatLocal.insertChat(it.toEntity()) } ?: chatLocal.getChat(chatId)?.toTdApiChat() ?: return null - return resolveChatPhotoInfoPath(chat.photo, ensureFullRes) + return resolveChatPhotoInfoPath(chat.photo, ensureFullRes, trackedFileIds) } private suspend fun resolveChatPhotoInfoPath( photoInfo: TdApi.ChatPhotoInfo?, - ensureFullRes: Boolean + ensureFullRes: Boolean, + trackedFileIds: MutableSet? = null ): String? { val smallId = photoInfo?.small?.id?.takeIf { it != 0 } val bigId = photoInfo?.big?.id?.takeIf { it != 0 } + smallId?.let { trackedFileIds?.add(it) } + bigId?.let { trackedFileIds?.add(it) } val preferredFile = if (ensureFullRes) { photoInfo?.big ?: photoInfo?.small } else { @@ -205,12 +291,18 @@ class ProfilePhotoRepositoryImpl( private suspend fun resolveUserProfilePhotoPath( photo: TdApi.ChatPhoto, - ensureFullRes: Boolean + ensureFullRes: Boolean, + trackedFileIds: MutableSet? = null ): String? { val animationFile = photo.animation?.file + animationFile?.id?.takeIf { it != 0 }?.let { trackedFileIds?.add(it) } val animationPath = animationFile?.local?.path?.takeIf { isValidFilePath(it) } if (animationPath != null) return animationPath + photo.sizes.forEach { size -> + size.photo.id.takeIf { it != 0 }?.let { trackedFileIds?.add(it) } + } + val bestPhotoFile = photo.sizes .maxByOrNull { it.width.toLong() * it.height.toLong() } ?.photo @@ -253,7 +345,9 @@ class ProfilePhotoRepositoryImpl( private suspend fun resolveDownloadedFilePath(fileId: Int?): String? { if (fileId == null || fileId == 0) return null - val file = coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() ?: return null + val file = fileObserverHub.getCachedFile(fileId) + ?: coRunCatching { gateway.execute(TdApi.GetFile(fileId)) }.getOrNull() + ?: return null return if (file.local.isDownloadingCompleted) { file.local.path.takeIf { isValidFilePath(it) } } else { diff --git a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt index c3c9818b..f01f7041 100644 --- a/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/StreamingRepositoryImpl.kt @@ -1,50 +1,33 @@ package org.monogram.data.repository import androidx.media3.datasource.DataSource -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.onStart import org.monogram.data.datasource.FileDataSource import org.monogram.data.datasource.TelegramStreamingDataSource -import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.infra.FileObserverHub import org.monogram.domain.repository.PlayerDataSourceFactory import org.monogram.domain.repository.StreamingRepository class StreamingRepositoryImpl( private val fileDataSource: FileDataSource, - private val updates: UpdateDispatcher, - private val scope: CoroutineScope + private val fileObserverHub: FileObserverHub ) : StreamingRepository, PlayerDataSourceFactory { - private val _fileProgressFlow = MutableSharedFlow>( - replay = 1, - extraBufferCapacity = 100, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - - init { - scope.launch { - updates.file.collect { update -> - val file = update.file - if (file.size > 0) { - val progress = file.local.downloadedSize.toFloat() / file.size.toFloat() - _fileProgressFlow.emit(file.id to progress) - } - } - } - } - override fun createPayload(fileId: Int): DataSource.Factory { return TelegramStreamingDataSource.Factory(fileDataSource, fileId) } override fun getDownloadProgress(fileId: Int): Flow { - return _fileProgressFlow - .filter { it.first == fileId } - .map { it.second } + val cachedProgress = fileObserverHub.getCachedFile(fileId)?.let { file -> + if (file.size > 0) file.local.downloadedSize.toFloat() / file.size.toFloat() else 0f + } ?: 0f + + return fileObserverHub.observeFile(fileId) + .map { it.downloadProgress.coerceIn(0f, 1f) } + .onStart { emit(cachedProgress) } + .distinctUntilChanged() } } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt index f8a51347..b76758a5 100644 --- a/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/WallpaperRepositoryImpl.kt @@ -1,82 +1,65 @@ package org.monogram.data.repository import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.datasource.remote.SettingsRemoteDataSource import org.monogram.data.db.dao.WallpaperDao -import org.monogram.data.db.model.WallpaperEntity -import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.infra.FileObserverHub import org.monogram.data.mapper.mapBackgrounds import org.monogram.data.mapper.toBackgroundType import org.monogram.data.mapper.toDomain +import org.monogram.data.mapper.toEntity import org.monogram.data.mapper.toInputBackground import org.monogram.domain.models.WallpaperModel +import org.monogram.domain.models.WallpaperType import org.monogram.domain.repository.WallpaperRepository +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean class WallpaperRepositoryImpl( private val remote: SettingsRemoteDataSource, - private val updates: UpdateDispatcher, private val wallpaperDao: WallpaperDao, + private val fileObserverHub: FileObserverHub, private val dispatchers: DispatcherProvider, private val scope: CoroutineScope ) : WallpaperRepository { - private val wallpaperUpdates = MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) private val wallpapers = MutableStateFlow>(emptyList()) + private val thumbnailFileToWallpaperId = ConcurrentHashMap() + private val documentFileToWallpaperId = ConcurrentHashMap() + private val refreshMutex = Mutex() + private val initialLoadRequested = AtomicBoolean(false) init { scope.launch { - updates.file.collectLatest { - wallpaperUpdates.tryEmit(Unit) + wallpaperDao.observeWallpapers().collect { entities -> + val models = entities.map { it.toDomain() } + wallpapers.value = models + rebuildTrackedFiles(models) } } scope.launch { - wallpaperDao.getWallpapers().collect { entities -> - val models = entities.mapNotNull { - try { - Json.decodeFromString(it.data) - } catch (_: Exception) { - null - } - } - if (models.isNotEmpty()) { - wallpapers.value = models - } + fileObserverHub.fileStates.collect { fileState -> + if (!fileState.isDownloaded || fileState.path.isNullOrBlank()) return@collect + val wallpaperId = documentFileToWallpaperId[fileState.fileId] + ?: thumbnailFileToWallpaperId[fileState.fileId] + ?: return@collect + applyFileUpdate(wallpaperId, fileState.fileId, fileState.path) } } } - override fun getWallpapers() = callbackFlow { - suspend fun fetch() { - val result = remote.getInstalledBackgrounds(false) - val mappedWallpapers = mapBackgrounds(result?.backgrounds ?: emptyArray()) - wallpapers.value = mappedWallpapers - saveWallpapersToDb(mappedWallpapers) - trySend(mappedWallpapers) - } - - val wallpaperJob = wallpaperUpdates - .onEach { fetch() } - .launchIn(this) - - if (wallpapers.value.isNotEmpty()) { - trySend(wallpapers.value) - } else { - fetch() - } - - awaitClose { wallpaperJob.cancel() } + override fun getWallpapers(): Flow> = wallpapers.onStart { + ensureInitialLoad() } override suspend fun downloadWallpaper(fileId: Int) { @@ -96,7 +79,7 @@ class WallpaperRepositoryImpl( forDarkTheme = false ) ?: return null - wallpaperUpdates.emit(Unit) + refreshFromRemote() return result.toDomain() } @@ -111,18 +94,103 @@ class WallpaperRepositoryImpl( forDarkTheme = false ) ?: return null - wallpaperUpdates.emit(Unit) + refreshFromRemote() return result.toDomain() } + private fun ensureInitialLoad() { + if (initialLoadRequested.compareAndSet(false, true)) { + scope.launch { + refreshFromRemote() + } + } + } + + private suspend fun refreshFromRemote() { + refreshMutex.withLock { + val result = remote.getInstalledBackgrounds(false) + val mappedWallpapers = mapBackgrounds(result?.backgrounds ?: emptyArray()) + wallpapers.value = mappedWallpapers + rebuildTrackedFiles(mappedWallpapers) + saveWallpapersToDb(mappedWallpapers) + } + } + + private fun rebuildTrackedFiles(models: List) { + thumbnailFileToWallpaperId.clear() + documentFileToWallpaperId.clear() + + models.forEach { wallpaper -> + wallpaper.thumbnail?.fileId?.takeIf { it != 0 }?.let { fileId -> + thumbnailFileToWallpaperId[fileId] = wallpaper.id + } + if (wallpaper.type == WallpaperType.WALLPAPER && wallpaper.documentId != 0L) { + wallpaper.documentId.toInt().takeIf { it != 0 }?.let { fileId -> + documentFileToWallpaperId[fileId] = wallpaper.id + } + } + } + } + + private suspend fun applyFileUpdate(wallpaperId: Long, fileId: Int, path: String) { + val current = wallpapers.value + if (current.isEmpty()) return + + var changed = false + val updated = current.map { wallpaper -> + if (wallpaper.id != wallpaperId) return@map wallpaper + + val next = when { + wallpaper.thumbnail?.fileId == fileId -> { + val thumbnail = wallpaper.thumbnail ?: return@map wallpaper + val currentThumbPath = thumbnail.localPath + if (currentThumbPath == path) { + wallpaper + } else { + wallpaper.copy( + thumbnail = thumbnail.copy(localPath = path) + ) + } + } + + wallpaper.documentId == fileId.toLong() -> { + if (wallpaper.localPath == path && wallpaper.isDownloaded) { + wallpaper + } else { + wallpaper.copy( + localPath = path, + isDownloaded = true + ) + } + } + + else -> wallpaper + } + + if (next != wallpaper) { + changed = true + } + next + } + + if (!changed) return + + wallpapers.value = updated + withContext(dispatchers.io) { + wallpaperDao.upsertWallpapers(updated.filter { it.id == wallpaperId } + .map { it.toEntity() }) + } + } + private suspend fun saveWallpapersToDb(wallpapers: List) { withContext(dispatchers.io) { - wallpaperDao.clearAll() - wallpaperDao.insertWallpapers( - wallpapers.map { - WallpaperEntity(it.id, Json.encodeToString(it)) - } - ) + val entities = wallpapers.map { it.toEntity() } + wallpaperDao.upsertWallpapers(entities) + if (entities.isEmpty()) { + wallpaperDao.clearAll() + } else { + wallpaperDao.deleteNotIn(entities.map { it.id }) + } } } } \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt index 7f50e2f8..42912698 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserRepositoryImpl.kt @@ -1,8 +1,19 @@ package org.monogram.data.repository.user -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.drinkless.tdlib.TdApi import org.monogram.data.chats.ChatCache import org.monogram.data.core.coRunCatching @@ -14,7 +25,12 @@ import org.monogram.data.db.model.KeyValueEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.gateway.UpdateDispatcher import org.monogram.data.infra.FileDownloadQueue -import org.monogram.data.mapper.user.* +import org.monogram.data.infra.FileObserverHub +import org.monogram.data.mapper.user.extractPersonalAvatarPath +import org.monogram.data.mapper.user.mapUserFullInfoToChat +import org.monogram.data.mapper.user.toDomain +import org.monogram.data.mapper.user.toEntity +import org.monogram.data.mapper.user.toTdApi import org.monogram.domain.models.ChatFullInfoModel import org.monogram.domain.models.UserModel import org.monogram.domain.repository.CacheProvider @@ -29,6 +45,7 @@ class UserRepositoryImpl( gateway: TelegramGateway, private val updates: UpdateDispatcher, fileQueue: FileDownloadQueue, + private val fileObserverHub: FileObserverHub, private val keyValueDao: KeyValueDao, private val cacheProvider: CacheProvider, private val scope: CoroutineScope @@ -57,6 +74,7 @@ class UserRepositoryImpl( UserUpdateSynchronizer( scope = scope, updates = updates, + fileObserverHub = fileObserverHub, userLocal = userLocal, keyValueDao = keyValueDao, emojiPathCache = mediaResolver.emojiPathCache, diff --git a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt index a92cda70..d664b273 100644 --- a/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt +++ b/data/src/main/java/org/monogram/data/repository/user/UserUpdateSynchronizer.kt @@ -6,11 +6,13 @@ import org.drinkless.tdlib.TdApi import org.monogram.data.datasource.cache.UserLocalDataSource import org.monogram.data.db.dao.KeyValueDao import org.monogram.data.gateway.UpdateDispatcher +import org.monogram.data.infra.FileObserverHub import java.util.concurrent.ConcurrentHashMap internal class UserUpdateSynchronizer( private val scope: CoroutineScope, private val updates: UpdateDispatcher, + private val fileObserverHub: FileObserverHub, private val userLocal: UserLocalDataSource, private val keyValueDao: KeyValueDao, private val emojiPathCache: ConcurrentHashMap, @@ -19,9 +21,19 @@ internal class UserUpdateSynchronizer( private val onUserIdChanged: suspend (Long) -> Unit, private val onCachedSimCountryIsoChanged: suspend (String?) -> Unit ) { + private val avatarFileIdToUserIds = ConcurrentHashMap>() + private val userIdToAvatarFileIds = ConcurrentHashMap>() + fun start() { + scope.launch { + userLocal.getAllUsers().forEach { user -> + updateAvatarIndex(user) + } + } + scope.launch { updates.user.collect { update -> + updateAvatarIndex(update.user) onUserUpdated(update.user) } } @@ -30,35 +42,31 @@ internal class UserUpdateSynchronizer( updates.userStatus.collect { update -> userLocal.getUser(update.userId)?.let { cached -> cached.status = update.status + updateAvatarIndex(cached) onUserUpdated(cached) } } } scope.launch { - updates.file.collect { update -> - val file = update.file - if (!file.local.isDownloadingCompleted) return@collect + fileObserverHub.fileStates.collect { state -> + if (!state.isDownloaded) return@collect + val fileId = state.fileId + val path = state.path ?: return@collect - userLocal.getAllUsers().forEach { user -> - val small = user.profilePhoto?.small - val big = user.profilePhoto?.big - if (small?.id == file.id || big?.id == file.id) { - onUserIdChanged(user.id) - } + avatarFileIdToUserIds[fileId]?.forEach { userId -> + onUserIdChanged(userId) } - if (file.local.path.isNotEmpty()) { - val userId = fileIdToUserIdMap.remove(file.id) - if (userId != null) { - userLocal.getUser(userId)?.let { user -> - val emojiId = user.extractEmojiStatusId() - if (emojiId != 0L) { - emojiPathCache[emojiId] = file.local.path - } + val userId = fileIdToUserIdMap.remove(fileId) + if (userId != null) { + userLocal.getUser(userId)?.let { user -> + val emojiId = user.extractEmojiStatusId() + if (emojiId != 0L) { + emojiPathCache[emojiId] = path } - onUserIdChanged(userId) } + onUserIdChanged(userId) } } } @@ -73,4 +81,26 @@ internal class UserUpdateSynchronizer( companion object { private const val KEY_CACHED_SIM_COUNTRY_ISO = "cached_sim_country_iso" } -} \ No newline at end of file + + private fun updateAvatarIndex(user: TdApi.User) { + val newFileIds = buildSet { + user.profilePhoto?.small?.id?.takeIf { it != 0 }?.let(::add) + user.profilePhoto?.big?.id?.takeIf { it != 0 }?.let(::add) + } + + val previousFileIds = userIdToAvatarFileIds.put(user.id, newFileIds) ?: emptySet() + + (previousFileIds - newFileIds).forEach { fileId -> + avatarFileIdToUserIds[fileId]?.let { userIds -> + userIds.remove(user.id) + if (userIds.isEmpty()) { + avatarFileIdToUserIds.remove(fileId) + } + } + } + + (newFileIds - previousFileIds).forEach { fileId -> + avatarFileIdToUserIds.getOrPut(fileId) { ConcurrentHashMap.newKeySet() }.add(user.id) + } + } +} From 5fa4aee8b61e009a4229b215f4d94392850cc3fc Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:44:18 +0300 Subject: [PATCH 36/83] localize chat and notification UI strings across modules Replace hardcoded Mini App, profile, sessions, scheduled messages, chat top bar, and notification texts with string resources/string provider so UI and notifications are translated consistently across supported locales. --- .../monogram/data/di/TdNotificationManager.kt | 76 +++++++++++-------- .../java/org/monogram/data/di/dataModule.kt | 2 +- .../chats/chatList/components/ChatListItem.kt | 7 +- .../currentChat/components/ChatTopBar.kt | 20 +++-- .../inputbar/ScheduledMessagesSheet.kt | 63 ++++++++------- .../profile/DefaultProfileComponent.kt | 57 ++++++++------ .../features/webapp/MiniAppState.kt | 36 +++++---- .../settings/sessions/SessionItem.kt | 10 ++- .../src/main/res/values-es/string.xml | 32 ++++++++ .../src/main/res/values-hy/string.xml | 32 ++++++++ .../src/main/res/values-pt-rBR/string.xml | 32 ++++++++ .../src/main/res/values-ru-rRU/string.xml | 32 ++++++++ .../src/main/res/values-sk/string.xml | 32 ++++++++ .../src/main/res/values-uk/string.xml | 32 ++++++++ .../src/main/res/values-zh-rCN/string.xml | 32 ++++++++ presentation/src/main/res/values/string.xml | 32 ++++++++ 16 files changed, 418 insertions(+), 109 deletions(-) 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 4c036272..1bf2f453 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -35,6 +35,7 @@ import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.NotificationSettingsRepository import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import org.monogram.domain.repository.PushProvider +import org.monogram.domain.repository.StringProvider import java.util.concurrent.ConcurrentHashMap import kotlin.math.min @@ -44,7 +45,8 @@ class TdNotificationManager( private val appPreferences: AppPreferencesProvider, private val notificationSettingsRepository: NotificationSettingsRepository, private val notificationSettingDao: NotificationSettingDao, - private val fileQueue: FileDownloadQueue + private val fileQueue: FileDownloadQueue, + private val stringProvider: StringProvider ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val notificationManager = NotificationManagerCompat.from(context) @@ -336,7 +338,7 @@ class TdNotificationManager( if (isChatMuted(chat)) return@launch val contentText = - if (appPreferences.showSenderOnly.value) "Новое сообщение" else getMessageText(message.content) + if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText(message.content) if (contentText.isBlank()) return@launch @@ -477,7 +479,7 @@ class TdNotificationManager( ) val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel("Ответить") + .setLabel(stringProvider.getString("menu_reply")) .build() val replyIntent = Intent(context, NotificationReplyReceiver::class.java).apply { @@ -505,18 +507,18 @@ class TdNotificationManager( val replyAction = NotificationCompat.Action.Builder( android.R.drawable.ic_menu_send, - "Ответить", + stringProvider.getString("menu_reply"), replyPendingIntent ).addRemoteInput(remoteInput).build() val readAction = NotificationCompat.Action.Builder( android.R.drawable.ic_menu_view, - "Прочитано", + stringProvider.getString("action_mark_as_read"), readPendingIntent ).build() - val myself = Person.Builder().setName("Я").build() + val myself = Person.Builder().setName(stringProvider.getString("notification_person_me")).build() val messagingStyle = NotificationCompat.MessagingStyle(myself) history.forEach { (_, msg) -> messagingStyle.addMessage(msg) @@ -570,7 +572,7 @@ class TdNotificationManager( } if (!appPreferences.inAppPreview.value) { - builder.setContentText("Новое сообщение") + builder.setContentText(stringProvider.getString("notification_new_message")) } if (chatIcon != null) { @@ -630,7 +632,7 @@ class TdNotificationManager( allMessages.take(5).forEach { (chatId, message, _) -> val chat = chatCache[chatId] - val senderName = message.person?.name ?: "Unknown" + val senderName = message.person?.name ?: stringProvider.getString("unknown_user") val chatTitle = chat?.title ?: senderName val sb = SpannableStringBuilder() @@ -643,8 +645,12 @@ class TdNotificationManager( inboxStyle.addLine(sb) } - val summaryTitle = "$totalMessagesCount сообщений из $activeChatsCount чатов" - inboxStyle.setSummaryText("$activeChatsCount чатов") + val summaryTitle = stringProvider.getString( + "notification_summary_title_format", + totalMessagesCount, + activeChatsCount + ) + inboxStyle.setSummaryText(stringProvider.getString("notification_summary_text_format", activeChatsCount)) inboxStyle.setBigContentTitle(summaryTitle) val builder = NotificationCompat.Builder(context, CHANNEL_PRIVATE) @@ -677,34 +683,34 @@ class TdNotificationManager( manager.createNotificationChannelGroups( listOf( - NotificationChannelGroup(GROUP_CHATS, "Чаты"), - NotificationChannelGroup(GROUP_OTHER, "Прочее") + NotificationChannelGroup(GROUP_CHATS, stringProvider.getString("notification_group_chats")), + NotificationChannelGroup(GROUP_OTHER, stringProvider.getString("notification_group_other")) ) ) val channels = listOf( - NotificationChannel(CHANNEL_PRIVATE, "Личные чаты", NotificationManager.IMPORTANCE_HIGH).apply { - description = "Уведомления из личных переписок" + NotificationChannel(CHANNEL_PRIVATE, stringProvider.getString("notification_channel_private_name"), NotificationManager.IMPORTANCE_HIGH).apply { + description = stringProvider.getString("notification_channel_private_description") group = GROUP_CHATS enableVibration(true) setShowBadge(true) lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE }, - NotificationChannel(CHANNEL_GROUPS, "Группы", NotificationManager.IMPORTANCE_DEFAULT).apply { - description = "Уведомления из групп" + NotificationChannel(CHANNEL_GROUPS, stringProvider.getString("notification_channel_groups_name"), NotificationManager.IMPORTANCE_DEFAULT).apply { + description = stringProvider.getString("notification_channel_groups_description") group = GROUP_CHATS enableVibration(true) setShowBadge(true) lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE }, - NotificationChannel(CHANNEL_CHANNELS, "Каналы", NotificationManager.IMPORTANCE_LOW).apply { - description = "Уведомления из каналов" + NotificationChannel(CHANNEL_CHANNELS, stringProvider.getString("notification_channel_channels_name"), NotificationManager.IMPORTANCE_LOW).apply { + description = stringProvider.getString("notification_channel_channels_description") group = GROUP_CHATS enableVibration(false) setShowBadge(true) }, - NotificationChannel(CHANNEL_OTHER, "Другое", NotificationManager.IMPORTANCE_LOW).apply { - description = "Прочие уведомления" + NotificationChannel(CHANNEL_OTHER, stringProvider.getString("notification_channel_other_name"), NotificationManager.IMPORTANCE_LOW).apply { + description = stringProvider.getString("notification_channel_other_description") group = GROUP_OTHER } ) @@ -714,19 +720,27 @@ class TdNotificationManager( } private fun getMessageText(content: TdApi.MessageContent): String { + fun withDetails(base: String, details: String?): String { + val cleanDetails = details?.trim().orEmpty() + return if (cleanDetails.isEmpty()) base else "$base $cleanDetails" + } + return when (content) { is TdApi.MessageText -> sanitizeSpoilers(content.text) - is TdApi.MessagePhoto -> "📷 Фотография ${sanitizeSpoilers(content.caption)}" - is TdApi.MessageVideo -> "📹 Видео ${sanitizeSpoilers(content.caption)}" - is TdApi.MessageVoiceNote -> "🎤 Голосовое сообщение" - is TdApi.MessageSticker -> "Стикер" - is TdApi.MessageAnimation -> "GIF" - is TdApi.MessageAudio -> "🎵 Аудио ${content.audio.title}" - is TdApi.MessageDocument -> "📄 Файл ${content.document.fileName}" - is TdApi.MessageLocation -> "📍 Локация ${content.location.latitude}, ${content.location.longitude}" - is TdApi.MessageContact -> "👤 Контакт ${content.contact.firstName} ${content.contact.lastName}" - is TdApi.MessagePoll -> "📊 Опрос ${content.poll.question.text}" - else -> "Сообщение" + is TdApi.MessagePhoto -> withDetails("📷 ${stringProvider.getString("logs_media_photo")}", sanitizeSpoilers(content.caption)) + is TdApi.MessageVideo -> withDetails("📹 ${stringProvider.getString("logs_media_video")}", sanitizeSpoilers(content.caption)) + is TdApi.MessageVoiceNote -> "🎤 ${stringProvider.getString("logs_media_voice")}" + is TdApi.MessageSticker -> stringProvider.getString("reply_content_sticker") + is TdApi.MessageAnimation -> stringProvider.getString("reply_content_gif") + is TdApi.MessageAudio -> withDetails("🎵 ${stringProvider.getString("logs_media_audio")}", content.audio.title) + is TdApi.MessageDocument -> withDetails("📄 ${stringProvider.getString("logs_media_document")}", content.document.fileName) + is TdApi.MessageLocation -> "📍 ${stringProvider.getString("location_label")} ${content.location.latitude}, ${content.location.longitude}" + is TdApi.MessageContact -> withDetails( + "👤 ${stringProvider.getString("logs_media_contact")}", + listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() }.joinToString(" ") + ) + is TdApi.MessagePoll -> withDetails("📊 ${stringProvider.getString("logs_media_poll")}", content.poll.question.text) + else -> stringProvider.getString("reply_content_message") } } 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 056eda4d..e73fe168 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -806,5 +806,5 @@ val dataModule = module { ) } - single(createdAtStart = true) { TdNotificationManager(androidContext(), get(), get(), get(), get(), get()) } + single(createdAtStart = true) { TdNotificationManager(androidContext(), get(), get(), get(), get(), get(), get()) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 49eea301..21592dfe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -197,6 +197,7 @@ private fun ChatListItemHeader( isSavedMessages: Boolean ) { val chatTime = chat.lastMessageDate.toDate().toShortRelativeDate() + val savedMessagesTitle = stringResource(R.string.menu_saved_messages) Row( verticalAlignment = Alignment.CenterVertically, @@ -214,13 +215,15 @@ private fun ChatListItemHeader( Spacer(Modifier.width(4.dp)) } Text( - text = if (isSavedMessages) stringResource(R.string.menu_saved_messages) else chat.title, + text = if (isSavedMessages) savedMessagesTitle else chat.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.semantics { contentDescription = "ChatTitle" } + modifier = Modifier.semantics { + contentDescription = if (isSavedMessages) savedMessagesTitle else chat.title + } ) if (!isSavedMessages && chat.isMuted) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt index a08f2cf6..264e24cd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt @@ -200,11 +200,21 @@ fun ChatTopBar( label = "StatusAnimation" ) { targetStatus -> if (!targetStatus.isNullOrEmpty()) { - val isTyping = targetStatus.contains("печатает") || - targetStatus.contains("записывает") || - targetStatus.contains("отправляет") || - targetStatus.contains("выбирает") || - targetStatus.contains("играет") + val normalizedStatus = targetStatus.lowercase() + val typingTokens = listOf( + stringResource(R.string.typing_typing), + stringResource(R.string.typing_recording_video), + stringResource(R.string.typing_recording_voice), + stringResource(R.string.typing_uploading_photo), + stringResource(R.string.typing_uploading_video), + stringResource(R.string.typing_uploading_document), + stringResource(R.string.typing_choosing_sticker), + stringResource(R.string.typing_playing_game), + stringResource(R.string.typing_multi_typing) + ).map { it.lowercase() } + val isTyping = typingTokens.any { token -> + token.isNotBlank() && normalizedStatus.contains(token) + } Row(verticalAlignment = Alignment.Bottom) { Text( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt index eb688fd4..7a913541 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt @@ -300,39 +300,50 @@ private fun ScheduledMessageRow( } } +@Composable private fun messagePreviewText(message: MessageModel): String = when (val content = message.content) { - is MessageContent.Text -> content.text - is MessageContent.Photo -> if (content.caption.isNotBlank()) content.caption else "Photo" - is MessageContent.Video -> if (content.caption.isNotBlank()) content.caption else "Video" - is MessageContent.Document -> if (content.caption.isNotBlank()) content.caption else "Document" - is MessageContent.Gif -> if (content.caption.isNotBlank()) content.caption else "GIF" - is MessageContent.Sticker -> "Sticker" - is MessageContent.Voice -> "Voice message" - is MessageContent.VideoNote -> "Video message" - is MessageContent.Audio -> "Audio" - is MessageContent.Location -> "Location" - is MessageContent.Venue -> content.title - is MessageContent.Contact -> listOf(content.firstName, content.lastName).filter { it.isNotBlank() } - .joinToString(" ") + is MessageContent.Text -> content.text.ifBlank { stringResource(R.string.reply_content_message) } + is MessageContent.Photo -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.reply_content_photo) + is MessageContent.Video -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.reply_content_video) + is MessageContent.Document -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.logs_media_document) + is MessageContent.Gif -> if (content.caption.isNotBlank()) content.caption else stringResource(R.string.reply_content_gif) + is MessageContent.Sticker -> stringResource(R.string.reply_content_sticker) + is MessageContent.Voice -> stringResource(R.string.reply_content_voice_message) + is MessageContent.VideoNote -> stringResource(R.string.reply_content_video_message) + is MessageContent.Audio -> stringResource(R.string.logs_media_audio) + is MessageContent.Location -> stringResource(R.string.location_label) + is MessageContent.Venue -> content.title.ifBlank { stringResource(R.string.logs_media_venue) } + is MessageContent.Contact -> { + val fullName = listOf(content.firstName, content.lastName) + .filter { it.isNotBlank() } + .joinToString(" ") + fullName.ifBlank { stringResource(R.string.logs_media_contact) } + } - is MessageContent.Service -> content.text - is MessageContent.Poll -> content.question - is MessageContent.Unsupported -> "Unsupported message" - else -> "Message" + is MessageContent.Service -> content.text.ifBlank { stringResource(R.string.profile_statistics_preview_service_message) } + is MessageContent.Poll -> content.question.ifBlank { stringResource(R.string.logs_media_poll) } + is MessageContent.Unsupported -> stringResource(R.string.logs_media_unsupported) } +@Composable private fun scheduledMessageTypeLabel(message: MessageModel): String = when (message.content) { - is MessageContent.Text -> "Text" - is MessageContent.Photo -> "Photo" - is MessageContent.Video -> "Video" - is MessageContent.Document -> "Document" - is MessageContent.Gif -> "GIF" - is MessageContent.Sticker -> "Sticker" - is MessageContent.Voice -> "Voice" - is MessageContent.VideoNote -> "Video message" - else -> "Message" + is MessageContent.Text -> stringResource(R.string.photo_editor_tool_text) + is MessageContent.Photo -> stringResource(R.string.reply_content_photo) + is MessageContent.Video -> stringResource(R.string.reply_content_video) + is MessageContent.Document -> stringResource(R.string.logs_media_document) + is MessageContent.Gif -> stringResource(R.string.reply_content_gif) + is MessageContent.Sticker -> stringResource(R.string.reply_content_sticker) + is MessageContent.Voice -> stringResource(R.string.logs_media_voice) + is MessageContent.VideoNote -> stringResource(R.string.reply_content_video_message) + is MessageContent.Audio -> stringResource(R.string.logs_media_audio) + is MessageContent.Contact -> stringResource(R.string.logs_media_contact) + is MessageContent.Location -> stringResource(R.string.location_label) + is MessageContent.Venue -> stringResource(R.string.logs_media_venue) + is MessageContent.Poll -> stringResource(R.string.logs_media_poll) + is MessageContent.Service -> stringResource(R.string.profile_statistics_preview_service_message) + MessageContent.Unsupported -> stringResource(R.string.reply_content_message) } private fun canEditScheduledMessage(message: MessageModel): Boolean = diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt index c983ad55..25dfe54a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/DefaultProfileComponent.kt @@ -71,6 +71,7 @@ class DefaultProfileComponent( private val locationRepository: LocationRepository = container.repositories.locationRepository private val gifRepository: GifRepository = container.repositories.gifRepository private val botPreferences: BotPreferencesProvider = container.preferences.botPreferencesProvider + private val stringProvider = container.utils.stringProvider() override val downloadUtils: IDownloadUtils = container.utils.downloadUtils() private val scope = componentScope @@ -610,7 +611,7 @@ class DefaultProfileComponent( } } is MessageContent.Location -> { - onLocationClick(content.latitude, content.longitude, "Location") + onLocationClick(content.latitude, content.longitude, stringProvider.getString("location_label")) } is MessageContent.Venue -> { @@ -1095,9 +1096,9 @@ class DefaultProfileComponent( override fun onShowPermissions() { val botId = _state.value.user?.id ?: return val permissions = mapOf( - "Location" to botPreferences.getWebappPermission(botId, "location"), - "Biometry" to botPreferences.getWebappPermission(botId, "biometry"), - "Terms of Service" to botPreferences.getWebappPermission(botId, "tos_accepted") + stringProvider.getString("location_label") to botPreferences.getWebappPermission(botId, "location"), + stringProvider.getString("mini_app_permission_biometry") to botPreferences.getWebappPermission(botId, "biometry"), + stringProvider.getString("terms_of_service_title") to botPreferences.getWebappPermission(botId, "tos_accepted") ) _state.update { it.copy(isPermissionsVisible = true, botPermissions = permissions) } } @@ -1109,9 +1110,9 @@ class DefaultProfileComponent( override fun onTogglePermission(permission: String) { val botId = _state.value.user?.id ?: return val key = when (permission) { - "Location" -> "location" - "Biometry" -> "biometry" - "Terms of Service" -> "tos_accepted" + stringProvider.getString("location_label") -> "location" + stringProvider.getString("mini_app_permission_biometry") -> "biometry" + stringProvider.getString("terms_of_service_title") -> "tos_accepted" else -> return } val current = botPreferences.getWebappPermission(botId, key) @@ -1153,10 +1154,12 @@ class DefaultProfileComponent( override fun onLocationClick(lat: Double, lon: Double, address: String) { scope.launch { var finalAddress = address - if (address == "Location") { + if (address == stringProvider.getString("location_label")) { val reverse = locationRepository.reverseGeocode(lat, lon) if (reverse != null) { - finalAddress = reverse.address?.city ?: reverse.address?.toString() ?: "Location" + finalAddress = reverse.address?.city + ?: reverse.address?.toString() + ?: stringProvider.getString("location_label") } } _state.update { @@ -1192,21 +1195,27 @@ class DefaultProfileComponent( private fun MessageContent.toStatisticsPreview(): String { return when (this) { - is MessageContent.Text -> text.ifBlank { "Message" } - is MessageContent.Photo -> caption.ifBlank { "Photo" } - is MessageContent.Video -> caption.ifBlank { "Video" } - is MessageContent.Gif -> caption.ifBlank { "GIF" } - is MessageContent.Document -> caption.ifBlank { fileName.ifBlank { "Document" } } - is MessageContent.Audio -> caption.ifBlank { title.ifBlank { "Audio" } } - is MessageContent.Voice -> "Voice message" - is MessageContent.VideoNote -> "Video message" - is MessageContent.Sticker -> "Sticker ${emoji.ifBlank { "" }}".trim() - is MessageContent.Contact -> "Contact: ${firstName} ${lastName}".trim() - is MessageContent.Location -> "Location" - is MessageContent.Venue -> "Venue: $title" - is MessageContent.Poll -> "Poll: $question" - is MessageContent.Service -> text.ifBlank { "Service message" } - MessageContent.Unsupported -> "Unsupported message" + is MessageContent.Text -> text.ifBlank { stringProvider.getString("reply_content_message") } + is MessageContent.Photo -> caption.ifBlank { stringProvider.getString("reply_content_photo") } + is MessageContent.Video -> caption.ifBlank { stringProvider.getString("reply_content_video") } + is MessageContent.Gif -> caption.ifBlank { stringProvider.getString("reply_content_gif") } + is MessageContent.Document -> caption.ifBlank { fileName.ifBlank { stringProvider.getString("logs_media_document") } } + is MessageContent.Audio -> caption.ifBlank { title.ifBlank { stringProvider.getString("logs_media_audio") } } + is MessageContent.Voice -> stringProvider.getString("reply_content_voice_message") + is MessageContent.VideoNote -> stringProvider.getString("reply_content_video_message") + is MessageContent.Sticker -> listOf( + stringProvider.getString("reply_content_sticker"), + emoji.ifBlank { "" } + ).filter { it.isNotBlank() }.joinToString(" ") + is MessageContent.Contact -> stringProvider.getString( + "profile_statistics_preview_contact_format", + "$firstName $lastName".trim() + ) + is MessageContent.Location -> stringProvider.getString("location_label") + is MessageContent.Venue -> stringProvider.getString("profile_statistics_preview_venue_format", title) + is MessageContent.Poll -> stringProvider.getString("profile_statistics_preview_poll_format", question) + is MessageContent.Service -> text.ifBlank { stringProvider.getString("profile_statistics_preview_service_message") } + MessageContent.Unsupported -> stringProvider.getString("logs_media_unsupported") } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt index ad2393bb..3ad5fc1d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/webapp/MiniAppState.kt @@ -497,8 +497,8 @@ class MiniAppState( reqId = "req_phone", method = "web_app_request_phone", params = "", - title = "Share Contact", - message = "Allow $botName to access your phone number?", + title = context.getString(R.string.mini_app_share_contact_title), + message = context.getString(R.string.mini_app_share_contact_message, botName), onConfirm = { scope.launch { val me = userRepository.getMe() @@ -531,8 +531,8 @@ class MiniAppState( reqId = "req_write_access", method = "web_app_request_write_access", params = "", - title = "Allow Messages", - message = "Allow $botName to send you messages?", + title = context.getString(R.string.mini_app_allow_messages_title), + message = context.getString(R.string.mini_app_allow_messages_message, botName), onConfirm = { telegramProxy?.dispatchToWebView("write_access_requested", JSONObject().put("status", "allowed")) activeCustomMethod = null @@ -813,8 +813,12 @@ class MiniAppState( reqId = "req_file_download", method = "web_app_request_file_download", params = "", - title = "Download File", - message = "Download ${if (fileName.isBlank()) "this file" else fileName}?", + title = context.getString(R.string.mini_app_download_file_title), + message = if (fileName.isBlank()) { + context.getString(R.string.mini_app_download_file_message_generic) + } else { + context.getString(R.string.mini_app_download_file_message_named, fileName) + }, onConfirm = { val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager if (downloadManager == null) { @@ -1042,9 +1046,9 @@ class MiniAppState( override fun onAuthenticationFailed() {} }) val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Biometric Authentication") - .setSubtitle(reason ?: "Authenticate to continue") - .setNegativeButtonText("Cancel") + .setTitle(context.getString(R.string.mini_app_biometric_auth_title)) + .setSubtitle(reason ?: context.getString(R.string.mini_app_biometric_auth_subtitle)) + .setNegativeButtonText(context.getString(R.string.cancel_button)) .build() biometricPrompt.authenticate(promptInfo) } @@ -1064,7 +1068,7 @@ class MiniAppState( return } showPermissionRequest = PermissionRequest( - message = "Allow this bot to access your location?", + message = context.getString(R.string.mini_app_request_location_access_message), onGranted = { savePermission("location", true) checkSystemLocationAndHandle() @@ -1121,9 +1125,9 @@ class MiniAppState( fun onShowPermissions() { val permissions = mapOf( - "Location" to botPreferences.getWebappPermission(botUserId, "location"), - "Biometry" to botPreferences.getWebappPermission(botUserId, "biometry"), - "Terms of Service" to botPreferences.getWebappPermission(botUserId, "tos_accepted") + context.getString(R.string.location_label) to botPreferences.getWebappPermission(botUserId, "location"), + context.getString(R.string.mini_app_permission_biometry) to botPreferences.getWebappPermission(botUserId, "biometry"), + context.getString(R.string.terms_of_service_title) to botPreferences.getWebappPermission(botUserId, "tos_accepted") ) botPermissions = permissions isPermissionsVisible = true @@ -1135,9 +1139,9 @@ class MiniAppState( fun onTogglePermission(permission: String) { val key = when (permission) { - "Location" -> "location" - "Biometry" -> "biometry" - "Terms of Service" -> "tos_accepted" + context.getString(R.string.location_label) -> "location" + context.getString(R.string.mini_app_permission_biometry) -> "biometry" + context.getString(R.string.terms_of_service_title) -> "tos_accepted" else -> return } val current = botPreferences.getWebappPermission(botUserId, key) diff --git a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt index 9538e749..8b987e22 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt @@ -25,9 +25,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.monogram.presentation.R import org.monogram.domain.models.SessionModel import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.spacer.WidthSpacer @@ -78,7 +80,7 @@ internal fun SessionItem( ) Text( - text = if (isPending) "Не подтверждено • ${session.lastActiveDate.toShortRelativeDate()}" + text = if (isPending) "${stringResource(R.string.sessions_unconfirmed)} • ${session.lastActiveDate.toShortRelativeDate()}" else "${session.location} • ${session.lastActiveDate.toShortRelativeDate()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) @@ -122,7 +124,7 @@ private fun PlatformName( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = if (isPending) "Попытка входа" else session.deviceModel, + text = if (isPending) stringResource(R.string.sessions_login_attempt) else session.deviceModel, style = MaterialTheme.typography.titleMedium, fontSize = 18.sp, fontWeight = if (isPending) FontWeight.Bold else FontWeight.Normal, @@ -133,7 +135,7 @@ private fun PlatformName( Spacer(modifier = Modifier.width(6.dp)) Icon( imageVector = Icons.Rounded.Verified, - contentDescription = "Official", + contentDescription = stringResource(R.string.sticker_official), modifier = Modifier.size(16.dp), tint = Color(0xFF31A6FD) ) @@ -151,7 +153,7 @@ private fun ExitButton( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.Logout, - contentDescription = "Terminate session", + contentDescription = stringResource(R.string.sessions_terminate_action), tint = MaterialTheme.colorScheme.error ) } diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 88f570c5..104a666f 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -12,6 +12,9 @@ Este dispositivo Solicitudes de inicio de sesión Sesiones activas + Intento de inicio de sesión + Sin confirmar + Finalizar sesión Navegar hacia atrás Cerrar escáner @@ -1103,6 +1106,20 @@ Detener Servicio en segundo plano Notificación sobre la aplicación en ejecución en segundo plano + Nuevo mensaje + Yo + %1$d mensajes de %2$d chats + %1$d chats + Chats + Otros + Chats privados + Notificaciones de chats privados + Grupos + Notificaciones de grupos + Canales + Notificaciones de canales + Otros + Otras notificaciones 📷 Foto @@ -1362,6 +1379,10 @@ vs anterior Tipo de estadística desconocido Clase de datos: %1$s + Contacto: %1$s + Lugar: %1$s + Encuesta: %1$s + Mensaje de servicio Atrás @@ -1720,6 +1741,17 @@ Solicitud de permiso Permitir Denegar + Biometría + Compartir contacto + ¿Permitir que %1$s acceda a tu número de teléfono? + Permitir mensajes + ¿Permitir que %1$s te envíe mensajes? + Descargar archivo + ¿Descargar este archivo? + ¿Descargar %1$s? + Autenticación biométrica + Autentícate para continuar + ¿Permitir que este bot acceda a tu ubicación? Permisos del bot Términos de servicio Al lanzar esta Mini App, aceptas los Términos de servicio y la Política de privacidad. El bot podrá acceder a tu información de perfil básica. diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 6d8d6d3a..13e3f2f3 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -12,6 +12,9 @@ Այս սարքը Մուտքի հարցումներ Ակտիվ սեանսներ + Մուտքի փորձ + Չհաստատված + Ավարտել սեանսը Հետ գնալ Փակել սկաները @@ -998,6 +1001,20 @@ Կանգնեցնել Հետին պլանի ծառայություն Ծանուցում հետին պլանում աշխատող հավելվածի մասին + Նոր հաղորդագրություն + Ես + %1$d հաղորդագրություն %2$d չատից + %1$d չատ + Չատեր + Այլ + Անձնական չատեր + Ծանուցումներ անձնական չատերից + Խմբեր + Ծանուցումներ խմբերից + Ալիքներ + Ծանուցումներ ալիքներից + Այլ + Այլ ծանուցումներ 📷 Նկար 📹 Վիդեո @@ -1238,6 +1255,10 @@ նախորդի համեմատ Անհայտ վիճակագրություն Տվյալների դաս՝ %1$s + Կոնտակտ՝ %1$s + Վայր՝ %1$s + Հարցում՝ %1$s + Ծառայողական հաղորդագրություն Հետ Տարբերակներ @@ -1562,6 +1583,17 @@ Թույլտվության հարցում Թույլատրել Մերժել + Կենսաչափություն + Կիսվել կոնտակտով + Թույլատրել %1$s-ին հասանելիություն ձեր հեռախոսահամարին՞ + Թույլատրել հաղորդագրությունները + Թույլատրել %1$s-ին ձեզ հաղորդագրություններ ուղարկել՞ + Ներբեռնել ֆայլը + Ներբեռնել այս ֆայլը՞ + Ներբեռնել %1$s-ը՞ + Կենսաչափական նույնականացում + Հաստատեք ինքնությունը՝ շարունակելու համար + Թույլատրել այս բոտին հասանելիություն ձեր տեղադրությանը՞ Բոտի թույլտվությունները Օգտագործման պայմաններ Գործարկելով այս մինի հավելվածը՝ Դուք համաձայնում եք օգտագործման պայմաններին: diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 072a4c06..0afdbeac 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -12,6 +12,9 @@ Este dispositivo Solicitações de login Sessões ativas + Tentativa de login + Não confirmado + Encerrar sessão Voltar Fechar scanner @@ -1131,6 +1134,20 @@ Parar Serviço em segundo plano Notificação sobre o app rodando em segundo plano + Nova mensagem + Eu + %1$d mensagens de %2$d chats + %1$d chats + Chats + Outros + Conversas privadas + Notificações de conversas privadas + Grupos + Notificações de grupos + Canais + Notificações de canais + Outros + Outras notificações 📷 Foto @@ -1390,6 +1407,10 @@ vs anterior Tipo de estatística desconhecido Classe de dados: %1$s + Contato: %1$s + Local: %1$s + Enquete: %1$s + Mensagem de serviço Voltar @@ -1748,6 +1769,17 @@ Solicitação de permissão Permitir Negar + Biometria + Compartilhar contato + Permitir que %1$s acesse seu número de telefone? + Permitir mensagens + Permitir que %1$s envie mensagens para você? + Baixar arquivo + Baixar este arquivo? + Baixar %1$s? + Autenticação biométrica + Autentique-se para continuar + Permitir que este bot acesse sua localização? Permissões do bot Termos de Serviço Ao iniciar este Mini App, você concorda com os Termos de Serviço e a Política de Privacidade. O bot poderá acessar suas informações básicas de perfil. diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 2e24ee89..10cb1667 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -12,6 +12,9 @@ Это устройство Запросы на вход Активные сеансы + Попытка входа + Не подтверждено + Завершить сеанс Назад Закрыть сканер @@ -1063,6 +1066,20 @@ Остановить Фоновая работа Уведомление о работе приложения в фоне + Новое сообщение + Я + %1$d сообщений из %2$d чатов + %1$d чатов + Чаты + Прочее + Личные чаты + Уведомления из личных переписок + Группы + Уведомления из групп + Каналы + Уведомления из каналов + Другое + Прочие уведомления 📷 Фото @@ -1324,6 +1341,10 @@ по сравнению с прошлым пер. Неизвестный тип статистики Класс данных: %1$s + Контакт: %1$s + Место: %1$s + Опрос: %1$s + Служебное сообщение Назад @@ -1684,6 +1705,17 @@ Запрос разрешения Разрешить Отклонить + Биометрия + Поделиться контактом + Разрешить %1$s доступ к Вашему номеру телефона? + Разрешить сообщения + Разрешить %1$s отправлять Вам сообщения? + Скачать файл + Скачать этот файл? + Скачать %1$s? + Биометрическая аутентификация + Подтвердите личность, чтобы продолжить + Разрешить этому боту доступ к Вашему местоположению? Разрешения бота Условия использования Запуская это мини-приложение, Вы принимаете Условия использования и Политику конфиденциальности. Бот получит доступ к данным Вашего профиля. diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 16488a7a..ec5f0ff8 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -12,6 +12,9 @@ Toto zariadenie Žiadosti o prihlásenie Aktívne relácie + Pokus o prihlásenie + Nepotvrdené + Ukončiť reláciu Späť Zavrieť skener @@ -1126,6 +1129,20 @@ Zastaviť Služba na pozadí Oznámenie o behu aplikácie na pozadí + Nová správa + Ja + %1$d správ z %2$d chatov + %1$d chatov + Chaty + Ostatné + Súkromné chaty + Upozornenia zo súkromných chatov + Skupiny + Upozornenia zo skupín + Kanály + Upozornenia z kanálov + Ostatné + Ostatné upozornenia Fotografia @@ -1388,6 +1405,10 @@ oproti predchádzajúcemu Neznámy typ štatistík Trieda dát: %1$s + Kontakt: %1$s + Miesto: %1$s + Anketa: %1$s + Servisná správa Späť @@ -1766,6 +1787,17 @@ Žiadosť o oprávnenie Povoliť Odmietnuť + Biometria + Zdieľať kontakt + Povoliť %1$s prístup k vášmu telefónnemu číslu? + Povoliť správy + Povoliť %1$s posielať vám správy? + Stiahnuť súbor + Stiahnuť tento súbor? + Stiahnuť %1$s? + Biometrické overenie + Overte sa, aby ste mohli pokračovať + Povoliť tomuto botovi prístup k vašej polohe? Oprávnenia bota Podmienky služby Spustením tejto mini aplikácie súhlasíte s Podmienkami služby a Zásadami ochrany diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 9567abc3..7af74f9e 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -12,6 +12,9 @@ Цей пристрій Запити на вхід Активні сеанси + Спроба входу + Не підтверджено + Завершити сеанс Назад Закрити сканер @@ -1063,6 +1066,20 @@ Зупинити Фонова робота Сповіщення про роботу програми у фоновому режимі + Нове повідомлення + Я + %1$d повідомлень із %2$d чатів + %1$d чатів + Чати + Інше + Приватні чати + Сповіщення з приватних чатів + Групи + Сповіщення з груп + Канали + Сповіщення з каналів + Інше + Інші сповіщення 📷 Фото @@ -1324,6 +1341,10 @@ порівняно з минулим пер. Невідомий тип статистики Клас даних: %1$s + Контакт: %1$s + Місце: %1$s + Опитування: %1$s + Службове повідомлення Назад @@ -1684,6 +1705,17 @@ Запит на дозвіл Дозволити Відхилити + Біометрія + Поділитися контактом + Дозволити %1$s доступ до вашого номера телефону? + Дозволити повідомлення + Дозволити %1$s надсилати вам повідомлення? + Завантажити файл + Завантажити цей файл? + Завантажити %1$s? + Біометрична автентифікація + Підтвердьте особу, щоб продовжити + Дозволити цьому боту доступ до вашого місцезнаходження? Дозволи бота Умови використання Запускаючи цей мінідодаток, ви погоджуєтеся з Умовами надання послуг та Політикою конфіденційності. Бот отримає доступ до основної інформації вашого профілю. diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 62c0e7d8..776444f2 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -12,6 +12,9 @@ 此设备 登录请求 活跃会话 + 登录尝试 + 未确认 + 终止会话 返回 关闭扫描器 @@ -1053,6 +1056,20 @@ 停止 后台服务 关于应用在后台运行的通知 + 新消息 + + 来自 %2$d 个聊天的 %1$d 条消息 + %1$d 个聊天 + 聊天 + 其他 + 私聊 + 来自私聊的通知 + 群组 + 来自群组的通知 + 频道 + 来自频道的通知 + 其他 + 其他通知 📷 照片 @@ -1311,6 +1328,10 @@ 与前期相比 未知统计类型 数据类: %1$s + 联系人:%1$s + 地点:%1$s + 投票:%1$s + 服务消息 返回 @@ -1668,6 +1689,17 @@ 权限请求 允许 拒绝 + 生物识别 + 分享联系人 + 允许 %1$s 访问你的电话号码吗? + 允许消息 + 允许 %1$s 向你发送消息吗? + 下载文件 + 下载此文件吗? + 下载 %1$s 吗? + 生物识别验证 + 验证身份以继续 + 允许此机器人访问你的位置吗? 机器人权限 服务条款 启动此小程序即表示您同意其服务条款和隐私政策。该机器人将能够访问您的基本个人资料信息。 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 47e880d4..cf0b9c10 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -12,6 +12,9 @@ This device Login requests Active sessions + Login attempt + Unconfirmed + Terminate session Navigate back Close scanner @@ -1144,6 +1147,20 @@ Stop Background Service Notification about app running in background + New message + Me + %1$d messages from %2$d chats + %1$d chats + Chats + Other + Private chats + Notifications from private conversations + Groups + Notifications from groups + Channels + Notifications from channels + Other + Other notifications 📷 Photo @@ -1403,6 +1420,10 @@ vs previous Unknown Statistics Type Data class: %1$s + Contact: %1$s + Venue: %1$s + Poll: %1$s + Service message Back @@ -1761,6 +1782,17 @@ Permission Request Allow Deny + Biometry + Share Contact + Allow %1$s to access your phone number? + Allow Messages + Allow %1$s to send you messages? + Download File + Download this file? + Download %1$s? + Biometric Authentication + Authenticate to continue + Allow this bot to access your location? Bot Permissions Terms of Service By launching this Mini App, you agree to the Terms of Service and Privacy Policy. The bot will be able to access your basic profile information. From a45151dd3bff94acd4a25f544a2b0a844a22f612 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:47:24 +0300 Subject: [PATCH 37/83] localize sender label in notification quick replies Use StringProvider in NotificationReplyReceiver so the inline reply sender name uses locale-aware resources instead of a hardcoded Russian string. --- .../org/monogram/data/service/NotificationReplyReceiver.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt index 2128a522..adc8e7f1 100644 --- a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt +++ b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt @@ -10,11 +10,13 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.monogram.data.di.TdNotificationManager import org.monogram.data.gateway.TelegramGateway +import org.monogram.domain.repository.StringProvider class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { private val gateway: TelegramGateway by inject() private val notificationManager: TdNotificationManager by inject() + private val stringProvider: StringProvider by inject() override fun onReceive(context: Context, intent: Intent) { val chatId = intent.getLongExtra("chat_id", 0L) @@ -63,7 +65,7 @@ class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { chatId = chatId, messageId = System.currentTimeMillis(), chatType = chat.type, - senderName = "Вы", + senderName = stringProvider.getString("notification_person_me"), senderBitmap = null, chatIcon = null, text = replyText, From 3e7a874a6a94d96af381fb230b3623d829846f95 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:01:44 +0300 Subject: [PATCH 38/83] fix profile top bar badge wrapping on narrow widths --- .../features/profile/components/ProfileTopBar.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt index 774b8f8d..e98205f0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileTopBar.kt @@ -63,6 +63,8 @@ fun ProfileTopBar( var showMenu by remember { mutableStateOf(false) } val hasMenuActions = canShare || canEdit || canEditContact || canReport || canBlock || canDelete val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() + val hasTextStatusBadges = isBot || isScam || isFake + val shouldCompactTopBar = hasTextStatusBadges && (title.length >= 18 || canSearch || hasMenuActions) val iconTint = lerp( start = MaterialTheme.colorScheme.onSurface, @@ -89,7 +91,8 @@ fun ProfileTopBar( fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false) ) if (isVerified) { @@ -111,7 +114,7 @@ fun ProfileTopBar( ) } - if (isBot) { + if (isBot && !shouldCompactTopBar) { Spacer(modifier = Modifier.width(4.dp)) TopBarStatusBadge( text = stringResource(R.string.label_bot_badge), @@ -120,7 +123,7 @@ fun ProfileTopBar( ) } - if (isScam) { + if (isScam && !shouldCompactTopBar) { Spacer(modifier = Modifier.width(4.dp)) TopBarStatusBadge( text = stringResource(R.string.label_scam_badge), @@ -129,7 +132,7 @@ fun ProfileTopBar( ) } - if (isFake) { + if (isFake && !shouldCompactTopBar) { Spacer(modifier = Modifier.width(4.dp)) TopBarStatusBadge( text = stringResource(R.string.label_fake_badge), From 6e175d4dee57b235df8f2b7ffc3e01da30c55ce1 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:17:19 +0300 Subject: [PATCH 39/83] Update TDLib badge to 1.8.63 --- README.md | 2 +- README_ES.md | 2 +- README_KOR.md | 2 +- README_RU.md | 2 +- README_UR.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5944817d..ed38bf06 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - + diff --git a/README_ES.md b/README_ES.md index 107687b7..58e33ae5 100644 --- a/README_ES.md +++ b/README_ES.md @@ -15,7 +15,7 @@ - + diff --git a/README_KOR.md b/README_KOR.md index bc0d3411..85f653ae 100644 --- a/README_KOR.md +++ b/README_KOR.md @@ -15,7 +15,7 @@ - + diff --git a/README_RU.md b/README_RU.md index 772f13b1..8d5552cd 100644 --- a/README_RU.md +++ b/README_RU.md @@ -15,7 +15,7 @@ - + diff --git a/README_UR.md b/README_UR.md index 9b0ddee8..6515691e 100644 --- a/README_UR.md +++ b/README_UR.md @@ -15,7 +15,7 @@ - + From 8d4cfc3f5143f75321b9d9cfab9b5a16a5a7e927 Mon Sep 17 00:00:00 2001 From: Andro_Dev <87223939+andr0d1v@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:47:27 +0300 Subject: [PATCH 40/83] feat: android workflow ci (#218) Implemented GitHub Actions CI for build and upload debug apk (without app_id) Originally written by @SnowVolf Fixed/modified by @andr0d1v --- .github/workflows/android.yml | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/android.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..87f7b56b --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,63 @@ +name: Android CI + +on: + pull_request: + branches: ["master", "develop"] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + submodules: recursive + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Make all scripts executable + run: | + chmod +x gradlew + chmod +x presentation/src/main/cpp/build.sh + + - name: Build libvpx + run: cd presentation/src/main/cpp && ./build.sh + + - name: Build Debug APK (without GMS) + run: ./gradlew assembleDebug + + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest + + - name: Upload universal APK + uses: actions/upload-artifact@v7 + with: + name: app-universal-no-appid + path: app/build/outputs/apk/debug/app-universal-debug.apk + + - name: Upload arm64-v8a APK + uses: actions/upload-artifact@v7 + with: + name: app-arm64-v8a-no-appid + path: app/build/outputs/apk/debug/app-arm64-v8a-debug.apk + + - name: Upload armeabi-v7a APK + uses: actions/upload-artifact@v7 + with: + name: app-armeabi-v7a-no-appid + path: app/build/outputs/apk/debug/app-armeabi-v7a-debug.apk + + - name: Upload x86_64 APK + uses: actions/upload-artifact@v7 + with: + name: app-x86_64-no-appid + path: app/build/outputs/apk/debug/app-x86_64-debug.apk From d3862e4101c2c49d2d32de893b3f0caa5feeeee7 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:10:01 +0300 Subject: [PATCH 41/83] implement Android 12+ SplashScreen API - add `androidx.core:core-splashscreen` dependency - implement `installSplashScreen` in `MainActivity` with a custom exit animation - add splash screen themes and drawables for light, dark, and system modes - implement dynamic startup theme resolution based on app night mode settings (System, Light, Dark, Scheduled, or Brightness) - update `StartupContent` with improved animations and conditional logo visibility for Android 12+ - add a smooth transition overlay between the splash screen and the main app content - update `AndroidManifest.xml` to use the new startup theme by default --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 2 +- .../java/org/monogram/app/MainActivity.kt | 77 +++++++++- .../main/java/org/monogram/app/MainContent.kt | 54 ++++++- .../res/drawable-night/startup_background.xml | 14 ++ app/src/main/res/drawable/splash_icon.xml | 7 + .../main/res/drawable/startup_background.xml | 14 ++ .../res/drawable/startup_background_dark.xml | 14 ++ .../res/drawable/startup_background_light.xml | 14 ++ app/src/main/res/values-night-v31/themes.xml | 21 +++ app/src/main/res/values-night/themes.xml | 17 +++ app/src/main/res/values-v31/themes.xml | 21 +++ app/src/main/res/values/themes.xml | 14 +- gradle/libs.versions.toml | 6 +- .../presentation/root/StartupComponent.kt | 141 ++++++++++++------ 15 files changed, 368 insertions(+), 50 deletions(-) create mode 100644 app/src/main/res/drawable-night/startup_background.xml create mode 100644 app/src/main/res/drawable/splash_icon.xml create mode 100644 app/src/main/res/drawable/startup_background.xml create mode 100644 app/src/main/res/drawable/startup_background_dark.xml create mode 100644 app/src/main/res/drawable/startup_background_light.xml create mode 100644 app/src/main/res/values-night-v31/themes.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values-v31/themes.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f8fb45b..1b9194b5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,7 +3,6 @@ import com.android.build.api.variant.FilterConfiguration import com.android.build.api.variant.impl.VariantOutputImpl import com.google.android.gms.oss.licenses.plugin.DependencyTask import com.google.gms.googleservices.GoogleServicesPlugin -import org.gradle.api.tasks.Sync plugins { alias(libs.plugins.android.application) @@ -114,6 +113,7 @@ androidComponents { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.androidx.compose) + implementation(libs.androidx.core.splashscreen) implementation(libs.bundles.decompose) implementation(libs.bundles.koin) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4edad1af..d799dff8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ android:label="@string/app_name" android:launchMode="singleTask" android:supportsPictureInPicture="true" - android:theme="@style/Theme.MonoGram" + android:theme="@style/Theme.MonoGram.Startup" android:windowSoftInputMode="adjustResize" tools:targetApi="33"> diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index 3ed05019..0f8b9389 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -3,10 +3,13 @@ package org.monogram.app import android.content.Intent import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.layout.WindowInfoTracker @@ -15,20 +18,37 @@ import com.arkivanov.decompose.retainedComponent import org.koin.android.ext.android.inject import org.monogram.app.ui.theme.AppThemeContainer import org.monogram.data.service.TdNotificationService -import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider +import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.LocalVideoPlayerPool +import org.monogram.presentation.core.util.NightMode import org.monogram.presentation.features.chats.currentChat.components.chats.LocalLinkHandler import org.monogram.presentation.root.DefaultAppComponentContext import org.monogram.presentation.root.DefaultRootComponent import org.monogram.presentation.root.RootComponent +import java.util.Calendar class MainActivity : FragmentActivity() { private lateinit var root: RootComponent - private val appPreferences: AppPreferencesProvider by inject() + private val appPreferences: AppPreferences by inject() + + @Volatile + private var keepSplashOnScreen: Boolean = true @OptIn(ExperimentalDecomposeApi::class) override fun onCreate(savedInstanceState: Bundle?) { + setTheme(resolveStartupTheme()) + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { keepSplashOnScreen } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + splashScreen.setOnExitAnimationListener { provider -> + provider.view.animate() + .alpha(0f) + .setDuration(220L) + .withEndAction { provider.remove() } + .start() + } + } super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -47,6 +67,10 @@ class MainActivity : FragmentActivity() { val windowInfoTracker = WindowInfoTracker.getOrCreate(this) setContent { + LaunchedEffect(Unit) { + keepSplashOnScreen = false + } + val windowLayoutInfo by windowInfoTracker.windowLayoutInfo(this) .collectAsStateWithLifecycle(initialValue = null) @@ -91,4 +115,53 @@ class MainActivity : FragmentActivity() { startService(intent) } } + + private fun resolveStartupTheme(): Int { + return when (appPreferences.nightMode.value) { + NightMode.SYSTEM -> R.style.Theme_MonoGram_Startup + NightMode.LIGHT -> R.style.Theme_MonoGram_Startup_Light + NightMode.DARK -> R.style.Theme_MonoGram_Startup_Dark + NightMode.SCHEDULED -> { + val calendar = Calendar.getInstance() + val now = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE) + + val start = appPreferences.nightModeStartTime.value + .split(":") + .takeIf { it.size == 2 } + ?.let { it[0].toIntOrNull()?.times(60)?.plus(it[1].toIntOrNull() ?: 0) } + ?: 22 * 60 + + val end = appPreferences.nightModeEndTime.value + .split(":") + .takeIf { it.size == 2 } + ?.let { it[0].toIntOrNull()?.times(60)?.plus(it[1].toIntOrNull() ?: 0) } + ?: 7 * 60 + + if (start < end) { + if (now in start until end) { + R.style.Theme_MonoGram_Startup_Dark + } else { + R.style.Theme_MonoGram_Startup_Light + } + } else { + if (now >= start || now < end) { + R.style.Theme_MonoGram_Startup_Dark + } else { + R.style.Theme_MonoGram_Startup_Light + } + } + } + + NightMode.BRIGHTNESS -> { + val brightness = runCatching { + Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS, 255) + }.getOrDefault(255) + if (brightness / 255f <= appPreferences.nightModeBrightnessThreshold.value) { + R.style.Theme_MonoGram_Startup_Dark + } else { + R.style.Theme_MonoGram_Startup_Light + } + } + } + } } diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt index d908843c..1368e667 100644 --- a/app/src/main/java/org/monogram/app/MainContent.kt +++ b/app/src/main/java/org/monogram/app/MainContent.kt @@ -15,10 +15,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.window.core.layout.WindowSizeClass as WindowSizeClassCore import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalClipboard @@ -26,6 +29,7 @@ import androidx.compose.ui.zIndex import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowLayoutInfo import com.arkivanov.decompose.extensions.compose.subscribeAsState +import kotlinx.coroutines.delay import org.monogram.app.components.ChatConfirmJoinSheet import org.monogram.app.components.LockScreen import org.monogram.app.components.MobileLayout @@ -36,6 +40,9 @@ import org.monogram.presentation.features.chats.currentChat.components.StickerSe import org.monogram.presentation.features.profile.ProfileViewers import org.monogram.presentation.features.stickers.core.toDomain import org.monogram.presentation.root.RootComponent +import org.monogram.presentation.root.StartupComponent +import org.monogram.presentation.root.StartupContent +import androidx.window.core.layout.WindowSizeClass as WindowSizeClassCore @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -53,6 +60,35 @@ fun MainContent( } == true val activeChild = childStack.active.instance + val isStartupActive = activeChild is RootComponent.Child.StartupChild + var startupOverlayComponent by remember { mutableStateOf(null) } + var startupOverlayVisible by remember { mutableStateOf(false) } + + LaunchedEffect(activeChild) { + when (activeChild) { + is RootComponent.Child.StartupChild -> { + startupOverlayComponent = activeChild.component + startupOverlayVisible = false + } + + else -> { + if (startupOverlayComponent != null) { + startupOverlayVisible = true + } + } + } + } + + LaunchedEffect(startupOverlayVisible, isStartupActive) { + if (startupOverlayVisible && !isStartupActive) { + delay(90) + startupOverlayVisible = false + delay(320) + if (!isStartupActive) { + startupOverlayComponent = null + } + } + } Box( modifier = Modifier @@ -99,6 +135,22 @@ fun MainContent( ChatConfirmJoinSheet(root) } + if (!isStartupActive && startupOverlayComponent != null) { + AnimatedVisibility( + visible = startupOverlayVisible, + enter = fadeIn(tween(80)), + exit = fadeOut(tween(260)), + modifier = Modifier + .fillMaxSize() + .zIndex(50f) + ) { + StartupContent( + component = startupOverlayComponent!!, + animateIn = false + ) + } + } + AnimatedVisibility( visible = isLocked, enter = fadeIn(tween(400)) + scaleIn( diff --git a/app/src/main/res/drawable-night/startup_background.xml b/app/src/main/res/drawable-night/startup_background.xml new file mode 100644 index 00000000..4a8f9aca --- /dev/null +++ b/app/src/main/res/drawable-night/startup_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/splash_icon.xml b/app/src/main/res/drawable/splash_icon.xml new file mode 100644 index 00000000..1cd70ee0 --- /dev/null +++ b/app/src/main/res/drawable/splash_icon.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/drawable/startup_background.xml b/app/src/main/res/drawable/startup_background.xml new file mode 100644 index 00000000..58e48b47 --- /dev/null +++ b/app/src/main/res/drawable/startup_background.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/startup_background_dark.xml b/app/src/main/res/drawable/startup_background_dark.xml new file mode 100644 index 00000000..4a8f9aca --- /dev/null +++ b/app/src/main/res/drawable/startup_background_dark.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/startup_background_light.xml b/app/src/main/res/drawable/startup_background_light.xml new file mode 100644 index 00000000..58e48b47 --- /dev/null +++ b/app/src/main/res/drawable/startup_background_light.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml new file mode 100644 index 00000000..142776a4 --- /dev/null +++ b/app/src/main/res/values-night-v31/themes.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..cf266e2e --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 00000000..b448b631 --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 3a110bdb..01da7a2c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,16 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1c5d0f3..76cc6af3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ androidx-biometric = "1.4.0-alpha06" androidx-camera = "1.6.0" androidx-compose-bom = "2026.03.01" androidx-compose-runtime = "1.10.6" -androidx-compose-ui = "1.11.0-beta02" +androidx-compose-ui = "1.11.0-rc01" androidx-material3 = "1.4.0" androidx-adaptive = "1.2.0" androidx-media3 = "1.10.0" @@ -23,6 +23,7 @@ androidx-uiautomator = "2.3.0" androidx-benchmark-macro-junit4 = "1.4.1" androidx-test-ext-junit = "1.3.0" androidx-window = "1.5.1" +androidx-core-splashscreen = "1.2.0" # KotlinX kotlinx-coroutines = "1.10.2" @@ -41,7 +42,7 @@ maplibre = "1.7.0" firebase-bom = "34.11.0" playServices-location = "21.3.0" playServices-mlkit-barcode = "18.3.1" -playServices-ossLicenses = "17.4.0" +playServices-ossLicenses = "17.5.0" # Others zxing = "3.5.4" @@ -52,6 +53,7 @@ libphonenumber = "9.0.27" # AndroidX Activity androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activityCompose" } androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } # AndroidX Biometric androidx-biometric = { module = "androidx.biometric:biometric-compose", version.ref = "androidx-biometric" } diff --git a/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt index c08b58a2..fac1b48f 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/StartupComponent.kt @@ -1,21 +1,46 @@ package org.monogram.presentation.root +import android.os.Build import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R @@ -33,69 +58,101 @@ class DefaultStartupComponent( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun StartupContent(component: StartupComponent) { +fun StartupContent( + component: StartupComponent, + modifier: Modifier = Modifier, + animateIn: Boolean = true, + logoSize: Dp = 72.dp +) { val connectionStatus by component.connectionStatus.collectAsState() + val showLogo = Build.VERSION.SDK_INT < Build.VERSION_CODES.S + var revealDynamicElements by remember(animateIn) { mutableStateOf(!animateIn) } + + LaunchedEffect(animateIn) { + if (animateIn) { + delay(120) + revealDynamicElements = true + } + } + + val startupAlpha by animateFloatAsState( + targetValue = if (revealDynamicElements) 1f else 0.92f, + animationSpec = tween(durationMillis = 260), + label = "StartupAlpha" + ) Surface( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { Column( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 24.dp) + .alpha(startupAlpha), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Surface( - modifier = Modifier.size(96.dp), - shape = ShapeDefaults.ExtraLargeIncreased, - color = MaterialTheme.colorScheme.primaryContainer - ) { + if (showLogo) { Image( painter = painterResource(id = R.drawable.ic_app_logo), contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.FillBounds + modifier = Modifier.size(logoSize), + contentScale = ContentScale.Fit ) } - Spacer(modifier = Modifier.height(20.dp)) + AnimatedVisibility( + visible = revealDynamicElements, + enter = fadeIn(tween(260)) + slideInVertically( + animationSpec = tween(260), + initialOffsetY = { it / 5 } + ), + exit = fadeOut(tween(120)) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(if (showLogo) 20.dp else 0.dp)) - Text( - text = stringResource(R.string.app_name_monogram), - style = MaterialTheme.typography.headlineMediumEmphasized, - color = MaterialTheme.colorScheme.primary - ) + Text( + text = stringResource(R.string.app_name_monogram), + style = MaterialTheme.typography.headlineMediumEmphasized, + color = MaterialTheme.colorScheme.primary + ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.height(6.dp)) - AnimatedContent( - targetState = connectionStatus, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "StartupStatus" - ) { status -> - Text( - text = when (status) { - ConnectionStatus.WaitingForNetwork -> stringResource(R.string.waiting_for_network) - ConnectionStatus.Connecting -> stringResource(R.string.connecting) - ConnectionStatus.Updating -> stringResource(R.string.updating) - ConnectionStatus.ConnectingToProxy -> stringResource(R.string.connecting_to_proxy) - ConnectionStatus.Connected -> stringResource(R.string.startup_connecting) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + AnimatedContent( + targetState = connectionStatus, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "StartupStatus" + ) { status -> + Text( + text = when (status) { + ConnectionStatus.WaitingForNetwork -> stringResource(R.string.waiting_for_network) + ConnectionStatus.Connecting -> stringResource(R.string.connecting) + ConnectionStatus.Updating -> stringResource(R.string.updating) + ConnectionStatus.ConnectingToProxy -> stringResource(R.string.connecting_to_proxy) + ConnectionStatus.Connected -> stringResource(R.string.startup_connecting) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(20.dp)) - LinearWavyProgressIndicator( - modifier = Modifier.width(220.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) + LinearWavyProgressIndicator( + modifier = Modifier.width(220.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } } } } From 5c4472e13257d7925bffb6830a01d3b0869fee3b Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:10:56 +0300 Subject: [PATCH 42/83] support saving media albums to downloads - add support for batch saving multiple files at once - add audio messages to downloadable content types - implement unique filename resolution to prevent overwriting existing files - improve download status reporting and error handling - update Scoped Storage implementation with `IS_PENDING` flag for better reliability - refactor `DownloadUtils` to share logic between single and batch operations --- .../presentation/core/util/DownloadUtils.kt | 177 +++++++++++++----- .../presentation/core/util/IDownloadUtils.kt | 2 + .../chatContent/ChatMessageOptionsMenu.kt | 60 ++++-- .../stickers/ui/menu/MessageOptionsMenu.kt | 129 +++++++++++-- 4 files changed, 292 insertions(+), 76 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt index 1189fbdc..bfe6b6dc 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/DownloadUtils.kt @@ -1,7 +1,11 @@ package org.monogram.presentation.core.util import android.app.Activity -import android.content.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ContentValues +import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.media.MediaScannerConnection import android.net.ConnectivityManager @@ -22,61 +26,60 @@ class DownloadUtils( ) : IDownloadUtils { override fun saveFileToDownloads(filePath: String) { - try { - val file = File(filePath) - if (!file.exists()) { - messageDisplayer.show("File not found") - return - } + when (saveFileToDownloadsInternal(filePath)) { + SaveResult.SUCCESS -> messageDisplayer.show("Saved to Downloads/MonoGram") + SaveResult.NOT_FOUND -> messageDisplayer.show("File not found") + SaveResult.FAILED -> messageDisplayer.show("Failed to save file") + } + } - val fileName = file.name - val mimeType = getMimeType(filePath) - val relativePath = "${Environment.DIRECTORY_DOWNLOADS}/MonoGram" + override fun saveFilesToDownloads(filePaths: List) { + val paths = filePaths + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .toList() + + if (paths.isEmpty()) { + messageDisplayer.show("No files to save") + return + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - put(MediaStore.MediaColumns.MIME_TYPE, mimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - } + var savedCount = 0 + var notFoundCount = 0 + var failedCount = 0 - val resolver = context.contentResolver - val uri = resolver.insert( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, - contentValues - ) + paths.forEach { path -> + when (saveFileToDownloadsInternal(path)) { + SaveResult.SUCCESS -> savedCount++ + SaveResult.NOT_FOUND -> notFoundCount++ + SaveResult.FAILED -> failedCount++ + } + } - if (uri != null) { - resolver.openOutputStream(uri)?.use { outputStream -> - FileInputStream(file).use { inputStream -> - inputStream.copyTo(outputStream) - } - } - messageDisplayer.show("Saved to Downloads/MonoGram") - } else { - messageDisplayer.show("Failed to create file in Downloads") - } - } else { - val downloadsDir = Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS - ) - val monoGramDir = File(downloadsDir, "MonoGram") - if (!monoGramDir.exists()) { - monoGramDir.mkdirs() - } + when { + savedCount == 0 && notFoundCount > 0 && failedCount == 0 -> { + messageDisplayer.show("Files not found") + } - val destinationFile = File(monoGramDir, fileName) + savedCount == 0 -> { + messageDisplayer.show("Failed to save files") + } - FileInputStream(file).use { inputStream -> - destinationFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } + notFoundCount == 0 && failedCount == 0 -> { + if (savedCount == 1) { + messageDisplayer.show("Saved to Downloads/MonoGram") + } else { + messageDisplayer.show("Saved $savedCount files to Downloads/MonoGram") } + } - messageDisplayer.show("Saved to Downloads/MonoGram") + else -> { + messageDisplayer.show( + "Saved $savedCount files to Downloads/MonoGram ($notFoundCount not found, $failedCount failed)" + ) } - } catch (e: Exception) { - messageDisplayer.show("Failed to save: ${e.message}") } } @@ -258,6 +261,86 @@ class DownloadUtils( } } + private fun saveFileToDownloadsInternal(filePath: String): SaveResult { + return try { + val file = File(filePath) + if (!file.exists()) return SaveResult.NOT_FOUND + + val fileName = file.name + val mimeType = getMimeType(filePath) + val relativePath = "${Environment.DIRECTORY_DOWNLOADS}/MonoGram" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + ?: return SaveResult.FAILED + + val isWritten = resolver.openOutputStream(uri)?.use { outputStream -> + FileInputStream(file).use { inputStream -> + inputStream.copyTo(outputStream) + } + true + } ?: false + + if (!isWritten) { + resolver.delete(uri, null, null) + return SaveResult.FAILED + } + + contentValues.clear() + contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, contentValues, null, null) + + SaveResult.SUCCESS + } else { + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val monoGramDir = File(downloadsDir, "MonoGram") + if (!monoGramDir.exists() && !monoGramDir.mkdirs()) { + return SaveResult.FAILED + } + + val destinationFile = resolveUniqueDestinationFile(monoGramDir, fileName) + FileInputStream(file).use { inputStream -> + destinationFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + SaveResult.SUCCESS + } + } catch (_: Exception) { + SaveResult.FAILED + } + } + + private fun resolveUniqueDestinationFile(directory: File, fileName: String): File { + val dotIndex = fileName.lastIndexOf('.') + val baseName = if (dotIndex > 0) fileName.substring(0, dotIndex) else fileName + val extension = if (dotIndex > 0) fileName.substring(dotIndex) else "" + + var candidate = File(directory, fileName) + var index = 1 + while (candidate.exists()) { + candidate = File(directory, "$baseName ($index)$extension") + index++ + } + return candidate + } + + private enum class SaveResult { + SUCCESS, + NOT_FOUND, + FAILED + } + override fun isWifiConnected(): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt index 91883322..c206e6c0 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/IDownloadUtils.kt @@ -8,6 +8,8 @@ interface IDownloadUtils { fun saveFileToDownloads(filePath: String) + fun saveFilesToDownloads(filePaths: List) + fun saveBitmapToGallery(bitmap: Bitmap) fun copyBitmapToClipboard(bitmap: Bitmap) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt index 880c4137..b03d8e4e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt @@ -2,7 +2,13 @@ package org.monogram.presentation.features.chats.currentChat.chatContent import android.content.ClipData import android.util.Log -import androidx.compose.runtime.* +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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.Clipboard @@ -26,7 +32,7 @@ import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.features.chats.currentChat.ChatComponent import org.monogram.presentation.features.stickers.ui.menu.MessageOptionsMenu -import java.util.* +import java.util.Locale @Composable fun ChatMessageOptionsMenu( @@ -303,17 +309,11 @@ fun ChatMessageOptionsMenu( onDismiss() }, onSaveToDownloads = { - val path = when (val content = selectedMessage.content) { - is MessageContent.Photo -> content.path - is MessageContent.Video -> content.path - is MessageContent.Gif -> content.path - is MessageContent.Document -> content.path - is MessageContent.Voice -> content.path - is MessageContent.VideoNote -> content.path - else -> null - } - path?.let { - downloadUtils.saveFileToDownloads(it) + val paths = collectDownloadPaths(selectedMessage, groupedMessages) + if (paths.size == 1) { + downloadUtils.saveFileToDownloads(paths.first()) + } else if (paths.isNotEmpty()) { + downloadUtils.saveFilesToDownloads(paths) } onDismiss() }, @@ -449,3 +449,37 @@ private fun canSummarize(message: MessageModel): Boolean { else -> false } } + +private fun collectDownloadPaths( + selectedMessage: MessageModel, + groupedMessages: List +): List { + val albumMessages = selectedMessage.mediaAlbumId + .takeIf { it != 0L } + ?.let { albumId -> + groupedMessages + .filterIsInstance() + .firstOrNull { album -> + album.albumId == albumId || album.messages.any { it.id == selectedMessage.id } + } + ?.messages + } + + val sourceMessages = albumMessages ?: listOf(selectedMessage) + return sourceMessages + .mapNotNull { extractDownloadPath(it.content) } + .distinct() +} + +private fun extractDownloadPath(content: MessageContent): String? { + return when (content) { + is MessageContent.Photo -> content.path + is MessageContent.Video -> content.path + is MessageContent.Gif -> content.path + is MessageContent.Document -> content.path + is MessageContent.Audio -> content.path + is MessageContent.Voice -> content.path + is MessageContent.VideoNote -> content.path + else -> null + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt index 5debd437..e6fff23f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt @@ -2,31 +2,111 @@ package org.monogram.presentation.features.stickers.ui.menu -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource -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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.automirrored.rounded.Forward import androidx.compose.material.icons.automirrored.rounded.Reply import androidx.compose.material.icons.automirrored.rounded.Undo -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.AutoAwesome +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Gavel +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.PushPin +import androidx.compose.material.icons.rounded.Report +import androidx.compose.material.icons.rounded.Translate +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.* -import androidx.compose.ui.graphics.* +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onGloballyPositioned @@ -37,7 +117,11 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.* +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -54,7 +138,8 @@ import org.monogram.presentation.features.chats.currentChat.chatContent.DeleteMe import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale @Composable fun MessageOptionsMenu( @@ -337,8 +422,12 @@ fun MessageOptionsMenu( ), topLeft = CornerRadius(if (!isMessageOutgoing && isSameSenderAbove) s else r), topRight = CornerRadius(if (isMessageOutgoing && isSameSenderAbove) s else r), - bottomRight = if (hasBottom) CornerRadius.Zero else CornerRadius(if (isMessageOutgoing) s else r), - bottomLeft = if (hasBottom) CornerRadius.Zero else CornerRadius(if (!isMessageOutgoing) s else r) + bottomRight = if (hasBottom) CornerRadius.Zero else CornerRadius( + if (isMessageOutgoing) s else r + ), + bottomLeft = if (hasBottom) CornerRadius.Zero else CornerRadius( + if (!isMessageOutgoing) s else r + ) ) ) @@ -351,7 +440,10 @@ fun MessageOptionsMenu( 0f, topHeight + currentGap ), - size = Size(messageSize.width.toFloat(), bottomHeight) + size = Size( + messageSize.width.toFloat(), + bottomHeight + ) ), topLeft = CornerRadius.Zero, topRight = CornerRadius.Zero, @@ -365,7 +457,10 @@ fun MessageOptionsMenu( RoundRect( rect = Rect( offset = messageOffset - containerOffset, - size = Size(messageSize.width.toFloat(), messageSize.height.toFloat()) + size = Size( + messageSize.width.toFloat(), + messageSize.height.toFloat() + ) ), topLeft = CornerRadius(if (!isMessageOutgoing && isSameSenderAbove) s else r), topRight = CornerRadius(if (isMessageOutgoing && isSameSenderAbove) s else r), @@ -389,7 +484,8 @@ fun MessageOptionsMenu( .widthIn(min = 208.dp, max = 276.dp) .heightIn(max = maxMenuHeight) .graphicsLayer { - this.alpha = if (menuSize == IntSize.Zero || containerSize == IntSize.Zero) 0f else menuAlpha + this.alpha = + if (menuSize == IntSize.Zero || containerSize == IntSize.Zero) 0f else menuAlpha scaleX = menuScale scaleY = menuScale this.transformOrigin = transformOrigin @@ -1260,6 +1356,7 @@ private fun shouldShowDownload(message: MessageModel): Boolean { is MessageContent.Video -> content.path != null is MessageContent.Gif -> content.path != null is MessageContent.Document -> content.path != null + is MessageContent.Audio -> content.path != null is MessageContent.Voice -> content.path != null is MessageContent.VideoNote -> content.path != null else -> false From 8dcb7c4ff2dc2d340bc0205dea84fb259db32960 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:29:00 +0300 Subject: [PATCH 43/83] update message bubble components and album handling - add support for forwarded message content in `DocumentMessageBubble`, `AudioMessageBubble`, and `ChatAlbumMessageBubble` - display sender name in group chat album bubbles - fix reaction source in channel albums to use the last message instead of the first - ensure album messages are sorted by date and ID to maintain consistent display - refactor `ChatAlbumMessageBubble` to use `MessageMetadata` and improve padding for reply/forward content - fix reply swipe action in albums to target the last message --- .../components/AlbumMessageBubbleContainer.kt | 35 +++++++++++--- .../channels/ChannelAlbumMessageBubble.kt | 34 +++++++++++--- .../components/chats/AudioMessageBubble.kt | 31 ++++++++++-- .../chats/ChatAlbumMessageBubble.kt | 47 ++++++++++++++----- .../components/chats/DocumentMessageBubble.kt | 33 +++++++++++-- 5 files changed, 147 insertions(+), 33 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt index a2a0da73..aca8a997 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt @@ -3,10 +3,25 @@ package org.monogram.presentation.features.chats.currentChat.components import android.content.res.Configuration import androidx.compose.animation.core.Animatable import androidx.compose.foundation.gestures.detectTapGestures -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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -67,8 +82,14 @@ fun AlbumMessageBubbleContainer( ) { if (messages.isEmpty()) return - val firstMsg = messages.first() - val lastMsg = messages.last() + val orderedMessages = remember(messages) { + messages + .distinctBy { it.id } + .sortedWith(compareBy { it.date }.thenBy { it.id }) + } + + val firstMsg = orderedMessages.first() + val lastMsg = orderedMessages.last() val isOutgoing = firstMsg.isOutgoing val configuration = LocalConfiguration.current @@ -111,7 +132,7 @@ fun AlbumMessageBubbleContainer( canReply = canReply, dragOffsetX = dragOffsetX, scope = rememberCoroutineScope(), - onReplySwipe = { onReplySwipe(messages.first()) }, + onReplySwipe = { onReplySwipe(lastMsg) }, maxWidth = maxWidth.value ) .pointerInput(Unit) { @@ -176,7 +197,7 @@ fun AlbumMessageBubbleContainer( if (isChannel) { ChannelAlbumMessageBubble( - messages = messages, + messages = orderedMessages, isSameSenderAbove = isSameSenderAbove, isSameSenderBelow = isSameSenderBelow, autoplayGifs = autoplayGifs, @@ -210,7 +231,7 @@ fun AlbumMessageBubbleContainer( ) } else { ChatAlbumMessageBubble( - messages = messages, + messages = orderedMessages, isOutgoing = isOutgoing, isGroup = isGroup, isSameSenderAbove = isSameSenderAbove, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt index 3a9470c9..d4fefdb8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt @@ -2,7 +2,14 @@ package org.monogram.presentation.features.chats.currentChat.components.channels import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -10,10 +17,18 @@ import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.* import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,7 +44,14 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.CompactMediaMosaic -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.formatFileSize +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelAlbumMessageBubble( @@ -477,7 +499,7 @@ fun ChannelDocumentAlbumBubble( } MessageReactionsView( - reactions = firstMsg.reactions, + reactions = lastMsg.reactions, onReactionClick = onReactionClick, modifier = Modifier.padding(top = 2.dp) ) @@ -682,7 +704,7 @@ fun ChannelAudioAlbumBubble( } MessageReactionsView( - reactions = firstMsg.reactions, + reactions = lastMsg.reactions, onReactionClick = onReactionClick, modifier = Modifier.padding(top = 2.dp) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt index 9bd282de..75bd934a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt @@ -2,17 +2,36 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.* import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -341,6 +360,12 @@ fun AudioAlbumBubble( MessageSenderName(lastMsg, toProfile = toProfile) } + lastMsg.forwardInfo?.let { forward -> + Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + ForwardContent(forward, isOutgoing, onForwardClick = toProfile) + } + } + lastMsg.replyToMsg?.let { reply -> Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { ReplyContent( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt index 332d085c..8f9bda8f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt @@ -1,11 +1,22 @@ package org.monogram.presentation.features.chats.currentChat.components.chats -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -164,8 +175,20 @@ fun ChatAlbumMessageBubble( modifier = Modifier.clip(bubbleShape) ) { Column { + if (isGroup && !isOutgoing && !isSameSenderAbove) { + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) { + MessageSenderName(lastMsg, toProfile = toProfile) + } + } + + lastMsg.forwardInfo?.let { forward -> + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) { + ForwardContent(forward, isOutgoing, onForwardClick = toProfile) + } + } + lastMsg.replyToMsg?.let { reply -> - Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp)) { ReplyContent( replyToMsg = reply, isOutgoing = isOutgoing, @@ -232,17 +255,15 @@ fun ChatAlbumMessageBubble( onLongClick = { offset -> onLongClick(bubblePosition + offset) } ) - Row( - modifier = Modifier.align(Alignment.End), - verticalAlignment = Alignment.CenterVertically - ) { - ChatTimestampInfo( - time = formattedTime, - isRead = lastMsg.isRead, + Box(modifier = Modifier.align(Alignment.End)) { + MessageMetadata( + msg = lastMsg, isOutgoing = isOutgoing, - sendingState = lastMsg.sendingState, - color = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.6f) - else MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) + contentColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt index 1cee123f..6f04033a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt @@ -1,20 +1,39 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.components.chats import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download -import androidx.compose.material3.* import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -363,6 +382,12 @@ fun DocumentAlbumBubble( MessageSenderName(lastMsg, toProfile = toProfile) } + lastMsg.forwardInfo?.let { forward -> + Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + ForwardContent(forward, isOutgoing, onForwardClick = toProfile) + } + } + lastMsg.replyToMsg?.let { reply -> Box(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { ReplyContent( From 7219766422a8606a59250e7411392f9b0d665c41 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:34:05 +0300 Subject: [PATCH 44/83] update ChatInputBarComposerSection vertical alignment to center --- .../inputbar/ChatInputBarComposerSection.kt | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt index 6e846c41..61f167a6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt @@ -1,12 +1,36 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,7 +42,15 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import org.monogram.domain.models.* +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.components.chats.BotCommandSuggestions @@ -174,7 +206,7 @@ fun ChatInputBarComposerSection( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom + verticalAlignment = Alignment.CenterVertically ) { AnimatedVisibility( visible = !voiceRecorder.isRecording, From 746915123c205ad8a3bf86cda056683a021395b6 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:50:08 +0300 Subject: [PATCH 45/83] fix #202 --- .../monogram/data/di/TdNotificationManager.kt | 109 +++++++++++++----- 1 file changed, 80 insertions(+), 29 deletions(-) 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 1bf2f453..62f55b52 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -120,14 +120,16 @@ class TdNotificationManager( is TdApi.UpdateUser -> userCache[update.user.id] = update.user is TdApi.UpdateFile -> { val file = update.file - if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) { + val local = file.local + val localPath = local?.path + if (local?.isDownloadingCompleted == true && !localPath.isNullOrEmpty()) { val callbacks = synchronized(activeDownloads) { activeDownloads.remove(file.id) } if (callbacks != null) { scope.launch(Dispatchers.IO) { val bitmap = try { - BitmapFactory.decodeFile(file.local.path) + BitmapFactory.decodeFile(localPath) } catch (e: Exception) { null } @@ -263,15 +265,17 @@ class TdNotificationManager( fun isChatMuted(chat: TdApi.Chat): Boolean { val cached = notificationSettingsCache[chat.id] - val muteFor = cached?.muteFor ?: chat.notificationSettings.muteFor - val useDefault = cached?.useDefault ?: chat.notificationSettings.useDefaultMuteFor + val chatSettings = chat.notificationSettings + val muteFor = cached?.muteFor ?: chatSettings?.muteFor ?: return true + val useDefault = cached?.useDefault ?: chatSettings?.useDefaultMuteFor ?: return true return if (useDefault) { - val scopeKey = when (chat.type) { + val chatType = chat.type ?: return true + val scopeKey = when (chatType) { is TdApi.ChatTypePrivate -> NotificationScopeKey.PRIVATE is TdApi.ChatTypeBasicGroup -> NotificationScopeKey.GROUPS is TdApi.ChatTypeSupergroup -> { - if ((chat.type as TdApi.ChatTypeSupergroup).isChannel) NotificationScopeKey.CHANNELS else NotificationScopeKey.GROUPS + if (chatType.isChannel) NotificationScopeKey.CHANNELS else NotificationScopeKey.GROUPS } else -> null @@ -321,6 +325,18 @@ class TdNotificationManager( private fun handleNewMessage(message: TdApi.Message) { if (message.isOutgoing) return + val messageContent = message.content + if (messageContent == null) { + Log.w(TAG, "Skipping notification for message ${message.id}: content is null") + return + } + + val senderId = message.senderId + if (senderId == null) { + Log.w(TAG, "Skipping notification for message ${message.id}: senderId is null") + return + } + val lastId = lastMessageIds[message.chatId] if (lastId != null && message.id <= lastId) { return @@ -329,6 +345,12 @@ class TdNotificationManager( getChat(message.chatId) { chat -> scope.launch { + val chatType = chat.type + if (chatType == null) { + Log.w(TAG, "Skipping notification for chat ${chat.id}: chat type is null") + return@launch + } + val isMember = checkMembership(chat) if (!isMember) { Log.d(TAG, "Skipping notification for chat ${chat.id}: user is not a member") @@ -338,7 +360,7 @@ class TdNotificationManager( if (isChatMuted(chat)) return@launch val contentText = - if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText(message.content) + if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText(messageContent) if (contentText.isBlank()) return@launch @@ -347,13 +369,13 @@ class TdNotificationManager( val shouldDownloadAvatar = !appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value - resolveSender(message.senderId, chat, !shouldDownloadAvatar) { senderName, senderBitmap -> + resolveSender(senderId, chat, !shouldDownloadAvatar) { senderName, senderBitmap -> if (shouldDownloadAvatar) { downloadAvatar(chat.photo, false) { chatIcon -> appendMessageToNotification( chatId = chat.id, messageId = message.id, - chatType = chat.type, + chatType = chatType, senderName = senderName, senderBitmap = senderBitmap, chatIcon = chatIcon ?: senderBitmap, @@ -365,7 +387,7 @@ class TdNotificationManager( appendMessageToNotification( chatId = chat.id, messageId = message.id, - chatType = chat.type, + chatType = chatType, senderName = senderName, senderBitmap = senderBitmap, chatIcon = senderBitmap, @@ -379,25 +401,26 @@ class TdNotificationManager( } private suspend fun checkMembership(chat: TdApi.Chat): Boolean { - return when (val type = chat.type) { + val chatType = chat.type ?: return true + return when (chatType) { is TdApi.ChatTypePrivate -> true is TdApi.ChatTypeBasicGroup -> { - if (type.basicGroupId == 0L) { + if (chatType.basicGroupId == 0L) { return true } coRunCatching { - val result = gateway.execute(TdApi.GetBasicGroup(type.basicGroupId)) + val result = gateway.execute(TdApi.GetBasicGroup(chatType.basicGroupId)) result.status is TdApi.ChatMemberStatusMember || result.status is TdApi.ChatMemberStatusCreator || result.status is TdApi.ChatMemberStatusAdministrator }.getOrDefault(true) } is TdApi.ChatTypeSupergroup -> { - if (type.supergroupId == 0L) { + if (chatType.supergroupId == 0L) { return true } coRunCatching { - val result = gateway.execute(TdApi.GetSupergroup(type.supergroupId)) + val result = gateway.execute(TdApi.GetSupergroup(chatType.supergroupId)) result.status is TdApi.ChatMemberStatusMember || result.status is TdApi.ChatMemberStatusCreator || result.status is TdApi.ChatMemberStatusAdministrator @@ -719,12 +742,16 @@ class TdNotificationManager( } } - private fun getMessageText(content: TdApi.MessageContent): String { + private fun getMessageText(content: TdApi.MessageContent?): String { fun withDetails(base: String, details: String?): String { val cleanDetails = details?.trim().orEmpty() return if (cleanDetails.isEmpty()) base else "$base $cleanDetails" } + if (content == null) { + return stringProvider.getString("reply_content_message") + } + return when (content) { is TdApi.MessageText -> sanitizeSpoilers(content.text) is TdApi.MessagePhoto -> withDetails("📷 ${stringProvider.getString("logs_media_photo")}", sanitizeSpoilers(content.caption)) @@ -732,14 +759,24 @@ class TdNotificationManager( is TdApi.MessageVoiceNote -> "🎤 ${stringProvider.getString("logs_media_voice")}" is TdApi.MessageSticker -> stringProvider.getString("reply_content_sticker") is TdApi.MessageAnimation -> stringProvider.getString("reply_content_gif") - is TdApi.MessageAudio -> withDetails("🎵 ${stringProvider.getString("logs_media_audio")}", content.audio.title) - is TdApi.MessageDocument -> withDetails("📄 ${stringProvider.getString("logs_media_document")}", content.document.fileName) - is TdApi.MessageLocation -> "📍 ${stringProvider.getString("location_label")} ${content.location.latitude}, ${content.location.longitude}" + is TdApi.MessageAudio -> withDetails("🎵 ${stringProvider.getString("logs_media_audio")}", content.audio?.title) + is TdApi.MessageDocument -> withDetails("📄 ${stringProvider.getString("logs_media_document")}", content.document?.fileName) + is TdApi.MessageLocation -> { + val location = content.location + if (location != null) { + "📍 ${stringProvider.getString("location_label")} ${location.latitude}, ${location.longitude}" + } else { + "📍 ${stringProvider.getString("location_label")}" + } + } is TdApi.MessageContact -> withDetails( "👤 ${stringProvider.getString("logs_media_contact")}", - listOf(content.contact.firstName, content.contact.lastName).filter { it.isNotBlank() }.joinToString(" ") + listOf(content.contact?.firstName, content.contact?.lastName) + .filterNotNull() + .filter { it.isNotBlank() } + .joinToString(" ") ) - is TdApi.MessagePoll -> withDetails("📊 ${stringProvider.getString("logs_media_poll")}", content.poll.question.text) + is TdApi.MessagePoll -> withDetails("📊 ${stringProvider.getString("logs_media_poll")}", content.poll?.question?.text) else -> stringProvider.getString("reply_content_message") } } @@ -807,16 +844,29 @@ class TdNotificationManager( private fun resolveSender( - senderId: TdApi.MessageSender, + senderId: TdApi.MessageSender?, chat: TdApi.Chat, onlyIfLocal: Boolean = false, callback: (String, Bitmap?) -> Unit ) { + val fallbackName = chat.title?.takeIf { it.isNotBlank() } ?: stringProvider.getString("unknown_user") + + if (senderId == null) { + downloadFile(chat.photo?.small, onlyIfLocal) { bitmap -> + callback(fallbackName, bitmap) + } + return + } + when (senderId) { is TdApi.MessageSenderUser -> { getUser(senderId.userId) { user -> + val fullName = listOf(user.firstName, user.lastName) + .filterNotNull() + .filter { it.isNotBlank() } + .joinToString(" ") val name = - if (chat.type is TdApi.ChatTypePrivate) chat.title else "${user.firstName} ${user.lastName}".trim() + if (chat.type is TdApi.ChatTypePrivate) fallbackName else fullName.ifBlank { fallbackName } val file = user.profilePhoto?.small ?: if (chat.type is TdApi.ChatTypePrivate) chat.photo?.small else null downloadFile(file, onlyIfLocal) { bitmap -> @@ -827,7 +877,7 @@ class TdNotificationManager( is TdApi.MessageSenderChat -> { getChat(senderId.chatId) { senderChat -> - val name = senderChat.title + val name = senderChat.title?.takeIf { it.isNotBlank() } ?: fallbackName downloadFile(senderChat.photo?.small, onlyIfLocal) { bitmap -> callback(name, bitmap) } @@ -835,9 +885,8 @@ class TdNotificationManager( } else -> { - val name = chat.title downloadFile(chat.photo?.small, onlyIfLocal) { bitmap -> - callback(name, bitmap) + callback(fallbackName, bitmap) } } } @@ -863,11 +912,13 @@ class TdNotificationManager( return } - if (file.local.isDownloadingCompleted && file.local.path.isNotEmpty()) { + val local = file.local + val localPath = local?.path + if (local?.isDownloadingCompleted == true && !localPath.isNullOrEmpty()) { val bitmap = try { - BitmapFactory.decodeFile(file.local.path) + BitmapFactory.decodeFile(localPath) } catch (e: Exception) { - Log.e(TAG, "Error decoding file: ${file.local.path}", e) + Log.e(TAG, "Error decoding file: $localPath", e) null } From 8e20634fdf3de2f0034c71a3b1b5e9a20f3e947e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:01:58 +0300 Subject: [PATCH 46/83] update splash screen to support monochrome icons and dynamic colors - add `splash_logo_monochrome.xml` with light, dark, and API 31 dynamic color variants - update splash and startup backgrounds to use the new monochrome logo - replace hardcoded splash background colors with system dynamic colors for API 31+ --- .../res/drawable-night-v31/splash_logo_monochrome.xml | 10 ++++++++++ .../main/res/drawable-night/splash_logo_monochrome.xml | 10 ++++++++++ app/src/main/res/drawable-night/startup_background.xml | 2 +- .../main/res/drawable-v31/splash_logo_monochrome.xml | 10 ++++++++++ app/src/main/res/drawable/splash_icon.xml | 2 +- app/src/main/res/drawable/splash_logo_monochrome.xml | 10 ++++++++++ app/src/main/res/drawable/startup_background.xml | 2 +- app/src/main/res/drawable/startup_background_dark.xml | 2 +- app/src/main/res/drawable/startup_background_light.xml | 2 +- app/src/main/res/values-night-v31/themes.xml | 6 +++--- app/src/main/res/values-v31/themes.xml | 6 +++--- 11 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 app/src/main/res/drawable-night-v31/splash_logo_monochrome.xml create mode 100644 app/src/main/res/drawable-night/splash_logo_monochrome.xml create mode 100644 app/src/main/res/drawable-v31/splash_logo_monochrome.xml create mode 100644 app/src/main/res/drawable/splash_logo_monochrome.xml diff --git a/app/src/main/res/drawable-night-v31/splash_logo_monochrome.xml b/app/src/main/res/drawable-night-v31/splash_logo_monochrome.xml new file mode 100644 index 00000000..7e99aef7 --- /dev/null +++ b/app/src/main/res/drawable-night-v31/splash_logo_monochrome.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable-night/splash_logo_monochrome.xml b/app/src/main/res/drawable-night/splash_logo_monochrome.xml new file mode 100644 index 00000000..3056482e --- /dev/null +++ b/app/src/main/res/drawable-night/splash_logo_monochrome.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable-night/startup_background.xml b/app/src/main/res/drawable-night/startup_background.xml index 4a8f9aca..11158fb3 100644 --- a/app/src/main/res/drawable-night/startup_background.xml +++ b/app/src/main/res/drawable-night/startup_background.xml @@ -9,6 +9,6 @@ diff --git a/app/src/main/res/drawable-v31/splash_logo_monochrome.xml b/app/src/main/res/drawable-v31/splash_logo_monochrome.xml new file mode 100644 index 00000000..ed6f6d97 --- /dev/null +++ b/app/src/main/res/drawable-v31/splash_logo_monochrome.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/splash_icon.xml b/app/src/main/res/drawable/splash_icon.xml index 1cd70ee0..ae2adee2 100644 --- a/app/src/main/res/drawable/splash_icon.xml +++ b/app/src/main/res/drawable/splash_icon.xml @@ -1,6 +1,6 @@ + + + diff --git a/app/src/main/res/drawable/startup_background.xml b/app/src/main/res/drawable/startup_background.xml index 58e48b47..bea5d321 100644 --- a/app/src/main/res/drawable/startup_background.xml +++ b/app/src/main/res/drawable/startup_background.xml @@ -9,6 +9,6 @@ diff --git a/app/src/main/res/drawable/startup_background_dark.xml b/app/src/main/res/drawable/startup_background_dark.xml index 4a8f9aca..11158fb3 100644 --- a/app/src/main/res/drawable/startup_background_dark.xml +++ b/app/src/main/res/drawable/startup_background_dark.xml @@ -9,6 +9,6 @@ diff --git a/app/src/main/res/drawable/startup_background_light.xml b/app/src/main/res/drawable/startup_background_light.xml index 58e48b47..bea5d321 100644 --- a/app/src/main/res/drawable/startup_background_light.xml +++ b/app/src/main/res/drawable/startup_background_light.xml @@ -9,6 +9,6 @@ diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml index 142776a4..25443c79 100644 --- a/app/src/main/res/values-night-v31/themes.xml +++ b/app/src/main/res/values-night-v31/themes.xml @@ -2,19 +2,19 @@ diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml index b448b631..072a8603 100644 --- a/app/src/main/res/values-v31/themes.xml +++ b/app/src/main/res/values-v31/themes.xml @@ -2,19 +2,19 @@ From cfaf58f4228f19db4cb7338929bf07ef1f4f6d9c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:37:23 +0300 Subject: [PATCH 47/83] support pasting images from clipboard - add "Paste image" option to the text context menu - implement `contentReceiver` to handle image URIs from clipboard - update `InputTextField` to use `TextFieldState` for improved content handling - add localized strings for "Paste image" action across multiple languages --- .../currentChat/components/ChatInputBar.kt | 94 +++++++++++- .../inputbar/ChatInputBarComposerSection.kt | 5 + .../components/inputbar/InputTextField.kt | 139 ++++++++++++++++-- .../inputbar/InputTextFieldContainer.kt | 19 ++- .../src/main/res/values-es/string.xml | 1 + .../src/main/res/values-hy/string.xml | 1 + .../src/main/res/values-pt-rBR/string.xml | 1 + .../src/main/res/values-ru-rRU/string.xml | 1 + .../src/main/res/values-sk/string.xml | 1 + .../src/main/res/values-uk/string.xml | 1 + .../src/main/res/values-zh-rCN/string.xml | 1 + presentation/src/main/res/values/string.xml | 1 + 12 files changed, 245 insertions(+), 20 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt index 81ed4653..804271b2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt @@ -6,17 +6,58 @@ import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.* +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -25,17 +66,44 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import kotlinx.coroutines.delay -import org.monogram.domain.models.* +import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.ReplyMarkupModel +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.UserModel import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.StickerRepository import org.monogram.presentation.R import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.features.camera.CameraScreen import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily -import org.monogram.presentation.features.chats.currentChat.components.inputbar.* +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ChatInputBarComposerSection +import org.monogram.presentation.features.chats.currentChat.components.inputbar.FullScreenEditorSheet +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleDatePickerDialog +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleTimePickerDialog +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduledMessagesSheet +import org.monogram.presentation.features.chats.currentChat.components.inputbar.applyMentionSuggestion +import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildEditingMessageTextValue +import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildScheduledDateEpochSeconds +import org.monogram.presentation.features.chats.currentChat.components.inputbar.copyUriToTempPath +import org.monogram.presentation.features.chats.currentChat.components.inputbar.declaredPermissions +import org.monogram.presentation.features.chats.currentChat.components.inputbar.extractEntities +import org.monogram.presentation.features.chats.currentChat.components.inputbar.hasAllPermissions +import org.monogram.presentation.features.chats.currentChat.components.inputbar.isInlineBotPrefillText +import org.monogram.presentation.features.chats.currentChat.components.inputbar.parseInlineQueryInput +import org.monogram.presentation.features.chats.currentChat.components.inputbar.rememberVoiceRecorder import org.monogram.presentation.features.gallery.GalleryScreen import java.text.DateFormat -import java.util.* +import java.util.Calendar +import java.util.Date +import java.util.Locale import kotlin.math.ceil @Immutable @@ -582,6 +650,7 @@ fun ChatInputBar( focusRequester = focusRequester, canWriteText = canWriteText, canSendMedia = canSendMedia, + canPasteMediaFromClipboard = canSendMedia && state.editingMessage == null, canSendStickers = canSendStickers, canSendVoice = canSendVoice, canSendVideoNotes = canSendVideoNotes, @@ -607,6 +676,15 @@ fun ChatInputBar( onCancelMedia = actions.onCancelMedia, onMediaOrderChange = actions.onMediaOrderChange, onMediaClick = actions.onMediaClick, + onPasteImages = { uris -> + if (!canSendMedia || state.editingMessage != null) return@ChatInputBarComposerSection + val localPaths = uris.mapNotNull { uri -> + context.copyUriToTempPath(uri) + } + if (localPaths.isNotEmpty()) { + actions.onMediaOrderChange((state.pendingMediaPaths + localPaths).distinct()) + } + }, onMentionClick = { user -> textValue = applyMentionSuggestion(textValue, user) }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt index 61f167a6..82f5abbe 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar +import android.net.Uri import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -78,6 +79,7 @@ fun ChatInputBarComposerSection( focusRequester: FocusRequester, canWriteText: Boolean, canSendMedia: Boolean, + canPasteMediaFromClipboard: Boolean, canSendStickers: Boolean, canSendVoice: Boolean, canSendVideoNotes: Boolean, @@ -104,6 +106,7 @@ fun ChatInputBarComposerSection( onCancelMedia: () -> Unit, onMediaOrderChange: (List) -> Unit, onMediaClick: (String) -> Unit, + onPasteImages: (List) -> Unit, onMentionClick: (UserModel) -> Unit, onMentionQueryClear: () -> Unit, onInlineResultClick: (String) -> Unit, @@ -263,6 +266,8 @@ fun ChatInputBarComposerSection( emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, + canPasteMediaFromClipboard = canPasteMediaFromClipboard, + onPasteImages = onPasteImages, onFocus = onInputFocus, onOpenFullScreenEditor = onOpenFullScreenEditor, modifier = Modifier.fillMaxWidth() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt index 855dace7..eb60b761 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt @@ -1,6 +1,17 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.foundation.layout.* +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.content.ReceiveContentListener +import androidx.compose.foundation.content.contentReceiver +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.InlineTextContent @@ -9,9 +20,22 @@ import androidx.compose.foundation.text.contextmenu.builder.item import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents import androidx.compose.foundation.text.contextmenu.modifier.filterTextContextMenuComponents +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -19,8 +43,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -61,9 +91,11 @@ private object RichMenuActionCode private object RichMenuActionPre private object RichMenuActionLink private object RichMenuActionClear +private object RichMenuActionPasteImage private data class Interval(val start: Int, val end: Int) +@OptIn(ExperimentalFoundationApi::class) @Composable fun InputTextField( textValue: TextFieldValue, @@ -76,6 +108,8 @@ fun InputTextField( emojiFontFamily: FontFamily, focusRequester: FocusRequester, pendingMediaPaths: List, + canPasteMediaFromClipboard: Boolean = false, + onPasteImages: (List) -> Unit = {}, fontScale: Float = 1f, maxEditorHeight: Dp = 140.dp, onFocus: () -> Unit = {}, @@ -85,6 +119,7 @@ fun InputTextField( var linkValue by remember { mutableStateOf("https://") } var showPreLanguageDialog by remember { mutableStateOf(false) } var preLanguageValue by remember { mutableStateOf("") } + val context = LocalContext.current val emojiSize = 20.sp val inlineContentMap = remember(knownCustomEmojis.size, knownCustomEmojis.hashCode()) { @@ -165,6 +200,34 @@ fun InputTextField( hasCustomEmojis || emojiFontFamily != FontFamily.Default || textValue.text.contains('@') || hasRichFormatting val scrollState = rememberScrollState() + val editorState = rememberTextFieldState(initialText = textValue.text) + + LaunchedEffect(textValue.text, textValue.selection) { + val currentText = editorState.text.toString() + val currentSelection = editorState.selection + if (currentText != textValue.text || currentSelection != textValue.selection) { + editorState.edit { + replace(0, length, textValue.text) + selection = textValue.selection + } + } + } + + val currentTextValue by rememberUpdatedState(textValue) + val currentOnValueChange by rememberUpdatedState(onValueChange) + LaunchedEffect(editorState) { + snapshotFlow { editorState.text.toString() to editorState.selection } + .collect { (editedText, editedSelection) -> + val external = currentTextValue + if (editedText == external.text && editedSelection == external.selection) return@collect + currentOnValueChange( + TextFieldValue( + annotatedString = AnnotatedString(editedText), + selection = editedSelection + ) + ) + } + } LaunchedEffect(textValue.text) { scrollState.scrollTo(scrollState.maxValue) @@ -179,6 +242,21 @@ fun InputTextField( val richTextPre = stringResource(R.string.rich_text_pre) val richTextLink = stringResource(R.string.rich_text_link) val richTextClear = stringResource(R.string.rich_text_clear) + val actionPasteImage = stringResource(R.string.action_paste_image) + val receiveContentListener = remember(context, canPasteMediaFromClipboard, onPasteImages) { + ReceiveContentListener { transferableContent -> + if (!canPasteMediaFromClipboard) return@ReceiveContentListener transferableContent + + val clipData = transferableContent.clipEntry.clipData + val imageUris = extractImageUrisFromClipData(context, clipData) + if (imageUris.isEmpty()) { + transferableContent + } else { + onPasteImages(imageUris) + null + } + } + } Box(modifier = modifier.fillMaxWidth()) { Box( @@ -193,6 +271,7 @@ fun InputTextField( .fillMaxWidth() .focusRequester(focusRequester) .onFocusChanged { if (it.isFocused) onFocus() } + .contentReceiver(receiveContentListener) .let { base -> when { !enableContextMenu -> base.filterTextContextMenuComponents { false } @@ -212,12 +291,23 @@ fun InputTextField( RichMenuActionCode, RichMenuActionPre, RichMenuActionLink, - RichMenuActionClear -> true + RichMenuActionClear, + RichMenuActionPasteImage -> true else -> false } } .appendTextContextMenuComponents { + if (canPasteMediaFromClipboard) { + val imageUris = extractImageUrisFromClipboard(context) + if (imageUris.isNotEmpty()) { + item(RichMenuActionPasteImage, actionPasteImage) { + close() + onPasteImages(imageUris) + } + } + } + if (hasFormattableSelection(textValue)) { separator() item(RichMenuActionBold, richTextBold) { @@ -327,16 +417,17 @@ fun InputTextField( ) BasicTextField( - value = textValue, - onValueChange = onValueChange, + state = editorState, modifier = fieldModifier, textStyle = textStyle.copy( color = if (shouldUseOverlayText) Color.Transparent else MaterialTheme.colorScheme.onSurface ), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - minLines = 1, - maxLines = Int.MAX_VALUE, - decorationBox = { innerTextField -> + lineLimits = TextFieldLineLimits.MultiLine( + minHeightInLines = 1, + maxHeightInLines = Int.MAX_VALUE + ), + decorator = { innerTextField -> Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart @@ -452,6 +543,34 @@ fun InputTextField( } } +private fun extractImageUrisFromClipboard(context: Context): List { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return emptyList() + val clip = clipboard.primaryClip ?: return emptyList() + return extractImageUrisFromClipData(context, clip) +} + +private fun extractImageUrisFromClipData(context: Context, clip: ClipData): List { + if (clip.itemCount <= 0) return emptyList() + + val hasImageMimeType = clip.description?.let { description -> + (0 until description.mimeTypeCount).any { index -> + description.getMimeType(index)?.startsWith("image/") == true + } + } == true + + return buildList { + for (index in 0 until clip.itemCount) { + val item = clip.getItemAt(index) + val uri = item.uri ?: item.intent?.data ?: continue + val mime = context.contentResolver.getType(uri).orEmpty() + if (mime.startsWith("image/") || hasImageMimeType) { + add(uri) + } + } + }.distinct() +} + internal fun mergeInputTextValuePreservingAnnotations( currentValue: TextFieldValue, incomingValue: TextFieldValue diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt index 1a1fb50d..6f915ac4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt @@ -1,7 +1,18 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.* -import androidx.compose.foundation.layout.* +import android.net.Uri +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -46,6 +57,8 @@ fun InputTextFieldContainer( emojiFontFamily: FontFamily, focusRequester: FocusRequester, pendingMediaPaths: List, + canPasteMediaFromClipboard: Boolean = false, + onPasteImages: (List) -> Unit = {}, onFocus: () -> Unit = {}, onOpenFullScreenEditor: () -> Unit = {}, modifier: Modifier = Modifier @@ -108,6 +121,8 @@ fun InputTextFieldContainer( emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, + canPasteMediaFromClipboard = canPasteMediaFromClipboard, + onPasteImages = onPasteImages, onFocus = onFocus, modifier = Modifier.weight(1f) ) diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 104a666f..f1692118 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -986,6 +986,7 @@ Cancelar Copiar Pegar + Pegar imagen Cortar Seleccionar todo Aplicar diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 13e3f2f3..8dedd8b6 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -925,6 +925,7 @@ Չեղարկել Պատճենել Տեղադրել + Տեղադրել պատկեր Կտրել Ընտրել ամբողջը Կիրառել diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 0afdbeac..2536f12a 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -988,6 +988,7 @@ Cancelar Copiar Colar + Colar imagem Recortar Selecionar tudo Aplicar diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 10cb1667..8630dfbc 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -966,6 +966,7 @@ Отмена Копировать Вставить + Вставить изображение Вырезать Выделить все Применить diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index ec5f0ff8..2cca3756 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -1030,6 +1030,7 @@ Zrušiť Kopírovať Vložiť + Vložiť obrázok Vystrihnúť Vybrať všetko Použiť diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 7af74f9e..20df1450 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -966,6 +966,7 @@ Скасувати Копіювати Вставити + Вставити зображення Вирізати Виділити все Застосувати diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 776444f2..acadc6d7 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -959,6 +959,7 @@ 取消 复制 粘贴 + 粘贴图片 剪切 全选 应用 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index cf0b9c10..b4099927 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1001,6 +1001,7 @@ Cancel Copy Paste + Paste image Cut Select all Apply From ac274628ceca8bb75bceaf767b99fb0eb1b98b7b Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:01:25 +0300 Subject: [PATCH 48/83] defer non-critical startup work for faster cold start Move notification service startup after the first Compose frame and lazily initialize warmup/sponsor sync in Koin to reduce startup pressure. --- app/src/main/java/org/monogram/app/MainActivity.kt | 2 +- data/src/main/java/org/monogram/data/di/dataModule.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index 0f8b9389..c051f5d3 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -62,13 +62,13 @@ class MainActivity : FragmentActivity() { } handleIntent(intent) - startNotificationService() val windowInfoTracker = WindowInfoTracker.getOrCreate(this) setContent { LaunchedEffect(Unit) { keepSplashOnScreen = false + startNotificationService() } val windowLayoutInfo by windowInfoTracker.windowLayoutInfo(this) 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 e73fe168..7ff39758 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -162,7 +162,7 @@ val dataModule = module { single { DefaultDispatcherProvider() } single { AndroidStringProvider(androidContext()) } single { TdLibParametersProvider(androidContext()) } - single(createdAtStart = true) { + single { OfflineWarmup( scope = get(), dispatchers = get(), @@ -177,7 +177,7 @@ val dataModule = module { stickerRepository = get() ) } - single(createdAtStart = true) { + single { SponsorSyncManager( scope = get(), gateway = get(), From 6340b9d4e87594dfa1bf82612010ca654639990d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:01:36 +0300 Subject: [PATCH 49/83] optimize chat compose state and list rendering paths Use rememberSaveable and derivedStateOf across chat surfaces, add stable keys for dynamic lazy lists, and throttle pagination triggers to cut recomposition and scroll churn. --- .../features/chats/currentChat/ChatContent.kt | 141 +++++++++++++----- .../chatContent/ChatContentList.kt | 96 ++++++------ .../chatContent/ChatContentTopBar.kt | 5 +- .../chatContent/ReportChatDialog.kt | 10 +- .../currentChat/components/ChatInputBar.kt | 116 +++++++------- .../currentChat/components/ChatTopBar.kt | 7 +- .../components/chats/PollVotersSheet.kt | 5 +- .../inputbar/FullScreenEditorSheet.kt | 39 ++--- .../FullScreenEditorTemplatesSheet.kt | 8 +- .../components/inputbar/InputTextField.kt | 9 +- .../components/inputbar/KeyboardMarkupView.kt | 7 +- .../pins/PinnedMessagesListSheet.kt | 5 +- 12 files changed, 268 insertions(+), 180 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index bde4127b..838a70c7 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.rounded.Block import androidx.compose.material3.* import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -90,7 +91,7 @@ fun ChatContent( var isRecordingVideo by remember { mutableStateOf(false) } // Menu States - var selectedMessageId by remember { mutableStateOf(null) } + var selectedMessageId by rememberSaveable { mutableStateOf(null) } val transformedMessageTexts = remember { mutableStateMapOf() } val originalMessageTexts = remember { mutableStateMapOf() } val latestMessagesState = rememberUpdatedState(state.messages) @@ -108,10 +109,13 @@ fun ChatContent( } } } + val displayMessagesById by remember(displayMessages) { + derivedStateOf { displayMessages.associateBy(MessageModel::id) } + } val selectedMessage by remember { derivedStateOf { val currentSelectedId = selectedMessageIdState.value - displayMessages.find { it.id == currentSelectedId } + currentSelectedId?.let(displayMessagesById::get) } } var menuOffset by remember { mutableStateOf(Offset.Zero) } @@ -119,14 +123,28 @@ fun ChatContent( var clickOffset by remember { mutableStateOf(Offset.Zero) } var contentRect by remember { mutableStateOf(Rect.Zero) } - var pendingMediaPaths by remember { mutableStateOf>(emptyList()) } - var editingPhotoPath by remember { mutableStateOf(null) } - var editingVideoPath by remember { mutableStateOf(null) } - var pendingBlockUserId by remember { mutableStateOf(null) } + var pendingMediaPaths by rememberSaveable { mutableStateOf>(emptyList()) } + var editingPhotoPath by rememberSaveable { mutableStateOf(null) } + var editingVideoPath by rememberSaveable { mutableStateOf(null) } + var pendingBlockUserId by rememberSaveable { mutableStateOf(null) } val groupedMessages by remember { derivedStateOf { groupMessagesByAlbum(displayMessages) } } + val groupedMessageIndexById by remember(groupedMessages) { + derivedStateOf { + buildMap { + groupedMessages.forEachIndexed { index, item -> + when (item) { + is GroupedMessageItem.Single -> put(item.message.id, index) + is GroupedMessageItem.Album -> item.messages.forEach { message -> + put(message.id, index) + } + } + } + } + } + } val isComments = state.rootMessage != null val isForumList = state.viewAsTopics && state.currentTopicId == null var showScrollToBottomButton by remember { mutableStateOf(false) } @@ -143,12 +161,7 @@ fun ChatContent( isRecordingVideo val scrollToMessageState = rememberUpdatedState(newValue = { msg: MessageModel -> - val index = groupedMessages.indexOfFirst { item -> - when (item) { - is GroupedMessageItem.Single -> item.message.id == msg.id - is GroupedMessageItem.Album -> item.messages.any { it.id == msg.id } - } - } + val index = groupedMessageIndexById[msg.id] ?: -1 if (index != -1) { coroutineScope.launch { val targetIndex = if (isComments) { @@ -173,6 +186,7 @@ fun ChatContent( } LaunchedEffect(state.messages) { + if (transformedMessageTexts.isEmpty() && originalMessageTexts.isEmpty()) return@LaunchedEffect val ids = state.messages.map { it.id }.toSet() transformedMessageTexts.keys.toList().forEach { id -> if (id !in ids) { @@ -209,16 +223,7 @@ fun ChatContent( // Scroll to message when requested by component LaunchedEffect(state.scrollToMessageId, groupedMessages) { state.scrollToMessageId?.let { id -> - val index = groupedMessages.indexOfFirst { item -> - if (id == state.currentTopicId) { - false - } else { - when (item) { - is GroupedMessageItem.Single -> item.message.id == id - is GroupedMessageItem.Album -> item.messages.any { it.id == id } - } - } - } + val index = if (id == state.currentTopicId) -1 else groupedMessageIndexById[id] ?: -1 if (index != -1) { component.onScrollToMessageConsumed() @@ -318,8 +323,8 @@ fun ChatContent( LaunchedEffect(scrollState, groupedMessages, state.rootMessage) { snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } .map { visibleItems -> - val visibleIds = mutableListOf() - val nearbyIds = mutableListOf() + val visibleIds = LinkedHashSet() + val nearbyIds = LinkedHashSet() if (visibleItems.isNotEmpty()) { val minIndex = visibleItems.minOf { it.index } val maxIndex = visibleItems.maxOf { it.index } @@ -329,7 +334,9 @@ fun ChatContent( groupedMessages.getOrNull(groupedIndex)?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> visibleIds.add(grouped.message.id) - is GroupedMessageItem.Album -> visibleIds.addAll(grouped.messages.map { it.id }) + is GroupedMessageItem.Album -> grouped.messages.forEach { message -> + visibleIds.add(message.id) + } } } } @@ -342,12 +349,15 @@ fun ChatContent( groupedMessages.getOrNull(groupedIndex)?.let { grouped -> when (grouped) { is GroupedMessageItem.Single -> nearbyIds.add(grouped.message.id) - is GroupedMessageItem.Album -> nearbyIds.addAll(grouped.messages.map { it.id }) + is GroupedMessageItem.Album -> grouped.messages.forEach { message -> + nearbyIds.add(message.id) + } } } } } - visibleIds.distinct() to nearbyIds.distinct().filterNot { it in visibleIds } + val visibleIdList = visibleIds.toList() + visibleIdList to nearbyIds.filterNot(visibleIds::contains) } .distinctUntilChanged() .debounce(100) @@ -428,14 +438,27 @@ fun ChatContent( label = "ContentOffset" ) - val showInputBar = (state.isMember || !state.isChannel && !state.isGroup) && - (state.canWrite || state.currentTopicId != null) && - !isRecordingVideo && - state.selectedMessageIds.isEmpty() && - (!state.viewAsTopics || state.currentTopicId != null) + val showInputBar by remember( + state.isMember, + state.isChannel, + state.isGroup, + state.canWrite, + state.currentTopicId, + state.selectedMessageIds, + state.viewAsTopics, + isRecordingVideo + ) { + derivedStateOf { + (state.isMember || !state.isChannel && !state.isGroup) && + (state.canWrite || state.currentTopicId != null) && + !isRecordingVideo && + state.selectedMessageIds.isEmpty() && + (!state.viewAsTopics || state.currentTopicId != null) + } + } var containerSize by remember { mutableStateOf(IntSize.Zero) } - var renderPinnedMessagesList by remember { mutableStateOf(state.showPinnedMessagesList) } + var renderPinnedMessagesList by rememberSaveable { mutableStateOf(state.showPinnedMessagesList) } var pendingPinnedSheetAction by remember { mutableStateOf<(() -> Unit)?>(null) } LaunchedEffect(state.showPinnedMessagesList) { @@ -450,14 +473,52 @@ fun ChatContent( } } - val isCustomBackHandlingEnabled = - (editingPhotoPath != null || editingVideoPath != null || selectedMessageId != null || state.selectedMessageIds.isNotEmpty() || state.currentTopicId != null || state.showBotCommands || state.restrictUserId != null || state.showPinnedMessagesList || state.fullScreenImages != null || state.fullScreenVideoPath != null || state.fullScreenVideoMessageId != null || state.miniAppUrl != null || state.webViewUrl != null || state.instantViewUrl != null || state.youtubeUrl != null) + val isCustomBackHandlingEnabled by remember( + editingPhotoPath, + editingVideoPath, + selectedMessageId, + state.selectedMessageIds, + state.currentTopicId, + state.showBotCommands, + state.restrictUserId, + state.showPinnedMessagesList, + state.fullScreenImages, + state.fullScreenVideoPath, + state.fullScreenVideoMessageId, + state.miniAppUrl, + state.webViewUrl, + state.instantViewUrl, + state.youtubeUrl + ) { + derivedStateOf { + editingPhotoPath != null || + editingVideoPath != null || + selectedMessageId != null || + state.selectedMessageIds.isNotEmpty() || + state.currentTopicId != null || + state.showBotCommands || + state.restrictUserId != null || + state.showPinnedMessagesList || + state.fullScreenImages != null || + state.fullScreenVideoPath != null || + state.fullScreenVideoMessageId != null || + state.miniAppUrl != null || + state.webViewUrl != null || + state.instantViewUrl != null || + state.youtubeUrl != null + } + } val selectedCount = state.selectedMessageIds.size - val canRevokeSelected = remember(state.selectedMessageIds, state.messages) { - if (state.selectedMessageIds.isEmpty()) { - false - } else { - state.messages.any { it.id in state.selectedMessageIds && it.canBeDeletedForAllUsers } + val selectedMessageIdSet by remember(state.selectedMessageIds) { + derivedStateOf { state.selectedMessageIds.toHashSet() } + } + val canRevokeSelected by remember(state.messages, selectedMessageIdSet) { + derivedStateOf { + if (selectedMessageIdSet.isEmpty()) { + false + } else { + state.messages.any { it.id in selectedMessageIdSet && it.canBeDeletedForAllUsers } + } } } val topBarUiState = remember( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index 09182a0c..3e5feecc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.chatContent +import android.os.SystemClock import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable @@ -80,16 +81,14 @@ fun ChatContentList( ) { val isComments = state.rootMessage != null val isScrolling by remember(scrollState) { derivedStateOf { scrollState.isScrollInProgress } } + val latestState by rememberUpdatedState(state) + var lastOlderLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } + var lastNewerLoadTriggerUptimeMs by remember { mutableLongStateOf(0L) } + val loadTriggerThrottleMs = 350L LaunchedEffect( scrollState, groupedMessages.size, - state.isLoading, - state.isLoadingOlder, - state.isLoadingNewer, - state.isLatestLoaded, - state.isOldestLoaded, - state.isAtBottom, isComments ) { snapshotFlow { scrollState.layoutInfo.visibleItemsInfo } @@ -101,24 +100,38 @@ fun ChatContentList( } .distinctUntilChanged() .collect { (firstVisibleIndex, lastVisibleIndex) -> - if (state.isLoading || state.isLoadingOlder || state.isLoadingNewer) return@collect + val currentState = latestState + if (currentState.isLoading || currentState.isLoadingOlder || currentState.isLoadingNewer) return@collect val nearStart = firstVisibleIndex <= 2 val nearEnd = lastVisibleIndex >= (groupedMessages.size - 3).coerceAtLeast(0) + val now = SystemClock.uptimeMillis() if (isComments) { if (!scrollState.isScrollInProgress) return@collect - if (nearStart && !state.isOldestLoaded) { - component.loadMore() - } else if (nearEnd && !state.isLatestLoaded) { - component.loadNewer() + if (nearStart && !currentState.isOldestLoaded) { + if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastOlderLoadTriggerUptimeMs = now + component.loadMore() + } + } else if (nearEnd && !currentState.isLatestLoaded) { + if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastNewerLoadTriggerUptimeMs = now + component.loadNewer() + } } } else { - if (nearEnd && !state.isOldestLoaded) { - component.loadMore() - } else if (nearStart && !state.isAtBottom && !state.isLatestLoaded) { - component.loadNewer() + if (nearEnd && !currentState.isOldestLoaded) { + if (now - lastOlderLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastOlderLoadTriggerUptimeMs = now + component.loadMore() + } + } else if (nearStart && !currentState.isAtBottom && !currentState.isLatestLoaded) { + if (now - lastNewerLoadTriggerUptimeMs >= loadTriggerThrottleMs) { + lastNewerLoadTriggerUptimeMs = now + component.loadNewer() + } } } } @@ -154,24 +167,22 @@ fun ChatContentList( } if (isComments) { - if (state.rootMessage != null) { - item(key = "root_header") { - RootMessageSection( - state, - component, - onPhotoClick, - onPhotoDownload, - onVideoClick, - onDocumentClick, - onAudioClick, - onMessageOptionsClick, - onGoToReply, - onViaBotClick, - toProfile, - downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } + item(key = "root_header") { + RootMessageSection( + state, + component, + onPhotoClick, + onPhotoDownload, + onVideoClick, + onDocumentClick, + onAudioClick, + onMessageOptionsClick, + onGoToReply, + onViaBotClick, + toProfile, + downloadUtils, + isAnyViewerOpen = isAnyViewerOpen + ) } itemsIndexed( @@ -228,25 +239,6 @@ fun ChatContentList( ) } } - if (state.rootMessage != null) { - item(key = "root_header") { - RootMessageSection( - state, - component, - onPhotoClick, - onPhotoDownload, - onVideoClick, - onDocumentClick, - onAudioClick, - onMessageOptionsClick, - onGoToReply, - onViaBotClick, - toProfile, - downloadUtils, - isAnyViewerOpen = isAnyViewerOpen - ) - } - } itemsIndexed( items = groupedMessages, key = { _, item -> diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt index 11fa35af..33e4add8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -120,7 +121,7 @@ fun ChatContentTopBar( val canReportChat = topBarState.isGroup || topBarState.isChannel || (otherUserId != null && topBarState.currentUser?.id != otherUserId) - var showDeleteSheet by remember { mutableStateOf(false) } + var showDeleteSheet by rememberSaveable { mutableStateOf(false) } var pendingUnpinMessage by remember { mutableStateOf(null) } val iconButtonShapes = ExpressiveDefaults.iconButtonShapes() @@ -194,7 +195,7 @@ fun ChatContentTopBar( contentDescription = stringResource(R.string.menu_delete) ) } - var showMenu by remember { mutableStateOf(false) } + var showMenu by rememberSaveable { mutableStateOf(false) } Box { IconButton(onClick = { onOpenMenu() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt index 10ff0110..5bddb4e0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ReportChatDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector @@ -28,8 +29,8 @@ fun ReportChatDialog( onDismiss: () -> Unit, onReasonSelected: (String) -> Unit ) { - var showCustomInput by remember { mutableStateOf(false) } - var customText by remember { mutableStateOf("") } + var showCustomInput by rememberSaveable { mutableStateOf(false) } + var customText by rememberSaveable { mutableStateOf("") } val reasons = listOf( ReportReason("spam", stringResource(R.string.report_reason_spam), stringResource(R.string.report_reason_spam_description), Icons.Outlined.Report), @@ -99,7 +100,10 @@ fun ReportChatDialog( .fillMaxWidth() .padding(bottom = 24.dp) ) { - itemsIndexed(reasons) { _, reason -> + itemsIndexed( + items = reasons, + key = { _, reason -> reason.id } + ) { _, reason -> if (reason.id == "custom") { HorizontalDivider( modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt index 804271b2..27b4ad95 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -50,6 +51,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -187,10 +189,10 @@ fun ChatInputBar( return } - val canWriteText = remember(state.isChannel, state.isAdmin, state.permissions.canSendBasicMessages) { - if (state.isChannel) true else (state.isAdmin || state.permissions.canSendBasicMessages) + val canWriteText by remember(state.isChannel, state.isAdmin, state.permissions.canSendBasicMessages) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendBasicMessages) } } - val canSendMedia = remember( + val canSendMedia by remember( state.isChannel, state.isAdmin, state.permissions.canSendPhotos, @@ -198,48 +200,52 @@ fun ChatInputBar( state.permissions.canSendDocuments, state.permissions.canSendAudios ) { - if (state.isChannel) { - true - } else { - state.isAdmin || - state.permissions.canSendPhotos || - state.permissions.canSendVideos || - state.permissions.canSendDocuments || - state.permissions.canSendAudios + derivedStateOf { + if (state.isChannel) { + true + } else { + state.isAdmin || + state.permissions.canSendPhotos || + state.permissions.canSendVideos || + state.permissions.canSendDocuments || + state.permissions.canSendAudios + } } } - val canSendStickers = remember(state.isChannel, state.isAdmin, state.permissions.canSendOtherMessages) { - if (state.isChannel) true else (state.isAdmin || state.permissions.canSendOtherMessages) + val canSendStickers by remember(state.isChannel, state.isAdmin, state.permissions.canSendOtherMessages) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendOtherMessages) } } - val canSendVoice = remember(state.isChannel, state.isAdmin, state.permissions.canSendVoiceNotes) { - if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVoiceNotes) + val canSendVoice by remember(state.isChannel, state.isAdmin, state.permissions.canSendVoiceNotes) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVoiceNotes) } } - val canSendVideoNotes = remember(state.isChannel, state.isAdmin, state.permissions.canSendVideoNotes) { - if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVideoNotes) + val canSendVideoNotes by remember(state.isChannel, state.isAdmin, state.permissions.canSendVideoNotes) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVideoNotes) } } - val canSendAnything = remember(canWriteText, canSendMedia, canSendStickers, canSendVoice, canSendVideoNotes) { - canWriteText || canSendMedia || canSendStickers || canSendVoice || canSendVideoNotes + val canSendAnything by remember(canWriteText, canSendMedia, canSendStickers, canSendVoice, canSendVideoNotes) { + derivedStateOf { canWriteText || canSendMedia || canSendStickers || canSendVoice || canSendVideoNotes } } val context = LocalContext.current val emojiStyle by appPreferences.emojiStyle.collectAsState() val emojiFontFamily = remember(context, emojiStyle) { getEmojiFontFamily(context, emojiStyle) } - var textValue by remember { mutableStateOf(TextFieldValue(state.draftText)) } - var isStickerMenuVisible by remember { mutableStateOf(false) } + var textValue by rememberSaveable(state.editingMessage?.id, stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(state.draftText)) + } + var isStickerMenuVisible by rememberSaveable { mutableStateOf(false) } var closeStickerMenuWithoutSlide by remember { mutableStateOf(false) } var openStickerMenuAfterKeyboardClosed by remember { mutableStateOf(false) } var openKeyboardAfterStickerMenuClosed by remember { mutableStateOf(false) } - var isVideoMessageMode by remember { mutableStateOf(false) } + var isVideoMessageMode by rememberSaveable { mutableStateOf(false) } var isGifSearchFocused by remember { mutableStateOf(false) } var showGallery by remember { mutableStateOf(false) } var showCamera by remember { mutableStateOf(false) } - var showFullScreenEditor by remember { mutableStateOf(false) } - var showSendOptionsSheet by remember { mutableStateOf(false) } - var showScheduleDatePicker by remember { mutableStateOf(false) } - var showScheduleTimePicker by remember { mutableStateOf(false) } - var pendingScheduleDateMillis by remember { mutableStateOf(null) } - var showScheduledMessagesSheet by remember { mutableStateOf(false) } + var showFullScreenEditor by rememberSaveable { mutableStateOf(false) } + var showSendOptionsSheet by rememberSaveable { mutableStateOf(false) } + var showScheduleDatePicker by rememberSaveable { mutableStateOf(false) } + var showScheduleTimePicker by rememberSaveable { mutableStateOf(false) } + var pendingScheduleDateMillis by rememberSaveable { mutableStateOf(null) } + var showScheduledMessagesSheet by rememberSaveable { mutableStateOf(false) } val knownCustomEmojis = remember { mutableStateMapOf() } @@ -312,7 +318,7 @@ fun ChatInputBar( } } - var lastEditingMessageId by remember { mutableStateOf(null) } + var lastEditingMessageId by rememberSaveable { mutableStateOf(null) } var slowModeRemainingSeconds by remember { mutableIntStateOf(0) @@ -330,8 +336,8 @@ fun ChatInputBar( slowModeRemainingSeconds = (slowModeRemainingSeconds - 1).coerceAtLeast(0) } } - val isSlowModeActive = remember(state.isAdmin, state.slowModeDelay, slowModeRemainingSeconds) { - !state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0 + val isSlowModeActive by remember(state.isAdmin, state.slowModeDelay, slowModeRemainingSeconds) { + derivedStateOf { !state.isAdmin && state.slowModeDelay > 0 && slowModeRemainingSeconds > 0 } } fun activateSlowModeCooldown() { @@ -345,11 +351,15 @@ fun ChatInputBar( actions.onSendVoice(path, duration, waveform) activateSlowModeCooldown() } - val maxMessageLength = remember(state.pendingMediaPaths, state.isPremiumUser) { - if (state.pendingMediaPaths.isNotEmpty() && !state.isPremiumUser) 1024 else 4096 + val maxMessageLength by remember(state.pendingMediaPaths, state.isPremiumUser) { + derivedStateOf { if (state.pendingMediaPaths.isNotEmpty() && !state.isPremiumUser) 1024 else 4096 } + } + val currentMessageLength by remember(textValue.text) { + derivedStateOf { textValue.text.length } + } + val isOverMessageLimit by remember(currentMessageLength, maxMessageLength) { + derivedStateOf { currentMessageLength > maxMessageLength } } - val currentMessageLength = textValue.text.length - val isOverMessageLimit = currentMessageLength > maxMessageLength val sendWithOptions: (MessageSendOptions) -> Unit = sendWithOptions@{ if (isOverMessageLimit) return@sendWithOptions @@ -394,12 +404,14 @@ fun ChatInputBar( } } - val filteredCommands = remember(textValue.text, state.botCommands) { - if (textValue.text.startsWith("/")) { - val query = textValue.text.substring(1).lowercase() - state.botCommands.filter { it.command.lowercase().startsWith(query) } - } else { - emptyList() + val filteredCommands by remember(textValue.text, state.botCommands) { + derivedStateOf { + if (textValue.text.startsWith("/")) { + val query = textValue.text.substring(1).lowercase() + state.botCommands.filter { it.command.lowercase().startsWith(query) } + } else { + emptyList() + } } } @@ -586,7 +598,7 @@ fun ChatInputBar( if (granted) showCamera = true } - val inputBarMode = remember( + val inputBarMode by remember( canSendAnything, isSlowModeActive, textValue.text, @@ -594,15 +606,17 @@ fun ChatInputBar( state.editingMessage, voiceRecorder.isRecording ) { - when { - !canSendAnything -> InputBarMode.Restricted - isSlowModeActive && - textValue.text.isBlank() && - state.pendingMediaPaths.isEmpty() && - state.editingMessage == null && - !voiceRecorder.isRecording -> InputBarMode.SlowMode - - else -> InputBarMode.Composer + derivedStateOf { + when { + !canSendAnything -> InputBarMode.Restricted + isSlowModeActive && + textValue.text.isBlank() && + state.pendingMediaPaths.isEmpty() && + state.editingMessage == null && + !voiceRecorder.isRecording -> InputBarMode.SlowMode + + else -> InputBarMode.Composer + } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt index 264e24cd..2c2bc816 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.rounded.* import androidx.compose.material3.* import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.window.core.layout.WindowSizeClass import androidx.compose.ui.Modifier @@ -70,9 +71,9 @@ fun ChatTopBar( personalAvatarPath: String? = null, isTablet: Boolean = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) ) { - var showMenu by remember { mutableStateOf(false) } - var showClearHistorySheet by remember { mutableStateOf(false) } - var showDeleteChatSheet by remember { mutableStateOf(false) } + var showMenu by rememberSaveable { mutableStateOf(false) } + var showClearHistorySheet by rememberSaveable { mutableStateOf(false) } + var showDeleteChatSheet by rememberSaveable { mutableStateOf(false) } val windowInsets = if (isTablet) WindowInsets(0, 0, 0, 0) else WindowInsets.statusBars diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt index 44d5861f..868f2b1e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollVotersSheet.kt @@ -83,7 +83,10 @@ fun PollVotersSheet( LazyColumn( modifier = Modifier.fillMaxWidth() ) { - itemsIndexed(voters) { index, user -> + itemsIndexed( + items = voters, + key = { _, user -> user.id } + ) { index, user -> VoterItem( user = user, onClick = { onUserClick(user.id) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt index cad1cc92..0a8d8ff4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -152,29 +153,29 @@ fun FullScreenEditorSheet( val context = LocalContext.current val focusRequester = remember { FocusRequester() } - var showEmojiPicker by remember { mutableStateOf(false) } - var showLinkDialog by remember { mutableStateOf(false) } - var linkValue by remember { mutableStateOf("https://") } - var showLanguageDialog by remember { mutableStateOf(false) } - var languageValue by remember { mutableStateOf("") } - var isPreviewMode by remember { mutableStateOf(false) } - var markdownMode by remember { mutableStateOf(false) } - var showFindReplace by remember { mutableStateOf(false) } - var findQuery by remember { mutableStateOf("") } - var replaceValue by remember { mutableStateOf("") } - var currentMatchIndex by remember { mutableIntStateOf(0) } - var showTemplatesSheet by remember { mutableStateOf(false) } + var showEmojiPicker by rememberSaveable { mutableStateOf(false) } + var showLinkDialog by rememberSaveable { mutableStateOf(false) } + var linkValue by rememberSaveable { mutableStateOf("https://") } + var showLanguageDialog by rememberSaveable { mutableStateOf(false) } + var languageValue by rememberSaveable { mutableStateOf("") } + var isPreviewMode by rememberSaveable { mutableStateOf(false) } + var markdownMode by rememberSaveable { mutableStateOf(false) } + var showFindReplace by rememberSaveable { mutableStateOf(false) } + var findQuery by rememberSaveable { mutableStateOf("") } + var replaceValue by rememberSaveable { mutableStateOf("") } + var currentMatchIndex by rememberSaveable { mutableIntStateOf(0) } + var showTemplatesSheet by rememberSaveable { mutableStateOf(false) } var showAutoSaved by remember { mutableStateOf(false) } var fontScale by remember { mutableFloatStateOf(1f) } - var showAiSheet by remember { mutableStateOf(false) } - var aiTranslateLanguage by remember { mutableStateOf("") } - var aiSelectedStyle by remember { mutableStateOf("") } - var aiAddEmojis by remember { mutableStateOf(false) } - var aiMode by remember { mutableStateOf(AiEditorMode.Stylize) } - var aiShowDiffMode by remember { mutableStateOf(true) } + var showAiSheet by rememberSaveable { mutableStateOf(false) } + var aiTranslateLanguage by rememberSaveable { mutableStateOf("") } + var aiSelectedStyle by rememberSaveable { mutableStateOf("") } + var aiAddEmojis by rememberSaveable { mutableStateOf(false) } + var aiMode by rememberSaveable { mutableStateOf(AiEditorMode.Stylize) } + var aiShowDiffMode by rememberSaveable { mutableStateOf(true) } var aiResultText by remember { mutableStateOf(null) } var aiResultTextValue by remember { mutableStateOf(null) } - var aiErrorMessage by remember { mutableStateOf(null) } + var aiErrorMessage by rememberSaveable { mutableStateOf(null) } var aiLoading by remember { mutableStateOf(false) } val snippetProvider: EditorSnippetProvider = koinInject() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt index 69fc16e5..25349fab 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorTemplatesSheet.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -29,7 +30,7 @@ fun FullScreenEditorTemplatesSheet( onDeleteSnippet: (EditorSnippet) -> Unit ) { if (!visible) return - var customTitle by remember { mutableStateOf("") } + var customTitle by rememberSaveable { mutableStateOf("") } ModalBottomSheet( onDismissRequest = onDismiss, @@ -91,7 +92,10 @@ fun FullScreenEditorTemplatesSheet( } } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(snippets) { snippet -> + items( + items = snippets, + key = { snippet -> "${snippet.title}|${snippet.text}" } + ) { snippet -> Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainer, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt index eb60b761..a50d92fc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -115,10 +116,10 @@ fun InputTextField( onFocus: () -> Unit = {}, modifier: Modifier = Modifier ) { - var showLinkDialog by remember { mutableStateOf(false) } - var linkValue by remember { mutableStateOf("https://") } - var showPreLanguageDialog by remember { mutableStateOf(false) } - var preLanguageValue by remember { mutableStateOf("") } + var showLinkDialog by rememberSaveable { mutableStateOf(false) } + var linkValue by rememberSaveable { mutableStateOf("https://") } + var showPreLanguageDialog by rememberSaveable { mutableStateOf(false) } + var preLanguageValue by rememberSaveable { mutableStateOf("") } val context = LocalContext.current val emojiSize = 20.sp diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt index b4e31f09..507b770e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/KeyboardMarkupView.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -56,7 +56,10 @@ fun KeyboardMarkupView( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp) ) { - items(rows) { row -> + itemsIndexed( + items = rows, + key = { index, row -> "${index}_${row.joinToString(separator = "|") { button -> button.text }}" } + ) { _, row -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt index 2e3a47a5..759027df 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/pins/PinnedMessagesListSheet.kt @@ -395,7 +395,10 @@ private fun PinnedMessagesLoadingSkeleton( contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - itemsIndexed(items) { _, item -> + itemsIndexed( + items = items, + key = { index, item -> "${index}_${item.isOutgoing}_${item.bubbleWidth}" } + ) { _, item -> val outgoing = !isChannel && item.isOutgoing val bubbleColor = if (outgoing) { MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) From 627bd9dceb6822af127fca3383f993b5d0db26b4 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:34:59 +0300 Subject: [PATCH 50/83] improve chat viewport restoration and scrolling precision - introduce `ChatScrollCommand` for unified handling of viewport restoration, jumping to messages, and scrolling to bottom - implement `ChatViewportCacheEntry` to store anchor message ID and precise pixel offsets - add staged scrolling to handle large distance jumps more efficiently - update `CacheProvider` and `CachePreferences` to persist and retrieve detailed viewport state - replace simple message ID scrolling with the new command-based system in `ChatContent` - enhance logic for restoring scroll position when opening chats or topic threads - optimize viewport snapshot capturing and saving on lifecycle events --- .../domain/models/ChatViewportCacheEntry.kt | 10 + .../domain/repository/CacheProvider.kt | 4 + .../core/util/CachePreferences.kt | 32 ++ .../chats/currentChat/ChatComponent.kt | 21 +- .../features/chats/currentChat/ChatContent.kt | 420 ++++++++++++++---- .../chats/currentChat/ChatScrollModels.kt | 32 ++ .../features/chats/currentChat/ChatStore.kt | 3 + .../chats/currentChat/ChatStoreFactory.kt | 74 ++- .../chats/currentChat/DefaultChatComponent.kt | 95 +++- .../chatContent/ChatContentList.kt | 71 ++- .../chats/currentChat/impl/MessageLoading.kt | 161 +++++-- .../chats/currentChat/impl/PinnedMessages.kt | 9 +- 12 files changed, 804 insertions(+), 128 deletions(-) create mode 100644 domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt diff --git a/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt b/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt new file mode 100644 index 00000000..573a78d0 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/ChatViewportCacheEntry.kt @@ -0,0 +1,10 @@ +package org.monogram.domain.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ChatViewportCacheEntry( + val anchorMessageId: Long? = null, + val anchorOffsetPx: Int = 0, + val atBottom: Boolean = true +) diff --git a/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt b/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt index 0cfed7fa..dc19ecc1 100644 --- a/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/CacheProvider.kt @@ -3,6 +3,7 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.FolderModel import org.monogram.domain.models.GifModel import org.monogram.domain.models.RecentEmojiModel @@ -34,6 +35,9 @@ interface CacheProvider { fun saveChatScrollPosition(chatId: Long, messageId: Long) fun getChatScrollPosition(chatId: Long): Long + fun saveChatViewport(chatId: Long, threadId: Long?, viewport: ChatViewportCacheEntry) + fun getChatViewport(chatId: Long, threadId: Long?): ChatViewportCacheEntry? + fun setSavedGifs(gifs: List) fun setInstalledStickerSets(sets: List) diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt index d6f7f69a..d2b62a29 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CachePreferences.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.Json import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.FolderModel import org.monogram.domain.models.GifModel import org.monogram.domain.models.RecentEmojiModel @@ -102,6 +103,33 @@ class CachePreferences(private val context: Context) : CacheProvider { return prefs.getLong("chat_scroll_$chatId", 0L) } + override fun saveChatViewport(chatId: Long, threadId: Long?, viewport: ChatViewportCacheEntry) { + prefs.edit() + .putString(chatViewportKey(chatId, threadId), Json.encodeToString(viewport)) + .apply() + } + + override fun getChatViewport(chatId: Long, threadId: Long?): ChatViewportCacheEntry? { + val directJson = prefs.getString(chatViewportKey(chatId, threadId), null) + if (!directJson.isNullOrBlank()) { + return try { + Json.decodeFromString(directJson) + } catch (_: Exception) { + null + } + } + + if (threadId != null) return null + if (!prefs.contains("chat_scroll_$chatId")) return null + + val legacy = prefs.getLong("chat_scroll_$chatId", 0L) + return if (legacy == 0L) { + ChatViewportCacheEntry(atBottom = true) + } else { + ChatViewportCacheEntry(anchorMessageId = legacy, anchorOffsetPx = 0, atBottom = false) + } + } + override fun setSavedGifs(gifs: List) { prefs.edit().putString(KEY_SAVED_GIFS, Json.encodeToString(gifs)).apply() _savedGifs.value = gifs @@ -138,5 +166,9 @@ class CachePreferences(private val context: Context) : CacheProvider { companion object { private const val KEY_SAVED_GIFS = "saved_gifs" + + private fun chatViewportKey(chatId: Long, threadId: Long?): String { + return "chat_viewport_${chatId}_${threadId ?: 0L}" + } } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index 2087a697..21e95bf8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -3,7 +3,22 @@ package org.monogram.presentation.features.chats.currentChat import androidx.compose.runtime.Stable import androidx.compose.ui.platform.Clipboard import kotlinx.coroutines.flow.StateFlow -import org.monogram.domain.models.* +import org.monogram.domain.models.AttachMenuBotModel +import org.monogram.domain.models.BotCommandModel +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.InlineKeyboardButtonModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.StickerSetModel +import org.monogram.domain.models.TopicModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.models.WallpaperModel import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.domain.repository.MessageRepository import org.monogram.domain.repository.StickerRepository @@ -80,11 +95,13 @@ interface ChatComponent { fun onShowAllPinnedMessages() fun onDismissPinnedMessages() fun onScrollToMessageConsumed() + fun onScrollCommandConsumed() fun onScrollToBottom() fun onDownloadFile(fileId: Int) fun onDownloadHighRes(messageId: Long) fun onCancelDownloadFile(fileId: Int) fun updateScrollPosition(messageId: Long) + fun updateViewport(viewport: ChatViewportCacheEntry) fun onBottomReached(isAtBottom: Boolean) fun onHighlightConsumed() fun onTyping() @@ -222,10 +239,12 @@ interface ChatComponent { val pinnedMessageCount: Int = 0, val pinnedMessageIndex: Int = 0, val scrollToMessageId: Long? = null, + val pendingScrollCommand: ChatScrollCommand? = null, val highlightedMessageId: Long? = null, val isAtBottom: Boolean = true, val currentScrollMessageId: Long = 0L, val lastScrollPosition: Long = 0L, + val lastSavedViewport: ChatViewportCacheEntry? = null, val isLatestLoaded: Boolean = true, val isOldestLoaded: Boolean = false, val fontSize: Float = 16f, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 838a70c7..4615e73e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -4,26 +4,66 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.interaction.collectIsDraggedAsState -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.rounded.Block -import androidx.compose.material3.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -47,16 +87,39 @@ import androidx.compose.ui.unit.toSize import androidx.compose.ui.zIndex import androidx.window.core.layout.WindowWidthSizeClass import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.ReplyMarkupModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ExpressiveDefaults -import org.monogram.presentation.features.chats.currentChat.chatContent.* -import org.monogram.presentation.features.chats.currentChat.components.* +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentBackground +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentList +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentTopBar +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentTopBarUiState +import org.monogram.presentation.features.chats.currentChat.chatContent.ChatMessageOptionsMenu +import org.monogram.presentation.features.chats.currentChat.chatContent.GroupedMessageItem +import org.monogram.presentation.features.chats.currentChat.chatContent.ReportChatDialog +import org.monogram.presentation.features.chats.currentChat.chatContent.RestrictUserSheet +import org.monogram.presentation.features.chats.currentChat.chatContent.chatContentLeadingItemsCount +import org.monogram.presentation.features.chats.currentChat.chatContent.groupMessagesByAlbum +import org.monogram.presentation.features.chats.currentChat.chatContent.groupedIndexToLazyIndex +import org.monogram.presentation.features.chats.currentChat.chatContent.lazyIndexToGroupedIndex +import org.monogram.presentation.features.chats.currentChat.components.AdvancedCircularRecorderScreen +import org.monogram.presentation.features.chats.currentChat.components.ChatInputBar +import org.monogram.presentation.features.chats.currentChat.components.ChatInputBarActions +import org.monogram.presentation.features.chats.currentChat.components.ChatInputBarState +import org.monogram.presentation.features.chats.currentChat.components.MessageListShimmer +import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet import org.monogram.presentation.features.chats.currentChat.components.chats.BotCommandsSheet import org.monogram.presentation.features.chats.currentChat.components.chats.LocalLinkHandler import org.monogram.presentation.features.chats.currentChat.components.chats.PollVotersSheet @@ -164,13 +227,21 @@ fun ChatContent( val index = groupedMessageIndexById[msg.id] ?: -1 if (index != -1) { coroutineScope.launch { - val targetIndex = if (isComments) { - if (state.rootMessage != null) index + 1 else index - } else index + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = false, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val targetIndex = groupedIndexToLazyIndex(index, leadingItems) - scrollState.scrollMessageToCenter( + scrollState.scrollToMessageIndex( index = targetIndex, - animated = state.isChatAnimationsEnabled + align = ScrollAlign.Center, + animated = state.isChatAnimationsEnabled, + staged = true ) } } else { @@ -220,21 +291,74 @@ fun ChatContent( } } - // Scroll to message when requested by component - LaunchedEffect(state.scrollToMessageId, groupedMessages) { - state.scrollToMessageId?.let { id -> - val index = if (id == state.currentTopicId) -1 else groupedMessageIndexById[id] ?: -1 - if (index != -1) { - component.onScrollToMessageConsumed() + // Unified command-based scrolling: restore, jump, bottom. + LaunchedEffect(state.pendingScrollCommand, isComments) { + val command = state.pendingScrollCommand ?: return@LaunchedEffect - val targetIndex = if (isComments) { - if (state.rootMessage != null) index + 1 else index - } else index + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = false, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) - scrollState.scrollMessageToCenter( - index = targetIndex, - animated = state.isChatAnimationsEnabled + when (command) { + is ChatScrollCommand.RestoreViewport -> { + if (command.atBottom || command.anchorMessageId == null) { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = false + ) + } else { + val groupedIndex = groupedMessageIndexById[command.anchorMessageId] + ?: awaitGroupedIndex( + messageId = command.anchorMessageId, + groupedMessageIndexByIdProvider = { groupedMessageIndexById } + ) + ?: -1 + if (groupedIndex >= 0) { + val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + scrollState.restoreViewportAtIndex( + targetIndex = targetIndex, + anchorOffsetPx = command.anchorOffsetPx + ) + } else { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = false + ) + } + } + component.onScrollCommandConsumed() + } + + is ChatScrollCommand.JumpToMessage -> { + val groupedIndex = groupedMessageIndexById[command.messageId] + ?: awaitGroupedIndex( + messageId = command.messageId, + groupedMessageIndexByIdProvider = { groupedMessageIndexById } + ) + ?: -1 + if (groupedIndex >= 0) { + val targetIndex = groupedIndexToLazyIndex(groupedIndex, leadingItems) + scrollState.scrollToMessageIndex( + index = targetIndex, + align = command.align, + animated = command.animated && state.isChatAnimationsEnabled, + staged = true + ) + } + component.onScrollCommandConsumed() + } + + is ChatScrollCommand.ScrollToBottom -> { + scrollState.scrollToChatBottomStaged( + isComments = isComments, + animated = command.animated && state.isChatAnimationsEnabled ) + component.onScrollCommandConsumed() } } } @@ -282,41 +406,61 @@ fun ChatContent( } } - // Save scroll position - LaunchedEffect(scrollState, groupedMessages, isComments, state.rootMessage, state.isLatestLoaded) { - snapshotFlow { scrollState.isScrollInProgress to scrollState.firstVisibleItemIndex } - .filter { !it.first } - .map { - val isAtBottom = scrollState.isAtBottom( - isComments = isComments, - isLatestLoaded = state.isLatestLoaded - ) - - if (isAtBottom && !isComments) { - 0L - } else { - val visibleItems = scrollState.layoutInfo.visibleItemsInfo - if (visibleItems.isNotEmpty()) { - val firstVisibleItem = if (isComments) { - visibleItems.firstOrNull { it.index > 0 } - } else { - visibleItems.firstOrNull { it.index >= 0 } - } - - if (firstVisibleItem != null) { - val groupedIndex = - if (state.rootMessage != null) firstVisibleItem.index - 1 else firstVisibleItem.index - groupedMessages.getOrNull(groupedIndex)?.firstMessageId - } else null - } else null - } - } + // Save full viewport (anchor + pixel offset) for precise restore after reopen. + LaunchedEffect( + scrollState, + groupedMessages, + isComments, + state.isLatestLoaded, + state.isLoadingOlder, + state.isLoadingNewer, + state.isAtBottom + ) { + snapshotFlow { + buildViewportSnapshot( + scrollState = scrollState, + groupedMessages = groupedMessages, + isComments = isComments, + isLatestLoaded = state.isLatestLoaded, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + showNavPadding = false + ) + } + .filterNotNull() .distinctUntilChanged() - .collect { messageId -> - if (messageId != null) { - component.updateScrollPosition(messageId) - } + .debounce(120) + .collect { viewport -> + component.updateViewport(viewport) + } + } + + DisposableEffect( + scrollState, + groupedMessages, + isComments, + state.currentTopicId, + state.isLatestLoaded, + state.isLoadingOlder, + state.isLoadingNewer, + state.isAtBottom + ) { + onDispose { + val viewport = buildViewportSnapshot( + scrollState = scrollState, + groupedMessages = groupedMessages, + isComments = isComments, + isLatestLoaded = state.isLatestLoaded, + isLoadingOlder = state.isLoadingOlder, + isLoadingNewer = state.isLoadingNewer, + isAtBottom = state.isAtBottom, + showNavPadding = false + ) + if (viewport != null) { + component.updateViewport(viewport) } + } } // Performance: Update visible range for repository @@ -383,7 +527,7 @@ fun ChatContent( !state.isLoadingNewer && !scrollState.isScrollInProgress ) { - scrollState.scrollToChatBottom( + scrollState.scrollToChatBottomStaged( isComments = isComments, animated = state.isChatAnimationsEnabled ) @@ -1029,16 +1173,7 @@ fun ChatContent( Box { FloatingActionButton( onClick = { - if (!state.isLatestLoaded) { - component.onScrollToBottom() - } else { - coroutineScope.launch { - scrollState.scrollToChatBottom( - isComments = isComments, - animated = state.isChatAnimationsEnabled - ) - } - } + component.onScrollToBottom() }, containerColor = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape, @@ -1356,16 +1491,40 @@ private fun MessageModel.withUpdatedTextContent(newText: String): MessageModel { return copy(content = updatedContent) } -private suspend fun LazyListState.scrollMessageToCenter( +private suspend fun LazyListState.scrollToMessageIndex( index: Int, - animated: Boolean + align: ScrollAlign, + animated: Boolean, + staged: Boolean ) { - if (animated) animateScrollToItem(index) else scrollToItem(index) + val total = layoutInfo.totalItemsCount + if (total <= 0) return + + val boundedIndex = index.coerceIn(0, total - 1) + val distance = abs(firstVisibleItemIndex - boundedIndex) + + if (staged && distance > 20) { + val coarseIndex = when { + boundedIndex > firstVisibleItemIndex -> (boundedIndex - 10).coerceAtLeast(0) + boundedIndex < firstVisibleItemIndex -> (boundedIndex + 10).coerceAtMost(total - 1) + else -> boundedIndex + } + scrollToItem(coarseIndex) + } + + scrollToItem(boundedIndex) - val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return - val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2 - val itemCenter = itemInfo.offset + (itemInfo.size / 2) - val delta = (itemCenter - viewportCenter).toFloat() + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + val viewportCenter = (viewportStart + viewportEnd) / 2 + + val targetPosition = when (align) { + ScrollAlign.Start -> viewportStart + ScrollAlign.Center -> viewportCenter - (itemInfo.size / 2) + ScrollAlign.End -> viewportEnd - itemInfo.size + } + val delta = (itemInfo.offset - targetPosition).toFloat() if (abs(delta) > 1f) { if (animated) { @@ -1419,15 +1578,23 @@ private fun LazyListState.isNearBottom(isComments: Boolean): Boolean { } } -private suspend fun LazyListState.scrollToChatBottom( +private suspend fun LazyListState.scrollToChatBottomStaged( isComments: Boolean, animated: Boolean ) { - val targetIndex = if (isComments) { - val total = layoutInfo.totalItemsCount - if (total > 0) total - 1 else 0 - } else { - 0 + val total = layoutInfo.totalItemsCount + if (total <= 0) return + + val targetIndex = if (isComments) total - 1 else 0 + val distance = abs(firstVisibleItemIndex - targetIndex) + + if (distance > 24) { + val coarse = if (isComments) { + (targetIndex - 8).coerceAtLeast(0) + } else { + (targetIndex + 8).coerceAtMost(total - 1) + } + scrollToItem(coarse) } if (animated) { @@ -1435,4 +1602,95 @@ private suspend fun LazyListState.scrollToChatBottom( } else { scrollToItem(targetIndex) } + + val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == targetIndex } + if (targetInfo != null) { + val delta = if (isComments) { + ((targetInfo.offset + targetInfo.size) - layoutInfo.viewportEndOffset).toFloat() + } else { + (targetInfo.offset - layoutInfo.viewportStartOffset).toFloat() + } + if (abs(delta) > 1f) { + scrollBy(delta) + } + } + + scrollToItem(targetIndex) +} + +private suspend fun awaitGroupedIndex( + messageId: Long, + groupedMessageIndexByIdProvider: () -> Map, + timeoutMs: Long = 1200L +): Int? { + return withTimeoutOrNull(timeoutMs) { + snapshotFlow { groupedMessageIndexByIdProvider()[messageId] } + .filterNotNull() + .first() + } +} + +private suspend fun LazyListState.restoreViewportAtIndex( + targetIndex: Int, + anchorOffsetPx: Int +) { + val total = layoutInfo.totalItemsCount + if (total <= 0) return + val boundedIndex = targetIndex.coerceIn(0, total - 1) + + scrollToItem(boundedIndex) + val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == boundedIndex } ?: return + val viewportStart = layoutInfo.viewportStartOffset + val desiredOffset = viewportStart + anchorOffsetPx + val delta = (itemInfo.offset - desiredOffset).toFloat() + + if (abs(delta) > 1f) { + scrollBy(delta) + } +} + +private fun buildViewportSnapshot( + scrollState: LazyListState, + groupedMessages: List, + isComments: Boolean, + isLatestLoaded: Boolean, + isLoadingOlder: Boolean, + isLoadingNewer: Boolean, + isAtBottom: Boolean, + showNavPadding: Boolean +): ChatViewportCacheEntry? { + if (groupedMessages.isEmpty()) { + return ChatViewportCacheEntry(atBottom = true) + } + + val atBottomNow = scrollState.isAtBottom( + isComments = isComments, + isLatestLoaded = isLatestLoaded + ) + if (atBottomNow) { + return ChatViewportCacheEntry(atBottom = true) + } + + val leadingItems = chatContentLeadingItemsCount( + isComments = isComments, + showNavPadding = showNavPadding, + isLoadingOlder = isLoadingOlder, + isLoadingNewer = isLoadingNewer, + isAtBottom = isAtBottom, + hasMessages = groupedMessages.isNotEmpty() + ) + val info = scrollState.layoutInfo + val anchorItem = info.visibleItemsInfo.firstOrNull { itemInfo -> + val groupedIndex = lazyIndexToGroupedIndex(itemInfo.index, leadingItems) + groupedIndex in groupedMessages.indices + } ?: return null + + val groupedIndex = lazyIndexToGroupedIndex(anchorItem.index, leadingItems) + val anchorMessageId = groupedMessages.getOrNull(groupedIndex)?.firstMessageId ?: return null + + return ChatViewportCacheEntry( + anchorMessageId = anchorMessageId, + anchorOffsetPx = anchorItem.offset - info.viewportStartOffset, + atBottom = false + ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt new file mode 100644 index 00000000..ef5c099f --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatScrollModels.kt @@ -0,0 +1,32 @@ +package org.monogram.presentation.features.chats.currentChat + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface ChatScrollCommand { + @Immutable + data class RestoreViewport( + val anchorMessageId: Long?, + val anchorOffsetPx: Int, + val atBottom: Boolean + ) : ChatScrollCommand + + @Immutable + data class JumpToMessage( + val messageId: Long, + val highlight: Boolean, + val align: ScrollAlign = ScrollAlign.Center, + val animated: Boolean = true + ) : ChatScrollCommand + + @Immutable + data class ScrollToBottom( + val animated: Boolean = true + ) : ChatScrollCommand +} + +enum class ScrollAlign { + Start, + Center, + End +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt index a97d076c..d4ad132a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt @@ -3,6 +3,7 @@ package org.monogram.presentation.features.chats.currentChat import androidx.compose.ui.platform.Clipboard import com.arkivanov.mvikotlin.core.store.Store import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatViewportCacheEntry import org.monogram.domain.models.GifModel import org.monogram.domain.models.InlineKeyboardButtonModel import org.monogram.domain.models.KeyboardButtonModel @@ -76,11 +77,13 @@ interface ChatStore : Store component._state.update { it.copy(scrollToMessageId = null) } + is Intent.ScrollToMessageConsumed, + is Intent.ScrollCommandConsumed -> component._state.update { + if (it.pendingScrollCommand == null && it.scrollToMessageId == null) it + else it.copy(scrollToMessageId = null, pendingScrollCommand = null) + } is Intent.ScrollToBottom -> component.scrollToBottomInternal() is Intent.DownloadFile -> component.handleDownloadFile(intent.fileId) is Intent.DownloadHighRes -> component.handleDownloadHighRes(intent.messageId) @@ -132,6 +199,9 @@ class ChatStoreFactory( is Intent.UpdateScrollPosition -> component._state.update { if (it.currentScrollMessageId == intent.messageId) it else it.copy(currentScrollMessageId = intent.messageId) } + is Intent.UpdateViewport -> component._state.update { + if (it.lastSavedViewport == intent.viewport) it else it.copy(lastSavedViewport = intent.viewport) + } is Intent.BottomReached -> component._state.update { if (it.isAtBottom == intent.isAtBottom) it else it.copy(isAtBottom = intent.isAtBottom) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index fcc0f1fe..de33f752 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -8,17 +8,63 @@ import com.arkivanov.essenty.lifecycle.doOnStop import com.arkivanov.mvikotlin.extensions.coroutines.labels import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext import org.monogram.core.DispatcherProvider import org.monogram.domain.managers.DistrManager -import org.monogram.domain.models.* -import org.monogram.domain.repository.* +import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatPermissionsModel +import org.monogram.domain.models.ChatViewportCacheEntry +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.InlineKeyboardButtonModel +import org.monogram.domain.models.KeyboardButtonModel +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.UserModel +import org.monogram.domain.models.WallpaperModel +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +import org.monogram.domain.repository.ChatMembersFilter +import org.monogram.domain.repository.ChatOperationsRepository +import org.monogram.domain.repository.ForumTopicsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.InlineBotRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WallpaperRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.componentScope -import org.monogram.presentation.features.chats.currentChat.impl.* +import org.monogram.presentation.features.chats.currentChat.impl.loadChatInfo +import org.monogram.presentation.features.chats.currentChat.impl.loadDraft +import org.monogram.presentation.features.chats.currentChat.impl.loadMessages +import org.monogram.presentation.features.chats.currentChat.impl.loadPinnedMessage +import org.monogram.presentation.features.chats.currentChat.impl.loadScheduledMessages +import org.monogram.presentation.features.chats.currentChat.impl.loadWallpapers +import org.monogram.presentation.features.chats.currentChat.impl.observePreferences +import org.monogram.presentation.features.chats.currentChat.impl.observeUserUpdates +import org.monogram.presentation.features.chats.currentChat.impl.setupMessageCollectors +import org.monogram.presentation.features.chats.currentChat.impl.setupPinnedMessageCollector import org.monogram.presentation.root.AppComponentContext import org.monogram.presentation.settings.storage.CacheController import java.io.File @@ -103,6 +149,7 @@ class DefaultChatComponent( scrollToMessageId = initialMessageId, highlightedMessageId = initialMessageId, lastScrollPosition = cacheProvider.getChatScrollPosition(chatId), + lastSavedViewport = cacheProvider.getChatViewport(chatId, null), isInstalledFromGooglePlay = distrManager.isInstalledFromGooglePlay() ) ) @@ -130,6 +177,9 @@ class DefaultChatComponent( lifecycle.doOnStop { autoLoadJob?.cancel() + _state.value.lastSavedViewport?.let { viewport -> + cacheProvider.saveChatViewport(chatId, _state.value.currentTopicId, viewport) + } } lifecycle.doOnResume { @@ -352,6 +402,8 @@ class DefaultChatComponent( override fun onScrollToMessageConsumed() = store.accept(ChatStore.Intent.ScrollToMessageConsumed) + override fun onScrollCommandConsumed() = store.accept(ChatStore.Intent.ScrollCommandConsumed) + override fun onScrollToBottom() = store.accept(ChatStore.Intent.ScrollToBottom) override fun onDownloadFile(fileId: Int) { @@ -367,14 +419,37 @@ class DefaultChatComponent( } override fun updateScrollPosition(messageId: Long) { - if (_state.value.currentTopicId == null) { - cacheProvider.saveChatScrollPosition(chatId, messageId) + updateViewport( + ChatViewportCacheEntry( + anchorMessageId = messageId, + anchorOffsetPx = 0, + atBottom = messageId == 0L + ) + ) + } + + override fun updateViewport(viewport: ChatViewportCacheEntry) { + val threadId = _state.value.currentTopicId + cacheProvider.saveChatViewport(chatId, threadId, viewport) + if (threadId == null) { + cacheProvider.saveChatScrollPosition(chatId, viewport.anchorMessageId ?: 0L) } _state.update { - if (it.lastScrollPosition == messageId) it else it.copy(lastScrollPosition = messageId) + if (it.lastSavedViewport == viewport && it.lastScrollPosition == (viewport.anchorMessageId + ?: 0L) + ) { + it + } else { + it.copy( + lastSavedViewport = viewport, + lastScrollPosition = viewport.anchorMessageId ?: 0L + ) + } } - if (_state.value.currentScrollMessageId != messageId) { - store.accept(ChatStore.Intent.UpdateScrollPosition(messageId)) + store.accept(ChatStore.Intent.UpdateViewport(viewport)) + val anchor = viewport.anchorMessageId ?: 0L + if (_state.value.currentScrollMessageId != anchor) { + store.accept(ChatStore.Intent.UpdateScrollPosition(anchor)) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt index 3e5feecc..cfffc43f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentList.kt @@ -13,7 +13,21 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +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.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -23,8 +37,22 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.PushPin -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -51,7 +79,10 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.ChatComponent -import org.monogram.presentation.features.chats.currentChat.components.* +import org.monogram.presentation.features.chats.currentChat.components.AlbumMessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.DateSeparator +import org.monogram.presentation.features.chats.currentChat.components.MessageBubbleContainer +import org.monogram.presentation.features.chats.currentChat.components.ServiceMessage import org.monogram.presentation.features.chats.currentChat.components.channels.ChannelMessageBubbleContainer import org.monogram.presentation.features.stickers.ui.view.StickerImage import java.io.File @@ -902,6 +933,38 @@ private fun isItemSelected(item: GroupedMessageItem, selectedIds: Set): Bo } } +internal fun chatContentLeadingItemsCount( + isComments: Boolean, + showNavPadding: Boolean, + isLoadingOlder: Boolean, + isLoadingNewer: Boolean, + isAtBottom: Boolean, + hasMessages: Boolean +): Int { + return if (isComments) { + val loadingOlderTop = if (isLoadingOlder && hasMessages) 1 else 0 + loadingOlderTop + 1 // root header + } else { + val navPadding = if (showNavPadding) 1 else 0 + val loadingNewerBottom = if (isLoadingNewer && !isAtBottom && hasMessages) 1 else 0 + navPadding + loadingNewerBottom + } +} + +internal fun groupedIndexToLazyIndex( + groupedIndex: Int, + leadingItemsCount: Int +): Int { + return groupedIndex + leadingItemsCount +} + +internal fun lazyIndexToGroupedIndex( + lazyIndex: Int, + leadingItemsCount: Int +): Int { + return lazyIndex - leadingItemsCount +} + private fun handlePhotoClick( msg: MessageModel, onPhotoClick: (MessageModel, List, List, List, Int) -> Unit diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt index 8bdc5c11..9229ae13 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt @@ -18,7 +18,9 @@ import org.monogram.domain.models.MessageSendingState import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ReadUpdate import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression +import org.monogram.presentation.features.chats.currentChat.ChatScrollCommand import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.currentChat.ScrollAlign import java.io.File @@ -230,7 +232,8 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { it.copy( isLoading = true, isOldestLoaded = false, - isLatestLoaded = false + isLatestLoaded = false, + pendingScrollCommand = null ) } @@ -238,26 +241,75 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { val currentState = _state.value val threadId = currentState.currentTopicId val isComments = currentState.rootMessage != null - val savedScrollPosition = if (threadId == null) cacheProvider.getChatScrollPosition(chatId) else 0L + val savedViewport = cacheProvider.getChatViewport(chatId, threadId) + _state.update { it.copy(lastSavedViewport = savedViewport) } + + val chat = chatListRepository.getChatById(chatId) + val firstUnreadId = chat?.lastReadInboxMessageId?.let { lastRead -> + if (chat.unreadCount > 0) { + repositoryMessage.getMessagesNewer(chatId, lastRead, 1, threadId) + .firstOrNull()?.id + ?: lastRead.takeIf { it > 0L } + } else { + null + } + } if (isComments && threadId != null) { - loadComments(threadId) - } else if (savedScrollPosition != 0L) { - loadAroundMessage(savedScrollPosition, threadId, shouldHighlight = false) - } else { - val chat = chatListRepository.getChatById(chatId) - val firstUnreadId = chat?.lastReadInboxMessageId?.let { lastRead -> - if (chat.unreadCount > 0) { - repositoryMessage.getMessagesNewer(chatId, lastRead, 1, threadId).firstOrNull()?.id - ?: lastRead.takeIf { it > 0L } - } else null + val commentsAnchorId = savedViewport?.anchorMessageId + if (commentsAnchorId != null && !savedViewport.atBottom) { + loadAroundMessage( + messageId = commentsAnchorId, + threadId = threadId, + shouldHighlight = false, + scrollCommand = ChatScrollCommand.RestoreViewport( + anchorMessageId = commentsAnchorId, + anchorOffsetPx = savedViewport.anchorOffsetPx, + atBottom = false + ) + ) + } else { + loadComments( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } - - if (firstUnreadId != null) { - loadAroundMessage(firstUnreadId, threadId, shouldHighlight = false) + } else if (firstUnreadId != null) { + loadAroundMessage( + messageId = firstUnreadId, + threadId = threadId, + shouldHighlight = false, + scrollCommand = ChatScrollCommand.JumpToMessage( + messageId = firstUnreadId, + highlight = false, + align = ScrollAlign.Center, + animated = false + ) + ) + } else if (savedViewport != null) { + if (savedViewport.atBottom || savedViewport.anchorMessageId == null) { + loadBottomMessages( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } else { - loadBottomMessages(threadId) + val savedAnchorId = savedViewport.anchorMessageId ?: return@launch + loadAroundMessage( + messageId = savedAnchorId, + threadId = threadId, + shouldHighlight = false, + scrollCommand = ChatScrollCommand.RestoreViewport( + anchorMessageId = savedAnchorId, + anchorOffsetPx = savedViewport.anchorOffsetPx, + atBottom = false + ) + ) } + } else { + loadBottomMessages( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } } catch (e: CancellationException) { throw e @@ -269,7 +321,10 @@ internal fun DefaultChatComponent.loadMessages(force: Boolean = false) { } } -internal suspend fun DefaultChatComponent.loadComments(threadId: Long) { +internal suspend fun DefaultChatComponent.loadComments( + threadId: Long, + scrollCommand: ChatScrollCommand? = ChatScrollCommand.ScrollToBottom(animated = false) +) { lastLoadedOlderId = 0L lastLoadedNewerId = 0L val messages = repositoryMessage.getMessagesNewer(chatId, threadId, PAGE_SIZE, threadId) @@ -280,14 +335,20 @@ internal suspend fun DefaultChatComponent.loadComments(threadId: Long) { isAtBottom = false, isLatestLoaded = reachedEnd, isOldestLoaded = true, - scrollToMessageId = messages.firstOrNull()?.id + scrollToMessageId = null ) } updateMessages(messages, replace = true) refreshCachedSenderProfiles(messages) + if (scrollCommand != null) { + _state.update { it.copy(pendingScrollCommand = scrollCommand) } + } } -private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { +private suspend fun DefaultChatComponent.loadBottomMessages( + threadId: Long?, + scrollCommand: ChatScrollCommand? = null +) { lastLoadedOlderId = 0L lastLoadedNewerId = 0L @@ -330,6 +391,9 @@ private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { val shouldReplaceCachedPreview = !hasCachedPreview || messages.isNotEmpty() updateMessages(messages, replace = shouldReplaceCachedPreview) refreshCachedSenderProfiles(messages) + if (scrollCommand != null) { + _state.update { it.copy(pendingScrollCommand = scrollCommand) } + } if (!isOldestLoaded) { delay(100) loadMoreMessages() @@ -339,7 +403,13 @@ private suspend fun DefaultChatComponent.loadBottomMessages(threadId: Long?) { private suspend fun DefaultChatComponent.loadAroundMessage( messageId: Long, threadId: Long?, - shouldHighlight: Boolean = true + shouldHighlight: Boolean = true, + scrollCommand: ChatScrollCommand? = ChatScrollCommand.JumpToMessage( + messageId = messageId, + highlight = shouldHighlight, + align = ScrollAlign.Center, + animated = true + ) ) { lastLoadedOlderId = 0L lastLoadedNewerId = 0L @@ -350,17 +420,23 @@ private suspend fun DefaultChatComponent.loadAroundMessage( isAtBottom = false, isLatestLoaded = false, isOldestLoaded = false, - scrollToMessageId = messageId, + scrollToMessageId = null, highlightedMessageId = if (shouldHighlight) messageId else null ) } updateMessages(messages, replace = true) refreshCachedSenderProfiles(messages) + if (scrollCommand != null) { + _state.update { it.copy(pendingScrollCommand = scrollCommand) } + } delay(100) loadMoreMessages() loadNewerMessages() } else { - loadBottomMessages(threadId) + loadBottomMessages( + threadId = threadId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = false) + ) } } @@ -524,11 +600,22 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { it.copy( isLoading = true, isOldestLoaded = false, - isLatestLoaded = false + isLatestLoaded = false, + pendingScrollCommand = null ) } try { - loadAroundMessage(messageId, _state.value.currentTopicId, shouldHighlight = true) + loadAroundMessage( + messageId = messageId, + threadId = _state.value.currentTopicId, + shouldHighlight = true, + scrollCommand = ChatScrollCommand.JumpToMessage( + messageId = messageId, + highlight = true, + align = ScrollAlign.Center, + animated = true + ) + ) } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to scroll to message", e) } finally { @@ -538,18 +625,32 @@ internal fun DefaultChatComponent.scrollToMessageInternal(messageId: Long) { } internal fun DefaultChatComponent.scrollToBottomInternal() { - if (_state.value.isLoading) return + val currentState = _state.value + if (currentState.messages.isNotEmpty() && currentState.isLatestLoaded) { + _state.update { + it.copy( + isAtBottom = true, + pendingScrollCommand = ChatScrollCommand.ScrollToBottom(animated = true) + ) + } + return + } + if (currentState.isLoading) return cancelAllLoadingJobs() messageLoadingJob = scope.launch { _state.update { it.copy( isLoading = true, isOldestLoaded = false, - isLatestLoaded = false + isLatestLoaded = false, + pendingScrollCommand = null ) } try { - loadBottomMessages(_state.value.currentTopicId) + loadBottomMessages( + threadId = _state.value.currentTopicId, + scrollCommand = ChatScrollCommand.ScrollToBottom(animated = true) + ) } catch (e: Exception) { Log.e("DefaultChatComponent", "Failed to scroll to bottom", e) } finally { @@ -1224,7 +1325,8 @@ internal fun DefaultChatComponent.handleTopicClick(topicId: Int) { isOldestLoaded = false, isLatestLoaded = false, rootMessage = null, - isAtBottom = id == null + isAtBottom = id == null, + pendingScrollCommand = null ) } loadMessages(force = true) @@ -1240,7 +1342,8 @@ internal fun DefaultChatComponent.handleCommentsClick(messageId: Long) { messages = emptyList(), isOldestLoaded = false, isLatestLoaded = false, - isAtBottom = false + isAtBottom = false, + pendingScrollCommand = null ) } loadComments(messageId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt index 223b501d..f2395079 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt @@ -6,7 +6,9 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.monogram.domain.models.MessageModel +import org.monogram.presentation.features.chats.currentChat.ChatScrollCommand import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent +import org.monogram.presentation.features.chats.currentChat.ScrollAlign internal fun DefaultChatComponent.loadPinnedMessage() { @@ -119,7 +121,12 @@ private fun DefaultChatComponent.jumpToMessage(message: MessageModel) { lastLoadedNewerId = 0L _state.update { it.copy( - scrollToMessageId = message.id, + pendingScrollCommand = ChatScrollCommand.JumpToMessage( + messageId = message.id, + highlight = true, + align = ScrollAlign.Center, + animated = true + ), highlightedMessageId = message.id, isAtBottom = false, isLatestLoaded = false, From d0fc90c4396be385c430c8960061ff3867538ec0 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:15:14 +0300 Subject: [PATCH 51/83] Start foreground notification before FCM stop Ensure startForegroundNotification() is called immediately when the service starts to satisfy startForegroundService() timing requirements on Android 8+. The FCM provider check was moved after starting the foreground notification (and wakelock acquisition was reordered) so the service can properly enter foreground before being stopped for FCM. Also add handling to stop the foreground service and return when FCM is selected if the service is already running. --- .../data/service/TdNotificationService.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt index 1fd15fd8..ada8254f 100644 --- a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt +++ b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt @@ -42,17 +42,23 @@ class TdNotificationService : Service() { return START_NOT_STICKY } - if (appPreferences.pushProvider.value == PushProvider.FCM) { - stopForegroundService() - return START_NOT_STICKY - } - if (!isServiceRunning) { isServiceRunning = true - acquireWakeLock() + // Call startForeground as soon as possible to satisfy + // startForegroundService() timing requirements on Android 8+. startForegroundNotification() + + if (appPreferences.pushProvider.value == PushProvider.FCM) { + stopForegroundService() + return START_NOT_STICKY + } + + acquireWakeLock() startListeningUpdates() startPeriodicCheck() + } else if (appPreferences.pushProvider.value == PushProvider.FCM) { + stopForegroundService() + return START_NOT_STICKY } return START_STICKY } From b5fbb54f29d16b186323a5e6b0bba1d5e440161a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:36:21 +0300 Subject: [PATCH 52/83] improve tablet layout, localization, and message rendering - adjust `ChatTopBar`, `ChatListTopBar`, and `ChatInputBar` to handle specific padding and inset requirements for tablet devices - introduce a header overlay in `ChatContent` for improved visual consistency on tablets - replace static member count strings with `plurals` resources across multiple languages (en, pt-br, hy, ru, sk, es, uk, zh-cn) - optimize `MessageLoading` logic to better merge previous message states and reactions during updates - fix `AlbumMessageBubbleContainer` to correctly toggle sender avatar and name visibility based on message grouping (same-sender logic) - remove redundant `activeChatId` parameter from `ChatListItem` and simplify its background color selection logic --- .../chats/chatList/ChatListContent.kt | 106 ++++++++++++++---- .../chats/chatList/components/ChatListItem.kt | 41 +++++-- .../chatList/components/ChatListTopBar.kt | 50 +++++++-- .../features/chats/currentChat/ChatContent.kt | 18 +++ .../chatContent/ChatContentTopBar.kt | 10 +- .../components/AlbumMessageBubbleContainer.kt | 20 ++-- .../currentChat/components/ChatTopBar.kt | 59 ++++++++-- .../components/MessageBubbleContainer.kt | 37 +++++- .../chats/currentChat/impl/MessageLoading.kt | 7 +- .../features/profile/ProfileContent.kt | 93 ++++++++++----- .../src/main/res/values-es/string.xml | 5 +- .../src/main/res/values-hy/string.xml | 5 +- .../src/main/res/values-pt-rBR/string.xml | 5 +- .../src/main/res/values-ru-rRU/string.xml | 7 +- .../src/main/res/values-sk/string.xml | 6 +- .../src/main/res/values-uk/string.xml | 7 +- .../src/main/res/values-zh-rCN/string.xml | 4 +- presentation/src/main/res/values/string.xml | 5 +- 18 files changed, 390 insertions(+), 95 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index 2e094599..c2bae6cd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -2,18 +2,48 @@ package org.monogram.presentation.features.chats.chatList import android.util.Log import androidx.activity.compose.BackHandler -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.snap +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -25,9 +55,34 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +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.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -64,7 +119,16 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.chatList.components.* +import org.monogram.presentation.features.chats.chatList.components.AccountMenu +import org.monogram.presentation.features.chats.chatList.components.ArchiveHeaderCard +import org.monogram.presentation.features.chats.chatList.components.ChatListItem +import org.monogram.presentation.features.chats.chatList.components.ChatListShimmer +import org.monogram.presentation.features.chats.chatList.components.ChatListTopBar +import org.monogram.presentation.features.chats.chatList.components.EmptyStateView +import org.monogram.presentation.features.chats.chatList.components.FolderTabs +import org.monogram.presentation.features.chats.chatList.components.MessageSearchItem +import org.monogram.presentation.features.chats.chatList.components.PermissionRequestSheet +import org.monogram.presentation.features.chats.chatList.components.SelectionTopBar import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily import org.monogram.presentation.features.instantview.InstantViewer import org.monogram.presentation.features.stickers.ui.menu.EmojisGrid @@ -557,7 +621,8 @@ fun ChatListContent(component: ChatListComponent) { .fillMaxWidth() .height(with(density) { if (isArchivePersistent) { - (archiveItemHeightPx + headerOffsetPx).coerceAtLeast(0f).toDp() + (archiveItemHeightPx + headerOffsetPx).coerceAtLeast(0f) + .toDp() } else if (isMainFolder) { archiveRevealPx.toDp() } else { @@ -788,7 +853,11 @@ fun ChatListContent(component: ChatListComponent) { .width(64.dp) .combinedClickable( onClick = { onChatClicked(chat.id) }, - onLongClick = { component.onRemoveSearchHistoryItem(chat.id) } + onLongClick = { + component.onRemoveSearchHistoryItem( + chat.id + ) + } ), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -806,7 +875,11 @@ fun ChatListContent(component: ChatListComponent) { .offset(x = 4.dp, y = (-4).dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable { component.onRemoveSearchHistoryItem(chat.id) }, + .clickable { + component.onRemoveSearchHistoryItem( + chat.id + ) + }, contentAlignment = Alignment.Center ) { Icon( @@ -846,8 +919,7 @@ fun ChatListContent(component: ChatListComponent) { isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos, - activeChatId = state.activeChatId + showPhotos = showPhotos ) } } @@ -873,8 +945,7 @@ fun ChatListContent(component: ChatListComponent) { isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos, - activeChatId = state.activeChatId + showPhotos = showPhotos ) } } @@ -903,8 +974,7 @@ fun ChatListContent(component: ChatListComponent) { isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos, - activeChatId = state.activeChatId + showPhotos = showPhotos ) } @@ -990,8 +1060,7 @@ fun ChatListContent(component: ChatListComponent) { isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos, - activeChatId = state.activeChatId + showPhotos = showPhotos ) } } @@ -1122,8 +1191,7 @@ fun ChatListContent(component: ChatListComponent) { isTabletSelected = isTablet && state.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, - showPhotos = showPhotos, - activeChatId = state.activeChatId + showPhotos = showPhotos ) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 21592dfe..0fb5d4cd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -1,14 +1,41 @@ package org.monogram.presentation.features.chats.chatList.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.* +import androidx.compose.material.icons.rounded.AlternateEmail +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Done +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Forum +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material.icons.rounded.PushPin +import androidx.compose.material.icons.rounded.Verified import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -29,7 +56,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.StateFlow +import org.monogram.core.date.toDate import org.monogram.domain.models.ChatModel import org.monogram.domain.models.MessageEntityType import org.monogram.presentation.R @@ -40,8 +67,6 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.add import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent import org.monogram.presentation.features.stickers.ui.view.StickerImage -import org.monogram.core.date.toDate -import org.monogram.presentation.features.chats.ChatListComponent @OptIn(ExperimentalFoundationApi::class) @Composable @@ -55,8 +80,7 @@ fun ChatListItem( messageLines: Int, showPhotos: Boolean, modifier: Modifier = Modifier, - isTabletSelected: Boolean = false, - activeChatId: Long? + isTabletSelected: Boolean = false ) { val isSavedMessages = chat.id == currentUserId @@ -65,7 +89,6 @@ fun ChatListItem( isTabletSelected -> MaterialTheme.colorScheme.primaryContainer isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) chat.isPinned -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - activeChatId == chat.id -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else -> Color.Transparent }, label = "ItemBg" diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt index 6d819f9c..bcaa1d08 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt @@ -1,8 +1,27 @@ package org.monogram.presentation.features.chats.chatList.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.filled.Star @@ -10,13 +29,23 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Shield import androidx.compose.material.icons.rounded.ShieldMoon +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +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.window.core.layout.WindowSizeClass import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.boundsInRoot @@ -26,6 +55,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowSizeClass import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R @@ -71,6 +101,7 @@ fun ChatListTopBar( modifier = Modifier .fillMaxWidth() .statusBarsPadding() + .then(if (isTablet) Modifier.padding(top = 6.dp) else Modifier) .padding(horizontal = 16.dp, vertical = 8.dp) ) { SearchBar( @@ -111,6 +142,7 @@ fun ChatListTopBar( modifier = Modifier .fillMaxWidth() .statusBarsPadding() + .then(if (isTablet) Modifier.padding(top = 6.dp) else Modifier) ) { Row( modifier = Modifier @@ -138,7 +170,9 @@ fun ChatListTopBar( Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier - .onGloballyPositioned { statusAnchorBounds = it.boundsInRoot() } + .onGloballyPositioned { + statusAnchorBounds = it.boundsInRoot() + } .clickable { onStatusClick(statusAnchorBounds) } ) { StickerImage( @@ -153,7 +187,9 @@ fun ChatListTopBar( contentDescription = stringResource(R.string.telegram_premium_title), modifier = Modifier .size(22.dp) - .onGloballyPositioned { statusAnchorBounds = it.boundsInRoot() } + .onGloballyPositioned { + statusAnchorBounds = it.boundsInRoot() + } .clickable { onStatusClick(statusAnchorBounds) }, tint = Color(0xFF31A6FD) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 4615e73e..b27921c3 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -74,6 +75,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -141,6 +143,7 @@ fun ChatContent( val state by component.state.collectAsState() val scrollState = rememberLazyListState() val context = LocalContext.current + val density = LocalDensity.current val localClipboard = LocalClipboard.current val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -724,6 +727,8 @@ fun ChatContent( } CompositionLocalProvider(LocalLinkHandler provides { component.onLinkClick(it) }) { + val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val headerOverlayHeight = statusBarHeight + 16.dp Box( modifier = Modifier .fillMaxSize() @@ -745,6 +750,19 @@ fun ChatContent( ChatContentBackground(state = state) } + if (isTablet) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(headerOverlayHeight) + .graphicsLayer { + alpha = contentAlpha + translationY = contentOffset.toPx() + } + .background(MaterialTheme.colorScheme.surface) + ) + } + Scaffold( modifier = Modifier .fillMaxSize() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt index 33e4add8..c0036798 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatContentTopBar.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup @@ -297,14 +298,19 @@ fun ChatContentTopBar( ) topBarState.isGroup -> { + val members = pluralStringResource( + R.plurals.members_count_format, + topBarState.memberCount, + topBarState.memberCount + ) if (topBarState.onlineCount > 0) { stringResource( R.string.members_online_count_format, - stringResource(R.string.members_count_format, topBarState.memberCount), + members, topBarState.onlineCount ) } else { - stringResource(R.string.members_count_format, topBarState.memberCount) + members } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt index aca8a997..696f8f50 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt @@ -160,13 +160,17 @@ fun AlbumMessageBubbleContainer( verticalAlignment = Alignment.Bottom ) { if (isGroup && !isOutgoing && !isChannel) { - Avatar( - path = firstMsg.senderAvatar, - fallbackPath = firstMsg.senderPersonalAvatar, - name = firstMsg.senderName, - size = 40.dp, - onClick = { toProfile(firstMsg.senderId) } - ) + if (!isSameSenderBelow) { + Avatar( + path = firstMsg.senderAvatar, + fallbackPath = firstMsg.senderPersonalAvatar, + name = firstMsg.senderName, + size = 40.dp, + onClick = { toProfile(firstMsg.senderId) } + ) + } else { + Spacer(modifier = Modifier.width(40.dp)) + } Spacer(modifier = Modifier.width(8.dp)) } @@ -186,7 +190,7 @@ fun AlbumMessageBubbleContainer( } } ) { - if (isGroup && !isOutgoing && !isChannel) { + if (isGroup && !isOutgoing && !isChannel && !isSameSenderAbove) { Text( text = firstMsg.senderName, style = MaterialTheme.typography.labelSmall, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt index 2c2bc816..23831699 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt @@ -1,25 +1,62 @@ package org.monogram.presentation.features.chats.currentChat.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.rounded.PlaylistAddCheck import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.CleaningServices +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Report +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.window.core.layout.WindowSizeClass import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin @@ -30,6 +67,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.window.core.layout.WindowSizeClass import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarForChat import org.monogram.presentation.core.ui.ConfirmationSheet @@ -76,8 +114,15 @@ fun ChatTopBar( var showDeleteChatSheet by rememberSaveable { mutableStateOf(false) } val windowInsets = if (isTablet) WindowInsets(0, 0, 0, 0) else WindowInsets.statusBars + val topInsetModifier = if (isTablet) { + Modifier + .fillMaxWidth() + .padding(top = 10.dp) + } else { + Modifier.fillMaxWidth() + } - Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = topInsetModifier) { AnimatedContent( targetState = isSearchActive, transitionSpec = { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt index fa6ef60d..678a1772 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt @@ -6,10 +6,27 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -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.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* +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.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -30,7 +47,21 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.chatContent.shouldShowDate -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.AudioMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.ContactMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.DocumentMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.GifMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.LocationMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageViaBotAttribution +import org.monogram.presentation.features.chats.currentChat.components.chats.PhotoMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.PollMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyMarkupView +import org.monogram.presentation.features.chats.currentChat.components.chats.StickerMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.TextMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VenueMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VideoMessageBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VideoNoteBubble +import org.monogram.presentation.features.chats.currentChat.components.chats.VoiceMessageBubble @Composable fun MessageBubbleContainer( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt index 9229ae13..57d80f27 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageLoading.kt @@ -173,6 +173,11 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( } else { emptyMap() } + val previousMessagesById = if (replace) { + state.messages.associateBy { it.id } + } else { + emptyMap() + } val isComments = state.rootMessage != null @@ -181,7 +186,7 @@ private suspend fun DefaultChatComponent.updateMessagesUnsafe( var hasChanges = replace filteredNewMessages.forEach { msg -> - val previous = messageMap[msg.id] + val previous = messageMap[msg.id] ?: previousMessagesById[msg.id] val mergedMessage = if (previous != null) mergeSenderVisuals(previous, msg) else msg val restoredMessage = if (mergedMessage.reactions.isEmpty()) { val previousReactions = existingReactionsById[msg.id] diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index a8e47b83..c708f315 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -6,7 +6,18 @@ import android.content.ClipData import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -17,9 +28,22 @@ import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Person -import androidx.compose.material3.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.* +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,6 +52,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -39,11 +64,23 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum import org.monogram.presentation.R -import org.monogram.presentation.core.ui.* +import org.monogram.presentation.core.ui.CollapsingToolbarScaffold +import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState +import org.monogram.presentation.core.ui.rememberShimmerBrush import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.chatList.components.SettingsTextField -import org.monogram.presentation.features.profile.components.* +import org.monogram.presentation.features.profile.components.ProfileHeaderTransformed +import org.monogram.presentation.features.profile.components.ProfileInfoSection +import org.monogram.presentation.features.profile.components.ProfileInfoSectionSkeleton +import org.monogram.presentation.features.profile.components.ProfilePermissionsDialog +import org.monogram.presentation.features.profile.components.ProfileQRDialog +import org.monogram.presentation.features.profile.components.ProfileReportDialog +import org.monogram.presentation.features.profile.components.ProfileTOSDialog +import org.monogram.presentation.features.profile.components.ProfileTopBar +import org.monogram.presentation.features.profile.components.profileMediaSection @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,36 +114,30 @@ fun ProfileContent(component: ProfileComponent) { .ifBlank { unknownTitle } } - val membersCountFormat = stringResource(R.string.members_count_format) val membersOnlineCountFormat = stringResource(R.string.members_online_count_format) val ownProfileSubtitle = stringResource(R.string.menu_my_profile_subtitle) - val subtitle = remember( - user, - chat, - isCurrentUserProfile, - membersCountFormat, - membersOnlineCountFormat, - ownProfileSubtitle - ) { - when { - chat?.isGroup == true || chat?.isChannel == true -> { - val members = String.format(membersCountFormat, chat.memberCount) - if (chat.onlineCount > 0) String.format( - membersOnlineCountFormat, - members, - chat.onlineCount - ) else members - } - - isCurrentUserProfile -> { - user.username - ?.takeIf { it.isNotBlank() } - ?.let { "$ownProfileSubtitle • @$it" } - ?: ownProfileSubtitle - } + val subtitle = when { + chat?.isGroup == true || chat?.isChannel == true -> { + val members = pluralStringResource( + R.plurals.members_count_format, + chat.memberCount, + chat.memberCount + ) + if (chat.onlineCount > 0) String.format( + membersOnlineCountFormat, + members, + chat.onlineCount + ) else members + } - else -> getUserStatusText(user, context) + isCurrentUserProfile -> { + user.username + ?.takeIf { it.isNotBlank() } + ?.let { "$ownProfileSubtitle • @$it" } + ?: ownProfileSubtitle } + + else -> getUserStatusText(user, context) } val isOnline = user?.type != UserTypeEnum.BOT && user?.userStatus == UserStatusType.ONLINE diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index f1692118..0a907bd1 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -250,7 +250,10 @@ Desconocido - %1$d miembros + + %1$d miembro + %1$d miembros + %1$s, %2$d en línea No implementado Búsqueda no implementada diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 8dedd8b6..67219280 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -239,7 +239,10 @@ Դիտումներ Անհայտ - %1$d անդամ + + %1$d անդամ + %1$d անդամ + %1$s, %2$d առցանց Դեռ հասանելի չէ Որոնումը դեռ հասանելի չէ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 2536f12a..2139f9fa 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -250,7 +250,10 @@ Desconhecido - %1$d membros + + %1$d membro + %1$d membros + %1$s, %2$d online Não implementado Busca não implementada diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 8630dfbc..498947fa 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -248,7 +248,12 @@ Неизвестно - Участники: %1$d + + %1$d участник + %1$d участника + %1$d участников + %1$d участника + %1$s, %2$d в сети Ещё не реализовано Поиск пока не работает diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 2cca3756..6b755765 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -258,7 +258,11 @@ Neznáme - %1$d členov + + %1$d člen + %1$d členovia + %1$d členov + %1$s, %2$d online Nie je implementované Vyhľadávanie nie je implementované diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 20df1450..39ee8a56 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -248,7 +248,12 @@ Невідомо - Учасники: %1$d + + %1$d учасник + %1$d учасники + %1$d учасників + %1$d учасника + %1$s, %2$d в мережі Ще не реалізовано Пошук поки не працює diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index acadc6d7..86eef50b 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -248,7 +248,9 @@ 未知 - %1$d 名成员 + + %1$d 名成员 + %1$s,%2$d 人在线 未实现 搜索尚未实现 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index b4099927..218a933c 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -250,7 +250,10 @@ Unknown - %1$d members + + %1$d member + %1$d members + %1$s, %2$d online Not implemented Search not implemented From 1e98ed5692b24f2e0c6c2f7bc3669a2325198697 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:42:55 +0300 Subject: [PATCH 53/83] refine message bubble grouping and sender block logic - introduce `shouldGroupSenderBlock` to centralize message grouping logic based on sender ID, name, custom title, and date breaks - update `MessageBubbleContainer` and `AlbumMessageBubbleContainer` to use the new grouping logic - expand `remember` keys to include sender metadata and message IDs for more accurate recomposition - ensure messages with invalid sender IDs (<= 0) are not grouped together --- .../components/AlbumMessageBubbleContainer.kt | 36 ++++++++++++++++--- .../components/MessageBubbleContainer.kt | 36 ++++++++++++++++--- .../currentChat/components/SenderGrouping.kt | 16 +++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt index 696f8f50..28104ca8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlbumMessageBubbleContainer.kt @@ -107,11 +107,39 @@ fun AlbumMessageBubbleContainer( } } - val isSameSenderAbove = remember(olderMsg?.senderId, firstMsg.senderId, olderMsg?.date, firstMsg.date) { - olderMsg?.senderId == firstMsg.senderId && !shouldShowDate(firstMsg, olderMsg) + val isSameSenderAbove = remember( + olderMsg?.id, + olderMsg?.senderId, + olderMsg?.senderName, + olderMsg?.senderCustomTitle, + olderMsg?.date, + firstMsg.senderId, + firstMsg.senderName, + firstMsg.senderCustomTitle, + firstMsg.date + ) { + shouldGroupSenderBlock( + current = firstMsg, + neighbor = olderMsg, + dateBreak = olderMsg?.let { shouldShowDate(firstMsg, it) } ?: true + ) } - val isSameSenderBelow = remember(newerMsg?.senderId, lastMsg.senderId, newerMsg?.date, lastMsg.date) { - newerMsg != null && newerMsg.senderId == lastMsg.senderId && !shouldShowDate(newerMsg, lastMsg) + val isSameSenderBelow = remember( + newerMsg?.id, + newerMsg?.senderId, + newerMsg?.senderName, + newerMsg?.senderCustomTitle, + newerMsg?.date, + lastMsg.senderId, + lastMsg.senderName, + lastMsg.senderCustomTitle, + lastMsg.date + ) { + shouldGroupSenderBlock( + current = lastMsg, + neighbor = newerMsg, + dateBreak = newerMsg?.let { shouldShowDate(it, lastMsg) } ?: true + ) } val topSpacing = if (isChannel && !isSameSenderAbove) 12.dp else 2.dp diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt index 678a1772..bd774a8a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/MessageBubbleContainer.kt @@ -122,11 +122,39 @@ fun MessageBubbleContainer( } val isOutgoing = msg.isOutgoing - val isSameSenderAbove = remember(olderMsg?.senderId, msg.senderId, olderMsg?.date, msg.date) { - olderMsg?.senderId == msg.senderId && !shouldShowDate(msg, olderMsg) + val isSameSenderAbove = remember( + olderMsg?.id, + olderMsg?.senderId, + olderMsg?.senderName, + olderMsg?.senderCustomTitle, + olderMsg?.date, + msg.senderId, + msg.senderName, + msg.senderCustomTitle, + msg.date + ) { + shouldGroupSenderBlock( + current = msg, + neighbor = olderMsg, + dateBreak = olderMsg?.let { shouldShowDate(msg, it) } ?: true + ) } - val isSameSenderBelow = remember(newerMsg?.senderId, msg.senderId, newerMsg?.date, msg.date) { - newerMsg != null && newerMsg.senderId == msg.senderId && !shouldShowDate(newerMsg, msg) + val isSameSenderBelow = remember( + newerMsg?.id, + newerMsg?.senderId, + newerMsg?.senderName, + newerMsg?.senderCustomTitle, + newerMsg?.date, + msg.senderId, + msg.senderName, + msg.senderCustomTitle, + msg.date + ) { + shouldGroupSenderBlock( + current = msg, + neighbor = newerMsg, + dateBreak = newerMsg?.let { shouldShowDate(it, msg) } ?: true + ) } val topSpacing = if (!isSameSenderAbove) 8.dp else 2.dp diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt new file mode 100644 index 00000000..56522ed8 --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/SenderGrouping.kt @@ -0,0 +1,16 @@ +package org.monogram.presentation.features.chats.currentChat.components + +import org.monogram.domain.models.MessageModel + +internal fun shouldGroupSenderBlock( + current: MessageModel, + neighbor: MessageModel?, + dateBreak: Boolean +): Boolean { + if (neighbor == null) return false + if (current.senderId <= 0L || neighbor.senderId <= 0L) return false + if (current.senderId != neighbor.senderId) return false + if (current.senderName != neighbor.senderName) return false + if (current.senderCustomTitle != neighbor.senderCustomTitle) return false + return !dateBreak +} From 709c766d8fe808336a24d657d1e110d833c60fad Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:50:36 +0300 Subject: [PATCH 54/83] refine chat message options and translation/summary logic - prevent showing summary and translation options when original text can be restored - disable message forwarding in the options menu when viewing translated or summarized content - update original text restoration logic to properly clear transformed state instead of overwriting it - add a visual separator line before the attribution text in AI-generated content - ensure read date metadata is preserved when creating the options menu message state --- .../features/chats/currentChat/ChatContent.kt | 6 ++++-- .../chatContent/ChatMessageOptionsMenu.kt | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index b27921c3..4b889dc0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -1381,8 +1381,10 @@ fun ChatContent( transformedMessageTexts[msg.id] = newText }, onRestoreOriginalText = { - val originalText = originalMessageTexts[msg.id] ?: return@ChatMessageOptionsMenu - transformedMessageTexts[msg.id] = originalText + if (!originalMessageTexts.containsKey(msg.id)) { + return@ChatMessageOptionsMenu + } + transformedMessageTexts.remove(msg.id) originalMessageTexts.remove(msg.id) }, onBlockRequest = { userId -> diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt index b03d8e4e..9b63400b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt @@ -216,11 +216,20 @@ fun ChatMessageOptionsMenu( val canCopyLink = state.isGroup || state.isChannel val canPinMessages = state.isAdmin || state.permissions.canPinMessages val isPremiumUser = state.currentUser?.isPremium == true - val canUseTelegramSummary = isPremiumUser && canSummarize(selectedMessage) - val canUseTelegramTranslator = isPremiumUser && canTranslate(selectedMessage) + val canUseTelegramSummary = + isPremiumUser && !canRestoreOriginalText && canSummarize(selectedMessage) + val canUseTelegramTranslator = + isPremiumUser && !canRestoreOriginalText && canTranslate(selectedMessage) val cocoonAttribution = stringResource(R.string.telegram_cocoon_attribution) + val menuMessage = remember(selectedMessage, canRestoreOriginalText) { + if (canRestoreOriginalText && selectedMessage.canBeForwarded) { + selectedMessage.copy(canBeForwarded = false) + } else { + selectedMessage + } + } MessageOptionsMenu( - message = messageWithReadDate, + message = menuMessage.copy(readDate = messageWithReadDate.readDate), canWrite = state.canWrite, canPinMessages = canPinMessages, isPinned = selectedMessage.id == state.pinnedMessage?.id, @@ -420,7 +429,7 @@ fun ChatMessageOptionsMenu( private fun String.withCocoonAttribution(attribution: String): String { val cleanText = trim() - return "$cleanText\n\n$attribution" + return "$cleanText\n\n-----\n$attribution" } private const val TELEGRAM_AI_LOG_TAG = "TelegramAiActions" From 520eb8fbe86c2f772bf7d632bdcf0a26ea8144a9 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:01:00 +0300 Subject: [PATCH 55/83] Refactor startup overlay state handling Consolidate and simplify startup overlay effects and state tracking. Introduces startupChild and wasStartupActive to more robustly detect startup transitions, replaces two LaunchedEffect blocks with a single effect keyed on isStartupActive and startupChild?.component, and centralizes the logic that shows/hides and clears the startupOverlayComponent (preserving the existing delay timings). This reduces race conditions and makes overlay lifecycle easier to reason about. --- .../main/java/org/monogram/app/MainContent.kt | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt index 1368e667..778d8b10 100644 --- a/app/src/main/java/org/monogram/app/MainContent.kt +++ b/app/src/main/java/org/monogram/app/MainContent.kt @@ -61,33 +61,30 @@ fun MainContent( val activeChild = childStack.active.instance val isStartupActive = activeChild is RootComponent.Child.StartupChild + val startupChild = activeChild as? RootComponent.Child.StartupChild var startupOverlayComponent by remember { mutableStateOf(null) } var startupOverlayVisible by remember { mutableStateOf(false) } + var wasStartupActive by remember { mutableStateOf(isStartupActive) } - LaunchedEffect(activeChild) { - when (activeChild) { - is RootComponent.Child.StartupChild -> { - startupOverlayComponent = activeChild.component - startupOverlayVisible = false - } - - else -> { - if (startupOverlayComponent != null) { - startupOverlayVisible = true - } - } + LaunchedEffect(isStartupActive, startupChild?.component) { + if (isStartupActive) { + startupOverlayComponent = startupChild?.component + startupOverlayVisible = false + wasStartupActive = true + return@LaunchedEffect } - } - LaunchedEffect(startupOverlayVisible, isStartupActive) { - if (startupOverlayVisible && !isStartupActive) { + if (wasStartupActive && startupOverlayComponent != null) { + startupOverlayVisible = true delay(90) startupOverlayVisible = false delay(320) - if (!isStartupActive) { - startupOverlayComponent = null - } + startupOverlayComponent = null + wasStartupActive = false + return@LaunchedEffect } + + wasStartupActive = false } Box( From e9d5db23d804980d8b880c650c6059888567110c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:03:44 +0300 Subject: [PATCH 56/83] replace gallery top bar puzzle icon with photo library --- .../presentation/features/gallery/components/GalleryTopBar.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt index 4f9816ae..fad2161c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/GalleryTopBar.kt @@ -2,7 +2,7 @@ package org.monogram.presentation.features.gallery.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -32,7 +32,7 @@ fun GalleryTopBar( }, actions = { IconButton(onClick = onPickFromOtherSources) { - Icon(Icons.Filled.Extension, contentDescription = stringResource(R.string.gallery_action_other_sources)) + Icon(Icons.Filled.PhotoLibrary, contentDescription = stringResource(R.string.gallery_action_other_sources)) } IconButton(onClick = onCameraClick) { Icon(Icons.Filled.PhotoCamera, contentDescription = stringResource(R.string.permission_camera_title)) From 334c3efb8dd190515a9a02dd764da317521f81f9 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:10:38 +0300 Subject: [PATCH 57/83] guard scheduled messages fetch by chat rights --- .../chats/currentChat/impl/PinnedMessages.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt index f2395079..9da44438 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/PinnedMessages.kt @@ -55,6 +55,11 @@ internal fun DefaultChatComponent.loadAllPinnedMessages() { internal fun DefaultChatComponent.loadScheduledMessages() { scope.launch { + if (!canLoadScheduledMessages()) { + _state.update { it.copy(scheduledMessages = emptyList()) } + return@launch + } + try { val scheduledMessages = repositoryMessage.getScheduledMessages(chatId) _state.update { it.copy(scheduledMessages = scheduledMessages) } @@ -64,6 +69,18 @@ internal fun DefaultChatComponent.loadScheduledMessages() { } } +private suspend fun DefaultChatComponent.canLoadScheduledMessages(): Boolean { + val currentState = _state.value + if (currentState.isChannel && !currentState.isAdmin) return false + if (currentState.canWrite) return true + + val chat = chatListRepository.getChatById(chatId) ?: return false + val canWrite = if (chat.isAdmin) true else chat.permissions.canSendBasicMessages + if (chat.isChannel && !chat.isAdmin) return false + + return canWrite +} + internal fun DefaultChatComponent.setupPinnedMessageCollector() { repositoryMessage.pinnedMessageFlow .onEach { cId -> From 998ad668c125311891126f535703097c666848f1 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:20:00 +0300 Subject: [PATCH 58/83] add copy cut paste actions to fullscreen editor --- .../inputbar/FullScreenEditorSheet.kt | 83 +++++++++++++++++++ .../src/main/res/values-es/string.xml | 3 + .../src/main/res/values-hy/string.xml | 3 + .../src/main/res/values-pt-rBR/string.xml | 3 + .../src/main/res/values-ru-rRU/string.xml | 3 + .../src/main/res/values-sk/string.xml | 3 + .../src/main/res/values-uk/string.xml | 3 + .../src/main/res/values-zh-rCN/string.xml | 3 + presentation/src/main/res/values/string.xml | 3 + 9 files changed, 107 insertions(+) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt index 0a8d8ff4..dc80821b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt @@ -1,5 +1,6 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar +import android.content.ClipData import android.widget.Toast import androidx.compose.animation.* import androidx.compose.foundation.* @@ -26,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -151,6 +153,8 @@ fun FullScreenEditorSheet( ) { if (!visible) return val context = LocalContext.current + val clipboardManager = LocalClipboard.current + val nativeClipboard = clipboardManager.nativeClipboard val focusRequester = remember { FocusRequester() } var showEmojiPicker by rememberSaveable { mutableStateOf(false) } @@ -316,6 +320,11 @@ fun FullScreenEditorSheet( } val richEntityCount = remember(entities) { entities.count { richEntityToAnnotation(it.type) != null } } val hasSelection = hasFormattableSelection(textValue) + val hasTextSelection = normalizedSelection(textValue.selection) != null + val canPasteFromClipboard = canWriteText && + nativeClipboard.primaryClip?.let { clip -> + clip.itemCount > 0 && clip.getItemAt(0).coerceToText(context).isNotEmpty() + } == true fun showAiPreview(result: FormattedTextResult) { val mappedTextValue = buildTextFieldValueFromTextAndEntities( @@ -559,6 +568,31 @@ fun FullScreenEditorSheet( AnimatedVisibility(visible = !isPreviewMode) { FullScreenEditorTools( hasSelection = hasSelection, + canCopy = hasTextSelection, + canCut = canWriteText && hasTextSelection, + canPaste = canPasteFromClipboard, + onCopy = { + selectedTextOrNull(textValue)?.let { selectedText -> + nativeClipboard.setPrimaryClip(ClipData.newPlainText("", selectedText)) + } + }, + onCut = { + selectedTextOrNull(textValue)?.let { selectedText -> + nativeClipboard.setPrimaryClip(ClipData.newPlainText("", selectedText)) + applyEditorChange(replaceSelection(textValue, "")) + } + }, + onPaste = { + val clipboardText = + nativeClipboard.primaryClip?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.coerceToText(context) + ?.toString() + .orEmpty() + if (clipboardText.isNotEmpty()) { + applyEditorChange(replaceSelection(textValue, clipboardText)) + } + }, onBold = { applyEditorChange(toggleRichEntity(textValue, MessageEntityType.Bold)) }, onItalic = { applyEditorChange(toggleRichEntity(textValue, MessageEntityType.Italic)) }, onUnderline = { applyEditorChange(toggleRichEntity(textValue, MessageEntityType.Underline)) }, @@ -1562,6 +1596,12 @@ private fun FullScreenEditorToolButton(icon: ImageVector, hint: String, enabled: @Composable private fun FullScreenEditorTools( hasSelection: Boolean, + canCopy: Boolean, + canCut: Boolean, + canPaste: Boolean, + onCopy: () -> Unit, + onCut: () -> Unit, + onPaste: () -> Unit, onBold: () -> Unit, onItalic: () -> Unit, onUnderline: () -> Unit, @@ -1590,6 +1630,24 @@ private fun FullScreenEditorTools( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { + FullScreenEditorToolButton( + Icons.Outlined.ContentCopy, + stringResource(R.string.editor_action_copy), + canCopy, + onCopy + ) + FullScreenEditorToolButton( + Icons.Outlined.ContentCut, + stringResource(R.string.editor_action_cut), + canCut, + onCut + ) + FullScreenEditorToolButton( + Icons.Outlined.ContentPaste, + stringResource(R.string.editor_action_paste), + canPaste, + onPaste + ) FullScreenEditorToolButton( Icons.Outlined.FormatBold, stringResource(R.string.rich_text_bold), @@ -1686,6 +1744,31 @@ private fun currentPreLanguage(value: TextFieldValue): String { .orEmpty() } +private fun selectedTextOrNull(value: TextFieldValue): String? { + val selection = normalizedSelection(value.selection) ?: return null + return value.text.substring(selection.start, selection.end) +} + +private fun replaceSelection(value: TextFieldValue, replacement: String): TextFieldValue { + val rawSelection = if (value.selection.start <= value.selection.end) { + value.selection + } else { + TextRange(value.selection.end, value.selection.start) + } + val maxLength = value.annotatedString.length + val selection = TextRange( + start = rawSelection.start.coerceIn(0, maxLength), + end = rawSelection.end.coerceIn(0, maxLength) + ) + val newAnnotated = buildAnnotatedString { + append(value.annotatedString.subSequence(0, selection.start)) + append(replacement) + append(value.annotatedString.subSequence(selection.end, value.annotatedString.length)) + } + val cursor = selection.start + replacement.length + return value.copy(annotatedString = newAnnotated, selection = TextRange(cursor, cursor)) +} + private fun insertSnippetAtSelection(value: TextFieldValue, snippet: String): TextFieldValue { if (snippet.isBlank()) return value val rawSelection = if (value.selection.start <= value.selection.end) { diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 0a907bd1..1c561ed0 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -1048,6 +1048,9 @@ %1$d palabras ~%1$d min de lectura Borrador guardado automáticamente + Copiar + Cortar + Pegar Insertar Anterior Siguiente diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 67219280..3014d217 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -952,6 +952,9 @@ %1$d ֆորմատավորման բլոկ Ընտրեք տեքստը՝ ձևավորում կիրառելու համար %1$d/%2$d + Պատճենել + Կտրել + Տեղադրել Թավ Շեղ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 2139f9fa..d3699298 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -1050,6 +1050,9 @@ %1$d palavras ~%1$d min de leitura Rascunho salvo automaticamente + Copiar + Recortar + Colar IA Editor de IA Traduzir diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 498947fa..bd76207d 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -1937,6 +1937,9 @@ %1$d слов ≈%1$d мин чтения Черновик сохранён + Копировать + Вырезать + Вставить AI ИИ-редактор Перевод diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 6b755765..250c8730 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -2068,6 +2068,9 @@ %1$d slov ≈%1$d min čítania Koncept uložený + Kopírovať + Vystrihnúť + Prilepiť AI AI editor Preklad diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 39ee8a56..62cbc886 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -1937,6 +1937,9 @@ %1$d слів ≈%1$d хв читання Чернетку збережено + Копіювати + Вирізати + Вставити AI AI-редактор Переклад diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 86eef50b..c0236325 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -1916,6 +1916,9 @@ %1$d 个词 约 %1$d 分钟阅读 草稿已自动保存 + 复制 + 剪切 + 粘贴 AI AI 编辑器 翻译 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 218a933c..42142e0d 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1063,6 +1063,9 @@ %1$d words ~%1$d min read Draft auto-saved + Copy + Cut + Paste AI AI editor Translate From 75e09f196ba9bbfc86a8ec1b213b8eba702aa63e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:41:13 +0300 Subject: [PATCH 59/83] armv7 crash and performance fix --- data/build.gradle.kts | 4 +- .../stickers/core/LottieStickerController.kt | 66 +++++++++++++++---- .../features/stickers/core/RLottieWrapper.kt | 15 ++++- 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 7300a4a2..96b96568 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,4 +1,4 @@ -import java.util.* +import java.util.Properties plugins { alias(libs.plugins.android.library) @@ -15,7 +15,7 @@ android { consumerProguardFiles("consumer-rules.pro") ndk { - abiFilters += listOf("arm64-v8a") + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") } val localProperties: Properties by rootProject.extra diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt index d76c1d83..9f17d29f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/LottieStickerController.kt @@ -1,14 +1,23 @@ package org.monogram.presentation.features.stickers.core -import org.monogram.presentation.core.util.coRunCatching import android.graphics.Bitmap +import android.os.Build import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.monogram.presentation.core.util.coRunCatching import java.io.File import kotlin.math.max @@ -33,6 +42,8 @@ class LottieStickerController( private var backBitmap: Bitmap? = null private var spareBitmap: Bitmap? = null private var decoder: RLottieWrapper? = null + private val isArmV7Device = + Build.SUPPORTED_ABIS.any { it.equals("armeabi-v7a", ignoreCase = true) } override fun start() { val previousJob = renderJob @@ -57,7 +68,12 @@ class LottieStickerController( val file = File(filePath) if (!file.exists()) return@withContext null - val localDecoder = RLottieWrapper() + val localDecoder = try { + RLottieWrapper() + } catch (t: Throwable) { + t.printStackTrace() + return@withContext null + } try { if (!localDecoder.open(file)) { return@withContext null @@ -67,8 +83,10 @@ class LottieStickerController( val compositionHeight = localDecoder.getHeight().coerceAtLeast(1) val extraPaddingX = minOf((compositionWidth * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) val extraPaddingY = minOf((compositionHeight * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) - val renderWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) - val renderHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val targetWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) + val targetHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val renderWidth = if (isArmV7Device) minOf(targetWidth, 384) else targetWidth + val renderHeight = if (isArmV7Device) minOf(targetHeight, 384) else targetHeight val boundsLeft = (renderWidth - compositionWidth) / 2 val boundsTop = (renderHeight - compositionHeight) / 2 @@ -88,11 +106,13 @@ class LottieStickerController( return@withContext null } - val imageBitmap = bitmap.asImageBitmap() - imageBitmap + createImageBitmapSnapshot(bitmap) } catch (e: Exception) { e.printStackTrace() null + } catch (oom: OutOfMemoryError) { + oom.printStackTrace() + null } finally { localDecoder.release() } @@ -111,7 +131,12 @@ class LottieStickerController( val file = File(filePath) if (!file.exists()) return - val localDecoder = RLottieWrapper() + val localDecoder = try { + RLottieWrapper() + } catch (t: Throwable) { + t.printStackTrace() + return + } if (!coRunCatching { localDecoder.open(file) }.getOrDefault(false)) { localDecoder.release() return @@ -122,8 +147,10 @@ class LottieStickerController( val compositionHeight = localDecoder.getHeight().coerceAtLeast(1) val extraPaddingX = minOf((compositionWidth * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) val extraPaddingY = minOf((compositionHeight * OVERFLOW_PADDING_RATIO).toInt(), MAX_OVERFLOW_PADDING_PX) - val renderWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) - val renderHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val targetWidth = maxOf(reqWidth, compositionWidth + extraPaddingX * 2) + val targetHeight = maxOf(reqHeight, compositionHeight + extraPaddingY * 2) + val renderWidth = if (isArmV7Device) minOf(targetWidth, 384) else targetWidth + val renderHeight = if (isArmV7Device) minOf(targetHeight, 384) else targetHeight val boundsLeft = (renderWidth - compositionWidth) / 2 val boundsTop = (renderHeight - compositionHeight) / 2 @@ -153,7 +180,7 @@ class LottieStickerController( return } - currentImageBitmap = firstBitmap.copy(Bitmap.Config.ARGB_8888, false).asImageBitmap() + currentImageBitmap = createImageBitmapSnapshot(firstBitmap) val totalFrames = localDecoder.getTotalFrames().coerceAtLeast(1) val frameRate = localDecoder.getFrameRate().takeIf { it > 0.0 } @@ -209,8 +236,11 @@ class LottieStickerController( val localFrontBitmap = frontBitmap if (localFrontBitmap != null) { - currentImageBitmap = localFrontBitmap.copy(Bitmap.Config.ARGB_8888, false).asImageBitmap() - frameVersion++ + val renderedImage = createImageBitmapSnapshot(localFrontBitmap) + if (renderedImage != null) { + currentImageBitmap = renderedImage + frameVersion++ + } } lastFrameTime = now @@ -251,4 +281,14 @@ class LottieStickerController( private const val OVERFLOW_PADDING_RATIO = 0.20f private const val MAX_OVERFLOW_PADDING_PX = 96 } + + private fun createImageBitmapSnapshot(bitmap: Bitmap): ImageBitmap? { + return try { + bitmap.copy(Bitmap.Config.ARGB_8888, false)?.asImageBitmap() ?: bitmap.asImageBitmap() + } catch (_: OutOfMemoryError) { + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt index 8da4932a..b45a6051 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/core/RLottieWrapper.kt @@ -1,7 +1,7 @@ package org.monogram.presentation.features.stickers.core -import org.monogram.presentation.core.util.coRunCatching import android.graphics.Bitmap +import org.monogram.presentation.core.util.coRunCatching import java.io.File import java.io.FileInputStream import java.util.zip.GZIPInputStream @@ -10,8 +10,9 @@ class RLottieWrapper { private var nativePtr: Long = 0 init { - System.loadLibrary("native-lib") - nativePtr = create() + if (isNativeLibraryLoaded) { + nativePtr = coRunCatching { create() }.getOrDefault(0L) + } } fun open(file: File): Boolean { @@ -84,4 +85,12 @@ class RLottieWrapper { private external fun getFrameRate(ptr: Long): Double private external fun getDurationMs(ptr: Long): Long private external fun destroy(ptr: Long) + + companion object { + private val isNativeLibraryLoaded: Boolean by lazy { + coRunCatching { + System.loadLibrary("native-lib") + }.isSuccess + } + } } From c6879910b849596d86ae20ac4b8c11aeeb32a4dc Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:24:24 +0300 Subject: [PATCH 60/83] add toggle for tablet split-screen interface - introduce `LocalTabletInterfaceEnabled` CompositionLocal to provide tablet layout preference across the app - add "Tablet Interface" switch in Chat Settings to toggle split-screen layout on supported devices - update `MainContent` to respect the tablet interface preference when deciding whether to show the expanded layout - integrate `LocalTabletInterfaceEnabled` into `AuthContent`, `ChatContent`, `ProfileContent`, and various TopBar components to gate tablet-specific UI logic - persist the tablet interface state in `AppPreferences` - add localized strings for the new setting in multiple languages (English, Portuguese, Armenian, Russian, Slovak, Spanish, Ukrainian, Chinese) --- .../main/java/org/monogram/app/MainContent.kt | 16 ++- .../presentation/core/util/AppPreferences.kt | 11 ++ .../core/util/CompositionLocal.kt | 4 +- .../presentation/features/auth/AuthContent.kt | 27 +++- .../chats/chatList/ChatListContent.kt | 5 +- .../chatList/components/ChatListTopBar.kt | 5 +- .../features/chats/currentChat/ChatContent.kt | 5 +- .../currentChat/components/ChatTopBar.kt | 5 +- .../inputbar/InputTextFieldContainer.kt | 4 +- .../features/profile/ProfileContent.kt | 5 +- .../chatSettings/ChatSettingsComponent.kt | 20 ++- .../chatSettings/ChatSettingsContent.kt | 132 ++++++++++++++++-- .../settings/settings/SettingsContent.kt | 9 +- .../src/main/res/values-es/string.xml | 2 + .../src/main/res/values-hy/string.xml | 2 + .../src/main/res/values-pt-rBR/string.xml | 2 + .../src/main/res/values-ru-rRU/string.xml | 2 + .../src/main/res/values-sk/string.xml | 2 + .../src/main/res/values-uk/string.xml | 2 + .../src/main/res/values-zh-rCN/string.xml | 2 + presentation/src/main/res/values/string.xml | 2 + 21 files changed, 235 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt index 778d8b10..968a6a4b 100644 --- a/app/src/main/java/org/monogram/app/MainContent.kt +++ b/app/src/main/java/org/monogram/app/MainContent.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -35,6 +36,7 @@ import org.monogram.app.components.LockScreen import org.monogram.app.components.MobileLayout import org.monogram.app.components.ProxyConfirmSheet import org.monogram.app.components.TabletLayout +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentViewers import org.monogram.presentation.features.chats.currentChat.components.StickerSetSheet import org.monogram.presentation.features.profile.ProfileViewers @@ -52,6 +54,7 @@ fun MainContent( ) { val childStack by root.childStack.subscribeAsState() val isLocked by root.isLocked.collectAsState() + val isTabletInterfaceEnabled by root.appPreferences.isTabletInterfaceEnabled.collectAsState() val localClipboard = LocalClipboard.current val isExpanded = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClassCore.WIDTH_DP_MEDIUM_LOWER_BOUND) || @@ -87,11 +90,12 @@ fun MainContent( wasStartupActive = false } - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { + CompositionLocalProvider(LocalTabletInterfaceEnabled provides isTabletInterfaceEnabled) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { val contentScale by animateFloatAsState( targetValue = 1f, animationSpec = tween(durationMillis = 300), @@ -107,6 +111,7 @@ fun MainContent( }, ) { if (isExpanded && + isTabletInterfaceEnabled && activeChild !is RootComponent.Child.AuthChild && activeChild !is RootComponent.Child.StartupChild ) { @@ -183,5 +188,6 @@ fun MainContent( } else -> {} } + } } } 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 9e102672..2f192fe5 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 @@ -315,6 +315,10 @@ class AppPreferences( private val _showChatListPhotos = MutableStateFlow(prefs.getBoolean(KEY_SHOW_CHAT_LIST_PHOTOS, true)) override val showChatListPhotos: StateFlow = _showChatListPhotos + private val _isTabletInterfaceEnabled = + MutableStateFlow(prefs.getBoolean(KEY_TABLET_INTERFACE_ENABLED, true)) + val isTabletInterfaceEnabled: StateFlow = _isTabletInterfaceEnabled + private val _isAdBlockEnabled = MutableStateFlow(prefs.getBoolean(KEY_ADBLOCK_ENABLED, false)) val isAdBlockEnabled: StateFlow = _isAdBlockEnabled @@ -804,6 +808,11 @@ class AppPreferences( _showChatListPhotos.value = enabled } + fun setTabletInterfaceEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_TABLET_INTERFACE_ENABLED, enabled).apply() + _isTabletInterfaceEnabled.value = enabled + } + fun setAdBlockEnabled(enabled: Boolean) { prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply() _isAdBlockEnabled.value = enabled @@ -958,6 +967,7 @@ class AppPreferences( _isChatAnimationsEnabled.value = true _chatListMessageLines.value = 1 _showChatListPhotos.value = true + _isTabletInterfaceEnabled.value = true _isAdBlockEnabled.value = false _adBlockKeywords.value = emptySet() _adBlockWhitelistedChannels.value = emptySet() @@ -1071,6 +1081,7 @@ class AppPreferences( private const val KEY_CHAT_ANIMATIONS_ENABLED = "chat_animations_enabled" private const val KEY_CHAT_LIST_MESSAGE_LINES = "chat_list_message_lines" private const val KEY_SHOW_CHAT_LIST_PHOTOS = "show_chat_list_photos" + private const val KEY_TABLET_INTERFACE_ENABLED = "tablet_interface_enabled" private const val KEY_ADBLOCK_ENABLED = "adblock_enabled" private const val KEY_ADBLOCK_KEYWORDS = "adblock_keywords" diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt b/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt index a329897c..bc299cd4 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/CompositionLocal.kt @@ -5,4 +5,6 @@ import org.monogram.presentation.features.chats.currentChat.components.VideoPlay val LocalVideoPlayerPool = staticCompositionLocalOf { error("VideoPlayerPool not provided") -} \ No newline at end of file +} + +val LocalTabletInterfaceEnabled = staticCompositionLocalOf { true } diff --git a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt index cbcdbd60..a19083ee 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/auth/AuthContent.kt @@ -2,14 +2,31 @@ package org.monogram.presentation.features.auth import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.SettingsEthernet +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -20,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.auth.components.AuthErrorDialog import org.monogram.presentation.features.auth.components.CodeInputScreen import org.monogram.presentation.features.auth.components.PasswordInputScreen @@ -30,7 +48,8 @@ import org.monogram.presentation.features.auth.components.PhoneInputScreen fun AuthContent(component: AuthComponent) { val model by component.model.subscribeAsState() val configuration = LocalConfiguration.current - val isTablet = configuration.screenWidthDp >= 600 + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = configuration.screenWidthDp >= 600 && isTabletInterfaceEnabled val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE val maxContentWidth = if (isTablet && isLandscape) 1000.dp else 600.dp val motionScheme = MaterialTheme.motionScheme diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index c2bae6cd..882033d1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -118,6 +118,7 @@ import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.chats.ChatListComponent import org.monogram.presentation.features.chats.chatList.components.AccountMenu import org.monogram.presentation.features.chats.chatList.components.ArchiveHeaderCard @@ -158,7 +159,9 @@ fun ChatListContent(component: ChatListComponent) { var showPermissionRequest by remember { mutableStateOf(!isPermissionRequested) } val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled val isCustomBackHandlingEnabled = state.isSearchActive || state.selectedChatIds.isNotEmpty() || state.selectedFolderId == -2 || state.isForwarding || state.instantViewUrl != null || state.webAppUrl != null || state.webViewUrl != null || showStatusMenu diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt index bcaa1d08..68ff309a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListTopBar.kt @@ -60,6 +60,7 @@ import org.monogram.domain.models.UserModel import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.ExpressiveDefaults +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.stickers.ui.view.StickerImage @@ -83,7 +84,9 @@ fun ChatListTopBar( val motionScheme = MaterialTheme.motionScheme val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled AnimatedContent( targetState = isSearchActive, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index 4b889dc0..c427a3ac 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -104,6 +104,7 @@ import org.monogram.domain.models.ReplyMarkupModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ExpressiveDefaults +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentBackground import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentList import org.monogram.presentation.features.chats.currentChat.chatContent.ChatContentTopBar @@ -150,7 +151,9 @@ fun ChatContent( val coroutineScope = rememberCoroutineScope() val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED && isTabletInterfaceEnabled var isVisible by remember { mutableStateOf(false) } var showInitialLoading by remember { mutableStateOf(false) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt index 23831699..bbf0c691 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatTopBar.kt @@ -72,6 +72,7 @@ import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarForChat import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.TypingDots +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow import org.monogram.presentation.features.stickers.ui.view.StickerImage import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown @@ -107,7 +108,9 @@ fun ChatTopBar( onManageMembers: (() -> Unit)? = null, showBack: Boolean = true, personalAvatarPath: String? = null, - isTablet: Boolean = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) + isTablet: Boolean = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND + ) && LocalTabletInterfaceEnabled.current ) { var showMenu by rememberSaveable { mutableStateOf(false) } var showClearHistorySheet by rememberSaveable { mutableStateOf(false) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt index 6f915ac4..833cc363 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt @@ -38,6 +38,7 @@ import org.monogram.domain.models.BotCommandModel import org.monogram.domain.models.BotMenuButtonModel import org.monogram.domain.models.StickerModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled @Composable fun InputTextFieldContainer( @@ -68,7 +69,8 @@ fun InputTextFieldContainer( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceVariant ) { - val isTablet = LocalConfiguration.current.screenWidthDp >= 600 + val isTablet = + LocalConfiguration.current.screenWidthDp >= 600 && LocalTabletInterfaceEnabled.current Row( verticalAlignment = Alignment.CenterVertically, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index c708f315..7bf37fdb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -69,6 +69,7 @@ import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState import org.monogram.presentation.core.ui.rememberShimmerBrush +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.chatList.components.SettingsTextField @@ -87,7 +88,9 @@ import org.monogram.presentation.features.profile.components.profileMediaSection fun ProfileContent(component: ProfileComponent) { val state by component.state.subscribeAsState() val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) && isTabletInterfaceEnabled val localClipboard = LocalClipboard.current val context = LocalContext.current val collapsingToolbarState = rememberCollapsingToolbarScaffoldState() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt index 28d3d218..90b8182d 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsComponent.kt @@ -17,7 +17,12 @@ import org.monogram.domain.models.WallpaperModel import org.monogram.domain.repository.EmojiRepository import org.monogram.domain.repository.StickerRepository import org.monogram.domain.repository.WallpaperRepository -import org.monogram.presentation.core.util.* +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.EmojiStyle +import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.core.util.NightMode +import org.monogram.presentation.core.util.coRunCatching +import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext import java.io.File import java.net.URL @@ -78,6 +83,7 @@ interface ChatSettingsComponent { fun onNightModeEndTimeChanged(time: String) fun onNightModeBrightnessThresholdChanged(threshold: Float) fun onDragToBackChanged(enabled: Boolean) + fun onTabletInterfaceEnabledChanged(enabled: Boolean) fun onAdBlockClick() fun onEmojiStyleChanged(style: EmojiStyle) fun onEmojiStyleLongClick(style: EmojiStyle) @@ -137,6 +143,7 @@ interface ChatSettingsComponent { val nightModeEndTime: String = "07:00", val nightModeBrightnessThreshold: Float = 0.2f, val isDragToBackEnabled: Boolean = true, + val isTabletInterfaceEnabled: Boolean = true, val emojiStyle: EmojiStyle = EmojiStyle.SYSTEM, val isAppleEmojiDownloaded: Boolean = false, val isTwitterEmojiDownloaded: Boolean = false, @@ -218,6 +225,7 @@ class DefaultChatSettingsComponent( nightModeEndTime = appPreferences.nightModeEndTime.value, nightModeBrightnessThreshold = appPreferences.nightModeBrightnessThreshold.value, isDragToBackEnabled = appPreferences.isDragToBackEnabled.value, + isTabletInterfaceEnabled = appPreferences.isTabletInterfaceEnabled.value, emojiStyle = appPreferences.emojiStyle.value, isAppleEmojiDownloaded = appPreferences.isAppleEmojiDownloaded.value, isTwitterEmojiDownloaded = appPreferences.isTwitterEmojiDownloaded.value, @@ -505,6 +513,12 @@ class DefaultChatSettingsComponent( } .launchIn(scope) + appPreferences.isTabletInterfaceEnabled + .onEach { enabled -> + _state.update { it.copy(isTabletInterfaceEnabled = enabled) } + } + .launchIn(scope) + appPreferences.emojiStyle .onEach { style -> _state.update { it.copy(emojiStyle = style) } @@ -996,6 +1010,10 @@ class DefaultChatSettingsComponent( appPreferences.setDragToBackEnabled(enabled) } + override fun onTabletInterfaceEnabledChanged(enabled: Boolean) { + appPreferences.setTabletInterfaceEnabled(enabled) + } + override fun onAdBlockClick() { onAdBlock() } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt index ecd2f415..abcd9045 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/chatSettings/ChatSettingsContent.kt @@ -7,10 +7,43 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.EaseInQuint +import androidx.compose.animation.core.EaseOutQuint +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -19,9 +52,69 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.StickyNote2 -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.Archive +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.Brightness4 +import androidx.compose.material.icons.rounded.BrightnessAuto +import androidx.compose.material.icons.rounded.BrightnessLow +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.DarkMode +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.EmojiEmotions +import androidx.compose.material.icons.rounded.FastForward +import androidx.compose.material.icons.rounded.FastRewind +import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.Gesture +import androidx.compose.material.icons.rounded.LightMode +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Photo +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.Square +import androidx.compose.material.icons.rounded.SwipeLeft +import androidx.compose.material.icons.rounded.TabletAndroid +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material.icons.rounded.VideoFile +import androidx.compose.material.icons.rounded.ZoomIn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -37,6 +130,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R import org.monogram.presentation.core.ui.ConfirmationSheet @@ -57,6 +151,9 @@ import java.io.FileOutputStream fun ChatSettingsContent(component: ChatSettingsComponent) { val state by component.state.subscribeAsState() val context = LocalContext.current + val adaptiveInfo = currentWindowAdaptiveInfo() + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) val wallpaperPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() @@ -362,7 +459,10 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { Box( modifier = Modifier .size(32.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape), + .background( + MaterialTheme.colorScheme.primary, + CircleShape + ), contentAlignment = Alignment.Center ) { Icon( @@ -824,6 +924,17 @@ fun ChatSettingsContent(component: ChatSettingsComponent) { position = ItemPosition.MIDDLE, onCheckedChange = component::onShowLinkPreviewsChanged ) + if (isTablet) { + SettingsSwitchTile( + icon = Icons.Rounded.TabletAndroid, + title = stringResource(R.string.tablet_interface_title), + subtitle = stringResource(R.string.tablet_interface_subtitle), + checked = state.isTabletInterfaceEnabled, + iconColor = greenColor, + position = ItemPosition.MIDDLE, + onCheckedChange = component::onTabletInterfaceEnabledChanged + ) + } SettingsSwitchTile( icon = Icons.Rounded.SwipeLeft, title = stringResource(R.string.drag_to_back_title), @@ -1221,7 +1332,10 @@ private fun ThemeModeItem( Box( modifier = Modifier .size(34.dp) - .background(contentColor.copy(alpha = if (selected) 0.16f else 0.1f), CircleShape), + .background( + contentColor.copy(alpha = if (selected) 0.16f else 0.1f), + CircleShape + ), contentAlignment = Alignment.Center ) { Icon( diff --git a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt index ffcda04b..5ab3bf40 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/settings/SettingsContent.kt @@ -79,9 +79,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -121,6 +121,7 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.presentation.R import org.monogram.presentation.core.ui.CollapsingToolbarScaffold import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem import org.monogram.presentation.core.ui.StyledQRCode import org.monogram.presentation.core.ui.UserProfileHeader @@ -130,10 +131,10 @@ import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState import org.monogram.presentation.core.ui.saveBitmapToGallery import org.monogram.presentation.core.ui.shareBitmap import org.monogram.presentation.core.util.CountryManager +import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.features.stickers.ui.menu.EmojisGrid import org.monogram.presentation.features.stickers.ui.view.StickerImage -import org.monogram.presentation.core.ui.SectionHeader import java.util.Locale import kotlin.math.roundToInt @@ -145,7 +146,9 @@ val QrSurfaceShapeColor = Color(0xFFE3E6D8) fun SettingsContent(component: SettingsComponent) { val state by component.state.subscribeAsState() val adaptiveInfo = currentWindowAdaptiveInfo() - val isTablet = adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) + val isTabletInterfaceEnabled = LocalTabletInterfaceEnabled.current + val isTablet = + adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) && isTabletInterfaceEnabled val context = LocalContext.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val haptic = LocalHapticFeedback.current diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 1c561ed0..f8d2e5a9 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -617,6 +617,8 @@ Mostrar vistas previas de enlaces en mensajes Arrastrar para Volver Deslizar desde el borde izquierdo para volver + Interfaz para tablet + Usar diseño de pantalla dividida en tablets Dos Líneas Tres Líneas Mostrar Fotos diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 3014d217..9e237294 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -587,6 +587,8 @@ Ցուցադրել հղումների բովանդակությունը հաղորդագրություններում Քաշել՝ հետ գնալու համար Սահեցրեք ձախ եզրից՝ հետ գնալու համար + Պլանշետային ինտերֆեյս + Պլանշետներում օգտագործել բաժանված էկրանի դասավորություն Երկտողանի Եռատողանի Ցույց տալ նկարները diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index d3699298..ecbc764b 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -618,6 +618,8 @@ Exiba prévias de links nas mensagens Arrastar para voltar Deslize da borda esquerda para retornar + Interface para tablet + Usar layout de tela dividida em tablets Duas linhas Três linhas Mostrar fotos diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index bd76207d..19f75b48 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -605,6 +605,8 @@ Отображать превью для ссылок в сообщениях Свайп для возврата Возврат назад свайпом от левого края + Планшетный интерфейс + Использовать разделенный интерфейс на планшетах Две строки Три строки Показывать фотографии diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 250c8730..32ce5780 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -635,6 +635,8 @@ Zobrazovať náhľady odkazov v správach Potiahnutím späť Potiahnutím z ľavého okraja sa vrátiť späť + Tabletové rozhranie + Použiť rozloženie rozdelenej obrazovky na tabletoch Dvojriadkové Trojriadkové Zobraziť fotografie diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 62cbc886..b1397231 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -605,6 +605,8 @@ Відображати прев\'ю для посилань у повідомленнях Свайп для повернення Повернення назад свайпом від лівого краю + Планшетний інтерфейс + Використовувати розділений інтерфейс на планшетах Дві рядки Три рядки Показувати фотографії diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index c0236325..74a03c66 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -602,6 +602,8 @@ 在消息中显示链接预览 拖拽返回 从左边缘滑动返回 + 平板界面 + 在平板上使用分屏布局 两行显示 三行显示 显示头像 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 42142e0d..678f2688 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -624,6 +624,8 @@ Display previews for links in messages Drag to Back Swipe from left edge to go back + Tablet Interface + Use split-screen layout on tablets Two-line Three-line Show Photos From 3ad52c399988ac04a673b34fd584789dacf5ff6c Mon Sep 17 00:00:00 2001 From: iWisp360 <114373500+iWisp360@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:02:05 +0000 Subject: [PATCH 61/83] Add 12h format support depending on locale (#219) --- .../java/org/monogram/app/di/AppModule.kt | 27 ++++++- .../monogram/core/date/DateFormatManager.kt | 23 ++++++ .../java/org/monogram/data/di/dataModule.kt | 2 +- .../org/monogram/data/mapper/ChatMapper.kt | 19 ++++- .../data/mapper/UserStatusFormatter.kt | 15 +++- .../monogram/data/mapper/ChatMapperTest.kt | 61 +++++++++++++++ .../presentation/core/util/DateUtils.kt | 12 ++- .../presentation/core/util/StringUtil.kt | 16 ++-- .../chats/chatList/components/ChatListItem.kt | 6 +- .../chatList/components/MessageSearchItem.kt | 7 +- .../chatContent/RestrictUserSheet.kt | 7 +- .../channels/ChannelAlbumMessageBubble.kt | 7 +- .../channels/ChannelGifMessageBubble.kt | 9 ++- .../channels/ChannelMessageUtils.kt | 4 +- .../channels/ChannelTextMessageBubble.kt | 8 +- .../chats/ChatAlbumMessageBubble.kt | 7 +- .../components/chats/GifMessageBubble.kt | 9 ++- .../components/chats/MessageUtils.kt | 10 ++- .../components/chats/PollMessageBubble.kt | 7 +- .../components/chats/StickerMessageBubble.kt | 7 +- .../components/chats/TextMessageBubble.kt | 8 +- .../components/chats/VideoNoteBubble.kt | 7 +- .../components/inputbar/MentionSuggestions.kt | 6 +- .../components/inputbar/SchedulePickers.kt | 5 +- .../inputbar/ScheduledMessagesSheet.kt | 10 ++- .../features/chats/newChat/NewChatContent.kt | 6 +- .../features/profile/ProfileContent.kt | 6 +- .../profile/components/ProfileMediaSection.kt | 8 +- .../profile/components/StatisticsViewer.kt | 16 ++-- .../profile/logs/components/ActionDetails.kt | 6 +- .../profile/logs/components/LogBubble.kt | 8 +- .../stickers/ui/menu/MessageOptionsMenu.kt | 11 ++- .../features/viewers/YouTubeViewer.kt | 6 +- .../components/VideoViewerComponents.kt | 6 +- .../viewers/components/ViewerComponents.kt | 6 +- .../settings/sessions/SessionItem.kt | 8 +- .../src/main/res/values-es/string.xml | 1 - .../src/main/res/values-hy/string.xml | 1 - .../src/main/res/values-pt-rBR/string.xml | 1 - .../src/main/res/values-ru-rRU/string.xml | 1 - .../src/main/res/values-sk/string.xml | 1 - .../src/main/res/values-uk/string.xml | 1 - .../src/main/res/values-zh-rCN/string.xml | 1 - presentation/src/main/res/values/string.xml | 1 - .../presentation/core/util/DateUtilsTest.kt | 75 ++++++++++++++++--- 45 files changed, 386 insertions(+), 83 deletions(-) create mode 100644 core/src/main/kotlin/org/monogram/core/date/DateFormatManager.kt create mode 100644 data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt diff --git a/app/src/main/java/org/monogram/app/di/AppModule.kt b/app/src/main/java/org/monogram/app/di/AppModule.kt index e3d35c20..cf75fc9b 100644 --- a/app/src/main/java/org/monogram/app/di/AppModule.kt +++ b/app/src/main/java/org/monogram/app/di/AppModule.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.ClipboardManager import android.content.Context import android.telephony.TelephonyManager +import android.text.format.DateFormat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -11,9 +12,27 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module import org.monogram.core.Logger import org.monogram.data.di.dataModule -import org.monogram.domain.managers.* -import org.monogram.domain.repository.* -import org.monogram.presentation.core.util.* +import org.monogram.domain.managers.AssetsManager +import org.monogram.domain.managers.ClipManager +import org.monogram.domain.managers.DistrManager +import org.monogram.domain.managers.DomainManager +import org.monogram.domain.managers.PhoneManager +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.EditorSnippetProvider +import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.BotPreferences +import org.monogram.presentation.core.util.CachePreferences +import org.monogram.presentation.core.util.DateFormatManager +import org.monogram.presentation.core.util.DateFormatManagerImpl +import org.monogram.presentation.core.util.DownloadUtils +import org.monogram.presentation.core.util.EditorSnippetPreferences +import org.monogram.presentation.core.util.ExternalNavigatorImpl +import org.monogram.presentation.core.util.IDownloadUtils +import org.monogram.presentation.core.util.ToastMessageDisplayer import org.monogram.presentation.di.uiModule import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool @@ -41,6 +60,8 @@ val appModule = module { } single { LoggerImpl() } + single { DateFormatManagerImpl(DateFormat.is24HourFormat(androidContext())) } + factory { PhoneManagerImpl( androidContext().getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager, diff --git a/core/src/main/kotlin/org/monogram/core/date/DateFormatManager.kt b/core/src/main/kotlin/org/monogram/core/date/DateFormatManager.kt new file mode 100644 index 00000000..4e2aeadd --- /dev/null +++ b/core/src/main/kotlin/org/monogram/core/date/DateFormatManager.kt @@ -0,0 +1,23 @@ +package org.monogram.core.date + +interface DateFormatManager { + fun is24HourFormat(): Boolean + fun getHourMinuteFormat(): String +} + +class DateFormatManagerImpl( + private val use24HourFormat: Boolean +) : DateFormatManager { + override fun is24HourFormat(): Boolean = use24HourFormat + override fun getHourMinuteFormat(): String = if (use24HourFormat) "HH:mm" else "h:mm a" +} + +class Fake12HourDateFormatManagerImpl : DateFormatManager { + override fun is24HourFormat(): Boolean = false + override fun getHourMinuteFormat(): String = "h:mm a" +} + +class Fake24HourDateFormatManagerImpl : DateFormatManager { + override fun is24HourFormat(): Boolean = true + override fun getHourMinuteFormat(): String = "HH:mm" +} 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 7ff39758..7ec83b2f 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -389,7 +389,7 @@ val dataModule = module { } single { - ChatMapper(get()) + ChatMapper(get(), get()) } single { diff --git a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt index acea7b6e..035d5afc 100644 --- a/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ChatMapper.kt @@ -1,13 +1,22 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi +import org.monogram.core.date.DateFormatManager import org.monogram.data.db.model.ChatEntity -import org.monogram.domain.models.* +import org.monogram.domain.models.ChatModel +import org.monogram.domain.models.ChatType +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.UsernamesModel import org.monogram.domain.repository.StringProvider import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale -class ChatMapper(private val stringProvider: StringProvider) { +class ChatMapper( + private val stringProvider: StringProvider, + private val dateFormatManager: DateFormatManager +) { fun mapChatToModel( chat: TdApi.Chat, order: Long, @@ -431,7 +440,8 @@ class ChatMapper(private val stringProvider: StringProvider) { } val date = lastMsg.date - val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + val timeFormat = + SimpleDateFormat(dateFormatManager.getHourMinuteFormat(), Locale.getDefault()) val time = if (date > 0) timeFormat.format(Date(date.toLong() * 1000)) else "" return Triple(txt, entities, time) } @@ -455,6 +465,7 @@ class ChatMapper(private val stringProvider: StringProvider) { return formatChatUserStatus( status = status, stringProvider = stringProvider, + dateFormatManager = dateFormatManager, isBot = isBot ) } diff --git a/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt index 5fd50ab0..fd101f1d 100644 --- a/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt +++ b/data/src/main/java/org/monogram/data/mapper/UserStatusFormatter.kt @@ -2,13 +2,16 @@ package org.monogram.data.mapper import android.text.format.DateUtils import org.drinkless.tdlib.TdApi +import org.monogram.core.date.DateFormatManager import org.monogram.domain.repository.StringProvider import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale internal fun formatChatUserStatus( status: TdApi.UserStatus, stringProvider: StringProvider, + dateFormatManager: DateFormatManager, isBot: Boolean = false ): String { if (isBot) return stringProvider.getString("chat_mapper_bot") @@ -29,13 +32,19 @@ internal fun formatChatUserStatus( DateUtils.isToday(wasOnline) -> { val date = Date(wasOnline) - val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + val format = SimpleDateFormat( + dateFormatManager.getHourMinuteFormat(), + Locale.getDefault() + ) stringProvider.getString("chat_mapper_seen_at", format.format(date)) } isYesterday(wasOnline) -> { val date = Date(wasOnline) - val format = SimpleDateFormat("HH:mm", Locale.getDefault()) + val format = SimpleDateFormat( + dateFormatManager.getHourMinuteFormat(), + Locale.getDefault() + ) stringProvider.getString("chat_mapper_seen_yesterday", format.format(date)) } diff --git a/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt b/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt new file mode 100644 index 00000000..fd3fcec8 --- /dev/null +++ b/data/src/test/java/org/monogram/data/mapper/ChatMapperTest.kt @@ -0,0 +1,61 @@ +package org.monogram.data.mapper + +import org.drinkless.tdlib.TdApi +import org.junit.Assert.assertEquals +import org.junit.Test +import org.monogram.core.date.Fake12HourDateFormatManagerImpl +import org.monogram.core.date.Fake24HourDateFormatManagerImpl +import org.monogram.domain.repository.StringProvider +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ChatMapperTest { + + @Test + fun `formatMessageInfo uses 12 hour time format`() { + val mapper = ChatMapper(FakeStringProvider(), Fake12HourDateFormatManagerImpl()) + val timestampSeconds = 1710948000 + val message = createTextMessage(timestampSeconds) + + val (_, _, time) = mapper.formatMessageInfo(message, null) { null } + + val expected = + SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(timestampSeconds * 1000L)) + assertEquals(expected, time) + } + + @Test + fun `formatMessageInfo uses 24 hour time format`() { + val mapper = ChatMapper(FakeStringProvider(), Fake24HourDateFormatManagerImpl()) + val timestampSeconds = 1710948000 + val message = createTextMessage(timestampSeconds) + + val (_, _, time) = mapper.formatMessageInfo(message, null) { null } + + val expected = + SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestampSeconds * 1000L)) + assertEquals(expected, time) + } + + private fun createTextMessage(timestampSeconds: Int): TdApi.Message { + return TdApi.Message().apply { + date = timestampSeconds + content = TdApi.MessageText().apply { + text = TdApi.FormattedText("test", emptyArray()) + } + } + } + + private class FakeStringProvider : StringProvider { + override fun getString(resName: String): String = resName + + override fun getString(resName: String, vararg formatArgs: Any): String = resName + + override fun getQuantityString( + resName: String, + quantity: Int, + vararg formatArgs: Any + ): String = resName + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt b/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt index 72056d4d..38f215f6 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/DateUtils.kt @@ -6,6 +6,10 @@ import java.util.Date import java.util.Locale import kotlin.math.abs import kotlin.math.roundToLong +import org.monogram.core.date.DateFormatManager as CoreDateFormatManager +import org.monogram.core.date.DateFormatManagerImpl as CoreDateFormatManagerImpl +import org.monogram.core.date.Fake12HourDateFormatManagerImpl as CoreFake12HourDateFormatManagerImpl +import org.monogram.core.date.Fake24HourDateFormatManagerImpl as CoreFake24HourDateFormatManagerImpl /** * Formats date as relative string as for day, week, year, periods and so-on @@ -14,6 +18,7 @@ import kotlin.math.roundToLong * @param now optional current date (for custom formatting or testing) **/ fun Date.toShortRelativeDate( + timeFormat: String, locale: Locale = Locale.getDefault(), now: Date = Date() ): String { @@ -42,7 +47,7 @@ fun Date.toShortRelativeDate( return when (diffDays) { 0L -> { - SimpleDateFormat("HH:mm", locale).format(this) + SimpleDateFormat(timeFormat, locale).format(this) } in 1..6 -> { SimpleDateFormat("EEE", locale).format(this) @@ -59,3 +64,8 @@ fun Date.toShortRelativeDate( } } } + +typealias DateFormatManager = CoreDateFormatManager +typealias DateFormatManagerImpl = CoreDateFormatManagerImpl +typealias Fake12HourDateFormatManagerImpl = CoreFake12HourDateFormatManagerImpl +typealias Fake24HourDateFormatManagerImpl = CoreFake24HourDateFormatManagerImpl diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt b/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt index 8df82199..2188a5c1 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/StringUtil.kt @@ -14,17 +14,17 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle +import org.koin.compose.koinInject import org.monogram.domain.models.* import org.monogram.presentation.R import java.text.SimpleDateFormat import java.util.* -fun formatLastSeen(lastSeen: Long?, context: Context): String { +fun formatLastSeen(lastSeen: Long?, context: Context, timeFormat: String): String { if (lastSeen == null || lastSeen <= 0L) return context.getString(R.string.last_seen_recently) val now = System.currentTimeMillis() val diff = now - lastSeen - if (diff < 0) return context.getString(R.string.last_seen_just_now) return when { @@ -35,12 +35,12 @@ fun formatLastSeen(lastSeen: Long?, context: Context): String { } DateUtils.isToday(lastSeen) -> { - val time = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(lastSeen)) + val time = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(lastSeen)) context.getString(R.string.last_seen_at, time) } isYesterday(lastSeen) -> { - val time = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(lastSeen)) + val time = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(lastSeen)) context.getString(R.string.last_seen_yesterday_at, time) } @@ -57,10 +57,12 @@ fun rememberUserStatusText(user: UserModel?): String { if (user.type == UserTypeEnum.BOT) return stringResource(R.string.status_bot) val context = LocalContext.current + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() return remember(user.userStatus, user.lastSeen) { when (user.userStatus) { UserStatusType.ONLINE -> context.getString(R.string.status_online) - UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context) + UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context, timeFormat) UserStatusType.RECENTLY -> context.getString(R.string.last_seen_recently) UserStatusType.LAST_WEEK -> context.getString(R.string.last_seen_within_week) UserStatusType.LAST_MONTH -> context.getString(R.string.last_seen_within_month) @@ -73,13 +75,13 @@ private fun isYesterday(timestamp: Long): Boolean { return DateUtils.isToday(timestamp + DateUtils.DAY_IN_MILLIS) } -fun getUserStatusText(user: UserModel?, context: Context): String { +fun getUserStatusText(user: UserModel?, context: Context, timeFormat: String): String { if (user == null) return context.getString(R.string.status_offline) if (user.type == UserTypeEnum.BOT) return context.getString(R.string.status_bot) return when (user.userStatus) { UserStatusType.ONLINE -> context.getString(R.string.status_online) - UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context) + UserStatusType.OFFLINE -> formatLastSeen(user.lastSeen, context, timeFormat) UserStatusType.RECENTLY -> context.getString(R.string.last_seen_recently) UserStatusType.LAST_WEEK -> context.getString(R.string.last_seen_within_week) UserStatusType.LAST_MONTH -> context.getString(R.string.last_seen_within_month) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 0fb5d4cd..3e9c0d24 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -57,11 +57,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import org.monogram.core.date.toDate +import org.koin.compose.koinInject import org.monogram.domain.models.ChatModel import org.monogram.domain.models.MessageEntityType import org.monogram.presentation.R import org.monogram.presentation.core.ui.AvatarForChat import org.monogram.presentation.core.ui.TypingDots +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.toShortRelativeDate import org.monogram.presentation.features.chats.currentChat.components.chats.addEmojiStyle import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji @@ -219,7 +221,9 @@ private fun ChatListItemHeader( chat: ChatModel, isSavedMessages: Boolean ) { - val chatTime = chat.lastMessageDate.toDate().toShortRelativeDate() + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val chatTime = chat.lastMessageDate.toDate().toShortRelativeDate(timeFormat) val savedMessagesTitle = stringResource(R.string.menu_saved_messages) Row( diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt index 8e46ef96..78a4978f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/MessageSearchItem.kt @@ -10,9 +10,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import java.text.SimpleDateFormat import java.util.* @@ -27,11 +29,14 @@ fun MessageSearchItem( val currentCalendar = Calendar.getInstance() calendar.time = date + val dateFormatManager: DateFormatManager = koinInject(); + val timeFormat = dateFormatManager.getHourMinuteFormat() + val isToday = calendar.get(Calendar.YEAR) == currentCalendar.get(Calendar.YEAR) && calendar.get(Calendar.DAY_OF_YEAR) == currentCalendar.get(Calendar.DAY_OF_YEAR) val format = if (isToday) { - SimpleDateFormat("HH:mm", Locale.getDefault()) + SimpleDateFormat(timeFormat, Locale.getDefault()) } else { SimpleDateFormat("MMM d", Locale.getDefault()) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt index ec49902a..9bc9dac0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/RestrictUserSheet.kt @@ -16,8 +16,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.ChatPermissionsModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import java.text.SimpleDateFormat import java.util.* @@ -40,6 +42,9 @@ fun RestrictUserSheet( } } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + if (showDatePicker) { val datePickerState = rememberDatePickerState( initialSelectedDateMillis = if (untilDate != 0) untilDate.toLong() * 1000 else System.currentTimeMillis() @@ -184,7 +189,7 @@ fun RestrictUserSheet( Text(stringResource(R.string.restrict_until), style = MaterialTheme.typography.bodyLarge) Text( text = if (untilDate == 0) stringResource(R.string.restrict_forever) else SimpleDateFormat( - "MMM d, yyyy, HH:mm", + "MMM d, yyyy, $timeFormat", Locale.getDefault() ).format(Date(untilDate.toLong() * 1000)), style = MaterialTheme.typography.bodySmall, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt index d4fefdb8..1b2f7972 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt @@ -40,8 +40,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.CompactMediaMosaic import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent @@ -179,7 +181,10 @@ fun ChannelAlbumMessageBubble( } } - val formattedTime = remember(lastMsg.date) { formatTime(context, lastMsg.date) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + + val formattedTime = remember(lastMsg.date) { formatTime(lastMsg.date, timeFormat) } val revealedSpoilers = remember { mutableStateListOf() } var bubblePosition by remember { mutableStateOf(Offset.Zero) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt index de3f2ab3..99bd7254 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt @@ -32,9 +32,11 @@ import androidx.compose.ui.zIndex import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression @@ -84,6 +86,9 @@ fun ChannelGifMessageBubble( var gifPosition by remember { mutableStateOf(Offset.Zero) } val revealedSpoilers = remember { mutableStateListOf() } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() val gifCacheKey = remember(stablePath, content.fileId) { @@ -299,7 +304,7 @@ fun ChannelGifMessageBubble( } } Text( - text = formatTime(context, msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = Color.White ) @@ -393,7 +398,7 @@ fun ChannelGifMessageBubble( } } Text( - text = formatTime(context, msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt index 78f54012..42e9961a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelMessageUtils.kt @@ -5,8 +5,8 @@ import org.monogram.presentation.R import java.text.SimpleDateFormat import java.util.* -fun formatTime(context: Context, ts: Int): String = - SimpleDateFormat(context.getString(R.string.format_time), Locale.getDefault()).format(Date(ts.toLong() * 1000)) +fun formatTime(ts: Int, timeFormat: String): String = + SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(ts.toLong() * 1000)) fun formatViews(context: Context, views: Int): String { return when { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt index e05cfa21..b7bc8225 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt @@ -23,9 +23,11 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.chats.currentChat.components.chats.* @Composable @@ -63,6 +65,9 @@ fun ChannelTextMessageBubble( bottomEnd = if (showComments && msg.canGetMessageThread) 4.dp else cornerRadius ) + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val revealedSpoilers = remember { mutableStateListOf() } Column( @@ -158,9 +163,8 @@ fun ChannelTextMessageBubble( Spacer(modifier = Modifier.width(8.dp)) } } - Text( - text = formatTime(context, msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt index 8f9bda8f..a0f2b991 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt @@ -26,9 +26,11 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.CompactMediaMosaic @@ -133,6 +135,9 @@ fun ChatAlbumMessageBubble( ) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val captionMsg = remember(uniqueMessages) { uniqueMessages.firstOrNull { val content = it.content @@ -161,7 +166,7 @@ fun ChatAlbumMessageBubble( } val lastMsg = uniqueMessages.last() - val formattedTime = remember(lastMsg.date) { formatTime(lastMsg.date) } + val formattedTime = remember(lastMsg.date) { formatTime(lastMsg.date, timeFormat) } val revealedSpoilers = remember { mutableStateListOf() } var bubblePosition by remember { mutableStateOf(Offset.Zero) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt index 7a85ad4a..57c6aa19 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt @@ -33,8 +33,10 @@ import androidx.media3.common.util.UnstableApi import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression @@ -73,6 +75,9 @@ fun GifMessageBubble( val smallCorner = 4.dp val tailCorner = 2.dp + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + var stablePath by remember(msg.id) { mutableStateOf(content.path) } !stablePath.isNullOrBlank() val gifCacheKey = remember(stablePath, content.fileId) { @@ -318,7 +323,7 @@ fun GifMessageBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = Color.White ) @@ -391,7 +396,7 @@ fun GifMessageBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = timeColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt index b385b50a..6328a001 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt @@ -25,10 +25,12 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.EmojiStyle import org.monogram.presentation.features.chats.currentChat.components.channels.formatViews import java.io.File @@ -42,8 +44,8 @@ val LocalLinkHandler = staticCompositionLocalOf<(String) -> Unit> { { _ -> } } -fun formatTime(ts: Int): String = - SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(ts.toLong() * 1000)) +fun formatTime(ts: Int, timeFormat: String): String = + SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date(ts.toLong() * 1000)) fun formatDuration(seconds: Int): String { val m = seconds / 60 @@ -236,8 +238,10 @@ fun MessageMetadata( tint = contentColor ) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = contentColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt index 3cfd3d40..3d0cd133 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt @@ -28,8 +28,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.* import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager @Composable fun PollMessageBubble( @@ -444,6 +446,9 @@ private fun PollFooter( ) { val metaColor = contentColor.copy(alpha = 0.65f) + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + Row( modifier = Modifier .fillMaxWidth() @@ -461,7 +466,7 @@ private fun PollFooter( Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = formatTime(date), + text = formatTime(date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = metaColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt index 0103eeda..a32897a0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/StickerMessageBubble.kt @@ -22,9 +22,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.util.UnstableApi +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.StickerModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.stickers.ui.view.StickerImage import org.monogram.presentation.features.stickers.ui.view.StickerSkeleton import java.io.File @@ -44,6 +46,9 @@ fun StickerMessageBubble( toProfile: (Long) -> Unit = {}, modifier: Modifier = Modifier ) { + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + Column( modifier = modifier, horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start @@ -146,7 +151,7 @@ fun StickerMessageBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = Color.White, ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt index 9b3bee5d..db337c93 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt @@ -19,8 +19,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager @Composable @@ -49,6 +51,9 @@ fun TextMessageBubble( val smallCorner = (bubbleRadius / 4f).coerceAtLeast(4f).dp val tailCorner = 2.dp + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val bubbleShape = remember(isOutgoing, isSameSenderAbove, isSameSenderBelow, cornerRadius, smallCorner) { RoundedCornerShape( topStart = if (!isOutgoing && isSameSenderAbove) smallCorner else cornerRadius, @@ -176,9 +181,8 @@ fun TextMessageBubble( ) Spacer(modifier = Modifier.width(4.dp)) } - Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = timeColor ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt index 4fdcd024..105584e9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoNoteBubble.kt @@ -48,8 +48,10 @@ import androidx.media3.ui.PlayerView import coil3.compose.rememberAsyncImagePainter import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getMimeType import org.monogram.presentation.features.stickers.ui.view.shimmerEffect import java.io.File @@ -73,6 +75,9 @@ fun VideoNoteBubble( val size = 260.dp var notePosition by remember { mutableStateOf(Offset.Zero) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + Column( modifier = modifier, horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start @@ -337,7 +342,7 @@ fun VideoNoteBubble( Spacer(modifier = Modifier.width(4.dp)) } Text( - text = formatTime(msg.date), + text = formatTime(msg.date, timeFormat), style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), color = Color.White ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt index fa2eb057..f4fe0961 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/MentionSuggestions.kt @@ -14,9 +14,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.UserModel import org.monogram.domain.models.UserStatusType import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getUserStatusText @Composable @@ -25,6 +27,8 @@ fun MentionSuggestions( onMentionClick: (UserModel) -> Unit ) { val context = LocalContext.current + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() Column( modifier = Modifier .fillMaxWidth() @@ -58,7 +62,7 @@ fun MentionSuggestions( maxLines = 1, overflow = TextOverflow.Ellipsis ) - val status = user.username?.let { "@$it" } ?: getUserStatusText(user, context) + val status = user.username?.let { "@$it" } ?: getUserStatusText(user, context, timeFormat) Text( text = status, style = MaterialTheme.typography.labelMedium, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt index f69c0b38..1d13fd40 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/SchedulePickers.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import java.text.SimpleDateFormat import java.util.* @@ -104,9 +105,9 @@ fun buildScheduledDateEpochSeconds(selectedDateMillis: Long, hour: Int, minute: return (selected.timeInMillis / 1000L).toInt() } -fun formatScheduledTimestamp(epochSeconds: Int): String { +fun formatScheduledTimestamp(epochSeconds: Int, timeFormat: String): String { return try { - val formatter = SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) + val formatter = SimpleDateFormat("dd MMM, $timeFormat", Locale.getDefault()) formatter.format(Date(epochSeconds * 1000L)) } catch (_: Exception) { "" diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt index 7a913541..dd8c0bf2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ScheduledMessagesSheet.kt @@ -14,9 +14,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -39,6 +41,9 @@ fun ScheduledMessagesSheet( scheduledMessagesSorted.count { canEditScheduledMessage(it) } } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + ModalBottomSheet( onDismissRequest = onDismiss, dragHandle = { BottomSheetDefaults.DragHandle() }, @@ -102,7 +107,7 @@ fun ScheduledMessagesSheet( text = if (nextScheduled != null) { stringResource( R.string.scheduled_messages_summary_next, - formatScheduledTimestamp(nextScheduled.date) + formatScheduledTimestamp(nextScheduled.date, timeFormat) ) } else { stringResource(R.string.scheduled_messages_empty) @@ -245,8 +250,9 @@ private fun ScheduledMessageRow( maxLines = 1, overflow = TextOverflow.Ellipsis ) + val dateFormatManager: DateFormatManager = koinInject() Text( - text = formatScheduledTimestamp(message.date), + text = formatScheduledTimestamp(message.date, dateFormatManager.getHourMinuteFormat()), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt index 01adf2ab..2ab9333f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/newChat/NewChatContent.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.domain.models.UserModel import org.monogram.domain.models.UserStatusType import org.monogram.presentation.R @@ -44,6 +45,7 @@ import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.shimmerBackground +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.FileUtils import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.chatList.components.NewChannelContent @@ -621,6 +623,8 @@ private fun ContactItem( onRemoveContact: () -> Unit ) { val context = LocalContext.current + val timeFormatManager: DateFormatManager = koinInject() + val timeFormat = timeFormatManager.getHourMinuteFormat() val isSupport = user.isSupport var showMenu by remember { mutableStateOf(false) } val cornerRadius = 24.dp @@ -684,7 +688,7 @@ private fun ContactItem( color = MaterialTheme.colorScheme.onSurface ) } else { - val statusText = getUserStatusText(user, context) + val statusText = getUserStatusText(user, context, timeFormat) Text( text = statusText, style = MaterialTheme.typography.bodySmall, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt index 7bf37fdb..5a169157 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/ProfileContent.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.window.core.layout.WindowSizeClass import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.koin.compose.koinInject import org.monogram.domain.models.UserStatusType import org.monogram.domain.models.UserTypeEnum import org.monogram.presentation.R @@ -69,6 +70,7 @@ import org.monogram.presentation.core.ui.ConfirmationSheet import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.rememberCollapsingToolbarScaffoldState import org.monogram.presentation.core.ui.rememberShimmerBrush +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.LocalTabletInterfaceEnabled import org.monogram.presentation.core.util.ScrollStrategy import org.monogram.presentation.core.util.getUserStatusText @@ -95,6 +97,8 @@ fun ProfileContent(component: ProfileComponent) { val context = LocalContext.current val collapsingToolbarState = rememberCollapsingToolbarScaffoldState() + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val chat = state.chat val user = state.user @@ -140,7 +144,7 @@ fun ProfileContent(component: ProfileComponent) { ?: ownProfileSubtitle } - else -> getUserStatusText(user, context) + else -> getUserStatusText(user, context, timeFormat) } val isOnline = user?.type != UserTypeEnum.BOT && user?.userStatus == UserStatusType.ONLINE diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt index 6581cb7b..6ded266c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/ProfileMediaSection.kt @@ -35,6 +35,7 @@ import androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import org.koin.compose.koinInject import org.monogram.domain.models.GroupMemberModel import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel @@ -42,6 +43,7 @@ import org.monogram.domain.models.UserStatusType import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.ui.rememberShimmerBrush +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.getUserStatusText import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType @@ -378,7 +380,11 @@ private fun LazyGridScope.membersList( }, supportingContent = { val context = LocalContext.current - val statusText = getUserStatusText(user, context) + + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + + val statusText = getUserStatusText(user, context, timeFormat) Text( text = statusText, diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt index 0baf0bbe..b5b498a0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/components/StatisticsViewer.kt @@ -36,8 +36,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.json.JSONObject +import org.koin.compose.koinInject import org.monogram.domain.models.* import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.chats.chatList.components.SectionHeader import java.text.SimpleDateFormat import java.util.* @@ -767,8 +769,10 @@ private fun RevenueMetricCard(modifier: Modifier = Modifier, label: String, valu @Composable fun GraphSection(title: String, graph: StatisticsGraphModel, color: Color, onLoadGraph: (String) -> Unit) { if (graph is StatisticsGraphModel.Error) return + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val parsedGraph = remember(graph) { - if (graph is StatisticsGraphModel.Data) parseStatisticsGraph(graph.jsonData) else null + if (graph is StatisticsGraphModel.Data) parseStatisticsGraph(graph.jsonData, timeFormat) else null } Column { @@ -1394,7 +1398,7 @@ private data class ParsedStatisticsSeries( val values: List ) -private fun parseStatisticsGraph(jsonData: String): ParsedStatisticsGraph? { +private fun parseStatisticsGraph(jsonData: String, timeFormat: String): ParsedStatisticsGraph? { return coRunCatching { val root = JSONObject(jsonData) val columnsArray = root.optJSONArray("columns") ?: return null @@ -1423,7 +1427,7 @@ private fun parseStatisticsGraph(jsonData: String): ParsedStatisticsGraph? { val labels = (0 until labelCount).map { index -> val rawX = xValues.getOrNull(index)?.toLong() ?: index.toLong() - formatChartXAxisLabel(rawX, labelCount) + formatChartXAxisLabel(rawX, labelCount, timeFormat) } val series = mutableListOf() @@ -1450,14 +1454,14 @@ private fun parseStatisticsGraph(jsonData: String): ParsedStatisticsGraph? { }.getOrNull() } -private fun formatChartXAxisLabel(rawValue: Long, pointCount: Int): String { +private fun formatChartXAxisLabel(rawValue: Long, pointCount: Int, timeFormat: String): String { return if (rawValue > 10_000_000_000L) { val date = Date(rawValue) - if (pointCount <= 24) SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + if (pointCount <= 24) SimpleDateFormat(timeFormat, Locale.getDefault()).format(date) else SimpleDateFormat("dd MMM", Locale.getDefault()).format(date) } else if (rawValue > 1_000_000_000L) { val date = Date(rawValue * 1000L) - if (pointCount <= 24) SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + if (pointCount <= 24) SimpleDateFormat(timeFormat, Locale.getDefault()).format(date) else SimpleDateFormat("dd MMM", Locale.getDefault()).format(date) } else { rawValue.toString() diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt index 8cef4e04..d25c43b0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/ActionDetails.kt @@ -47,10 +47,12 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import org.koin.compose.koinInject import org.monogram.domain.models.ChatEventActionModel import org.monogram.domain.models.ChatPermissionsModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.profile.logs.ProfileLogsComponent import java.io.File import java.text.SimpleDateFormat @@ -141,8 +143,10 @@ fun ActionDetails( if (action.untilDate > 0) { val date = Date(action.untilDate.toLong() * 1000) + val dateFormatManager: DateFormatManager = koinInject(); + val timeFormat = dateFormatManager.getHourMinuteFormat() val dateText = - SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(date) + SimpleDateFormat("MMM dd, yyyy $timeFormat", Locale.getDefault()).format(date) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp) diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt index 8e62ff58..4a0af0bd 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/LogBubble.kt @@ -19,11 +19,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.koin.compose.koinInject import org.monogram.domain.models.ChatEventActionModel import org.monogram.domain.models.ChatEventModel import org.monogram.domain.models.MessageSenderModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.profile.logs.ProfileLogsComponent import java.text.SimpleDateFormat import java.util.* @@ -87,10 +89,12 @@ fun LogBubble( var showFullDate by remember { mutableStateOf(false) } val date = Date(event.date.toLong() * 1000) + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val dateText = if (showFullDate) { - SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()).format(date) + SimpleDateFormat("MMM dd, $timeFormat:ss", Locale.getDefault()).format(date) } else { - SimpleDateFormat("HH:mm", Locale.getDefault()).format(date) + SimpleDateFormat(timeFormat, Locale.getDefault()).format(date) } Row( diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt index e6fff23f..574d9e4d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt @@ -134,6 +134,7 @@ import org.monogram.domain.repository.EmojiRepository import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar import org.monogram.presentation.core.util.AppPreferences +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.chats.currentChat.chatContent.DeleteMessagesSheet import org.monogram.presentation.features.chats.currentChat.components.chats.getEmojiFontFamily import org.monogram.presentation.features.stickers.ui.view.StickerImage @@ -196,6 +197,10 @@ fun MessageOptionsMenu( val windowSize = LocalWindowInfo.current.containerSize val screenHeight = windowSize.height + + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val windowInsets = WindowInsets.systemBars.union(WindowInsets.ime) val topInset = windowInsets.getTop(density) val bottomInset = windowInsets.getBottom(density) @@ -874,7 +879,7 @@ fun MessageOptionsMenu( else -> { val viewerDateFormat = - remember { SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) } + remember { SimpleDateFormat("MMM d, $timeFormat", Locale.getDefault()) } val scrollState = rememberScrollState() Column( modifier = Modifier @@ -1212,7 +1217,9 @@ private fun InternalMenuHeaderInfo( showReadInfo: Boolean, showViewsInfo: Boolean ) { - val dateFormat = remember { SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val dateFormat = remember { SimpleDateFormat("MMM d, $timeFormat", Locale.getDefault()) } val editDate = if (message.editDate > 0) dateFormat.format(Date(message.editDate.toLong() * 1000)) else null val readDate = if (showReadInfo) diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt index 3677933d..bd2f9923 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/YouTubeViewer.kt @@ -50,6 +50,8 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.features.viewers.components.* import java.io.ByteArrayInputStream import java.text.SimpleDateFormat @@ -71,6 +73,8 @@ fun YouTubeViewer( val youtubeId = extractYouTubeId(videoUrl) ?: return val startTime = extractYouTubeTime(videoUrl) val context = LocalContext.current + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() val lifecycleOwner = LocalLifecycleOwner.current val playerState = remember { YouTubePlayerState() } var isInPipMode by remember { mutableStateOf(false) } @@ -290,7 +294,7 @@ fun YouTubeViewer( LaunchedEffect(Unit) { while (true) { - currentTimeStr = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()) + currentTimeStr = SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date()) delay(1000) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt index 3e5eeb82..17374990 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt @@ -55,6 +55,7 @@ import org.koin.compose.koinInject import org.monogram.domain.repository.PlayerDataSourceFactory import org.monogram.domain.repository.StreamingRepository import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.getMimeType import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow @@ -100,6 +101,9 @@ fun VideoPage( val playerFactory = koinInject() val seekDurationMs = seekDuration * 1000L + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + val currentOnDismiss by rememberUpdatedState(onDismiss) val currentOnToggleControls by rememberUpdatedState(onToggleControls) val currentOnToggleSettings by rememberUpdatedState(onToggleSettings) @@ -401,7 +405,7 @@ fun VideoPage( isEnded = isEnded, currentPosition = currentPosition, totalDuration = totalDuration, - currentTime = currentTime(), + currentTime = currentTime(timeFormat), isSettingsOpen = showSettingsMenu, caption = caption, downloadProgress = downloadProgress, diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt index 4e1da8cd..f7b6aa1d 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ViewerComponents.kt @@ -30,6 +30,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.presentation.R +import org.monogram.presentation.core.util.DateFormatManager +import java.text.SimpleDateFormat import java.util.* @Composable @@ -231,8 +233,8 @@ fun formatDuration(durationMs: Long): String { return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) } -fun currentTime(): String = - java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()) +fun currentTime(timeFormat: String): String = + SimpleDateFormat(timeFormat, Locale.getDefault()).format(Date()) fun Context.findActivity(): ComponentActivity? = when (this) { is ComponentActivity -> this diff --git a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt index 8b987e22..02fe9655 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/sessions/SessionItem.kt @@ -29,10 +29,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.koinInject import org.monogram.presentation.R import org.monogram.domain.models.SessionModel import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.spacer.WidthSpacer +import org.monogram.presentation.core.util.DateFormatManager import org.monogram.presentation.core.util.toShortRelativeDate @Composable @@ -43,6 +45,8 @@ internal fun SessionItem( position: ItemPosition = ItemPosition.STANDALONE, onTerminate: (() -> Unit)? ) { + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() Surface( color = MaterialTheme.colorScheme.surfaceContainer, shape = position.toShape(), @@ -80,8 +84,8 @@ internal fun SessionItem( ) Text( - text = if (isPending) "${stringResource(R.string.sessions_unconfirmed)} • ${session.lastActiveDate.toShortRelativeDate()}" - else "${session.location} • ${session.lastActiveDate.toShortRelativeDate()}", + text = if (isPending) "${stringResource(R.string.sessions_unconfirmed)} • ${session.lastActiveDate.toShortRelativeDate(timeFormat)}" + else "${session.location} • ${session.lastActiveDate.toShortRelativeDate(timeFormat)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index f8d2e5a9..fa9e935e 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -1637,7 +1637,6 @@ Mensaje no soportado - HH:mm %1$02d:%2$02d diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 9e237294..8018843e 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -1490,7 +1490,6 @@ Վայր Չաջակցվող հաղորդագրություն - HH:mm %1$02d:%2$02d %1$.1fՀ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index ecbc764b..068ac906 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -1665,7 +1665,6 @@ Mensagem não compatível - HH:mm %1$02d:%2$02d diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 19f75b48..e3b4de43 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -1598,7 +1598,6 @@ Неподдерживаемое сообщение - HH:mm %1$02d:%2$02d diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 32ce5780..3ad90187 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -1661,7 +1661,6 @@ Nepodporovaná správa - HH:mm %1$02d:%2$02d diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index b1397231..a8422be3 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -1598,7 +1598,6 @@ Непідтримуване повідомлення - HH:mm %1$02d:%2$02d diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 74a03c66..a49fae5f 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -1582,7 +1582,6 @@ 不支持的消息 - HH:mm %1$02d:%2$02d diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 678f2688..f0a88250 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1678,7 +1678,6 @@ Unsupported message - HH:mm %1$02d:%2$02d diff --git a/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt b/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt index 732c6a76..eb55cafd 100644 --- a/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt +++ b/presentation/src/test/java/org/monogram/presentation/core/util/DateUtilsTest.kt @@ -9,15 +9,17 @@ import java.util.Locale /** * Test cases for date utils **/ -class DateUtilsTest { +class DateUtils24HourTest { private val ruLocale = Locale.forLanguageTag("ru") private val dateFormatter = SimpleDateFormat("dd.MM.yyyy HH:mm", ruLocale) private val mockToday: Date = dateFormatter.parse("20.03.2024 12:00")!! + private val time24HourFormat = Fake24HourDateFormatManagerImpl().getHourMinuteFormat() @Test fun `When date are today, then returns only hours and minutes`() { + val targetDate = dateFormatter.parse("20.03.2024 15:20")!! - val result = targetDate.toShortRelativeDate(locale = ruLocale, now = mockToday) + val result = targetDate.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday) assertEquals("15:20", result) } @@ -26,38 +28,91 @@ class DateUtilsTest { fun `When date are yesterday and within 1 week, then returns day of week`() { // Yesterday val yesterday = dateFormatter.parse("19.03.2024 10:15")!! - assertEquals("вт", yesterday.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("вт", yesterday.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) // Six days ago val sixDaysAgo = dateFormatter.parse("14.03.2024 09:00")!! - assertEquals("чт", sixDaysAgo.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("чт", sixDaysAgo.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) } @Test fun `When date are older than 1 week but within 1 year, then return day and month`() { // 13.03.2024 - ровно 7 дней назад val sevenDaysAgo = dateFormatter.parse("13.03.2024 14:00")!! - assertEquals("13 мар", sevenDaysAgo.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("13 мар", sevenDaysAgo.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) // Прошлый месяц (меньше 365 дней назад) val monthsAgo = dateFormatter.parse("25.10.2023 18:30")!! - assertEquals("25 окт", monthsAgo.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("25 окт", monthsAgo.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) } @Test fun `When date are older than 1 year, then return full date`() { // Past year val olderThanYear = dateFormatter.parse("10.03.2023 11:11")!! - assertEquals("10.03.2023", olderThanYear.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("10.03.2023", olderThanYear.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) // A long time ago... val veryOldDate = dateFormatter.parse("01.01.2020 00:00")!! - assertEquals("01.01.2020", veryOldDate.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("01.01.2020", veryOldDate.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) } @Test fun `Then future date is older than 1 year, then return full date`() { val futureDate = dateFormatter.parse("16.03.2029 10:00")!! - assertEquals("16.03.2029", futureDate.toShortRelativeDate(locale = ruLocale, now = mockToday)) + assertEquals("16.03.2029", futureDate.toShortRelativeDate(timeFormat = time24HourFormat, locale = ruLocale, now = mockToday)) + } +} + +class DateUtils12HourTest { + private val enLocale = Locale.forLanguageTag("en") + private val dateFormatter = SimpleDateFormat("dd.MM.yyyy h:mm a", enLocale) + private val mockToday: Date = dateFormatter.parse("20.03.2024 12:00 PM")!! + private val time12HourFormat = Fake12HourDateFormatManagerImpl().getHourMinuteFormat() + + @Test + fun `When date are today, then returns only hours and minutes`() { + + val targetDate = dateFormatter.parse("20.03.2024 3:20 PM")!! + val result = targetDate.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday) + + assertEquals("3:20 PM", result) + } + + @Test + fun `When date are yesterday and within 1 week, then returns day of week`() { + // Yesterday + val yesterday = dateFormatter.parse("19.03.2024 10:15 AM")!! + assertEquals("tue", yesterday.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + + // Six days ago + val sixDaysAgo = dateFormatter.parse("14.03.2024 9:00 AM")!! + assertEquals("thu", sixDaysAgo.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + } + + @Test + fun `When date are older than 1 week but within 1 year, then return day and month`() { + val sevenDaysAgo = dateFormatter.parse("13.03.2024 2:00 PM")!! + assertEquals("13 mar", sevenDaysAgo.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + + val monthsAgo = dateFormatter.parse("25.10.2023 6:30 PM")!! + assertEquals("25 oct", monthsAgo.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + } + + @Test + fun `When date are older than 1 year, then return full date`() { + // Past year + val olderThanYear = dateFormatter.parse("10.03.2023 11:11 AM")!! + assertEquals("10.03.2023", olderThanYear.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + + // A long time ago... + val veryOldDate = dateFormatter.parse("01.01.2020 12:00 AM")!! + assertEquals("01.01.2020", veryOldDate.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) + } + + @Test + fun `Then future date is older than 1 year, then return full date`() { + val futureDate = dateFormatter.parse("16.03.2029 10:00 AM")!! + assertEquals("16.03.2029", futureDate.toShortRelativeDate(timeFormat = time12HourFormat, locale = enLocale, now = mockToday)) } -} \ No newline at end of file +} From ebd9459545fb6c640c5a82ca8daedbb9b0320721 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:13:12 +0300 Subject: [PATCH 62/83] eagerly initialize OfflineWarmup and SponsorSyncManager Set `createdAtStart = true` for `OfflineWarmup` and `SponsorSyncManager` in the Koin module to ensure these services start immediately on application launch. --- data/src/main/java/org/monogram/data/di/dataModule.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7ec83b2f..139b011b 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -162,7 +162,7 @@ val dataModule = module { single { DefaultDispatcherProvider() } single { AndroidStringProvider(androidContext()) } single { TdLibParametersProvider(androidContext()) } - single { + single(createdAtStart = true) { OfflineWarmup( scope = get(), dispatchers = get(), @@ -177,7 +177,7 @@ val dataModule = module { stickerRepository = get() ) } - single { + single(createdAtStart = true) { SponsorSyncManager( scope = get(), gateway = get(), From 755565c76eba5886fd313f618cfd8219c1e26257 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:40:35 +0300 Subject: [PATCH 63/83] fix message text rendering and prevent substring out-of-bounds crashes - add `rawText` parameter to `MessageText` to ensure text blocks use unformatted source strings for entity mapping - implement `safeSubstring` and add bounds checking via `coerceIn` when calculating offsets in `MessageText`, `Mappers`, and `MessageUtils` - pass `content.caption` or `content.text` as `rawText` across all message bubble types (Photo, Video, Document, GIF, etc.) - update `TextMessageBubble` to include text and entities in its `AnimatedContent` target state to ensure synchronized updates during edits - move `rememberMessageInlineContent` inside the `AnimatedContent` block in `TextMessageBubble` to fix stale inline content during transitions --- .../channels/ChannelAlbumMessageBubble.kt | 5 +- .../channels/ChannelGifMessageBubble.kt | 53 ++++++++++++++++--- .../channels/ChannelPhotoMessageBubble.kt | 32 +++++++++-- .../channels/ChannelTextMessageBubble.kt | 25 +++++++-- .../channels/ChannelVideoMessageBubble.kt | 11 +++- .../components/chats/AudioMessageBubble.kt | 3 ++ .../chats/ChatAlbumMessageBubble.kt | 1 + .../components/chats/DocumentMessageBubble.kt | 3 ++ .../components/chats/GifMessageBubble.kt | 16 ++++-- .../components/chats/MessageText.kt | 23 +++++--- .../components/chats/MessageUtils.kt | 34 +++++++++--- .../components/chats/PhotoMessageBubble.kt | 36 +++++++++++-- .../components/chats/TextMessageBubble.kt | 28 +++++++--- .../components/chats/VideoMessageBubble.kt | 33 ++++++++++-- .../components/chats/model/Mappers.kt | 9 +++- .../profile/logs/components/MessagePreview.kt | 16 +++++- 16 files changed, 277 insertions(+), 51 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt index 1b2f7972..fec1af4c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelAlbumMessageBubble.kt @@ -86,7 +86,7 @@ fun ChannelAlbumMessageBubble( ) { if (messages.isEmpty()) return - val context = LocalContext.current + LocalContext.current val uniqueMessages = remember(messages) { messages.distinct() } val isDocumentAlbum = remember(uniqueMessages) { uniqueMessages.all { it.content is MessageContent.Document } } val isAudioAlbum = remember(uniqueMessages) { uniqueMessages.all { it.content is MessageContent.Audio } } @@ -255,6 +255,7 @@ fun ChannelAlbumMessageBubble( MessageText( text = finalAnnotatedString, + rawText = caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -463,6 +464,7 @@ fun ChannelDocumentAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -668,6 +670,7 @@ fun ChannelAudioAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt index 99bd7254..23b54576 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelGifMessageBubble.kt @@ -1,21 +1,46 @@ package org.monogram.presentation.features.chats.currentChat.components.channels -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -42,7 +67,14 @@ import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression import org.monogram.presentation.features.chats.currentChat.components.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelGifMessageBubble( @@ -239,7 +271,10 @@ fun ChannelGifMessageBubble( modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(6.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( @@ -282,7 +317,10 @@ fun ChannelGifMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -358,6 +396,7 @@ fun ChannelGifMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt index ce99f3c9..a649a8ca 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelPhotoMessageBubble.kt @@ -5,13 +5,25 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -36,7 +48,15 @@ import org.monogram.domain.models.MessageModel import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.chats.currentChat.AutoDownloadSuppression -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingAction +import org.monogram.presentation.features.chats.currentChat.components.chats.MediaLoadingBackground +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageMetadata +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelPhotoMessageBubble( @@ -256,7 +276,10 @@ fun ChannelPhotoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, msg.isOutgoing, Color.White) @@ -284,6 +307,7 @@ fun ChannelPhotoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt index b7bc8225..a891f8be 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelTextMessageBubble.kt @@ -1,8 +1,19 @@ package org.monogram.presentation.features.chats.currentChat.components.channels -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -28,7 +39,14 @@ import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState import org.monogram.presentation.core.util.DateFormatManager -import org.monogram.presentation.features.chats.currentChat.components.chats.* +import org.monogram.presentation.features.chats.currentChat.components.chats.ForwardContent +import org.monogram.presentation.features.chats.currentChat.components.chats.LinkPreview +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageReactionsView +import org.monogram.presentation.features.chats.currentChat.components.chats.MessageText +import org.monogram.presentation.features.chats.currentChat.components.chats.ReplyContent +import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.isBigEmoji +import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent @Composable fun ChannelTextMessageBubble( @@ -111,6 +129,7 @@ fun ChannelTextMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.text, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = finalFontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt index aec89204..8960a6bc 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/channels/ChannelVideoMessageBubble.kt @@ -301,7 +301,10 @@ fun ChannelVideoMessageBubble( modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(6.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( @@ -349,7 +352,10 @@ fun ChannelVideoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, msg.isOutgoing, Color.White) @@ -377,6 +383,7 @@ fun ChannelVideoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt index 75bd934a..0ebe1e73 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/AudioMessageBubble.kt @@ -174,6 +174,7 @@ fun AudioMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -405,6 +406,7 @@ fun AudioAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -547,6 +549,7 @@ fun ChannelAudioAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt index a0f2b991..28f0025f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/ChatAlbumMessageBubble.kt @@ -242,6 +242,7 @@ fun ChatAlbumMessageBubble( MessageText( text = finalAnnotatedString, + rawText = caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt index 6f04033a..ce0db2fb 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/DocumentMessageBubble.kt @@ -197,6 +197,7 @@ fun DocumentMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -428,6 +429,7 @@ fun DocumentAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, @@ -571,6 +573,7 @@ fun ChannelDocumentAlbumBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt index 57c6aa19..71f29111 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/GifMessageBubble.kt @@ -174,7 +174,10 @@ fun GifMessageBubble( .heightIn(min = 160.dp, max = 360.dp) .aspectRatio( if (content.width > 0 && content.height > 0) - (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) + (content.width.toFloat() / content.height.toFloat()).coerceIn( + 0.5f, + 2f + ) else 1f ) .clipToBounds() @@ -247,7 +250,10 @@ fun GifMessageBubble( modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(6.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(6.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( @@ -309,7 +315,10 @@ fun GifMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(10.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -364,6 +373,7 @@ fun GifMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt index ef908270..f5e6b436 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageText.kt @@ -2,7 +2,6 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import android.content.ClipData import android.os.Build -import android.util.Log import android.widget.Toast import androidx.compose.animation.core.withInfiniteAnimationFrameMillis import androidx.compose.foundation.gestures.detectTapGestures @@ -33,6 +32,7 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.mod @Composable fun MessageText( text: AnnotatedString, + rawText: String = text.text, inlineContent: Map, style: TextStyle, modifier: Modifier = Modifier, @@ -70,10 +70,14 @@ fun MessageText( ) } else { var lastOffset = 0 + val displayTextLength = text.length blockEntities.forEach { entity -> - if (entity.offset > lastOffset) { - val subText = text.subSequence(lastOffset, entity.offset) + val safeLastOffset = lastOffset.coerceIn(0, displayTextLength) + val safeEntityStart = entity.offset.coerceIn(0, displayTextLength) + + if (safeEntityStart > safeLastOffset) { + val subText = text.subSequence(safeLastOffset, safeEntityStart) if (subText.text.isNotBlank()) { DefaultTextRender( text = subText, @@ -93,16 +97,21 @@ fun MessageText( } TextBlocks( - text = text.text, + text = rawText, entity = entity, isOutgoing = isOutgoing, ) - lastOffset = entity.offset + entity.length + val safeEntityEnd = (entity.offset.toLong() + entity.length.toLong()) + .coerceAtLeast(entity.offset.toLong()) + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt() + lastOffset = maxOf(lastOffset, safeEntityEnd) } - if (lastOffset < text.length) { - val subText = text.subSequence(lastOffset, text.length) + val safeLastOffset = lastOffset.coerceIn(0, displayTextLength) + if (safeLastOffset < displayTextLength) { + val subText = text.subSequence(safeLastOffset, displayTextLength) if (subText.text.isNotBlank()) { DefaultTextRender( text = subText, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt index 6328a001..d64f798b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MessageUtils.kt @@ -1,13 +1,30 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import android.content.Context -import androidx.compose.animation.* -import androidx.compose.animation.core.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -36,7 +53,8 @@ import org.monogram.presentation.features.chats.currentChat.components.channels. import java.io.File import java.text.BreakIterator import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale import kotlin.math.log10 import kotlin.math.pow @@ -141,8 +159,12 @@ fun isBigEmoji(text: String, entities: List): Boolean { if (emojiEntities.size == 1) { val entity = emojiEntities[0] - val textBefore = text.substring(0, entity.offset) - val textAfter = text.substring(entity.offset + entity.length) + val safeStart = entity.offset.coerceIn(0, text.length) + val safeEnd = (entity.offset.toLong() + entity.length.toLong()) + .coerceIn(safeStart.toLong(), text.length.toLong()) + .toInt() + val textBefore = text.substring(0, safeStart) + val textAfter = text.substring(safeEnd) return textBefore.trim().isEmpty() && textAfter.trim().isEmpty() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt index 6166c0bd..6188fa20 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PhotoMessageBubble.kt @@ -5,12 +5,31 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds @@ -183,7 +202,10 @@ fun PhotoMessageBubble( .heightIn(min = 160.dp, max = 320.dp) .aspectRatio( if (content.width > 0 && content.height > 0) - (content.width.toFloat() / content.height.toFloat()).coerceIn(0.5f, 2f) + (content.width.toFloat() / content.height.toFloat()).coerceIn( + 0.5f, + 2f + ) else 1f ) .clipToBounds() @@ -294,7 +316,10 @@ fun PhotoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(12.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, isOutgoing, Color.White) @@ -325,6 +350,7 @@ fun PhotoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt index db337c93..2c8bb8f4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/TextMessageBubble.kt @@ -1,9 +1,24 @@ package org.monogram.presentation.features.chats.currentChat.components.chats -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit @@ -107,7 +122,6 @@ fun TextMessageBubble( ) } - val inlineContent = rememberMessageInlineContent(content.entities, fontSize) val finalAnnotatedString = buildAnnotatedMessageTextWithEmoji( text = content.text, entities = content.entities, @@ -121,7 +135,7 @@ fun TextMessageBubble( val finalFontSize = if (isBigEmoji) fontSize * 5f else fontSize AnimatedContent( - targetState = finalAnnotatedString, + targetState = Triple(finalAnnotatedString, content.text, content.entities), transitionSpec = { (fadeIn(animationSpec = tween(240, easing = FastOutSlowInEasing)) + scaleIn(initialScale = 0.97f, animationSpec = tween(240, easing = FastOutSlowInEasing)) + @@ -133,10 +147,12 @@ fun TextMessageBubble( ) }, label = "TextEditAnimation" - ) { targetText -> + ) { (targetText, targetRawText, targetEntities) -> + val inlineContent = rememberMessageInlineContent(targetEntities, fontSize) MessageText( text = targetText, - entities = content.entities, + rawText = targetRawText, + entities = targetEntities, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = finalFontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt index e30c3d73..07aa4aa5 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/VideoMessageBubble.kt @@ -5,7 +5,15 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -14,8 +22,21 @@ import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.rounded.Stream -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -385,7 +406,10 @@ fun VideoMessageBubble( modifier = Modifier .align(Alignment.BottomEnd) .padding(6.dp) - .background(Color.Black.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + .background( + Color.Black.copy(alpha = 0.45f), + RoundedCornerShape(12.dp) + ) .padding(horizontal = 6.dp, vertical = 2.dp) ) { MessageMetadata(msg, isOutgoing, Color.White) @@ -416,6 +440,7 @@ fun VideoMessageBubble( MessageText( text = finalAnnotatedString, + rawText = content.caption, inlineContent = inlineContent, style = MaterialTheme.typography.bodyLarge.copy( fontSize = fontSize.sp, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt index c9a4ed52..5d605045 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/model/Mappers.kt @@ -7,7 +7,14 @@ import org.monogram.domain.models.MessageEntityType * Gets text part for current [MessageEntity] **/ internal infix fun String.blockFor(entity: MessageEntity): String = - this.substring(entity.offset, entity.offset + entity.length) + safeSubstring(entity.offset, entity.offset.toLong() + entity.length.toLong()) + +private fun String.safeSubstring(start: Int, end: Long): String { + if (isEmpty()) return "" + val safeStart = start.coerceIn(0, length) + val safeEnd = end.coerceIn(safeStart.toLong(), length.toLong()).toInt() + return substring(safeStart, safeEnd) +} /** * Checks if [MessageEntityType] is block element diff --git a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt index 944791b8..a9f23c03 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/profile/logs/components/MessagePreview.kt @@ -2,7 +2,13 @@ package org.monogram.presentation.features.profile.logs.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -69,7 +75,11 @@ fun MessagePreview( .background(MaterialTheme.colorScheme.surfaceVariant) .clickable { when (content) { - is MessageContent.Photo -> component.onPhotoClick(mediaPath, content.caption) + is MessageContent.Photo -> component.onPhotoClick( + mediaPath, + content.caption + ) + is MessageContent.Gif -> component.onVideoClick( mediaPath, content.caption, @@ -115,6 +125,7 @@ fun MessagePreview( ) MessageText( text = annotatedText, + rawText = content.text, inlineContent = inlineContent, style = MaterialTheme.typography.bodyMedium, entities = content.entities @@ -275,6 +286,7 @@ private fun MediaPreviewText( MessageText( text = annotatedText, + rawText = text, inlineContent = emptyMap(), style = MaterialTheme.typography.bodyMedium, entities = if (oldText != null && oldText != text) emptyList() else entities From fca9fb26f2652ec6bc7a763369134e4b27694e97 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:21:02 +0300 Subject: [PATCH 64/83] remove telega proxy integration and settings flow This keeps proxy management fully user-controlled and removes legacy external telega syncing, selection rules, and UI/prefs wiring across layers. --- .../remote/ExternalProxyDataSource.kt | 5 - .../remote/HttpExternalProxyDataSource.kt | 55 ------- .../java/org/monogram/data/di/dataModule.kt | 12 +- .../monogram/data/infra/ConnectionManager.kt | 58 +------ .../repository/ExternalProxyRepositoryImpl.kt | 52 +----- .../repository/AppPreferencesProvider.kt | 4 - .../repository/ExternalProxyRepository.kt | 1 - .../presentation/core/util/AppPreferences.kt | 20 --- .../presentation/root/DefaultRootComponent.kt | 25 --- .../settings/proxy/ProxyComponent.kt | 149 ++---------------- .../settings/proxy/ProxyContent.kt | 118 ++------------ .../src/main/res/values-es/string.xml | 5 - .../src/main/res/values-hy/string.xml | 4 - .../src/main/res/values-pt-rBR/string.xml | 5 - .../src/main/res/values-ru-rRU/string.xml | 4 - .../src/main/res/values-sk/string.xml | 5 - .../src/main/res/values-uk/string.xml | 4 - .../src/main/res/values-zh-rCN/string.xml | 4 - presentation/src/main/res/values/string.xml | 5 - 19 files changed, 33 insertions(+), 502 deletions(-) delete mode 100644 data/src/main/java/org/monogram/data/datasource/remote/ExternalProxyDataSource.kt delete mode 100644 data/src/main/java/org/monogram/data/datasource/remote/HttpExternalProxyDataSource.kt diff --git a/data/src/main/java/org/monogram/data/datasource/remote/ExternalProxyDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/ExternalProxyDataSource.kt deleted file mode 100644 index c8de2a0e..00000000 --- a/data/src/main/java/org/monogram/data/datasource/remote/ExternalProxyDataSource.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.monogram.data.datasource.remote - -interface ExternalProxyDataSource { - suspend fun fetchProxyUrls(): List -} \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/datasource/remote/HttpExternalProxyDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/HttpExternalProxyDataSource.kt deleted file mode 100644 index 060e8896..00000000 --- a/data/src/main/java/org/monogram/data/datasource/remote/HttpExternalProxyDataSource.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.monogram.data.datasource.remote - -import org.monogram.core.DispatcherProvider -import kotlinx.coroutines.withContext -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.net.HttpURLConnection -import java.net.URL -import java.util.zip.GZIPInputStream - -class HttpExternalProxyDataSource( - private val dispatchers: DispatcherProvider -) : ExternalProxyDataSource { - - private val json = Json { - ignoreUnknownKeys = true - coerceInputValues = true - isLenient = true - } - - @OptIn(InternalSerializationApi::class) - override suspend fun fetchProxyUrls(): List = withContext(dispatchers.io) { - var connection: HttpURLConnection? = null - try { - val url = URL("https://api.telega.info/v1/auth/proxy") - connection = (url.openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - connectTimeout = 15_000 - readTimeout = 15_000 - instanceFollowRedirects = true - setRequestProperty("User-Agent", "DAHL-Mobile-App") - setRequestProperty("Accept", "application/json") - setRequestProperty("Accept-Encoding", "gzip") - } - - if (connection.responseCode != HttpURLConnection.HTTP_OK) return@withContext emptyList() - - val stream = if ("gzip".equals(connection.contentEncoding, ignoreCase = true)) { - GZIPInputStream(connection.inputStream) - } else { - connection.inputStream - } - - json.decodeFromString(stream.bufferedReader().use { it.readText() }).proxies - } catch (e: Exception) { - emptyList() - } finally { - connection?.disconnect() - } - } -} -@Serializable -@InternalSerializationApi -private data class ProxyResponse(val proxies: List = emptyList()) \ No newline at end of file diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt index 139b011b..d1c19d29 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -28,9 +28,7 @@ import org.monogram.data.datasource.remote.AuthRemoteDataSource import org.monogram.data.datasource.remote.ChatRemoteSource import org.monogram.data.datasource.remote.ChatsRemoteDataSource import org.monogram.data.datasource.remote.EmojiRemoteSource -import org.monogram.data.datasource.remote.ExternalProxyDataSource import org.monogram.data.datasource.remote.GifRemoteSource -import org.monogram.data.datasource.remote.HttpExternalProxyDataSource import org.monogram.data.datasource.remote.LinkRemoteDataSource import org.monogram.data.datasource.remote.MessageFileApi import org.monogram.data.datasource.remote.MessageFileCoordinator @@ -161,7 +159,7 @@ val dataModule = module { single { DefaultDispatcherProvider() } single { AndroidStringProvider(androidContext()) } - single { TdLibParametersProvider(androidContext()) } + single(createdAtStart = true) { TdLibParametersProvider(androidContext()) } single(createdAtStart = true) { OfflineWarmup( scope = get(), @@ -768,17 +766,9 @@ val dataModule = module { ) } - factory { - HttpExternalProxyDataSource( - dispatchers = get() - ) - } - single { ExternalProxyRepositoryImpl( remote = get(), - externalSource = get(), - dispatchers = get(), appPreferences = get() ) } diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt index 1b5e313c..86bee3a1 100644 --- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt +++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt @@ -3,14 +3,11 @@ package org.monogram.data.infra import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest -import android.net.Uri import android.os.Build import android.util.Log import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching @@ -38,7 +35,6 @@ class ConnectionManager( private var retryJob: Job? = null private var proxyModeWatcherJob: Job? = null private var autoBestJob: Job? = null - private var telegaSwitchJob: Job? = null private var watchdogJob: Job? = null private var networkCallback: ConnectivityManager.NetworkCallback? = null private var reconnectAttempts = 0 @@ -175,16 +171,15 @@ class ConnectionManager( } private suspend fun maybeAdjustProxyOnFailures(force: Boolean = false) { - val isTelegaEnabled = appPreferences.isTelegaProxyEnabled.value val isAutoBestEnabled = appPreferences.isAutoBestProxyEnabled.value - if (!isAutoBestEnabled && !isTelegaEnabled) return + if (!isAutoBestEnabled) return if (!force) { if (reconnectAttempts < 4) return if (reconnectAttempts % 3 != 0) return } - coRunCatching { selectBestProxy(telegaOnly = isTelegaEnabled) } + coRunCatching { selectBestProxy() } .onFailure { Log.e(TAG, "Proxy fallback failed during reconnect", it) } } @@ -207,22 +202,14 @@ class ConnectionManager( appPreferences.enabledProxyId.value?.let { proxyId -> if (!proxyRemoteSource.enableProxy(proxyId)) { appPreferences.setEnabledProxyId(null) - coRunCatching { selectBestProxy(telegaOnly = appPreferences.isTelegaProxyEnabled.value) } + coRunCatching { selectBestProxy() } } } - combine( - appPreferences.isAutoBestProxyEnabled, - appPreferences.isTelegaProxyEnabled - ) { autoBest, telega -> autoBest to telega } - .distinctUntilChanged() - .collect { (autoBest, telega) -> + appPreferences.isAutoBestProxyEnabled.collect { autoBest -> autoBestJob?.cancel() - telegaSwitchJob?.cancel() - if (telega) { - telegaSwitchJob = launchTelegaSwitchLoop() - } else if (autoBest) { + if (autoBest) { autoBestJob = launchAutoBestLoop() } } @@ -231,28 +218,15 @@ class ConnectionManager( private fun launchAutoBestLoop(): Job = scope.launch(dispatchers.default) { while (isActive) { - coRunCatching { selectBestProxy(telegaOnly = false) } + coRunCatching { selectBestProxy() } .onFailure { Log.e(TAG, "Error selecting best proxy", it) } delay(300_000L) } } - private fun launchTelegaSwitchLoop(): Job = scope.launch(dispatchers.default) { - while (isActive) { - coRunCatching { selectBestProxy(telegaOnly = true) } - .onFailure { Log.e(TAG, "Error selecting telega proxy", it) } - delay(60_000L) - } - } - - private suspend fun selectBestProxy(telegaOnly: Boolean = false) { + private suspend fun selectBestProxy() { val allProxies = proxyRemoteSource.getProxies() - val proxies = if (telegaOnly) { - val telegaIds = getTelegaIdentifiers() - allProxies.filter { "${it.server}:${it.port}" in telegaIds } - } else { - allProxies - } + val proxies = allProxies if (proxies.isEmpty()) return @@ -285,22 +259,6 @@ class ConnectionManager( } } - private fun getTelegaIdentifiers(): Set { - return appPreferences.telegaProxyUrls.value.mapNotNull { parseTelegaIdentifier(it) }.toSet() - } - - private fun parseTelegaIdentifier(url: String): String? { - val normalized = url.replace("t.me/proxy", "tg://proxy") - val uri = runCatching { Uri.parse(normalized) }.getOrNull() - val server = uri?.getQueryParameter("server") - val port = uri?.getQueryParameter("port") ?: "443" - if (!server.isNullOrBlank()) return "$server:$port" - - val serverMatch = Regex("server=([^&]+)").find(url)?.groupValues?.get(1) - val regexPort = Regex("port=([^&]+)").find(url)?.groupValues?.get(1) ?: "443" - return serverMatch?.let { "$it:$regexPort" } - } - private fun startWatchdog() { watchdogJob?.cancel() watchdogJob = scope.launch(dispatchers.default) { diff --git a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt index 95ec6f75..3e622906 100644 --- a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt @@ -1,56 +1,18 @@ package org.monogram.data.repository +import kotlinx.coroutines.* import org.monogram.data.core.coRunCatching +import org.monogram.data.datasource.remote.ProxyRemoteDataSource import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ExternalProxyRepository -import kotlinx.coroutines.* -import androidx.core.net.toUri -import org.monogram.core.DispatcherProvider -import org.monogram.data.datasource.remote.ExternalProxyDataSource -import org.monogram.data.datasource.remote.ProxyRemoteDataSource class ExternalProxyRepositoryImpl( private val remote: ProxyRemoteDataSource, - private val externalSource: ExternalProxyDataSource, - private val appPreferences: AppPreferencesProvider, - private val dispatchers: DispatcherProvider + private val appPreferences: AppPreferencesProvider ) : ExternalProxyRepository { - override suspend fun fetchExternalProxies(): List = withContext(dispatchers.io) { - if (!appPreferences.isTelegaProxyEnabled.value) return@withContext emptyList() - - val urls = externalSource.fetchProxyUrls().distinct() - if (urls.isEmpty()) return@withContext emptyList() - - val parsed = urls.mapNotNull { url -> - parseProxyUrl(url)?.let { (server, port, secret) -> - Triple(url, server to port, ProxyTypeModel.Mtproto(secret)) - } - } - - val oldIdentifiers = appPreferences.telegaProxyUrls.value - .mapNotNull { parseProxyUrl(it)?.let { (s, p, _) -> "$s:$p" } } - .toSet() - - val newIdentifiers = parsed.map { (_, sp, _) -> "${sp.first}:${sp.second}" }.toSet() - appPreferences.setTelegaProxyUrls(parsed.map { it.first }.toSet()) - - val added = parsed.mapNotNull { (_, sp, type) -> - coRunCatching { remote.addProxy(sp.first, sp.second, false, type) }.getOrNull() - } - - remote.getProxies().forEach { proxy -> - val iden = "${proxy.server}:${proxy.port}" - if (iden in oldIdentifiers && iden !in newIdentifiers && !proxy.isEnabled) { - coRunCatching { remote.removeProxy(proxy.id) } - } - } - - added - } - override suspend fun getProxies(): List = remote.getProxies() override suspend fun addProxy( @@ -103,12 +65,4 @@ class ExternalProxyRepositoryImpl( appPreferences.setPreferIpv6(enabled) } - private fun parseProxyUrl(url: String): Triple? = - coRunCatching { - val uri = url.toUri() - val server = uri.getQueryParameter("server") ?: return null - val port = uri.getQueryParameter("port")?.toIntOrNull() ?: 443 - val secret = uri.getQueryParameter("secret") ?: "" - Triple(server, port, secret) - }.getOrNull() } \ No newline at end of file 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 8cea1ab6..0a1c578d 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -42,8 +42,6 @@ interface AppPreferencesProvider { val enabledProxyId: StateFlow val isAutoBestProxyEnabled: StateFlow - val isTelegaProxyEnabled: StateFlow - val telegaProxyUrls: StateFlow> val preferIpv6: StateFlow val userProxyBackups: StateFlow> @@ -88,8 +86,6 @@ interface AppPreferencesProvider { fun setEnabledProxyId(proxyId: Int?) fun setAutoBestProxyEnabled(enabled: Boolean) - fun setTelegaProxyEnabled(enabled: Boolean) - fun setTelegaProxyUrls(urls: Set) fun setPreferIpv6(enabled: Boolean) fun setUserProxyBackups(backups: Set) diff --git a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt index 2711a13d..6678d0a5 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt @@ -4,7 +4,6 @@ import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel interface ExternalProxyRepository { - suspend fun fetchExternalProxies(): List suspend fun getProxies(): List suspend fun addProxy(server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? suspend fun editProxy(proxyId: Int, server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? 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 2f192fe5..1c8dbbbf 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 @@ -338,12 +338,6 @@ class AppPreferences( private val _isAutoBestProxyEnabled = MutableStateFlow(prefs.getBoolean(KEY_AUTO_BEST_PROXY, false)) override val isAutoBestProxyEnabled: StateFlow = _isAutoBestProxyEnabled - private val _isTelegaProxyEnabled = MutableStateFlow(prefs.getBoolean(KEY_TELEGA_PROXY, false)) - override val isTelegaProxyEnabled: StateFlow = _isTelegaProxyEnabled - - private val _telegaProxyUrls = MutableStateFlow(prefs.getStringSet(KEY_TELEGA_PROXY_URLS, emptySet()) ?: emptySet()) - override val telegaProxyUrls: StateFlow> = _telegaProxyUrls - private val _preferIpv6 = MutableStateFlow(prefs.getBoolean(KEY_PREFER_IPV6, false)) override val preferIpv6: StateFlow = _preferIpv6 @@ -842,16 +836,6 @@ class AppPreferences( _isAutoBestProxyEnabled.value = enabled } - override fun setTelegaProxyEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_TELEGA_PROXY, enabled).apply() - _isTelegaProxyEnabled.value = enabled - } - - override fun setTelegaProxyUrls(urls: Set) { - prefs.edit().putStringSet(KEY_TELEGA_PROXY_URLS, urls).apply() - _telegaProxyUrls.value = urls - } - override fun setPreferIpv6(enabled: Boolean) { prefs.edit().putBoolean(KEY_PREFER_IPV6, enabled).apply() _preferIpv6.value = enabled @@ -973,8 +957,6 @@ class AppPreferences( _adBlockWhitelistedChannels.value = emptySet() _enabledProxyId.value = null _isAutoBestProxyEnabled.value = false - _isTelegaProxyEnabled.value = false - _telegaProxyUrls.value = emptySet() _preferIpv6.value = false _userProxyBackups.value = emptySet() _isPermissionRequested.value = false @@ -1089,8 +1071,6 @@ class AppPreferences( private const val KEY_ENABLED_PROXY_ID = "enabled_proxy_id" private const val KEY_AUTO_BEST_PROXY = "auto_best_proxy" - private const val KEY_TELEGA_PROXY = "telega_proxy" - private const val KEY_TELEGA_PROXY_URLS = "telega_proxy_urls" private const val KEY_PREFER_IPV6 = "prefer_ipv6" private const val KEY_USER_PROXY_BACKUPS = "user_proxy_backups" 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 91d9246f..27d1790d 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -9,7 +9,6 @@ import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -114,7 +113,6 @@ class DefaultRootComponent( observeStickerLoading() checkLockState() updateSimCountryIso() - initExternalProxies() } private fun observeAuthState() { @@ -192,29 +190,6 @@ class DefaultRootComponent( } } - private fun initExternalProxies() { - scope.launch { - if (appPreferences.isTelegaProxyEnabled.first()) { - fetchExternalProxies() - } - } - } - - private suspend fun fetchExternalProxies() { - Log.d("RootComponent", "Fetching external proxies...") - val addedProxies = externalProxyRepository.fetchExternalProxies() - Log.d("RootComponent", "Added ${addedProxies.size} proxies. Starting ping...") - - coroutineScope { - addedProxies.forEach { proxy -> - launch { - externalProxyRepository.pingProxy(proxy.id) - } - } - } - Log.d("RootComponent", "Finished pinging external proxies") - } - override fun onBack() { navigation.pop() } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt index 1b953d9e..5b7ddb0a 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt @@ -1,7 +1,5 @@ package org.monogram.presentation.settings.proxy -import android.util.Log -import androidx.core.net.toUri import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update @@ -14,7 +12,6 @@ import org.json.JSONObject import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.repository.AppPreferencesProvider -import org.monogram.domain.repository.CacheProvider import org.monogram.domain.repository.ExternalProxyRepository import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -39,9 +36,7 @@ interface ProxyComponent { fun onConfirmDelete() fun onDismissAddEdit() fun onAutoBestProxyToggled(enabled: Boolean) - fun onTelegaProxyToggled(enabled: Boolean) fun onPreferIpv6Toggled(enabled: Boolean) - fun onFetchTelegaProxies() fun onClearUnavailableProxies() fun onRemoveAllProxies() fun onConfirmClearUnavailableProxies() @@ -51,9 +46,6 @@ interface ProxyComponent { data class State( val proxies: List = emptyList(), - val telegaProxies: List = emptyList(), - val telegaProxyPing: Long? = null, - val isTelegaProxyEnabled: Boolean = false, val isLoading: Boolean = false, val isAddingProxy: Boolean = false, val isAutoBestProxyEnabled: Boolean = false, @@ -62,9 +54,7 @@ interface ProxyComponent { val proxyToDelete: ProxyModel? = null, val testPing: Long? = null, val isTesting: Boolean = false, - val isFetchingExternal: Boolean = false, val toastMessage: String? = null, - val isRussianNumber: Boolean = false, val showClearOfflineConfirmation: Boolean = false, val showRemoveAllConfirmation: Boolean = false ) @@ -76,7 +66,6 @@ class DefaultProxyComponent( ) : ProxyComponent, AppComponentContext by context { private val appPreferences: AppPreferencesProvider = container.preferences.appPreferences - private val cacheProvider: CacheProvider = container.cacheProvider private val externalProxyRepository: ExternalProxyRepository = container.repositories.externalProxyRepository private val _state = MutableValue(ProxyComponent.State()) @@ -85,59 +74,35 @@ class DefaultProxyComponent( private var restoreAttempted = false init { - checkIfRussianNumber() scope.launch { refreshProxies(shouldPing = true) } combine( appPreferences.isAutoBestProxyEnabled, - appPreferences.isTelegaProxyEnabled, appPreferences.preferIpv6 - ) { autoBest, telega, ipv6 -> Triple(autoBest, telega, ipv6) } + ) { autoBest, ipv6 -> autoBest to ipv6 } .distinctUntilChanged() - .onEach { (autoBest, telega, ipv6) -> - if (telega && autoBest) { - appPreferences.setAutoBestProxyEnabled(false) - } + .onEach { (autoBest, ipv6) -> _state.update { it.copy( - isAutoBestProxyEnabled = if (telega) false else autoBest, - isTelegaProxyEnabled = telega, + isAutoBestProxyEnabled = autoBest, preferIpv6 = ipv6 ) } }.launchIn(scope) } - private fun checkIfRussianNumber() { - val cachedIso = cacheProvider.cachedSimCountryIso.value - if (cachedIso != null) { - val isRussian = cachedIso.equals("ru", ignoreCase = true) - _state.update { it.copy(isRussianNumber = isRussian) } - return - } else { - _state.update { it.copy(isRussianNumber = false) } - } - } - private suspend fun refreshProxies(shouldPing: Boolean = false) { _state.update { it.copy(isLoading = true) } restoreUserProxiesIfNeeded() val allProxies = externalProxyRepository.getProxies() - val telegaIdentifiers = getTelegaIdentifiers() - - val telegaProxies = allProxies.filter { isProxyTelega(it, telegaIdentifiers) } - val regularProxies = allProxies.filter { !isProxyTelega(it, telegaIdentifiers) } - _state.update { it.copy( - proxies = regularProxies, - telegaProxies = telegaProxies, + proxies = allProxies, isLoading = false ) } - updateTelegaStatus(allProxies) if (shouldPing) { performPingAll() } @@ -240,51 +205,6 @@ class DefaultProxyComponent( val type: ProxyTypeModel ) - private fun updateTelegaStatus(allProxies: List) { - val identifiers = getTelegaIdentifiers() - val enabledProxy = allProxies.find { it.isEnabled } - val isTelega = enabledProxy?.let { isProxyTelega(it, identifiers) } ?: false - - _state.update { - it.copy( - isTelegaProxyEnabled = appPreferences.isTelegaProxyEnabled.value, - telegaProxyPing = if (isTelega) enabledProxy.ping else null - ) - } - } - - private fun getTelegaIdentifiers(): Set { - val urls = appPreferences.telegaProxyUrls.value - Log.d("ProxyComponent", "Getting identifiers for ${urls.size} URLs") - return urls.mapNotNull { url -> - try { - val uri = url.replace("t.me/proxy", "tg://proxy").toUri() - val server = uri.getQueryParameter("server") - if (server != null) { - val port = uri.getQueryParameter("port") ?: "443" - "$server:$port" - } else { - val serverMatch = Regex("server=([^&]+)").find(url) - val portMatch = Regex("port=([^&]+)").find(url) - val s = serverMatch?.groupValues?.get(1) - val p = portMatch?.groupValues?.get(1) ?: "443" - if (s != null) "$s:$p" else null - } - } catch (e: Exception) { - null - } - }.toSet().also { - Log.d("ProxyComponent", "Generated ${it.size} identifiers: $it") - } - } - - private fun isProxyTelega(proxy: ProxyModel, identifiers: Set): Boolean { - val id = "${proxy.server}:${proxy.port}" - val isTelega = id in identifiers - if (isTelega) Log.d("ProxyComponent", "Proxy $id is identified as Telega") - return isTelega - } - override fun onBackClicked() = onBack() override fun onAddProxyClicked() { @@ -325,7 +245,7 @@ class DefaultProxyComponent( } override fun onRemoveProxy(proxyId: Int) { - val proxy = (_state.value.proxies + _state.value.telegaProxies).find { it.id == proxyId } + val proxy = _state.value.proxies.find { it.id == proxyId } _state.update { it.copy(proxyToDelete = proxy) } } @@ -336,7 +256,7 @@ class DefaultProxyComponent( } private suspend fun performPingAll() { - val allProxies = _state.value.proxies + _state.value.telegaProxies + val allProxies = _state.value.proxies val pings = coroutineScope { allProxies.map { proxy -> proxy.id to async { @@ -350,12 +270,8 @@ class DefaultProxyComponent( val updatedRegular = _state.value.proxies.map { proxy -> pings[proxy.id]?.let { proxy.copy(ping = it) } ?: proxy } - val updatedTelega = _state.value.telegaProxies.map { proxy -> - pings[proxy.id]?.let { proxy.copy(ping = it) } ?: proxy - } - _state.update { it.copy(proxies = updatedRegular, telegaProxies = updatedTelega) } - updateTelegaStatus(updatedRegular + updatedTelega) + _state.update { it.copy(proxies = updatedRegular) } } override fun onPingProxy(proxyId: Int) { @@ -367,12 +283,8 @@ class DefaultProxyComponent( val updatedRegular = _state.value.proxies.map { if (it.id == proxyId) it.copy(ping = ping) else it } - val updatedTelega = _state.value.telegaProxies.map { - if (it.id == proxyId) it.copy(ping = ping) else it - } - _state.update { it.copy(proxies = updatedRegular, telegaProxies = updatedTelega) } - updateTelegaStatus(updatedRegular + updatedTelega) + _state.update { it.copy(proxies = updatedRegular) } } } @@ -400,7 +312,7 @@ class DefaultProxyComponent( override fun onEditProxy(proxyId: Int, server: String, port: Int, type: ProxyTypeModel) { scope.launch { - val oldProxy = (_state.value.proxies + _state.value.telegaProxies).find { it.id == proxyId } + val oldProxy = _state.value.proxies.find { it.id == proxyId } val proxy = externalProxyRepository.editProxy(proxyId, server, port, true, type) if (proxy != null) { replaceProxyInBackup(oldProxy, proxy) @@ -434,53 +346,10 @@ class DefaultProxyComponent( appPreferences.setAutoBestProxyEnabled(enabled) } - override fun onTelegaProxyToggled(enabled: Boolean) { - appPreferences.setTelegaProxyEnabled(enabled) - if (enabled) { - appPreferences.setAutoBestProxyEnabled(false) - scope.launch { - if (_state.value.telegaProxies.isEmpty()) { - onFetchTelegaProxies() - } - refreshProxies(shouldPing = false) - } - } else { - scope.launch { - externalProxyRepository.disableProxy() - refreshProxies(shouldPing = false) - } - } - } - override fun onPreferIpv6Toggled(enabled: Boolean) { externalProxyRepository.setPreferIpv6(enabled) } - override fun onFetchTelegaProxies() { - if (_state.value.isFetchingExternal) return - - scope.launch { - _state.update { it.copy(isFetchingExternal = true) } - try { - Log.d("ProxyComponent", "Fetching telega proxies...") - val addedProxies = externalProxyRepository.fetchExternalProxies() - Log.d("ProxyComponent", "Added ${addedProxies.size} proxies") - - if (addedProxies.isEmpty()) { - _state.update { it.copy(isFetchingExternal = false) } - return@launch - } - - refreshProxies(shouldPing = false) - } catch (e: Exception) { - Log.e("ProxyComponent", "Error fetching proxies", e) - _state.update { it.copy(toastMessage = "Failed to fetch proxies") } - } finally { - _state.update { it.copy(isFetchingExternal = false) } - } - } - } - override fun onClearUnavailableProxies() { _state.update { it.copy( diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt index 56ce6a0e..a22789bd 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt @@ -2,8 +2,6 @@ package org.monogram.presentation.settings.proxy -import android.content.Intent -import android.net.Uri import androidx.compose.animation.* import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -25,7 +23,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight @@ -45,7 +42,6 @@ import org.monogram.presentation.core.ui.SettingsTile fun ProxyContent(component: ProxyComponent) { val state by component.state.subscribeAsState() val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current LaunchedEffect(state.toastMessage) { state.toastMessage?.let { message -> @@ -111,21 +107,15 @@ fun ProxyContent(component: ProxyComponent) { .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainer) ) { - AnimatedVisibility( - visible = !state.isTelegaProxyEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - SettingsSwitchTile( - icon = Icons.Rounded.Bolt, - title = stringResource(R.string.smart_switching_title), - subtitle = stringResource(R.string.smart_switching_subtitle), - checked = state.isAutoBestProxyEnabled, - iconColor = Color(0xFFAF52DE), - position = ItemPosition.TOP, - onCheckedChange = component::onAutoBestProxyToggled - ) - } + SettingsSwitchTile( + icon = Icons.Rounded.Bolt, + title = stringResource(R.string.smart_switching_title), + subtitle = stringResource(R.string.smart_switching_subtitle), + checked = state.isAutoBestProxyEnabled, + iconColor = Color(0xFFAF52DE), + position = ItemPosition.TOP, + onCheckedChange = component::onAutoBestProxyToggled + ) SettingsSwitchTile( icon = Icons.Rounded.Public, @@ -133,11 +123,11 @@ fun ProxyContent(component: ProxyComponent) { subtitle = stringResource(R.string.prefer_ipv6_subtitle), checked = state.preferIpv6, iconColor = Color(0xFF34A853), - position = if (state.isTelegaProxyEnabled) ItemPosition.TOP else ItemPosition.MIDDLE, + position = ItemPosition.MIDDLE, onCheckedChange = component::onPreferIpv6Toggled ) - val isDirect = state.proxies.none { it.isEnabled } && state.telegaProxies.none { it.isEnabled } + val isDirect = state.proxies.none { it.isEnabled } SettingsTile( icon = Icons.Rounded.LinkOff, title = stringResource(R.string.disable_proxy_title), @@ -156,90 +146,6 @@ fun ProxyContent(component: ProxyComponent) { } } - item { - AnimatedVisibility( - visible = state.isRussianNumber, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column { - SectionHeader( - text = stringResource(R.string.telega_proxy_header), - subtitle = stringResource(R.string.telega_proxy_subtitle), - onSubtitleClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/telegaru")) - context.startActivity(intent) - } - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - SettingsSwitchTile( - icon = Icons.Rounded.CloudDownload, - title = stringResource(R.string.enable_telega_proxy_title), - subtitle = stringResource(R.string.enable_telega_proxy_subtitle), - checked = state.isTelegaProxyEnabled, - iconColor = Color(0xFF0088CC), - position = if (state.isTelegaProxyEnabled && state.telegaProxies.isNotEmpty()) ItemPosition.TOP else ItemPosition.STANDALONE, - onCheckedChange = component::onTelegaProxyToggled - ) - - AnimatedVisibility( - visible = state.isTelegaProxyEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - SettingsTile( - icon = Icons.Rounded.Refresh, - title = stringResource(R.string.refresh_list_title), - subtitle = stringResource(R.string.refresh_list_subtitle), - iconColor = Color(0xFF0088CC), - position = ItemPosition.BOTTOM, - onClick = { component.onFetchTelegaProxies() }, - trailingContent = { - if (state.isFetchingExternal) { - LoadingIndicator( - modifier = Modifier.size(20.dp), - ) - } - } - ) - } - } - - AnimatedVisibility( - visible = state.isTelegaProxyEnabled && state.telegaProxies.isNotEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Spacer(modifier = Modifier.height(8.dp)) - state.telegaProxies.forEachIndexed { index, proxy -> - val position = when { - state.telegaProxies.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == state.telegaProxies.size - 1 -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - } - - ProxyItem( - proxy = proxy, - position = position, - onClick = { component.onProxyClicked(proxy) }, - onLongClick = { component.onProxyLongClicked(proxy) }, - onRefreshPing = { component.onPingProxy(proxy.id) } - ) - } - } - } - } - } - } - item { Row( modifier = Modifier @@ -297,7 +203,7 @@ fun ProxyContent(component: ProxyComponent) { } } - if (state.proxies.isEmpty() && (!state.isTelegaProxyEnabled || state.telegaProxies.isEmpty()) && !state.isLoading) { + if (state.proxies.isEmpty() && !state.isLoading) { item { Column( modifier = Modifier diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index fa9e935e..12607cfc 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -485,11 +485,6 @@ Deshabilitar Proxy Conectado directamente Cambiar a conexión directa - Telega Proxy - Se ha acusado a Telega Proxy de interceptar tráfico. MTProto protege los datos de los mensajes de la intercepción, pero usa este proxy bajo tu propio riesgo. Más info: t.me/te[...] - - Habilitar Telega Proxy - Auto-obtener y cambiar al mejor Actualizar Lista Obtener últimos proxys de la comunidad Tus Proxys diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 8018843e..b95d0e6e 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -465,10 +465,6 @@ Անջատել պրոքսին Միացված է ուղղակիորեն Անցնել ուղիղ միացման - Telega Proxy - Telega Proxy-ն մեղադրվել է թրաֆիկի գաղտնալսման մեջ: MTProto-ն պաշտպանում է հաղորդագրությունները, բայց օգտագործեք այս պրոքսին Ձեր ռիսկով: Մանրամասն՝ t.me/telegaru - Միացնել Telega Proxy-ն - Ավտոմատ ընտրել լավագույնը Թարմացնել ցուցակը Ներբեռնել համայնքի թարմ պրոքսիները Ձեր պրոքսիները diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 068ac906..45bd06db 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -485,11 +485,6 @@ Desativar proxy Conectado diretamente Alternar para conexão direta - Proxy Telega - O Telega Proxy foi acusado de interceptar tráfego. O MTProto protege os dados das mensagens, mas use este proxy por sua conta e risco. Saiba mais: t.me/telegaru - - Ativar Telega Proxy - Buscar automaticamente e alternar para o melhor Atualizar lista Buscar os proxies comunitários mais recentes Seus proxies diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index e3b4de43..3b3e89ff 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -479,10 +479,6 @@ Отключить прокси Прямое подключение Переключиться на прямое соединение - Telega Proxy - Telega Proxy обвиняли в перехвате трафика. MTProto защищает данные сообщений от перехвата, но используйте этот прокси на свой риск. Подробнее: t.me/telegaru - Включить Telega Proxy - Автоматическое получение и переключение Обновить список Получить актуальные прокси от сообщества Ваши прокси diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 3ad90187..5409d507 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -503,11 +503,6 @@ Vypnúť proxy Pripojené priamo Prepnúť na priame pripojenie - Telega Proxy - Proxy od komunity. Používajte na vlastné riziko. Viac info: t.me/telegaru - - Povoliť Telega Proxy - Automaticky načítať a prepnúť na najlepšie Obnoviť zoznam Načítať najnovšie proxy od komunity Vaše proxy diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index a8422be3..aa81862a 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -479,10 +479,6 @@ Вимкнути проксі Пряме підключення Перемкнутися на пряме з\'єднання - Telega Proxy - Telega Proxy звинувачували у перехопленні трафіку. MTProto захищає дані повідомлень від перехоплення, але використовуйте цей проксі на власний ризик. Докладніше: t.me/telegaru - Увімкнути Telega Proxy - Автоматичне отримання та перемикання Оновити список Отримати актуальні проксі від спільноти Ваші проксі diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index a49fae5f..962f67f1 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -476,10 +476,6 @@ 禁用代理 直接连接 切换到直接连接 - Telega Proxy - Telega Proxy 曾被曝光存在流量拦截风险。MTProto 可保护消息数据不被中间人拦截,但仍请自行评估风险后使用。更多信息: t.me/telegaru - 启用 Telega Proxy - 自动获取并切换到最佳代理 刷新列表 获取最新的社区代理 您的代理 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index f0a88250..48845ef0 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -491,11 +491,6 @@ Disable Proxy Connected directly Switch to direct connection - Telega Proxy - Telega Proxy has been accused of intercepting traffic. MTProto protects message data from interception, but use this proxy at your own risk. More info: t.me/telegaru - - Enable Telega Proxy - Auto-fetch and switch to best Refresh List Fetch latest community proxies Your Proxies From 9de8caecc75f9d90c80fc3d0cf98cf301e2ece29 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:35:33 +0300 Subject: [PATCH 65/83] advanced proxy management and network-specific routing rules - implement automated proxy switching based on network type (Wi-Fi, Mobile, VPN, Other) - add support for proxy "Network Rules" with modes: Direct, Best Proxy, Last Used, and Specific Proxy - introduce configurable fallback behavior when a selected proxy is unavailable - implement proxy list sorting by active status, latency, server name, type, or status - add ability to mark proxies as favorites and hide offline proxies from the list - implement proxy list import and export via JSON files - support copying proxy configurations as deep-link URLs (`tg://proxy`) - update `ConnectionManager` to dynamically apply proxy rules on network changes - refine proxy item UI with favorite indicators, detailed status, and a new options menu - update localized strings across multiple languages to support new proxy features --- .../monogram/data/infra/ConnectionManager.kt | 216 ++++- .../repository/AppPreferencesProvider.kt | 54 ++ .../presentation/core/util/AppPreferences.kt | 187 +++++ .../settings/proxy/ProxyComponent.kt | 416 +++++++++- .../settings/proxy/ProxyContent.kt | 747 +++++++++++++++++- .../src/main/res/values-es/string.xml | 46 +- .../src/main/res/values-hy/string.xml | 46 +- .../src/main/res/values-pt-rBR/string.xml | 46 +- .../src/main/res/values-ru-rRU/string.xml | 46 +- .../src/main/res/values-sk/string.xml | 46 +- .../src/main/res/values-uk/string.xml | 46 +- .../src/main/res/values-zh-rCN/string.xml | 46 +- presentation/src/main/res/values/string.xml | 46 +- 13 files changed, 1903 insertions(+), 85 deletions(-) diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt index 86bee3a1..e1116048 100644 --- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt +++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt @@ -2,12 +2,24 @@ package org.monogram.data.infra import android.net.ConnectivityManager import android.net.Network +import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import android.util.Log -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching @@ -16,6 +28,11 @@ import org.monogram.data.datasource.remote.ProxyRemoteDataSource import org.monogram.data.gateway.UpdateDispatcher import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ConnectionStatus +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxyUnavailableFallback +import org.monogram.domain.repository.defaultProxyNetworkMode import kotlin.random.Random class ConnectionManager( @@ -27,7 +44,7 @@ class ConnectionManager( private val connectivityManager: ConnectivityManager, private val scope: CoroutineScope ) { - private val TAG = "ConnectionManager" + private val tag = "ConnectionManager" private val _connectionStateFlow = MutableStateFlow(ConnectionStatus.Connecting) val connectionStateFlow = _connectionStateFlow.asStateFlow() @@ -40,6 +57,7 @@ class ConnectionManager( private var reconnectAttempts = 0 private var lastRetryAtMs = 0L private var lastStateChangeAtMs = System.currentTimeMillis() + private val proxyRuleMutex = Mutex() private val minRetryIntervalMs = 1_200L private val maxRetryDelayMs = 60_000L @@ -81,7 +99,7 @@ class ConnectionManager( val previous = _connectionStateFlow.value if (previous != status) { lastStateChangeAtMs = System.currentTimeMillis() - Log.d(TAG, "Connection state changed: $previous -> $status ($source)") + Log.d(tag, "Connection state changed: $previous -> $status ($source)") } _connectionStateFlow.value = status @@ -129,19 +147,22 @@ class ConnectionManager( lastRetryAtMs = now reconnectAttempts++ - Log.d(TAG, "Reconnect attempt #$reconnectAttempts ($reason), state=${_connectionStateFlow.value}") + Log.d( + tag, + "Reconnect attempt #$reconnectAttempts ($reason), state=${_connectionStateFlow.value}" + ) val networkTypeUpdated = coRunCatching { withContext(dispatchers.io) { chatRemoteSource.setNetworkType() } }.getOrElse { error -> - Log.e(TAG, "Reconnect attempt failed", error) + Log.e(tag, "Reconnect attempt failed", error) false } if (!networkTypeUpdated) { - Log.w(TAG, "Reconnect attempt did not update network type") + Log.w(tag, "Reconnect attempt did not update network type") } coRunCatching { @@ -179,8 +200,8 @@ class ConnectionManager( if (reconnectAttempts % 3 != 0) return } - coRunCatching { selectBestProxy() } - .onFailure { Log.e(TAG, "Proxy fallback failed during reconnect", it) } + coRunCatching { applyNetworkProxyRule("reconnect_failures") } + .onFailure { Log.e(tag, "Proxy fallback failed during reconnect", it) } } private fun calculateRetryDelayMs(status: ConnectionStatus, attempts: Int): Long { @@ -200,35 +221,103 @@ class ConnectionManager( proxyModeWatcherJob?.cancel() proxyModeWatcherJob = scope.launch { appPreferences.enabledProxyId.value?.let { proxyId -> - if (!proxyRemoteSource.enableProxy(proxyId)) { + if (!enableProxy(proxyId, getCurrentNetworkType(), "startup_restore")) { appPreferences.setEnabledProxyId(null) - coRunCatching { selectBestProxy() } } } - appPreferences.isAutoBestProxyEnabled.collect { autoBest -> + applyNetworkProxyRule("startup") + + launch { + appPreferences.proxyNetworkRules.collect { + applyNetworkProxyRule("rules_changed") + } + } + + launch { + appPreferences.proxyUnavailableFallback.collect { + applyNetworkProxyRule("fallback_changed") + } + } + + launch { + appPreferences.isAutoBestProxyEnabled.collect { autoBest -> autoBestJob?.cancel() if (autoBest) { autoBestJob = launchAutoBestLoop() } } + } } } private fun launchAutoBestLoop(): Job = scope.launch(dispatchers.default) { while (isActive) { - coRunCatching { selectBestProxy() } - .onFailure { Log.e(TAG, "Error selecting best proxy", it) } + coRunCatching { applyNetworkProxyRule("auto_best_loop") } + .onFailure { Log.e(tag, "Error applying proxy rule in auto loop", it) } delay(300_000L) } } - private suspend fun selectBestProxy() { - val allProxies = proxyRemoteSource.getProxies() - val proxies = allProxies + private suspend fun applyNetworkProxyRule(reason: String) { + proxyRuleMutex.withLock { + val networkType = getCurrentNetworkType() + val rule = appPreferences.proxyNetworkRules.value[networkType] + ?: ProxyNetworkRule(defaultProxyNetworkMode(networkType)) + + when (rule.mode) { + ProxyNetworkMode.DIRECT -> { + disableProxyIfNeeded("$reason:direct") + } + + ProxyNetworkMode.BEST_PROXY -> { + selectBestProxy(networkType, "$reason:best") + } + + ProxyNetworkMode.LAST_USED -> { + val target = rule.lastUsedProxyId + if (target != null && enableProxy( + target, + networkType, + "$reason:last_used" + ) + ) return + handleUnavailableFallback(networkType, "$reason:last_used") + } + + ProxyNetworkMode.SPECIFIC_PROXY -> { + val target = rule.specificProxyId + if (target != null && enableProxy( + target, + networkType, + "$reason:specific" + ) + ) return + handleUnavailableFallback(networkType, "$reason:specific") + } + } + } + } + + private suspend fun handleUnavailableFallback(networkType: ProxyNetworkType, reason: String) { + when (appPreferences.proxyUnavailableFallback.value) { + ProxyUnavailableFallback.BEST_PROXY -> selectBestProxy( + networkType, + "$reason:fallback_best" + ) + + ProxyUnavailableFallback.DIRECT -> disableProxyIfNeeded("$reason:fallback_direct") + ProxyUnavailableFallback.KEEP_CURRENT -> Unit + } + } - if (proxies.isEmpty()) return + private suspend fun selectBestProxy(networkType: ProxyNetworkType, reason: String): Boolean { + val proxies = proxyRemoteSource.getProxies() + if (proxies.isEmpty()) { + disableProxyIfNeeded("$reason:no_proxies") + return false + } val best = coroutineScope { proxies.map { proxy -> @@ -239,24 +328,70 @@ class ConnectionManager( proxy to ping } }.awaitAll() - }.minByOrNull { it.second } ?: return + }.minByOrNull { it.second } ?: return false if (best.second == Long.MAX_VALUE) { - Log.w(TAG, "All candidate proxies are unreachable, switching to direct connection") - coRunCatching { - proxyRemoteSource.disableProxy() - appPreferences.setEnabledProxyId(null) - }.onFailure { Log.e(TAG, "Failed to switch to direct connection", it) } - return + Log.w(tag, "All proxies are unreachable, switching to direct connection") + disableProxyIfNeeded("$reason:all_unreachable") + return false } val currentEnabled = proxies.find { it.isEnabled } if (best.first.id != currentEnabled?.id) { - Log.d(TAG, "Switching to better proxy: ${best.first.server}:${best.first.port} (ping: ${best.second}ms)") - if (proxyRemoteSource.enableProxy(best.first.id)) { - appPreferences.setEnabledProxyId(best.first.id) + Log.d( + tag, + "Switching to best proxy ${best.first.server}:${best.first.port} (${best.second}ms) ($reason)" + ) + return enableProxy(best.first.id, networkType, "$reason:switch") + } + + appPreferences.setLastUsedProxyIdForNetwork(networkType, best.first.id) + return true + } + + private suspend fun enableProxy( + proxyId: Int, + networkType: ProxyNetworkType, + reason: String + ): Boolean { + if (appPreferences.enabledProxyId.value == proxyId) { + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId) + return true + } + + val enabled = coRunCatching { + withContext(dispatchers.io) { + proxyRemoteSource.enableProxy(proxyId) } + }.getOrDefault(false) + + if (enabled) { + appPreferences.setEnabledProxyId(proxyId) + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId) + } else { + Log.w(tag, "Failed to enable proxy $proxyId ($reason)") } + + return enabled + } + + private suspend fun disableProxyIfNeeded(reason: String): Boolean { + if (appPreferences.enabledProxyId.value == null) return true + + val disabled = coRunCatching { + withContext(dispatchers.io) { + proxyRemoteSource.disableProxy() + } + true + }.getOrDefault(false) + + if (disabled) { + appPreferences.setEnabledProxyId(null) + } else { + Log.w(tag, "Failed to disable proxy ($reason)") + } + + return disabled } private fun startWatchdog() { @@ -300,7 +435,7 @@ class ConnectionManager( } true }.getOrElse { - Log.w(TAG, "Failed to register network callback", it) + Log.w(tag, "Failed to register network callback", it) false } @@ -311,16 +446,39 @@ class ConnectionManager( private fun onNetworkChanged(reason: String) { scope.launch(dispatchers.default) { + applyNetworkProxyRule("network_$reason") runReconnectAttempt("network_$reason", force = true) syncConnectionStateFromTdlib("network_$reason") } } + private fun getCurrentNetworkType(): ProxyNetworkType { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val active = connectivityManager.activeNetwork ?: return ProxyNetworkType.OTHER + val capabilities = + connectivityManager.getNetworkCapabilities(active) ?: return ProxyNetworkType.OTHER + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> ProxyNetworkType.VPN + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> ProxyNetworkType.WIFI + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> ProxyNetworkType.MOBILE + else -> ProxyNetworkType.OTHER + } + } else { + @Suppress("DEPRECATION") + when (connectivityManager.activeNetworkInfo?.type) { + ConnectivityManager.TYPE_VPN -> ProxyNetworkType.VPN + ConnectivityManager.TYPE_WIFI -> ProxyNetworkType.WIFI + ConnectivityManager.TYPE_MOBILE -> ProxyNetworkType.MOBILE + else -> ProxyNetworkType.OTHER + } + } + } + private fun hasActiveNetwork(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val active = connectivityManager.activeNetwork ?: return false val capabilities = connectivityManager.getNetworkCapabilities(active) ?: return false - capabilities.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } else { @Suppress("DEPRECATION") connectivityManager.activeNetworkInfo?.isConnected == true 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 0a1c578d..5de4c8ce 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -6,6 +6,48 @@ enum class PushProvider { FCM, GMS_LESS } +enum class ProxyNetworkType { + WIFI, + MOBILE, + VPN, + OTHER +} + +enum class ProxyNetworkMode { + DIRECT, + BEST_PROXY, + LAST_USED, + SPECIFIC_PROXY +} + +enum class ProxySortMode { + ACTIVE_FIRST, + LOWEST_PING, + SERVER_NAME, + PROXY_TYPE, + STATUS +} + +enum class ProxyUnavailableFallback { + BEST_PROXY, + DIRECT, + KEEP_CURRENT +} + +data class ProxyNetworkRule( + val mode: ProxyNetworkMode, + val specificProxyId: Int? = null, + val lastUsedProxyId: Int? = null +) + +fun defaultProxyNetworkMode(networkType: ProxyNetworkType): ProxyNetworkMode { + return if (networkType == ProxyNetworkType.VPN) { + ProxyNetworkMode.DIRECT + } else { + ProxyNetworkMode.BEST_PROXY + } +} + interface AppPreferencesProvider { val autoDownloadMobile: StateFlow val autoDownloadWifi: StateFlow @@ -43,6 +85,11 @@ interface AppPreferencesProvider { val enabledProxyId: StateFlow val isAutoBestProxyEnabled: StateFlow val preferIpv6: StateFlow + val proxySortMode: StateFlow + val proxyUnavailableFallback: StateFlow + val hideOfflineProxies: StateFlow + val favoriteProxyId: StateFlow + val proxyNetworkRules: StateFlow> val userProxyBackups: StateFlow> val isBiometricEnabled: StateFlow @@ -87,6 +134,13 @@ interface AppPreferencesProvider { fun setEnabledProxyId(proxyId: Int?) fun setAutoBestProxyEnabled(enabled: Boolean) fun setPreferIpv6(enabled: Boolean) + fun setProxySortMode(mode: ProxySortMode) + fun setProxyUnavailableFallback(fallback: ProxyUnavailableFallback) + fun setHideOfflineProxies(enabled: Boolean) + fun setFavoriteProxyId(proxyId: Int?) + fun setProxyNetworkMode(networkType: ProxyNetworkType, mode: ProxyNetworkMode) + fun setSpecificProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) + fun setLastUsedProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) fun setUserProxyBackups(backups: Set) fun setBiometricEnabled(enabled: Boolean) 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 1c8dbbbf..5b666ca0 100644 --- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt +++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt @@ -11,7 +11,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.PushProvider +import org.monogram.domain.repository.defaultProxyNetworkMode enum class NightMode { SYSTEM, LIGHT, DARK, SCHEDULED, BRIGHTNESS @@ -341,6 +347,47 @@ class AppPreferences( private val _preferIpv6 = MutableStateFlow(prefs.getBoolean(KEY_PREFER_IPV6, false)) override val preferIpv6: StateFlow = _preferIpv6 + private val _proxySortMode = MutableStateFlow( + runCatching { + ProxySortMode.valueOf( + prefs.getString(KEY_PROXY_SORT_MODE, ProxySortMode.LOWEST_PING.name) + ?: ProxySortMode.LOWEST_PING.name + ) + }.getOrDefault(ProxySortMode.LOWEST_PING) + ) + override val proxySortMode: StateFlow = _proxySortMode + + private val _proxyUnavailableFallback = MutableStateFlow( + runCatching { + ProxyUnavailableFallback.valueOf( + prefs.getString( + KEY_PROXY_UNAVAILABLE_FALLBACK, + ProxyUnavailableFallback.BEST_PROXY.name + ) + ?: ProxyUnavailableFallback.BEST_PROXY.name + ) + }.getOrDefault(ProxyUnavailableFallback.BEST_PROXY) + ) + override val proxyUnavailableFallback: StateFlow = + _proxyUnavailableFallback + + private val _hideOfflineProxies = + MutableStateFlow(prefs.getBoolean(KEY_HIDE_OFFLINE_PROXIES, false)) + override val hideOfflineProxies: StateFlow = _hideOfflineProxies + + private val _favoriteProxyId = + MutableStateFlow( + if (prefs.contains(KEY_FAVORITE_PROXY_ID)) prefs.getInt( + KEY_FAVORITE_PROXY_ID, + 0 + ) else null + ) + override val favoriteProxyId: StateFlow = _favoriteProxyId + + private val _proxyNetworkRules = MutableStateFlow(readProxyNetworkRules()) + override val proxyNetworkRules: StateFlow> = + _proxyNetworkRules + private val _userProxyBackups = MutableStateFlow(prefs.getStringSet(KEY_USER_PROXY_BACKUPS, emptySet()) ?: emptySet()) override val userProxyBackups: StateFlow> = _userProxyBackups @@ -377,6 +424,89 @@ class AppPreferences( setAdBlockKeywords(keywords) } + private fun readProxyNetworkRules(): Map { + return ProxyNetworkType.entries.associateWith { networkType -> + val mode = runCatching { + ProxyNetworkMode.valueOf( + prefs.getString( + proxyModeKey(networkType), + defaultProxyNetworkMode(networkType).name + ) + ?: defaultProxyNetworkMode(networkType).name + ) + }.getOrDefault(defaultProxyNetworkMode(networkType)) + + val specificProxyId = if (prefs.contains(proxySpecificKey(networkType))) { + prefs.getInt(proxySpecificKey(networkType), 0) + } else { + null + } + + val lastUsedProxyId = if (prefs.contains(proxyLastUsedKey(networkType))) { + prefs.getInt(proxyLastUsedKey(networkType), 0) + } else { + null + } + + ProxyNetworkRule( + mode = mode, + specificProxyId = specificProxyId, + lastUsedProxyId = lastUsedProxyId + ) + } + } + + private fun updateProxyNetworkRule( + networkType: ProxyNetworkType, + transform: (ProxyNetworkRule) -> ProxyNetworkRule + ) { + val current = _proxyNetworkRules.value[networkType] ?: ProxyNetworkRule( + defaultProxyNetworkMode(networkType) + ) + val updated = transform(current) + val specificProxyId = updated.specificProxyId + val lastUsedProxyId = updated.lastUsedProxyId + + prefs.edit().apply { + putString(proxyModeKey(networkType), updated.mode.name) + if (specificProxyId != null) { + putInt(proxySpecificKey(networkType), specificProxyId) + } else { + remove(proxySpecificKey(networkType)) + } + if (lastUsedProxyId != null) { + putInt(proxyLastUsedKey(networkType), lastUsedProxyId) + } else { + remove(proxyLastUsedKey(networkType)) + } + }.apply() + + _proxyNetworkRules.value = _proxyNetworkRules.value.toMutableMap().apply { + put(networkType, updated) + } + } + + private fun proxyModeKey(networkType: ProxyNetworkType): String = when (networkType) { + ProxyNetworkType.WIFI -> KEY_PROXY_MODE_WIFI + ProxyNetworkType.MOBILE -> KEY_PROXY_MODE_MOBILE + ProxyNetworkType.VPN -> KEY_PROXY_MODE_VPN + ProxyNetworkType.OTHER -> KEY_PROXY_MODE_OTHER + } + + private fun proxySpecificKey(networkType: ProxyNetworkType): String = when (networkType) { + ProxyNetworkType.WIFI -> KEY_PROXY_SPECIFIC_WIFI + ProxyNetworkType.MOBILE -> KEY_PROXY_SPECIFIC_MOBILE + ProxyNetworkType.VPN -> KEY_PROXY_SPECIFIC_VPN + ProxyNetworkType.OTHER -> KEY_PROXY_SPECIFIC_OTHER + } + + private fun proxyLastUsedKey(networkType: ProxyNetworkType): String = when (networkType) { + ProxyNetworkType.WIFI -> KEY_PROXY_LAST_USED_WIFI + ProxyNetworkType.MOBILE -> KEY_PROXY_LAST_USED_MOBILE + ProxyNetworkType.VPN -> KEY_PROXY_LAST_USED_VPN + ProxyNetworkType.OTHER -> KEY_PROXY_LAST_USED_OTHER + } + fun setFontSize(size: Float) { prefs.edit().putFloat(KEY_FONT_SIZE, size).apply() _fontSize.value = size @@ -841,6 +971,42 @@ class AppPreferences( _preferIpv6.value = enabled } + override fun setProxySortMode(mode: ProxySortMode) { + prefs.edit().putString(KEY_PROXY_SORT_MODE, mode.name).apply() + _proxySortMode.value = mode + } + + override fun setProxyUnavailableFallback(fallback: ProxyUnavailableFallback) { + prefs.edit().putString(KEY_PROXY_UNAVAILABLE_FALLBACK, fallback.name).apply() + _proxyUnavailableFallback.value = fallback + } + + override fun setHideOfflineProxies(enabled: Boolean) { + prefs.edit().putBoolean(KEY_HIDE_OFFLINE_PROXIES, enabled).apply() + _hideOfflineProxies.value = enabled + } + + override fun setFavoriteProxyId(proxyId: Int?) { + if (proxyId != null) { + prefs.edit().putInt(KEY_FAVORITE_PROXY_ID, proxyId).apply() + } else { + prefs.edit().remove(KEY_FAVORITE_PROXY_ID).apply() + } + _favoriteProxyId.value = proxyId + } + + override fun setProxyNetworkMode(networkType: ProxyNetworkType, mode: ProxyNetworkMode) { + updateProxyNetworkRule(networkType) { it.copy(mode = mode) } + } + + override fun setSpecificProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) { + updateProxyNetworkRule(networkType) { it.copy(specificProxyId = proxyId) } + } + + override fun setLastUsedProxyIdForNetwork(networkType: ProxyNetworkType, proxyId: Int?) { + updateProxyNetworkRule(networkType) { it.copy(lastUsedProxyId = proxyId) } + } + override fun setUserProxyBackups(backups: Set) { prefs.edit().putStringSet(KEY_USER_PROXY_BACKUPS, backups).apply() _userProxyBackups.value = backups @@ -958,6 +1124,11 @@ class AppPreferences( _enabledProxyId.value = null _isAutoBestProxyEnabled.value = false _preferIpv6.value = false + _proxySortMode.value = ProxySortMode.LOWEST_PING + _proxyUnavailableFallback.value = ProxyUnavailableFallback.BEST_PROXY + _hideOfflineProxies.value = false + _favoriteProxyId.value = null + _proxyNetworkRules.value = readProxyNetworkRules() _userProxyBackups.value = emptySet() _isPermissionRequested.value = false } @@ -1072,6 +1243,22 @@ class AppPreferences( private const val KEY_ENABLED_PROXY_ID = "enabled_proxy_id" private const val KEY_AUTO_BEST_PROXY = "auto_best_proxy" private const val KEY_PREFER_IPV6 = "prefer_ipv6" + private const val KEY_PROXY_SORT_MODE = "proxy_sort_mode" + private const val KEY_PROXY_UNAVAILABLE_FALLBACK = "proxy_unavailable_fallback" + private const val KEY_HIDE_OFFLINE_PROXIES = "hide_offline_proxies" + private const val KEY_FAVORITE_PROXY_ID = "favorite_proxy_id" + private const val KEY_PROXY_MODE_WIFI = "proxy_mode_wifi" + private const val KEY_PROXY_MODE_MOBILE = "proxy_mode_mobile" + private const val KEY_PROXY_MODE_VPN = "proxy_mode_vpn" + private const val KEY_PROXY_MODE_OTHER = "proxy_mode_other" + private const val KEY_PROXY_SPECIFIC_WIFI = "proxy_specific_wifi" + private const val KEY_PROXY_SPECIFIC_MOBILE = "proxy_specific_mobile" + private const val KEY_PROXY_SPECIFIC_VPN = "proxy_specific_vpn" + private const val KEY_PROXY_SPECIFIC_OTHER = "proxy_specific_other" + private const val KEY_PROXY_LAST_USED_WIFI = "proxy_last_used_wifi" + private const val KEY_PROXY_LAST_USED_MOBILE = "proxy_last_used_mobile" + private const val KEY_PROXY_LAST_USED_VPN = "proxy_last_used_vpn" + private const val KEY_PROXY_LAST_USED_OTHER = "proxy_last_used_other" private const val KEY_USER_PROXY_BACKUPS = "user_proxy_backups" private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled" diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt index 5b7ddb0a..97e31afe 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt @@ -3,16 +3,26 @@ package org.monogram.presentation.settings.proxy import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update -import kotlinx.coroutines.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONArray import org.json.JSONObject import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyUnavailableFallback +import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext @@ -37,6 +47,14 @@ interface ProxyComponent { fun onDismissAddEdit() fun onAutoBestProxyToggled(enabled: Boolean) fun onPreferIpv6Toggled(enabled: Boolean) + fun onProxySortModeChanged(mode: ProxySortMode) + fun onProxyUnavailableFallbackChanged(fallback: ProxyUnavailableFallback) + fun onHideOfflineProxiesToggled(enabled: Boolean) + fun onToggleFavoriteProxy(proxyId: Int) + fun exportProxiesJson(): String + fun importProxiesJson(json: String) + fun onProxyNetworkModeChanged(networkType: ProxyNetworkType, mode: ProxyNetworkMode) + fun onSpecificProxyForNetworkSelected(networkType: ProxyNetworkType, proxyId: Int) fun onClearUnavailableProxies() fun onRemoveAllProxies() fun onConfirmClearUnavailableProxies() @@ -46,10 +64,18 @@ interface ProxyComponent { data class State( val proxies: List = emptyList(), + val visibleProxies: List = emptyList(), val isLoading: Boolean = false, val isAddingProxy: Boolean = false, val isAutoBestProxyEnabled: Boolean = false, val preferIpv6: Boolean = false, + val proxySortMode: ProxySortMode = ProxySortMode.LOWEST_PING, + val proxyUnavailableFallback: ProxyUnavailableFallback = ProxyUnavailableFallback.BEST_PROXY, + val hideOfflineProxies: Boolean = false, + val favoriteProxyId: Int? = null, + val proxyNetworkRules: Map = ProxyNetworkType.entries.associateWith { + ProxyNetworkRule(defaultProxyNetworkMode(it)) + }, val proxyToEdit: ProxyModel? = null, val proxyToDelete: ProxyModel? = null, val testPing: Long? = null, @@ -80,19 +106,75 @@ class DefaultProxyComponent( combine( appPreferences.isAutoBestProxyEnabled, - appPreferences.preferIpv6 - ) { autoBest, ipv6 -> autoBest to ipv6 } + appPreferences.preferIpv6, + appPreferences.proxySortMode, + appPreferences.proxyUnavailableFallback, + appPreferences.hideOfflineProxies, + ) { autoBest, ipv6, sortMode, fallback, hideOffline -> + ProxyPreferencesBaseState( + autoBest = autoBest, + preferIpv6 = ipv6, + sortMode = sortMode, + fallback = fallback, + hideOffline = hideOffline + ) + } + .combine(appPreferences.favoriteProxyId) { base, favoriteProxyId -> + base to favoriteProxyId + } + .combine(appPreferences.proxyNetworkRules) { baseWithFavorite, networkRules -> + val (base, favoriteProxyId) = baseWithFavorite + ProxyPreferencesState( + autoBest = base.autoBest, + preferIpv6 = base.preferIpv6, + sortMode = base.sortMode, + fallback = base.fallback, + hideOffline = base.hideOffline, + favoriteProxyId = favoriteProxyId, + networkRules = networkRules + ) + } .distinctUntilChanged() - .onEach { (autoBest, ipv6) -> - _state.update { - it.copy( - isAutoBestProxyEnabled = autoBest, - preferIpv6 = ipv6 + .onEach { prefs -> + _state.update { current -> + val visible = buildVisibleProxies( + current.proxies, + prefs.sortMode, + prefs.hideOffline, + prefs.favoriteProxyId + ) + current.copy( + isAutoBestProxyEnabled = prefs.autoBest, + preferIpv6 = prefs.preferIpv6, + proxySortMode = prefs.sortMode, + proxyUnavailableFallback = prefs.fallback, + hideOfflineProxies = prefs.hideOffline, + favoriteProxyId = prefs.favoriteProxyId, + proxyNetworkRules = prefs.networkRules, + visibleProxies = visible ) } }.launchIn(scope) } + private data class ProxyPreferencesBaseState( + val autoBest: Boolean, + val preferIpv6: Boolean, + val sortMode: ProxySortMode, + val fallback: ProxyUnavailableFallback, + val hideOffline: Boolean + ) + + private data class ProxyPreferencesState( + val autoBest: Boolean, + val preferIpv6: Boolean, + val sortMode: ProxySortMode, + val fallback: ProxyUnavailableFallback, + val hideOffline: Boolean, + val favoriteProxyId: Int?, + val networkRules: Map + ) + private suspend fun refreshProxies(shouldPing: Boolean = false) { _state.update { it.copy(isLoading = true) } restoreUserProxiesIfNeeded() @@ -100,6 +182,12 @@ class DefaultProxyComponent( _state.update { it.copy( proxies = allProxies, + visibleProxies = buildVisibleProxies( + allProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ), isLoading = false ) } @@ -108,6 +196,79 @@ class DefaultProxyComponent( } } + private fun buildVisibleProxies( + proxies: List, + sortMode: ProxySortMode, + hideOffline: Boolean, + favoriteProxyId: Int? + ): List { + val filtered = if (hideOffline) proxies.filter { it.ping != -1L } else proxies + return when (sortMode) { + ProxySortMode.ACTIVE_FIRST -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.LOWEST_PING -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { pingSortValue(it.ping) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.SERVER_NAME -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.PROXY_TYPE -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { proxyTypeOrder(it.type) } + .thenBy { it.server.lowercase() } + .thenBy { it.port } + ) + + ProxySortMode.STATUS -> filtered.sortedWith( + compareBy { topPriorityOrder(it, favoriteProxyId) } + .thenBy { statusOrder(it) } + .thenBy { pingSortValue(it.ping) } + .thenBy { it.server.lowercase() } + ) + } + } + + private fun topPriorityOrder(proxy: ProxyModel, favoriteProxyId: Int?): Int { + return when { + proxy.isEnabled -> 0 + favoriteProxyId != null && proxy.id == favoriteProxyId -> 1 + else -> 2 + } + } + + private fun pingSortValue(ping: Long?): Long { + return if (ping == null || ping < 0L) Long.MAX_VALUE else ping + } + + private fun proxyTypeOrder(type: ProxyTypeModel): Int { + return when (type) { + is ProxyTypeModel.Mtproto -> 0 + is ProxyTypeModel.Socks5 -> 1 + is ProxyTypeModel.Http -> 2 + } + } + + private fun statusOrder(proxy: ProxyModel): Int { + val ping = proxy.ping + return when { + proxy.isEnabled -> 0 + ping != null && ping >= 0L -> 1 + ping == null -> 2 + else -> 3 + } + } + private suspend fun restoreUserProxiesIfNeeded() { if (restoreAttempted) return restoreAttempted = true @@ -205,6 +366,65 @@ class DefaultProxyComponent( val type: ProxyTypeModel ) + private fun proxyFingerprint(server: String, port: Int, type: ProxyTypeModel): String { + return when (type) { + is ProxyTypeModel.Mtproto -> "mtproto|$server|$port|${type.secret}" + is ProxyTypeModel.Socks5 -> "socks5|$server|$port|${type.username}|${type.password}" + is ProxyTypeModel.Http -> "http|$server|$port|${type.username}|${type.password}|${type.httpOnly}" + } + } + + private fun parseProxyBackupJson(json: JSONObject): ProxyBackup? { + val type = when (json.optString("type")) { + "mtproto" -> ProxyTypeModel.Mtproto(json.optString("secret")) + "socks5" -> ProxyTypeModel.Socks5( + username = json.optString("username"), + password = json.optString("password") + ) + + "http" -> ProxyTypeModel.Http( + username = json.optString("username"), + password = json.optString("password"), + httpOnly = json.optBoolean("httpOnly", false) + ) + + else -> return null + } + + val server = json.optString("server") + val port = json.optInt("port", 443) + if (server.isBlank() || port !in 1..65535) return null + + return ProxyBackup(server = server, port = port, type = type) + } + + private fun proxyToJson(proxy: ProxyModel): JSONObject { + return JSONObject().apply { + put("server", proxy.server) + put("port", proxy.port) + when (val type = proxy.type) { + is ProxyTypeModel.Mtproto -> { + put("type", "mtproto") + put("secret", type.secret) + } + + is ProxyTypeModel.Socks5 -> { + put("type", "socks5") + put("username", type.username) + put("password", type.password) + } + + is ProxyTypeModel.Http -> { + put("type", "http") + put("username", type.username) + put("password", type.password) + put("httpOnly", type.httpOnly) + } + } + put("favorite", proxy.id == appPreferences.favoriteProxyId.value) + } + } + override fun onBackClicked() = onBack() override fun onAddProxyClicked() { @@ -227,9 +447,108 @@ class DefaultProxyComponent( onEditProxyClicked(proxy) } + override fun onToggleFavoriteProxy(proxyId: Int) { + val currentFavorite = appPreferences.favoriteProxyId.value + val nextFavorite = if (currentFavorite == proxyId) null else proxyId + appPreferences.setFavoriteProxyId(nextFavorite) + } + + override fun exportProxiesJson(): String { + val proxiesArray = JSONArray() + _state.value.proxies.forEach { proxy -> + proxiesArray.put(proxyToJson(proxy)) + } + + return JSONObject().apply { + put("version", 1) + put("proxies", proxiesArray) + }.toString(2) + } + + override fun importProxiesJson(json: String) { + scope.launch { + val existing = externalProxyRepository.getProxies() + val fingerprintToId = existing.associate { proxy -> + proxyFingerprint(proxy.server, proxy.port, proxy.type) to proxy.id + }.toMutableMap() + + var added = 0 + var skipped = 0 + var invalid = 0 + var favoriteProxyIdToSet: Int? = null + + val parsedEntries = runCatching { + val trimmed = json.trim() + if (trimmed.startsWith("[")) { + val array = JSONArray(trimmed) + List(array.length()) { index -> array.optJSONObject(index) } + } else { + val root = JSONObject(trimmed) + val array = root.optJSONArray("proxies") ?: JSONArray() + List(array.length()) { index -> array.optJSONObject(index) } + } + }.getOrNull() + + if (parsedEntries == null) { + _state.update { it.copy(toastMessage = "Import failed: invalid file") } + return@launch + } + + parsedEntries.forEach { item -> + if (item == null) { + invalid++ + return@forEach + } + + val backup = parseProxyBackupJson(item) + if (backup == null) { + invalid++ + return@forEach + } + + val fingerprint = proxyFingerprint(backup.server, backup.port, backup.type) + val existingId = fingerprintToId[fingerprint] + if (existingId != null) { + skipped++ + if (item.optBoolean("favorite", false) && favoriteProxyIdToSet == null) { + favoriteProxyIdToSet = existingId + } + return@forEach + } + + val proxy = externalProxyRepository.addProxy( + server = backup.server, + port = backup.port, + enable = false, + type = backup.type + ) + + if (proxy != null) { + addProxyToBackup(proxy) + fingerprintToId[fingerprint] = proxy.id + added++ + if (item.optBoolean("favorite", false) && favoriteProxyIdToSet == null) { + favoriteProxyIdToSet = proxy.id + } + } else { + invalid++ + } + } + + favoriteProxyIdToSet?.let { appPreferences.setFavoriteProxyId(it) } + refreshProxies(shouldPing = false) + _state.update { + it.copy(toastMessage = "Imported: $added, skipped: $skipped, invalid: $invalid") + } + } + } + override fun onEnableProxy(proxyId: Int) { scope.launch { if (externalProxyRepository.enableProxy(proxyId)) { + ProxyNetworkType.entries.forEach { networkType -> + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxyId) + } refreshProxies(shouldPing = false) onPingProxy(proxyId) } @@ -267,11 +586,21 @@ class DefaultProxyComponent( }.associate { (id, job) -> id to job.await() } } - val updatedRegular = _state.value.proxies.map { proxy -> + val updatedProxies = _state.value.proxies.map { proxy -> pings[proxy.id]?.let { proxy.copy(ping = it) } ?: proxy } - _state.update { it.copy(proxies = updatedRegular) } + _state.update { + it.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ) + ) + } } override fun onPingProxy(proxyId: Int) { @@ -280,11 +609,21 @@ class DefaultProxyComponent( externalProxyRepository.pingProxy(proxyId) } ?: -1L - val updatedRegular = _state.value.proxies.map { + val updatedProxies = _state.value.proxies.map { if (it.id == proxyId) it.copy(ping = ping) else it } - _state.update { it.copy(proxies = updatedRegular) } + _state.update { + it.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + it.proxySortMode, + it.hideOfflineProxies, + it.favoriteProxyId + ) + ) + } } } @@ -303,6 +642,9 @@ class DefaultProxyComponent( val proxy = externalProxyRepository.addProxy(server, port, true, type) if (proxy != null) { addProxyToBackup(proxy) + ProxyNetworkType.entries.forEach { networkType -> + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) + } _state.update { it.copy(isAddingProxy = false) } refreshProxies(shouldPing = false) onPingProxy(proxy.id) @@ -316,6 +658,14 @@ class DefaultProxyComponent( val proxy = externalProxyRepository.editProxy(proxyId, server, port, true, type) if (proxy != null) { replaceProxyInBackup(oldProxy, proxy) + ProxyNetworkType.entries.forEach { networkType -> + if (appPreferences.proxyNetworkRules.value[networkType]?.specificProxyId == proxyId) { + appPreferences.setSpecificProxyIdForNetwork(networkType, proxy.id) + } + if (appPreferences.proxyNetworkRules.value[networkType]?.lastUsedProxyId == proxyId) { + appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) + } + } _state.update { it.copy(proxyToEdit = null) } refreshProxies(shouldPing = false) onPingProxy(proxy.id) @@ -332,6 +682,18 @@ class DefaultProxyComponent( scope.launch { if (externalProxyRepository.removeProxy(proxy.id)) { removeProxyFromBackup(proxy) + if (appPreferences.favoriteProxyId.value == proxy.id) { + appPreferences.setFavoriteProxyId(null) + } + ProxyNetworkType.entries.forEach { networkType -> + val rule = appPreferences.proxyNetworkRules.value[networkType] + if (rule?.specificProxyId == proxy.id) { + appPreferences.setSpecificProxyIdForNetwork(networkType, null) + } + if (rule?.lastUsedProxyId == proxy.id) { + appPreferences.setLastUsedProxyIdForNetwork(networkType, null) + } + } _state.update { it.copy(proxyToDelete = null) } refreshProxies(shouldPing = false) } @@ -350,6 +712,27 @@ class DefaultProxyComponent( externalProxyRepository.setPreferIpv6(enabled) } + override fun onProxySortModeChanged(mode: ProxySortMode) { + appPreferences.setProxySortMode(mode) + } + + override fun onProxyUnavailableFallbackChanged(fallback: ProxyUnavailableFallback) { + appPreferences.setProxyUnavailableFallback(fallback) + } + + override fun onHideOfflineProxiesToggled(enabled: Boolean) { + appPreferences.setHideOfflineProxies(enabled) + } + + override fun onProxyNetworkModeChanged(networkType: ProxyNetworkType, mode: ProxyNetworkMode) { + appPreferences.setProxyNetworkMode(networkType, mode) + } + + override fun onSpecificProxyForNetworkSelected(networkType: ProxyNetworkType, proxyId: Int) { + appPreferences.setSpecificProxyIdForNetwork(networkType, proxyId) + appPreferences.setProxyNetworkMode(networkType, ProxyNetworkMode.SPECIFIC_PROXY) + } + override fun onClearUnavailableProxies() { _state.update { it.copy( @@ -362,11 +745,15 @@ class DefaultProxyComponent( override fun onConfirmClearUnavailableProxies() { scope.launch { val proxiesToDelete = _state.value.proxies.filter { it.ping == -1L } + val deletedIds = proxiesToDelete.map { it.id }.toSet() proxiesToDelete.forEach { proxy -> if (externalProxyRepository.removeProxy(proxy.id)) { removeProxyFromBackup(proxy) } } + if (appPreferences.favoriteProxyId.value in deletedIds) { + appPreferences.setFavoriteProxyId(null) + } _state.update { it.copy(showClearOfflineConfirmation = false) } refreshProxies(shouldPing = false) } @@ -388,6 +775,11 @@ class DefaultProxyComponent( removeProxyFromBackup(proxy) } } + appPreferences.setFavoriteProxyId(null) + ProxyNetworkType.entries.forEach { networkType -> + appPreferences.setSpecificProxyIdForNetwork(networkType, null) + appPreferences.setLastUsedProxyIdForNetwork(networkType, null) + } _state.update { it.copy(showRemoveAllConfirmation = false) } refreshProxies(shouldPing = false) } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt index a22789bd..c4c80d95 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt @@ -2,12 +2,40 @@ package org.monogram.presentation.settings.proxy -import androidx.compose.animation.* +import android.content.ClipData +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable @@ -16,32 +44,157 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Key +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.LinkOff +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.Password +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Public +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Sort +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.StarBorder +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.repository.ProxyNetworkMode +import org.monogram.domain.repository.ProxyNetworkRule +import org.monogram.domain.repository.ProxyNetworkType +import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyUnavailableFallback +import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.R -import org.monogram.presentation.features.chats.chatList.components.SettingsTextField import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsSwitchTile import org.monogram.presentation.core.ui.SettingsTile +import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.features.stickers.ui.menu.MenuOptionRow +import org.monogram.presentation.features.viewers.components.ViewerSettingsDropdown +import java.net.URLEncoder +import java.nio.charset.StandardCharsets @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ProxyContent(component: ProxyComponent) { val state by component.state.subscribeAsState() val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + LocalClipboard.current + var expandedNetworkMenu by remember { mutableStateOf(null) } + var sortMenuExpanded by remember { mutableStateOf(false) } + var fallbackMenuExpanded by remember { mutableStateOf(false) } + var showTopMenu by remember { mutableStateOf(false) } + + val exportLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openOutputStream(uri)?.bufferedWriter()?.use { writer -> + writer.write(component.exportProxiesJson()) + } + }.onSuccess { + Toast.makeText( + context, + context.getString(R.string.proxy_export_success), + Toast.LENGTH_SHORT + ).show() + }.onFailure { + Toast.makeText( + context, + context.getString(R.string.proxy_export_failed), + Toast.LENGTH_SHORT + ).show() + } + } + + val importLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openInputStream(uri)?.bufferedReader() + ?.use { it.readText() }.orEmpty() + }.onSuccess { json -> + component.importProxiesJson(json) + }.onFailure { + Toast.makeText( + context, + context.getString(R.string.proxy_import_failed), + Toast.LENGTH_SHORT + ).show() + } + } LaunchedEffect(state.toastMessage) { state.toastMessage?.let { message -> @@ -73,6 +226,12 @@ fun ProxyContent(component: ProxyComponent) { IconButton(onClick = component::onPingAll) { Icon(Icons.Rounded.Refresh, contentDescription = stringResource(R.string.refresh_pings_cd)) } + IconButton(onClick = { showTopMenu = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options_cd) + ) + } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background @@ -146,6 +305,244 @@ fun ProxyContent(component: ProxyComponent) { } } + item { + SectionHeader( + text = stringResource(R.string.proxy_network_rules_header), + subtitle = stringResource(R.string.proxy_network_rules_subtitle) + ) + } + + item { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + ProxyNetworkType.entries.forEachIndexed { index, networkType -> + val rule = state.proxyNetworkRules[networkType] ?: ProxyNetworkRule( + defaultProxyNetworkMode(networkType) + ) + val position = itemPosition(index, ProxyNetworkType.entries.size) + Box { + SettingsTile( + icon = Icons.Rounded.Wifi, + title = stringResource(networkTitleRes(networkType)), + subtitle = stringResource(networkRuleSubtitleRes(rule)), + iconColor = Color(0xFF1E88E5), + position = position, + onClick = { expandedNetworkMenu = networkType }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + networkModeLabelRes(rule.mode) + ) + ) + } + ) + + StyledDropdownMenu( + expanded = expandedNetworkMenu == networkType, + onDismissRequest = { expandedNetworkMenu = null } + ) { + ProxyNetworkMode.entries.forEach { mode -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = networkModeIcon(mode), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { Text(stringResource(networkModeLabelRes(mode))) }, + trailingIcon = { + if (rule.mode == mode) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxyNetworkModeChanged(networkType, mode) + expandedNetworkMenu = null + } + ) + } + if (state.proxies.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + state.proxies.forEach { proxy -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { + Text( + stringResource( + R.string.proxy_specific_target_format, + proxy.server, + proxy.port + ) + ) + }, + trailingIcon = { + if (rule.mode == ProxyNetworkMode.SPECIFIC_PROXY && rule.specificProxyId == proxy.id) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onSpecificProxyForNetworkSelected( + networkType, + proxy.id + ) + expandedNetworkMenu = null + } + ) + } + } + } + } + } + } + } + + item { + SectionHeader(stringResource(R.string.proxy_list_behavior_header)) + } + + item { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + Box { + SettingsTile( + icon = Icons.Rounded.Sort, + title = stringResource(R.string.proxy_sort_mode_title), + subtitle = stringResource(R.string.proxy_sort_mode_subtitle), + iconColor = Color(0xFFF9A825), + position = ItemPosition.TOP, + onClick = { sortMenuExpanded = true }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + sortModeLabelRes( + state.proxySortMode + ) + ) + ) + } + ) + + StyledDropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false } + ) { + ProxySortMode.entries.forEach { mode -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = sortModeIcon(mode), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { Text(stringResource(sortModeLabelRes(mode))) }, + trailingIcon = { + if (state.proxySortMode == mode) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxySortModeChanged(mode) + sortMenuExpanded = false + } + ) + } + } + } + + Box { + SettingsTile( + icon = Icons.Rounded.SwapHoriz, + title = stringResource(R.string.proxy_unavailable_fallback_title), + subtitle = stringResource(R.string.proxy_unavailable_fallback_subtitle), + iconColor = Color(0xFF6A1B9A), + position = ItemPosition.MIDDLE, + onClick = { fallbackMenuExpanded = true }, + trailingContent = { + DropdownSelectionTrailing( + text = stringResource( + fallbackLabelRes( + state.proxyUnavailableFallback + ) + ) + ) + } + ) + + StyledDropdownMenu( + expanded = fallbackMenuExpanded, + onDismissRequest = { fallbackMenuExpanded = false } + ) { + ProxyUnavailableFallback.entries.forEach { fallback -> + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = fallbackIcon(fallback), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + text = { Text(stringResource(fallbackLabelRes(fallback))) }, + trailingIcon = { + if (state.proxyUnavailableFallback == fallback) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + component.onProxyUnavailableFallbackChanged(fallback) + fallbackMenuExpanded = false + } + ) + } + } + } + + SettingsSwitchTile( + icon = Icons.Rounded.VisibilityOff, + title = stringResource(R.string.hide_offline_proxies_title), + subtitle = stringResource(R.string.hide_offline_proxies_subtitle), + checked = state.hideOfflineProxies, + iconColor = Color(0xFF00897B), + position = ItemPosition.BOTTOM, + onCheckedChange = component::onHideOfflineProxiesToggled + ) + } + } + item { Row( modifier = Modifier @@ -182,11 +579,11 @@ fun ProxyContent(component: ProxyComponent) { } } - itemsIndexed(state.proxies, key = { _, it -> it.id }) { index, proxy -> + itemsIndexed(state.visibleProxies, key = { _, it -> it.id }) { index, proxy -> val position = when { - state.proxies.size == 1 -> ItemPosition.STANDALONE + state.visibleProxies.size == 1 -> ItemPosition.STANDALONE index == 0 -> ItemPosition.TOP - index == state.proxies.size - 1 -> ItemPosition.BOTTOM + index == state.visibleProxies.size - 1 -> ItemPosition.BOTTOM else -> ItemPosition.MIDDLE } @@ -195,15 +592,17 @@ fun ProxyContent(component: ProxyComponent) { ) { ProxyItem( proxy = proxy, + isFavorite = state.favoriteProxyId == proxy.id, position = position, onClick = { component.onProxyClicked(proxy) }, onLongClick = { component.onProxyLongClicked(proxy) }, - onRefreshPing = { component.onPingProxy(proxy.id) } + onRefreshPing = { component.onPingProxy(proxy.id) }, + onOpenMenu = { component.onEditProxyClicked(proxy) } ) } } - if (state.proxies.isEmpty() && !state.isLoading) { + if (state.visibleProxies.isEmpty() && !state.isLoading) { item { Column( modifier = Modifier @@ -220,7 +619,7 @@ fun ProxyContent(component: ProxyComponent) { ) Spacer(Modifier.height(16.dp)) Text( - stringResource(R.string.no_proxies_label), + stringResource(if (state.proxies.isEmpty()) R.string.no_proxies_label else R.string.no_proxies_match_filter_label), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -232,6 +631,62 @@ fun ProxyContent(component: ProxyComponent) { } } + if (showTopMenu) { + Popup( + onDismissRequest = { showTopMenu = false }, + properties = PopupProperties(focusable = true) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { showTopMenu = false } + ) { + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { isVisible = true } + + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(MaterialTheme.motionScheme.defaultEffectsSpec()) + scaleIn( + animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), + initialScale = 0.8f, + transformOrigin = TransformOrigin(1f, 0f) + ), + exit = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()) + scaleOut( + animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(), + targetScale = 0.9f, + transformOrigin = TransformOrigin(1f, 0f) + ), + modifier = Modifier + .align(Alignment.TopEnd) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 56.dp, end = 16.dp) + ) { + ViewerSettingsDropdown { + MenuOptionRow( + icon = Icons.Rounded.Upload, + title = stringResource(R.string.proxy_export_action), + onClick = { + showTopMenu = false + exportLauncher.launch("monogram_proxies.json") + } + ) + MenuOptionRow( + icon = Icons.Rounded.Download, + title = stringResource(R.string.proxy_import_action), + onClick = { + showTopMenu = false + importLauncher.launch(arrayOf("application/json", "text/plain")) + } + ) + } + } + } + } + } + if (state.isAddingProxy || state.proxyToEdit != null) { ProxyAddEditSheet( proxy = state.proxyToEdit, @@ -239,6 +694,16 @@ fun ProxyContent(component: ProxyComponent) { onTest = component::onTestProxy, testPing = state.testPing, isTesting = state.isTesting, + isFavorite = state.proxyToEdit?.id == state.favoriteProxyId, + onToggleFavorite = { + state.proxyToEdit?.let { component.onToggleFavoriteProxy(it.id) } + }, + onDelete = { + state.proxyToEdit?.let { + component.onRemoveProxy(it.id) + component.onDismissAddEdit() + } + }, onSave = { server, port, type -> if (state.proxyToEdit != null) { component.onEditProxy(state.proxyToEdit!!.id, server, port, type) @@ -311,16 +776,164 @@ fun ProxyContent(component: ProxyComponent) { } ) } + +} + +private fun itemPosition(index: Int, total: Int): ItemPosition { + return when { + total <= 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == total - 1 -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } +} + +@Composable +private fun DropdownSelectionTrailing(text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.widthIn(max = 180.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun StyledDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + offset = DpOffset(x = 0.dp, y = 8.dp), + shape = RoundedCornerShape(22.dp), + containerColor = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Surface( + modifier = Modifier.widthIn(min = 220.dp, max = 320.dp), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier.padding(vertical = 8.dp), + content = content + ) + } + } +} + +private fun networkModeIcon(mode: ProxyNetworkMode): ImageVector = when (mode) { + ProxyNetworkMode.DIRECT -> Icons.Rounded.LinkOff + ProxyNetworkMode.BEST_PROXY -> Icons.Rounded.Bolt + ProxyNetworkMode.LAST_USED -> Icons.Rounded.History + ProxyNetworkMode.SPECIFIC_PROXY -> Icons.Rounded.Tune +} + +private fun sortModeIcon(mode: ProxySortMode): ImageVector = when (mode) { + ProxySortMode.ACTIVE_FIRST -> Icons.Rounded.CheckCircle + ProxySortMode.LOWEST_PING -> Icons.Rounded.Speed + ProxySortMode.SERVER_NAME -> Icons.Rounded.Language + ProxySortMode.PROXY_TYPE -> Icons.Rounded.Tune + ProxySortMode.STATUS -> Icons.Rounded.Info +} + +private fun fallbackIcon(fallback: ProxyUnavailableFallback): ImageVector = when (fallback) { + ProxyUnavailableFallback.BEST_PROXY -> Icons.Rounded.Bolt + ProxyUnavailableFallback.DIRECT -> Icons.Rounded.LinkOff + ProxyUnavailableFallback.KEEP_CURRENT -> Icons.Rounded.Pause +} + +@StringRes +private fun networkTitleRes(networkType: ProxyNetworkType): Int = when (networkType) { + ProxyNetworkType.WIFI -> R.string.proxy_network_wifi + ProxyNetworkType.MOBILE -> R.string.proxy_network_mobile + ProxyNetworkType.VPN -> R.string.proxy_network_vpn + ProxyNetworkType.OTHER -> R.string.proxy_network_other +} + +@StringRes +private fun networkModeLabelRes(mode: ProxyNetworkMode): Int = when (mode) { + ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct + ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best + ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used + ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific +} + +@StringRes +private fun networkRuleSubtitleRes(rule: ProxyNetworkRule): Int = when (rule.mode) { + ProxyNetworkMode.DIRECT -> R.string.proxy_network_mode_direct_subtitle + ProxyNetworkMode.BEST_PROXY -> R.string.proxy_network_mode_best_subtitle + ProxyNetworkMode.LAST_USED -> R.string.proxy_network_mode_last_used_subtitle + ProxyNetworkMode.SPECIFIC_PROXY -> R.string.proxy_network_mode_specific_subtitle +} + +@StringRes +private fun sortModeLabelRes(mode: ProxySortMode): Int = when (mode) { + ProxySortMode.ACTIVE_FIRST -> R.string.proxy_sort_mode_active_first + ProxySortMode.LOWEST_PING -> R.string.proxy_sort_mode_lowest_ping + ProxySortMode.SERVER_NAME -> R.string.proxy_sort_mode_server_name + ProxySortMode.PROXY_TYPE -> R.string.proxy_sort_mode_proxy_type + ProxySortMode.STATUS -> R.string.proxy_sort_mode_status +} + +@StringRes +private fun fallbackLabelRes(fallback: ProxyUnavailableFallback): Int = when (fallback) { + ProxyUnavailableFallback.BEST_PROXY -> R.string.proxy_fallback_best_proxy + ProxyUnavailableFallback.DIRECT -> R.string.proxy_fallback_direct + ProxyUnavailableFallback.KEEP_CURRENT -> R.string.proxy_fallback_keep_current +} + +private fun proxyToDeepLink(proxy: ProxyModel): String { + fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8.toString()) + + return when (val type = proxy.type) { + is ProxyTypeModel.Mtproto -> { + "tg://proxy?server=${encode(proxy.server)}&port=${proxy.port}&secret=${encode(type.secret)}" + } + + is ProxyTypeModel.Socks5 -> { + buildString { + append("tg://socks?server=${encode(proxy.server)}&port=${proxy.port}") + if (type.username.isNotBlank()) append("&user=${encode(type.username)}") + if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") + } + } + + is ProxyTypeModel.Http -> { + buildString { + append("tg://http?server=${encode(proxy.server)}&port=${proxy.port}") + if (type.username.isNotBlank()) append("&user=${encode(type.username)}") + if (type.password.isNotBlank()) append("&pass=${encode(type.password)}") + } + } + } } @OptIn(ExperimentalFoundationApi::class) @Composable fun ProxyItem( proxy: ProxyModel, + isFavorite: Boolean, position: ItemPosition, onClick: () -> Unit, onLongClick: () -> Unit, - onRefreshPing: () -> Unit + onRefreshPing: () -> Unit, + onOpenMenu: () -> Unit ) { val typeName = when (proxy.type) { is ProxyTypeModel.Mtproto -> "MTProto" @@ -391,19 +1004,34 @@ fun ProxyItem( Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = proxy.server, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - color = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = proxy.server, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + color = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false) + ) + if (isFavorite) { + Spacer(Modifier.width(6.dp)) + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = stringResource(R.string.proxy_action_remove_favorite), + tint = Color(0xFFFFB300), + modifier = Modifier.size(16.dp) + ) + } + } Row(verticalAlignment = Alignment.CenterVertically) { Text( text = typeName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp)) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) .padding(horizontal = 4.dp, vertical = 1.dp) ) Spacer(Modifier.width(8.dp)) @@ -422,13 +1050,23 @@ fun ProxyItem( isChecking = proxy.ping == null, ) - IconButton(onClick = onRefreshPing, modifier = Modifier.size(32.dp)) { - Icon( - Icons.Rounded.Refresh, - contentDescription = stringResource(R.string.refresh_list_title), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onRefreshPing, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Rounded.Refresh, + contentDescription = stringResource(R.string.refresh_list_title), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onOpenMenu, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.more_options_cd), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @@ -522,9 +1160,14 @@ fun ProxyAddEditSheet( onTest: (String, Int, ProxyTypeModel) -> Unit, testPing: Long?, isTesting: Boolean, + isFavorite: Boolean, + onToggleFavorite: () -> Unit, + onDelete: () -> Unit, onSave: (String, Int, ProxyTypeModel) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val clipboard = LocalClipboard.current var server by remember { mutableStateOf(proxy?.server ?: "") } var port by remember { mutableStateOf(proxy?.port?.toString() ?: "") } @@ -593,7 +1236,10 @@ fun ProxyAddEditSheet( Modifier .selectableGroup() .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), RoundedCornerShape(50)) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(50) + ) .padding(4.dp), horizontalArrangement = Arrangement.SpaceBetween ) { @@ -680,6 +1326,51 @@ fun ProxyAddEditSheet( } } + if (proxy != null) { + Spacer(modifier = Modifier.height(20.dp)) + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + MenuOptionRow( + icon = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder, + title = stringResource( + if (isFavorite) R.string.proxy_action_remove_favorite else R.string.proxy_action_set_favorite + ), + iconTint = if (isFavorite) Color(0xFFFFB300) else MaterialTheme.colorScheme.primary, + onClick = onToggleFavorite + ) + MenuOptionRow( + icon = Icons.Rounded.ContentCopy, + title = stringResource(R.string.proxy_action_copy_link), + onClick = { + val link = proxyToDeepLink(proxy) + clipboard.nativeClipboard.setPrimaryClip( + ClipData.newPlainText("", AnnotatedString(link)) + ) + Toast.makeText( + context, + context.getString(R.string.proxy_link_copied), + Toast.LENGTH_SHORT + ).show() + } + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + MenuOptionRow( + icon = Icons.Rounded.Delete, + title = stringResource(R.string.proxy_action_delete), + textColor = MaterialTheme.colorScheme.error, + iconTint = MaterialTheme.colorScheme.error, + onClick = onDelete + ) + } + } + } + Spacer(modifier = Modifier.height(32.dp)) if (testPing != null || isTesting) { diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 12607cfc..da8eaee7 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -476,7 +476,7 @@ Configuración de Proxy Actualizar Pings - Añadir Proxy + Añadir Conexión Cambio Inteligente Usar automáticamente el proxy más rápido @@ -485,6 +485,47 @@ Deshabilitar Proxy Conectado directamente Cambiar a conexión directa + Reglas de red + Elige cómo funciona el proxy para cada tipo de red + Wi-Fi + Datos móviles + VPN + Otras redes + Directo + Mejor proxy + Último usado + Proxy específico + Conectar siempre de forma directa en esta red + Elegir el proxy disponible más rápido en esta red + Reutilizar el último proxy usado en esta red + Usar siempre un proxy elegido en esta red + Específico: %1$s:%2$d + Comportamiento de la lista + Ordenar proxys + Elige cómo se ordena la lista de proxys + Activo primero + Menor latencia + Nombre del servidor + Tipo de proxy + Estado + Si el proxy seleccionado no está disponible + Comportamiento de respaldo para modos específico/último usado + Cambiar al mejor proxy + Cambiar a conexión directa + Mantener estado actual + Ocultar proxys sin conexión + Mostrar solo proxys disponibles o sin comprobar + Exportar proxys + Importar proxys + Lista de proxys exportada + Error al exportar la lista de proxys + Error al leer el archivo de importación + Marcar como favorito + Quitar de favoritos + Copiar como enlace + Editar proxy + Eliminar proxy + Enlace de proxy copiado Actualizar Lista Obtener últimos proxys de la comunidad Tus Proxys @@ -495,6 +536,7 @@ Eliminar Todos los Proxys Esto eliminará todos los proxys configurados de la aplicación. ¿Continuar? Sin proxys añadidos + Ningún proxy coincide con los filtros actuales Eliminar Proxy ¿Estás seguro de que quieres eliminar el proxy %1$s? Nuevo Proxy @@ -504,7 +546,7 @@ Secreto (Hex) Nombre de Usuario (Opcional) Contraseña (Opcional) - Guardar Cambios + Guardar Probar Resultado de la Prueba Eliminar diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index b95d0e6e..64967bdc 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -456,7 +456,7 @@ Պրոքսիի կարգավորումներ Թարմացնել պինգերը - Ավելացնել պրոքսի + Ավելացնել Միացում Խելացի փոխանջատում Ավտոմատ օգտագործել ամենաարագ պրոքսին @@ -465,6 +465,47 @@ Անջատել պրոքսին Միացված է ուղղակիորեն Անցնել ուղիղ միացման + Ցանցային կանոններ + Ընտրեք պրոքսիի վարքագիծը ցանցի յուրաքանչյուր տեսակի համար + Wi-Fi + Բջջային տվյալներ + VPN + Այլ ցանցեր + Ուղիղ միացում + Լավագույն պրոքսի + Վերջինը օգտագործված + Կոնկրետ պրոքսի + Այս ցանցում միշտ միանալ ուղիղ + Այս ցանցում ընտրել ամենաարագ հասանելի պրոքսին + Այս ցանցում կրկին օգտագործել վերջին պրոքսին + Այս ցանցում միշտ օգտագործել ընտրված պրոքսին + Կոնկրետ: %1$s:%2$d + Ցուցակի վարքագիծ + Պրոքսիների դասավորում + Ընտրեք պրոքսիների ցուցակի դասավորման կարգը + Ակտիվը՝ առաջինը + Նվազագույն ուշացում + Սերվերի անուն + Պրոքսիի տեսակ + Կարգավիճակ + Եթե ընտրված պրոքսին անհասանելի է + Պահեստային վարքագիծ «կոնկրետ» և «վերջին» ռեժիմների համար + Փոխանցվել լավագույն պրոքսիի + Փոխանցվել ուղիղ միացման + Պահել ընթացիկ վիճակը + Թաքցնել անցանց պրոքսիները + Ցույց տալ միայն հասանելի կամ չստուգված պրոքսիները + Արտահանել պրոքսիները + Ներմուծել պրոքսիները + Պրոքսիների ցուցակն արտահանվել է + Չհաջողվեց արտահանել պրոքսիների ցուցակը + Չհաջողվեց կարդալ ներմուծման ֆայլը + Դարձնել ընտրյալ + Հեռացնել ընտրյալներից + Պատճենել որպես հղում + Խմբագրել պրոքսին + Ջնջել պրոքսին + Պրոքսիի հղումը պատճենված է Թարմացնել ցուցակը Ներբեռնել համայնքի թարմ պրոքսիները Ձեր պրոքսիները @@ -475,6 +516,7 @@ Ջնջել բոլոր պրոքսիները Սա կհեռացնի բոլոր կարգավորված պրոքսիները: Շարունակե՞լ: Պրոքսիներ չկան + Ընթացիկ ֆիլտրերին համապատասխան պրոքսի չկա Ջնջել պրոքսին Իսկապե՞ս ցանկանում եք ջնջել %1$s պրոքսին: Նոր պրոքսի @@ -484,7 +526,7 @@ Secret (Hex) Օգտանուն (ոչ պարտադիր) Գաղտնաբառ (ոչ պարտադիր) - Պահպանել փոփոխությունները + Պահպանել Թեստ Թեստի արդյունքը Ջնջել diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 45bd06db..24d35d6f 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -476,7 +476,7 @@ Configurações de proxy Atualizar pings - Adicionar proxy + Adicionar Conexão Troca inteligente Use automaticamente o proxy mais rápido @@ -485,6 +485,47 @@ Desativar proxy Conectado diretamente Alternar para conexão direta + Regras de rede + Escolha como o proxy funciona para cada tipo de rede + Wi-Fi + Dados móveis + VPN + Outras redes + Direto + Melhor proxy + Último usado + Proxy específico + Sempre conectar diretamente nesta rede + Selecionar o proxy disponível mais rápido nesta rede + Reutilizar o último proxy usado nesta rede + Sempre usar um proxy escolhido nesta rede + Específico: %1$s:%2$d + Comportamento da lista + Ordenar proxies + Escolha como a lista de proxies é ordenada + Ativo primeiro + Menor latência + Nome do servidor + Tipo de proxy + Status + Se o proxy selecionado estiver indisponível + Comportamento de fallback para os modos específico/último usado + Alternar para o melhor proxy + Alternar para conexão direta + Manter estado atual + Ocultar proxies offline + Mostrar apenas proxies disponíveis ou não verificados + Exportar proxies + Importar proxies + Lista de proxies exportada + Falha ao exportar a lista de proxies + Falha ao ler o arquivo de importação + Definir como favorito + Remover dos favoritos + Copiar como link + Editar proxy + Excluir proxy + Link do proxy copiado Atualizar lista Buscar os proxies comunitários mais recentes Seus proxies @@ -495,6 +536,7 @@ Excluir todos os proxies Isso removerá todos os proxies configurados no app. Deseja continuar? Nenhum proxy adicionado + Nenhum proxy corresponde aos filtros atuais Excluir proxy Tem certeza de que deseja excluir o proxy %1$s? Novo proxy @@ -504,7 +546,7 @@ Segredo (Hex) Nome de usuário (opcional) Senha (opcional) - Salvar alterações + Salvar Testar Resultado do teste Excluir diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 3b3e89ff..a4986116 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -470,7 +470,7 @@ Настройки прокси Обновить задержку - Добавить прокси + Добавить Соединение Умное переключение Автоматически выбирать самый быстрый прокси @@ -479,6 +479,47 @@ Отключить прокси Прямое подключение Переключиться на прямое соединение + Правила сети + Выберите поведение прокси для каждого типа сети + Wi-Fi + Мобильная сеть + VPN + Другие сети + Без прокси + Лучший прокси + Последний использованный + Конкретный прокси + Всегда подключаться напрямую в этой сети + Выбирать самый быстрый доступный прокси в этой сети + Использовать последний прокси для этой сети + Всегда использовать выбранный прокси в этой сети + Выбрать: %1$s:%2$d + Поведение списка + Сортировка прокси + Выберите порядок отображения списка + Сначала активный + Минимальная задержка + Имя сервера + Тип прокси + Статус + Если выбранный прокси недоступен + Поведение для режимов «конкретный» и «последний» + Переключаться на лучший прокси + Переключаться на прямое соединение + Оставлять как есть + Скрывать офлайн-прокси + Показывать только доступные или непроверенные прокси + Экспорт прокси + Импорт прокси + Список прокси экспортирован + Не удалось экспортировать список прокси + Не удалось прочитать файл импорта + Сделать избранным + Убрать из избранного + Скопировать ссылку + Изменить прокси + Удалить прокси + Ссылка прокси скопирована Обновить список Получить актуальные прокси от сообщества Ваши прокси @@ -489,6 +530,7 @@ Удалить все прокси Это действие удалит все настроенные прокси из приложения. Продолжить? Нет добавленных прокси + Нет прокси, подходящих под текущие фильтры Удалить прокси Вы уверены, что хотите удалить прокси %1$s? Новый прокси @@ -498,7 +540,7 @@ Секрет (Hex) Имя пользователя (опционально) Пароль (опционально) - Сохранить изменения + Сохранить Проверить Результат проверки Удалить diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 5409d507..171cc9c2 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -494,7 +494,7 @@ Nastavenia proxy Obnoviť pingy - Pridať proxy + Pridať Pripojenie Inteligentné prepínanie Automaticky použiť najrýchlejšie proxy @@ -503,6 +503,47 @@ Vypnúť proxy Pripojené priamo Prepnúť na priame pripojenie + Pravidlá siete + Vyberte správanie proxy pre každý typ siete + Wi-Fi + Mobilné dáta + VPN + Ostatné siete + Priame pripojenie + Najlepšie proxy + Naposledy použité + Konkrétne proxy + V tejto sieti sa vždy pripájať priamo + V tejto sieti vybrať najrýchlejšie dostupné proxy + V tejto sieti použiť naposledy použité proxy + V tejto sieti vždy použiť vybrané proxy + Konkrétne: %1$s:%2$d + Správanie zoznamu + Zoradenie proxy + Vyberte poradie zobrazenia zoznamu proxy + Aktívne ako prvé + Najnižší ping + Názov servera + Typ proxy + Stav + Ak je vybrané proxy nedostupné + Náhradné správanie pre režimy konkrétne/naposledy použité + Prepnúť na najlepšie proxy + Prepnúť na priame pripojenie + Ponechať aktuálny stav + Skryť offline proxy + Zobraziť iba dostupné alebo neoverené proxy + Exportovať proxy + Importovať proxy + Zoznam proxy bol exportovaný + Export zoznamu proxy zlyhal + Nepodarilo sa načítať importovaný súbor + Nastaviť ako obľúbené + Odstrániť z obľúbených + Kopírovať ako odkaz + Upraviť proxy + Odstrániť proxy + Odkaz na proxy bol skopírovaný Obnoviť zoznam Načítať najnovšie proxy od komunity Vaše proxy @@ -513,6 +554,7 @@ Odstrániť všetky proxy Táto akcia odstráni všetky nakonfigurované proxy z aplikácie. Pokračovať? Žiadne proxy nie sú pridané + Žiadne proxy nezodpovedajú aktuálnym filtrom Odstrániť proxy Naozaj chcete odstrániť proxy %1$s? Nové proxy @@ -522,7 +564,7 @@ Tajný kľúč (Hex) Používateľské meno (voliteľné) Heslo (voliteľné) - Uložiť zmeny + Uložiť Otestovať Výsledok testu Odstrániť diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index aa81862a..40d221a4 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -470,7 +470,7 @@ Налаштування проксі Оновити затримку - Додати проксі + Додати Підключення Розумне перемикання Автоматично вибирати найшвидший проксі @@ -479,6 +479,47 @@ Вимкнути проксі Пряме підключення Перемкнутися на пряме з\'єднання + Правила мережі + Оберіть поведінку проксі для кожного типу мережі + Wi-Fi + Мобільна мережа + VPN + Інші мережі + Без проксі + Найкращий проксі + Останній використаний + Конкретний проксі + Завжди підключатися напряму в цій мережі + Обирати найшвидший доступний проксі в цій мережі + Повторно використовувати останній проксі для цієї мережі + Завжди використовувати вибраний проксі в цій мережі + Конкретний: %1$s:%2$d + Поведінка списку + Сортування проксі + Оберіть порядок відображення списку проксі + Активний спочатку + Найменша затримка + Назва сервера + Тип проксі + Статус + Якщо вибраний проксі недоступний + Поведінка для режимів «конкретний» та «останній» + Перемикатися на найкращий проксі + Перемикатися на пряме з\'єднання + Залишати поточний стан + Приховувати офлайн-проксі + Показувати лише доступні або неперевірені проксі + Експорт проксі + Імпорт проксі + Список проксі експортовано + Не вдалося експортувати список проксі + Не вдалося прочитати файл імпорту + Додати в обране + Прибрати з обраного + Скопіювати як посилання + Змінити проксі + Видалити проксі + Посилання на проксі скопійовано Оновити список Отримати актуальні проксі від спільноти Ваші проксі @@ -489,6 +530,7 @@ Видалити всі проксі Ця дія видалить усі налаштовані проксі з застосунку. Продовжити? Немає доданих проксі + Немає проксі, що відповідають поточним фільтрам Видалити проксі Ви впевнені, що хочете видалити проксі %1$s? Новий проксі @@ -498,7 +540,7 @@ Секрет (Hex) Ім\'я користувача (опціонально) Пароль (опціонально) - Зберегти зміни + Зберегти Перевірити Результат перевірки Видалити diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 962f67f1..b0e47c06 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -467,7 +467,7 @@ 代理设置 刷新延迟 - 添加代理 + 添加 连接 智能切换 自动使用速度最快的代理 @@ -476,6 +476,47 @@ 禁用代理 直接连接 切换到直接连接 + 网络规则 + 为每种网络类型选择代理行为 + Wi-Fi + 移动数据 + VPN + 其他网络 + 直连 + 最佳代理 + 上次使用 + 指定代理 + 在该网络下始终直连 + 在该网络下自动选择最快可用代理 + 在该网络下复用上次使用的代理 + 在该网络下始终使用指定代理 + 指定: %1$s:%2$d + 列表行为 + 代理排序 + 选择代理列表的排序方式 + 活动优先 + 最低延迟 + 服务器名称 + 代理类型 + 状态 + 当所选代理不可用时 + 指定/上次使用模式下的后备行为 + 切换到最佳代理 + 切换为直连 + 保持当前状态 + 隐藏离线代理 + 仅显示可用或未检测的代理 + 导出代理 + 导入代理 + 代理列表已导出 + 导出代理列表失败 + 读取导入文件失败 + 设为收藏 + 取消收藏 + 复制为链接 + 编辑代理 + 删除代理 + 代理链接已复制 刷新列表 获取最新的社区代理 您的代理 @@ -486,6 +527,7 @@ 删除所有代理 此操作将从应用中删除所有已配置的代理。是否继续? 未添加代理 + 没有代理符合当前筛选条件 删除代理 您确定要删除代理 %1$s 吗? 新代理 @@ -495,7 +537,7 @@ 密钥 (Hex) 用户名(选填) 密码(选填) - 保存修改 + 保存 测试 测试结果 删除 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 48845ef0..2ee5812d 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -482,7 +482,7 @@ Proxy Settings Refresh Pings - Add Proxy + Add Connection Smart Switching Automatically use the fastest proxy @@ -491,6 +491,47 @@ Disable Proxy Connected directly Switch to direct connection + Network Rules + Choose how proxy works for each network type + Wi-Fi + Mobile data + VPN + Other networks + Direct + Best proxy + Last used + Specific proxy + Always connect directly on this network + Pick the fastest available proxy on this network + Reuse the last proxy used on this network + Always use a chosen proxy on this network + Specific: %1$s:%2$d + List Behavior + Sort proxies + Choose how your proxy list is ordered + Active first + Lowest ping + Server name + Proxy type + Status + If selected proxy is unavailable + Fallback behavior for specific/last-used modes + Switch to best proxy + Switch to direct + Keep current state + Hide offline proxies + Show only proxies that are available or unchecked + Export proxies + Import proxies + Proxy list exported + Failed to export proxy list + Failed to read import file + Set as favorite + Remove from favorites + Copy as link + Edit proxy + Delete proxy + Proxy link copied Refresh List Fetch latest community proxies Your Proxies @@ -501,6 +542,7 @@ Delete All Proxies This will remove all configured proxies from the app. Continue? No proxies added + No proxies match current filters Delete Proxy Are you sure you want to delete the proxy %1$s? New Proxy @@ -510,7 +552,7 @@ Secret (Hex) Username (Optional) Password (Optional) - Save Changes + Save Test Test Result Delete From 9de90dbd50ca4aa13aff2cbc49c1335d3d24e67a Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:37:22 +0300 Subject: [PATCH 66/83] rename development build to Alpha across localized strings --- presentation/src/main/res/values-es/string.xml | 4 ++-- presentation/src/main/res/values-hy/string.xml | 4 ++-- presentation/src/main/res/values-pt-rBR/string.xml | 4 ++-- presentation/src/main/res/values-ru-rRU/string.xml | 4 ++-- presentation/src/main/res/values-sk/string.xml | 4 ++-- presentation/src/main/res/values-uk/string.xml | 4 ++-- presentation/src/main/res/values-zh-rCN/string.xml | 4 ++-- presentation/src/main/res/values/string.xml | 4 ++-- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index da8eaee7..b711dec4 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -139,7 +139,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Añadir Cuenta Inicia sesión en otra cuenta Mi Perfil @@ -155,7 +155,7 @@ Ayuda y Comentarios Preguntas frecuentes y soporte Política de Privacidad - MonoGram Dev para Android v%1$s + MonoGram Alpha para Android v%1$s Usuario Desconocido Sin información Mostrar cuentas diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 64967bdc..6cb51871 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -132,7 +132,7 @@ Սկսեք նոր զրույց Մինի հավելված - MonoGram Dev + MonoGram Alpha Ավելացնել հաշիվ Մուտք գործել այլ հաշիվ Իմ պրոֆիլը @@ -148,7 +148,7 @@ Օգնություն և հետադարձ կապ Հաճախ տրվող հարցեր և աջակցություն Գաղտնիության քաղաքականություն - MonoGram Dev Android-ի համար v%1$s + MonoGram Alpha Android-ի համար v%1$s Անհայտ օգտատեր Տեղեկություն չկա Ցույց տալ հաշիվները diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 24d35d6f..07b62519 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -140,7 +140,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Adicionar conta Entrar em outra conta Meu perfil @@ -156,7 +156,7 @@ Ajuda e feedback FAQ e suporte Política de privacidade - MonoGram Dev para Android v%1$s + MonoGram Alpha para Android v%1$s Usuário desconhecido Sem informações Mostrar contas diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index a4986116..34907cfc 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -139,7 +139,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Добавить аккаунт Войти в другой аккаунт Мой профиль @@ -155,7 +155,7 @@ Помощь FAQ и поддержка Политика конфиденциальности - MonoGram Dev для Android v%1$s + MonoGram Alpha для Android v%1$s Неизвестный пользователь Нет информации Показать аккаунты diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 171cc9c2..6a4edc6a 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -142,7 +142,7 @@ Mini aplikácia - MonoGram Dev + MonoGram Alpha Pridať účet Prihlásiť sa do iného účtu Môj profil @@ -158,7 +158,7 @@ Pomoc a spätná väzba FAQ a podpora Zásady ochrany súkromia - MonoGram Dev pre Android v%1$s + MonoGram Alpha pre Android v%1$s Neznámy používateľ Žiadne informácie Zobraziť účty diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 40d221a4..ab87273f 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -139,7 +139,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Додати акаунт Увійти в інший акаунт Мій профіль @@ -155,7 +155,7 @@ Допомога FAQ та підтримка Політика конфіденційності - MonoGram Dev для Android v%1$s + MonoGram Alpha для Android v%1$s Невідомий користувач Немає інформації Показати акаунти diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index b0e47c06..4a11de8a 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -139,7 +139,7 @@ 小程序 - MonoGram Dev + MonoGram Alpha 添加账号 登录另一个账号 我的个人资料 @@ -155,7 +155,7 @@ 帮助 常见问题与支持 隐私政策 - MonoGram Dev for Android v%1$s + MonoGram Alpha for Android v%1$s 未知用户 暂无信息 显示账号 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 2ee5812d..d300c61d 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -140,7 +140,7 @@ Mini App - MonoGram Dev + MonoGram Alpha Add Account Login to another account My Profile @@ -156,7 +156,7 @@ Help & Feedback FAQ and support Privacy Policy - MonoGram Dev for Android v%1$s + MonoGram Alpha for Android v%1$s Unknown User No info Show accounts From d82301294779d46500f679de0728a4c3c403fee6 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:20:02 +0300 Subject: [PATCH 67/83] implement separate audio recording and improve transcoding in `AdvancedCircularRecorderScreen` fix #221 - introduce `SessionAudioRecorder` using `MediaRecorder` to capture audio independently from video segments - update `CircularTranscoder` to merge the independent audio session file with concatenated video segments - increase output resolution to 640x640 and adjust bitrate/frame rate for better quality - implement `handleSegmentError` and enhance cleanup logic to ensure audio/video files are deleted on cancellation or failure - fix zoom handling by explicitly updating `currentZoomRatio` during transformation gestures - refine `QualitySelector` to prefer HD with a fallback to SD - ensure audio session stops and cleans up across lifecycle events and recording states --- .../AdvancedCircularRecorderScreen.kt | 312 ++++++++++++++---- 1 file changed, 252 insertions(+), 60 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt index e388b822..a41e74c4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AdvancedCircularRecorderScreen.kt @@ -1,4 +1,4 @@ -@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package org.monogram.presentation.features.chats.currentChat.components @@ -7,11 +7,18 @@ import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import android.graphics.SurfaceTexture -import android.media.* +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMetadataRetriever +import android.media.MediaMuxer +import android.media.MediaRecorder import android.opengl.EGL14 import android.opengl.EGLExt import android.opengl.GLES11Ext import android.opengl.GLES20 +import android.os.Build import android.util.Size import android.view.Surface import android.widget.Toast @@ -25,15 +32,40 @@ import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.ResolutionSelector import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.video.* +import androidx.camera.video.FallbackStrategy +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent import androidx.camera.view.PreviewView import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.* +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures -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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -41,10 +73,24 @@ import androidx.compose.material.icons.filled.Cameraswitch import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Stop -import androidx.compose.material3.* import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator -import androidx.compose.runtime.* +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -70,7 +116,7 @@ import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer -import java.util.* +import java.util.Locale private const val EGL_RECORDABLE_ANDROID = 0x3142 @@ -148,6 +194,7 @@ fun NativeCircularCameraContent( var lensFacing by remember { mutableIntStateOf(CameraSelector.LENS_FACING_BACK) } var camera by remember { mutableStateOf(null) } + val sessionAudioRecorder = remember { SessionAudioRecorder(context) } var currentZoomRatio by remember { mutableFloatStateOf(1f) } var minZoom by remember { mutableFloatStateOf(1f) } var maxZoom by remember { mutableFloatStateOf(1f) } @@ -195,7 +242,12 @@ fun NativeCircularCameraContent( val videoCapture = remember { val recorder = Recorder.Builder() - .setQualitySelector(QualitySelector.from(Quality.SD)) + .setQualitySelector( + QualitySelector.fromOrderedList( + listOf(Quality.HD, Quality.SD), + FallbackStrategy.lowerQualityOrHigherThan(Quality.SD) + ) + ) .build() val resolutionSelector = ResolutionSelector.Builder() @@ -210,6 +262,7 @@ fun NativeCircularCameraContent( DisposableEffect(Unit) { onDispose { + sessionAudioRecorder.stop(keepFile = false) try { val cameraProvider = ProcessCameraProvider.getInstance(context).get() cameraProvider.unbindAll() @@ -221,6 +274,7 @@ fun NativeCircularCameraContent( fun finishRecording() { if (recordedSegments.isEmpty()) { + sessionAudioRecorder.stop(keepFile = false) isRecording = false recording = null recordingStartMs = 0L @@ -232,6 +286,7 @@ fun NativeCircularCameraContent( recording = null recordingStartMs = 0L elapsedSeconds = 0L + val sessionAudioFile = sessionAudioRecorder.stop(keepFile = true) try { val cameraProvider = ProcessCameraProvider.getInstance(context).get() cameraProvider.unbindAll() @@ -241,7 +296,7 @@ fun NativeCircularCameraContent( val finalFile = File(context.filesDir, "CIRCLE_FULL_${System.currentTimeMillis()}.mp4") try { val segmentsToProcess = ArrayList(recordedSegments) - val transcoder = CircularTranscoder(segmentsToProcess, finalFile) + val transcoder = CircularTranscoder(segmentsToProcess, sessionAudioFile, finalFile) transcoder.start() withContext(Dispatchers.Main) { @@ -259,6 +314,7 @@ fun NativeCircularCameraContent( Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } finally { + sessionAudioFile?.delete() recordedSegments.clear() isProcessing = false } @@ -286,6 +342,7 @@ fun NativeCircularCameraContent( return } + sessionAudioRecorder.stop(keepFile = false) recordedSegments.forEach { it.delete() } recordedSegments.clear() recording = null @@ -297,6 +354,7 @@ fun NativeCircularCameraContent( fun handleSegmentSaved(file: File) { if (shouldDiscardAll) { + sessionAudioRecorder.stop(keepFile = false) file.delete() recordedSegments.forEach { it.delete() } recordedSegments.clear() @@ -321,6 +379,33 @@ fun NativeCircularCameraContent( } } + fun handleSegmentError(error: VideoRecordEvent.Finalize) { + sessionAudioRecorder.stop(keepFile = false) + if (shouldDiscardAll) { + recordedSegments.forEach { it.delete() } + recordedSegments.clear() + shouldDiscardAll = false + isSwitchingCamera = false + pendingResume = false + isRecording = false + recording = null + recordingStartMs = 0L + elapsedSeconds = 0L + onClose() + return + } + + isSwitchingCamera = false + pendingResume = false + isRecording = false + recording = null + recordingStartMs = 0L + elapsedSeconds = 0L + + val message = error.cause?.message ?: "Recording error" + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + LaunchedEffect(lensFacing) { val cameraProvider = ProcessCameraProvider.getInstance(context).get() val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build() @@ -328,7 +413,7 @@ fun NativeCircularCameraContent( cameraProvider.unbindAll() val cam = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, videoCapture) camera = cam - cam.cameraInfo.zoomState.observe(lifecycleOwner) { state -> + cam.cameraInfo.zoomState.value?.let { state -> currentZoomRatio = state.zoomRatio minZoom = state.minZoomRatio maxZoom = state.maxZoomRatio @@ -343,7 +428,8 @@ fun NativeCircularCameraContent( context, videoCapture, onStart = { rec -> recording = rec }, - onSegmentSaved = ::handleSegmentSaved + onSegmentSaved = ::handleSegmentSaved, + onSegmentError = ::handleSegmentError ) } } catch (e: Exception) { e.printStackTrace() } @@ -361,6 +447,7 @@ fun NativeCircularCameraContent( detectTransformGestures { _, _, zoom, _ -> camera?.let { cam -> val newZoom = (currentZoomRatio * zoom).coerceIn(minZoom, maxZoom) + currentZoomRatio = newZoom cam.cameraControl.setZoomRatio(newZoom) } } @@ -368,8 +455,13 @@ fun NativeCircularCameraContent( .pointerInput(Unit) { detectTapGestures { offset -> camera?.let { cam -> - val point = previewView.meteringPointFactory.createPoint(offset.x, offset.y) - cam.cameraControl.startFocusAndMetering(FocusMeteringAction.Builder(point).build()) + val point = + previewView.meteringPointFactory.createPoint(offset.x, offset.y) + cam.cameraControl.startFocusAndMetering( + FocusMeteringAction.Builder( + point + ).build() + ) } } } @@ -410,7 +502,7 @@ fun NativeCircularCameraContent( .fillMaxWidth() .statusBarsPadding() ) { - IconButton(onClick = onClose, enabled = !isRecording && !isProcessing) { + IconButton(onClick = ::cancelAndClose, enabled = !isRecording && !isProcessing) { Icon( Icons.Default.Close, contentDescription = stringResource(R.string.recorder_close_cd), @@ -434,7 +526,11 @@ fun NativeCircularCameraContent( modifier = Modifier .clip(RoundedCornerShape(999.dp)) .background(Color.Black.copy(alpha = 0.45f)) - .border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(999.dp)) + .border( + 1.dp, + Color.White.copy(alpha = 0.2f), + RoundedCornerShape(999.dp) + ) .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -532,13 +628,24 @@ fun NativeCircularCameraContent( detectTapGestures(onTap = { if (isProcessing || isSwitchingCamera) return@detectTapGestures if (!isRecording) { + if (!sessionAudioRecorder.isRecording) { + val audioStarted = sessionAudioRecorder.start() + if (!audioStarted) { + Toast.makeText( + context, + "Unable to start audio capture", + Toast.LENGTH_SHORT + ).show() + } + } isRecording = true recordingStartMs = System.currentTimeMillis() startNativeSegment( context, videoCapture, onStart = { r -> recording = r }, - onSegmentSaved = ::handleSegmentSaved + onSegmentSaved = ::handleSegmentSaved, + onSegmentError = ::handleSegmentError ) } else { recording?.stop() @@ -634,19 +741,21 @@ fun startNativeSegment( context: Context, videoCapture: VideoCapture, onStart: (Recording) -> Unit, - onSegmentSaved: (File) -> Unit + onSegmentSaved: (File) -> Unit, + onSegmentError: (VideoRecordEvent.Finalize) -> Unit ) { val tempFile = File(context.cacheDir, "segment_${System.currentTimeMillis()}.mp4") val outputOptions = FileOutputOptions.Builder(tempFile).build() val recording = videoCapture.output .prepareRecording(context, outputOptions) - .withAudioEnabled() .start(ContextCompat.getMainExecutor(context)) { event -> if (event is VideoRecordEvent.Finalize) { if (!event.hasError()) { onSegmentSaved(tempFile) } else { + tempFile.delete() + onSegmentError(event) if (event.cause != null) event.cause?.printStackTrace() } } @@ -654,11 +763,86 @@ fun startNativeSegment( onStart(recording) } -class CircularTranscoder(private val inputFiles: List, private val outputFile: File) { - private val OUTPUT_WIDTH = 384 - private val OUTPUT_HEIGHT = 384 - private val OUTPUT_BIT_RATE = 1_800_000 - private val FRAME_RATE = 60 +private class SessionAudioRecorder(private val context: Context) { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + + var isRecording: Boolean = false + private set + + @Suppress("DEPRECATION") + fun start(): Boolean { + if (isRecording) return true + + return try { + val file = File(context.cacheDir, "circle_audio_${System.currentTimeMillis()}.m4a") + outputFile = file + + recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + + recorder?.apply { + setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioChannels(1) + setAudioEncodingBitRate(96_000) + setAudioSamplingRate(48_000) + setOutputFile(file.absolutePath) + prepare() + start() + } + + isRecording = true + true + } catch (e: Exception) { + e.printStackTrace() + stop(keepFile = false) + false + } + } + + fun stop(keepFile: Boolean): File? { + val file = outputFile + recorder?.let { mediaRecorder -> + try { + mediaRecorder.stop() + } catch (e: Exception) { + e.printStackTrace() + } finally { + try { + mediaRecorder.release() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + recorder = null + isRecording = false + + if (!keepFile) { + file?.delete() + outputFile = null + return null + } + + outputFile = null + return if (file?.exists() == true) file else null + } +} + +class CircularTranscoder( + private val inputFiles: List, + private val inputAudioFile: File?, + private val outputFile: File +) { + private val OUTPUT_WIDTH = 640 + private val OUTPUT_HEIGHT = 640 + private val OUTPUT_BIT_RATE = 1_920_000 + private val FRAME_RATE = 30 private var muxerAudioTrackIndex = -1 private var muxerVideoTrackIndex = -1 @@ -670,9 +854,9 @@ class CircularTranscoder(private val inputFiles: List, private val outputF val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) try { - processVideoSequence(muxer) + val totalVideoDurationUs = processVideoSequence(muxer) if (muxerStarted) { - processAudioSequence(muxer) + processAudioSequence(muxer, totalVideoDurationUs) } } finally { try { @@ -682,7 +866,7 @@ class CircularTranscoder(private val inputFiles: List, private val outputF } } - private fun processVideoSequence(muxer: MediaMuxer) { + private fun processVideoSequence(muxer: MediaMuxer): Long { val outputFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, OUTPUT_WIDTH, OUTPUT_HEIGHT) outputFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) outputFormat.setInteger(MediaFormat.KEY_BIT_RATE, OUTPUT_BIT_RATE) @@ -814,14 +998,19 @@ class CircularTranscoder(private val inputFiles: List, private val outputF val newFormat = encoder.outputFormat muxerVideoTrackIndex = muxer.addTrack(newFormat) - val audioEx = MediaExtractor() - audioEx.setDataSource(inputFiles[0].absolutePath) - val at = selectTrack(audioEx, "audio/") - if (at >= 0) { - val af = audioEx.getTrackFormat(at) - muxerAudioTrackIndex = muxer.addTrack(af) + if (inputAudioFile?.exists() == true) { + val audioEx = MediaExtractor() + try { + audioEx.setDataSource(inputAudioFile.absolutePath) + val at = selectTrack(audioEx, "audio/") + if (at >= 0) { + val af = audioEx.getTrackFormat(at) + muxerAudioTrackIndex = muxer.addTrack(af) + } + } finally { + audioEx.release() + } } - audioEx.release() muxer.start() muxerStarted = true @@ -869,43 +1058,46 @@ class CircularTranscoder(private val inputFiles: List, private val outputF encoder.stop(); encoder.release() inputSurface.release() + return totalDurationUs } - private fun processAudioSequence(muxer: MediaMuxer) { - if (muxerAudioTrackIndex < 0) return + private fun processAudioSequence(muxer: MediaMuxer, totalVideoDurationUs: Long) { + val audioFile = inputAudioFile ?: return + if (muxerAudioTrackIndex < 0 || !audioFile.exists()) return - var totalDurationUs = 0L + val extractor = MediaExtractor() val buffer = ByteBuffer.allocate(256 * 1024) val bufferInfo = MediaCodec.BufferInfo() - for (file in inputFiles) { - val extractor = MediaExtractor() - try { - extractor.setDataSource(file.absolutePath) - val trackIndex = selectTrack(extractor, "audio/") - if (trackIndex < 0) continue + try { + extractor.setDataSource(audioFile.absolutePath) + val trackIndex = selectTrack(extractor, "audio/") + if (trackIndex < 0) return - extractor.selectTrack(trackIndex) - var fileLastPts = 0L + extractor.selectTrack(trackIndex) - while (true) { - val chunkSize = extractor.readSampleData(buffer, 0) - if (chunkSize < 0) break + while (true) { + val chunkSize = extractor.readSampleData(buffer, 0) + if (chunkSize < 0) break - bufferInfo.offset = 0 - bufferInfo.size = chunkSize - bufferInfo.flags = extractor.sampleFlags + val originalPts = extractor.sampleTime + if (originalPts < 0) break + if (totalVideoDurationUs > 0 && originalPts > totalVideoDurationUs) { + break + } - val originalPts = extractor.sampleTime - fileLastPts = originalPts - bufferInfo.presentationTimeUs = originalPts + totalDurationUs + bufferInfo.offset = 0 + bufferInfo.size = chunkSize + bufferInfo.flags = extractor.sampleFlags + bufferInfo.presentationTimeUs = originalPts - muxer.writeSampleData(muxerAudioTrackIndex, buffer, bufferInfo) - extractor.advance() - } - totalDurationUs += (fileLastPts + 20000L) - } catch (e: Exception) { e.printStackTrace() } - finally { extractor.release() } + muxer.writeSampleData(muxerAudioTrackIndex, buffer, bufferInfo) + extractor.advance() + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + extractor.release() } } From 9950df77a60e7b465be4b6a4c94bf508a183b6a3 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:45:03 +0300 Subject: [PATCH 68/83] refine video gesture controls and dismissal logic - improve brightness and volume gesture reliability by using accumulated drag deltas and specific control zones (33% width) - prevent vertical dismissal gestures when video controls are hidden or the player is locked - add `allowZoom` and `allowDismiss` flags to `detectZoomAndDismissGestures` for more granular gesture control - sync volume gesture increments with system audio steps using `roundToInt` - ensure gesture overlays only trigger when dragging within the designated side zones --- .../viewers/components/ImageGestures.kt | 20 ++-- .../viewers/components/VideoGestures.kt | 84 +++++++++++++---- .../components/VideoViewerComponents.kt | 93 +++++++++++++++++-- 3 files changed, 163 insertions(+), 34 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt index 91ac2248..b17932e6 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/ImageGestures.kt @@ -1,6 +1,10 @@ package org.monogram.presentation.features.viewers.components -import androidx.compose.animation.core.* +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculatePan @@ -150,6 +154,8 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( screenHeightPx: Float, dismissThreshold: Float, dismissVelocityThreshold: Float, + allowZoom: Boolean = true, + allowDismiss: Boolean = true, onDismiss: () -> Unit, scope: CoroutineScope ) { @@ -173,9 +179,9 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( val zoomChange = event.calculateZoom() val panChange = event.calculatePan() - if (pointerCount > 1) isZooming = true + if (allowZoom && pointerCount > 1) isZooming = true - if (!isZooming && !isVerticalDrag && zoomState.scale.value == 1f && pointerCount == 1) { + if (allowDismiss && !isZooming && !isVerticalDrag && zoomState.scale.value == 1f && pointerCount == 1) { pan += panChange val totalPan = pan.getDistance() if (totalPan > touchSlop) { @@ -187,10 +193,10 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( } } - if (isZooming || zoomState.scale.value > 1f) { + if (allowZoom && (isZooming || zoomState.scale.value > 1f)) { zoomState.onTransform(scope, panChange, zoomChange, IntSize(size.width, size.height), 3f) event.changes.forEach { if (it.positionChanged()) it.consume() } - } else if (isVerticalDrag) { + } else if (allowDismiss && isVerticalDrag) { scope.launch { rootState.drag(panChange.y) } event.changes.forEach { if (it.positionChanged()) it.consume() } } @@ -199,9 +205,9 @@ suspend fun PointerInputScope.detectZoomAndDismissGestures( } val velocity = tracker.calculateVelocity() - if (zoomState.scale.value > 1f) { + if (allowZoom && zoomState.scale.value > 1f) { zoomState.ensureBounds(size.width.toFloat(), size.height.toFloat(), scope) - } else if (isVerticalDrag) { + } else if (allowDismiss && isVerticalDrag) { val offsetY = rootState.offsetY.value val shouldDismiss = abs(offsetY) > dismissThreshold || abs(velocity.y) > dismissVelocityThreshold if (shouldDismiss) { diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt index 7306314c..60c26cd0 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoGestures.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.media3.exoplayer.ExoPlayer import kotlin.math.max +import kotlin.math.roundToInt @Composable fun Modifier.videoGestures( exoPlayer: ExoPlayer, isLocked: Boolean, isInPipMode: Boolean, + showControls: Boolean, isDoubleTapSeekEnabled: Boolean, isGesturesEnabled: Boolean, isZoomEnabled: Boolean, @@ -36,6 +38,7 @@ fun Modifier.videoGestures( context: Context ): Modifier { val scope = rememberCoroutineScope() + val controlZoneRatio = 0.33f return this .pointerInput(isLocked, isInPipMode) { @@ -60,30 +63,69 @@ fun Modifier.videoGestures( } .pointerInput(isLocked, isInPipMode) { if (isInPipMode) return@pointerInput + var dragOnLeft = false + var dragOnRight = false + var startBrightness = 0.5f + var startVolume = 0 + var maxVolume = 1 + var accumulatedDragY = 0f + var lastAppliedVolume = -1 detectVerticalDragGestures( onDragStart = { change -> + accumulatedDragY = 0f if (!isLocked && isGesturesEnabled && zoomState.scale.value == 1f) { val width = size.width val x = change.x - // Only show overlay if dragging on the edges (15% width) - if (x < width * 0.15f || x > width * 0.85f) { + dragOnLeft = x < width * controlZoneRatio + dragOnRight = x > width * (1f - controlZoneRatio) + if (dragOnLeft || dragOnRight) { + if (dragOnLeft) { + val activity = context.findActivity() + val currentBrightness = + activity?.window?.attributes?.screenBrightness + startBrightness = + (currentBrightness?.takeIf { it != -1f } ?: 0.5f).coerceIn( + 0f, + 1f + ) + } + if (dragOnRight) { + val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + maxVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + .coerceAtLeast(1) + startVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + lastAppliedVolume = startVolume + } onGestureOverlayChange(true, null, null) + } else { + dragOnLeft = false + dragOnRight = false } } }, - onDragEnd = { onGestureOverlayChange(false, null, null) }, - onDragCancel = { onGestureOverlayChange(false, null, null) } + onDragEnd = { + dragOnLeft = false + dragOnRight = false + accumulatedDragY = 0f + onGestureOverlayChange(false, null, null) + }, + onDragCancel = { + dragOnLeft = false + dragOnRight = false + accumulatedDragY = 0f + onGestureOverlayChange(false, null, null) + } ) { change, dragAmount -> if (!isLocked && isGesturesEnabled && zoomState.scale.value == 1f) { - val width = size.width - val x = change.position.x - val isLeft = x < width * 0.15f - val isRight = x > width * 0.85f + accumulatedDragY += dragAmount val activity = context.findActivity() - if (isLeft && activity != null) { + if (dragOnLeft && activity != null) { val lp = activity.window.attributes - var newBrightness = (lp.screenBrightness.takeIf { it != -1f } ?: 0.5f) - (dragAmount / 1000f) + var newBrightness = startBrightness - (accumulatedDragY / 1000f) newBrightness = newBrightness.coerceIn(0f, 1f) lp.screenBrightness = newBrightness activity.window.attributes = lp @@ -92,23 +134,25 @@ fun Modifier.videoGestures( Icons.Rounded.BrightnessMedium, "${(newBrightness * 100).toInt()}%" ) - } else if (isRight) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val maxVol = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val currentVol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val delta = -(dragAmount / 50f) - val newVol = (currentVol + delta).coerceIn(0f, maxVol.toFloat()) - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVol.toInt(), 0) + } else if (dragOnRight) { + val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val volumeDelta = (-accumulatedDragY / 50f).roundToInt() + val newVol = (startVolume + volumeDelta).coerceIn(0, maxVolume) + if (newVol != lastAppliedVolume) { + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVol, 0) + lastAppliedVolume = newVol + } onGestureOverlayChange( true, Icons.AutoMirrored.Rounded.VolumeUp, - "${((newVol / maxVol) * 100).toInt()}%" + "${((newVol.toFloat() / maxVolume) * 100).toInt()}%" ) } } } } - .pointerInput(isLocked, isInPipMode) { + .pointerInput(isLocked, isInPipMode, isZoomEnabled, showControls) { if (isInPipMode) return@pointerInput detectZoomAndDismissGestures( zoomState = zoomState, @@ -116,6 +160,8 @@ fun Modifier.videoGestures( screenHeightPx = screenHeightPx, dismissThreshold = dismissDistancePx, dismissVelocityThreshold = dismissVelocityThreshold, + allowZoom = isZoomEnabled, + allowDismiss = showControls && !isLocked, onDismiss = onDismiss, scope = scope ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt index 17374990..f6b7679f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/viewers/components/VideoViewerComponents.kt @@ -7,22 +7,90 @@ import android.util.Log import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import androidx.annotation.OptIn -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState -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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.* -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Forward +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.AspectRatio +import androidx.compose.material.icons.rounded.Camera +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.ContentPaste +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.FitScreen +import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.Gif +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PictureInPicture +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.RepeatOne +import androidx.compose.material.icons.rounded.Replay +import androidx.compose.material.icons.rounded.Replay10 +import androidx.compose.material.icons.rounded.ScreenRotation +import androidx.compose.material.icons.rounded.Speed +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,7 +108,12 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.media3.common.* +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultRenderersFactory @@ -299,6 +372,7 @@ fun VideoPage( exoPlayer = exoPlayer, isLocked = isLocked, isInPipMode = isInPipMode, + showControls = showControls, isDoubleTapSeekEnabled = isDoubleTapSeekEnabled, isGesturesEnabled = isGesturesEnabled, isZoomEnabled = isZoomEnabled, @@ -596,7 +670,10 @@ fun VideoPlayerControls( .size(84.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f)) - .clickable(interactionSource = interactionSource, indication = null) { onPlayPauseToggle() }, + .clickable( + interactionSource = interactionSource, + indication = null + ) { onPlayPauseToggle() }, contentAlignment = Alignment.Center ) { Icon( From 1ce58117423104e9e7114306f8d47bac058f1a99 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:57:55 +0300 Subject: [PATCH 69/83] bump version to 0.0.7 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b9194b5..ff16f497 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "org.monogram" minSdk = 25 targetSdk = 36 - versionCode = 6 - versionName = "0.0.6" + versionCode = 7 + versionName = "0.0.7" } splits { From d5abbd129bcf7ef672d75db501ec459ab52924d4 Mon Sep 17 00:00:00 2001 From: xtex Date: Sun, 12 Apr 2026 13:38:09 +0800 Subject: [PATCH 70/83] Add fast +1 button (#182) Signed-off-by: xtex --- .../chats/currentChat/ChatComponent.kt | 1 + .../features/chats/currentChat/ChatStore.kt | 1 + .../chats/currentChat/ChatStoreFactory.kt | 2 ++ .../chats/currentChat/DefaultChatComponent.kt | 2 ++ .../chatContent/ChatMessageOptionsMenu.kt | 6 ++++- .../chats/currentChat/impl/MessageActions.kt | 6 +++++ .../stickers/ui/menu/MessageOptionsMenu.kt | 27 ++++++++++++++----- .../src/main/res/values-es/string.xml | 7 ++--- .../src/main/res/values-hy/string.xml | 1 + .../src/main/res/values-pt-rBR/string.xml | 1 + .../src/main/res/values-ru-rRU/string.xml | 1 + .../src/main/res/values-sk/string.xml | 1 + .../src/main/res/values-uk/string.xml | 1 + .../src/main/res/values-zh-rCN/string.xml | 1 + presentation/src/main/res/values/string.xml | 1 + 15 files changed, 49 insertions(+), 10 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index 21e95bf8..cec5e0f9 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -84,6 +84,7 @@ interface ChatComponent { fun onVideoRecorded(file: File) fun onForwardMessage(message: MessageModel) fun onForwardSelectedMessages() + fun onRepeatMessage(message: MessageModel) fun onDeleteMessage(message: MessageModel, revoke: Boolean = false) fun onEditMessage(message: MessageModel) fun onCancelEdit() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt index d4ad132a..91760061 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt @@ -66,6 +66,7 @@ interface ChatStore : Store component.handleRepeatMessage(intent.message) is Intent.DeleteMessage -> component.handleDeleteMessage(intent.message, intent.revoke) is Intent.EditMessage -> component._state.update { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index de33f752..88174293 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -376,6 +376,8 @@ class DefaultChatComponent( override fun onForwardSelectedMessages() = store.accept(ChatStore.Intent.ForwardSelectedMessages) + override fun onRepeatMessage(message: MessageModel) = store.accept(ChatStore.Intent.RepeatMessage(message)) + override fun onDeleteMessage(message: MessageModel, revoke: Boolean) = store.accept(ChatStore.Intent.DeleteMessage(message, revoke)) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt index 9b63400b..a19d3279 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/chatContent/ChatMessageOptionsMenu.kt @@ -423,7 +423,11 @@ fun ChatMessageOptionsMenu( } onDismiss() }, - onDismiss = onDismiss + onRepeat = { + component.onRepeatMessage(selectedMessage) + onDismiss() + }, + onDismiss = onDismiss, ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt index 49085ce5..adf33516 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt @@ -341,3 +341,9 @@ internal fun DefaultChatComponent.handleCopyLink(localClipboard: Clipboard) { } } } + +internal fun DefaultChatComponent.handleRepeatMessage(message: MessageModel) { + scope.launch { + repositoryMessage.forwardMessage(chatId, chatId, message.id) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt index 574d9e4d..851278be 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/menu/MessageOptionsMenu.kt @@ -67,6 +67,7 @@ import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Gavel import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material.icons.rounded.PlusOne import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material.icons.rounded.Report import androidx.compose.material.icons.rounded.Translate @@ -188,7 +189,8 @@ fun MessageOptionsMenu( onReport: () -> Unit = {}, onBlock: () -> Unit = {}, onRestrict: () -> Unit = {}, - onDismiss: () -> Unit + onRepeat: () -> Unit, + onDismiss: () -> Unit, ) { val density = LocalDensity.current val haptic = LocalHapticFeedback.current @@ -759,6 +761,14 @@ fun MessageOptionsMenu( ) } + if (sections.hasRepeatAction) { + InternalMenuOptionItem( + icon = Icons.Rounded.PlusOne, + text = stringResource(R.string.menu_repeat), + onClick = { animateOutAndDismiss(onRepeat) } + ) + } + if (sections.hasDownloadAction) { InternalMenuOptionItem( icon = Icons.Rounded.Download, @@ -927,7 +937,8 @@ private data class MessageMenuSections( val hasRestrictAction: Boolean, val hasTelegramSummaryAction: Boolean, val hasTelegramTranslatorAction: Boolean, - val hasRestoreOriginalTextAction: Boolean + val hasRestoreOriginalTextAction: Boolean, + val hasRepeatAction: Boolean, ) { fun merge(other: MessageMenuSections): MessageMenuSections { return MessageMenuSections( @@ -948,7 +959,8 @@ private data class MessageMenuSections( hasRestrictAction = hasRestrictAction || other.hasRestrictAction, hasTelegramSummaryAction = hasTelegramSummaryAction || other.hasTelegramSummaryAction, hasTelegramTranslatorAction = hasTelegramTranslatorAction || other.hasTelegramTranslatorAction, - hasRestoreOriginalTextAction = hasRestoreOriginalTextAction || other.hasRestoreOriginalTextAction + hasRestoreOriginalTextAction = hasRestoreOriginalTextAction || other.hasRestoreOriginalTextAction, + hasRepeatAction = hasRepeatAction || other.hasRepeatAction, ) } @@ -973,7 +985,8 @@ private data class MessageMenuSections( it.hasRestrictAction, it.hasTelegramSummaryAction, it.hasTelegramTranslatorAction, - it.hasRestoreOriginalTextAction + it.hasRestoreOriginalTextAction, + it.hasRepeatAction, ) }, restore = { values -> @@ -995,7 +1008,8 @@ private data class MessageMenuSections( hasRestrictAction = values[14], hasTelegramSummaryAction = values[15], hasTelegramTranslatorAction = values[16], - hasRestoreOriginalTextAction = values[17] + hasRestoreOriginalTextAction = values[17], + hasRepeatAction = values[18], ) } ) @@ -1039,7 +1053,8 @@ private fun buildMenuSections( hasRestrictAction = canBlock && canRestrict, hasTelegramSummaryAction = showTelegramSummary, hasTelegramTranslatorAction = showTelegramTranslator, - hasRestoreOriginalTextAction = showRestoreOriginalText + hasRestoreOriginalTextAction = showRestoreOriginalText, + hasRepeatAction = message.canBeForwarded && canWrite, ) } diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index b711dec4..edcd602a 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -232,6 +232,7 @@ Fijar Desfijar Reenviar + +1 Seleccionar Más Eliminar @@ -723,7 +724,7 @@ - + Stickers y Emoji Stickers Emoji @@ -833,7 +834,7 @@ Buscar usuarios No se encontraron usuarios - + Cambiar Código de Acceso Establecer Código de Acceso Tu aplicación está protegida actualmente con un código de acceso. Ingresa uno nuevo para cambiarlo. @@ -949,7 +950,7 @@ Añadir foto Cambiar foto - + Permisos Requeridos Para proporcionar la mejor experiencia, MonoGram necesita los siguientes permisos. Notificaciones diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 6cb51871..e93956a9 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -223,6 +223,7 @@ Ամրացնել Ապաամրացնել Վերահասցեագրել + +1 Ընտրել Ավելին Ջնջել diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 07b62519..7090ef0e 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -233,6 +233,7 @@ Fixar Desafixar Encaminhar + +1 Selecionar Mais Excluir diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index 34907cfc..b3771e5f 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -231,6 +231,7 @@ Закрепить Открепить Переслать + +1 Выбрать Ещё Удалить diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 6a4edc6a..0d8bd0bd 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -241,6 +241,7 @@ Pripnúť Odopnúť Preposlať + +1 Vybrať Viac Odstrániť diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index ab87273f..329a52ec 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -231,6 +231,7 @@ Закріпити Відкріпити Переслати + +1 Вибрати Ще Видалити diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 4a11de8a..41833daa 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -231,6 +231,7 @@ 置顶 取消置顶 转发 + +1 选择 更多 删除 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index d300c61d..3f20c15c 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -233,6 +233,7 @@ Pin Unpin Forward + +1 Select More Delete From 9c21d3b9ee83ecc1688f8eece17122d3eeca374a Mon Sep 17 00:00:00 2001 From: Daniil <48557356+fakelog@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:11:38 +0500 Subject: [PATCH 71/83] Refactor chat list state decomposition into focused StateFlows (#242) Previously, a single broad state object caused unnecessary recompositions of the entire ChatListContent screen on unrelated changes (connectionStatus, updateState, activeChatId, search, proxy, selection). Now state is split by responsibility: - uiState: chrome, overlays, navigation flags - foldersState: folders, selected folder, loading, scroll position - chatsState: active chat list - searchState: search queries and results - selectionState: selection and active chat Benefits: - Unrelated updates no longer recompose the whole chat list - Chat list subscribes to a narrower flow, reducing noise - State model aligns better with actual screen use-cases, improving maintainability - Removed redundant ChatListStore intermediate layer Additionally, added a fast path in the repository for point updates by chatId to avoid rebuilding the entire list on individual item changes. --- .../repository/ChatsListRepositoryImpl.kt | 41 +++- .../features/chats/ChatListComponent.kt | 62 +++-- .../features/chats/ChatListStore.kt | 48 ---- .../features/chats/ChatListStoreFactory.kt | 76 ------- .../chats/chatList/ChatListContent.kt | 213 +++++++++--------- .../chatList/DefaultChatListComponent.kt | 210 +++++++++-------- .../chats/chatList/components/ChatListItem.kt | 6 +- 7 files changed, 304 insertions(+), 352 deletions(-) delete mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt delete mode 100644 presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt 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 64848ef5..9f4e5b98 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -197,6 +197,8 @@ class ChatsListRepositoryImpl( private val modelCache = SynchronizedLruMap(MODEL_CACHE_SIZE) private val invalidatedModels = ConcurrentHashMap.newKeySet() + @Volatile + private var invalidateAllModels = true private var lastList: List? = null private var lastListFolderId: Int = -1 @@ -293,7 +295,11 @@ class ChatsListRepositoryImpl( } private fun rebuildChatModels(limit: Int): List { - return listManager.rebuildChatList(limit, emptyList()) { chat, order, isPinned -> + if (!invalidateAllModels) { + rebuildVisibleModels(limit)?.let { return it } + } + + val rebuilt = listManager.rebuildChatList(limit, emptyList()) { chat, order, isPinned -> val cached = modelCache[chat.id] if (cached != null && cached.order == order && @@ -308,6 +314,37 @@ class ChatsListRepositoryImpl( } } } + invalidatedModels.clear() + invalidateAllModels = false + return rebuilt + } + + private fun rebuildVisibleModels(limit: Int): List? { + val previous = lastList ?: return null + if (lastListFolderId != activeFolderId) return null + if (invalidatedModels.isEmpty()) return previous + + val visibleIndexes = previous.mapIndexed { index, chat -> chat.id to index }.toMap() + val updated = previous.toMutableList() + + for (chatId in invalidatedModels.toList()) { + val index = visibleIndexes[chatId] ?: return null + val chat = cache.allChats[chatId] ?: return null + val position = cache.activeListPositions[chatId] ?: return null + val oldModel = previous[index] + if (oldModel.order != position.order || oldModel.isPinned != position.isPinned) { + return null + } + updated[index] = modelFactory.mapChatToModel(chat, position.order, position.isPinned).also { mapped -> + modelCache[chatId] = mapped + } + invalidatedModels.remove(chatId) + } + + if (updated.size > limit) { + return null + } + return updated } private fun shouldEmitList(folderId: Int, newList: List): Boolean { @@ -324,6 +361,7 @@ class ChatsListRepositoryImpl( private fun clearTransientState() { modelCache.clear() invalidatedModels.clear() + invalidateAllModels = true lastList = null lastListFolderId = -1 _chatListFlow.value = emptyList() @@ -332,6 +370,7 @@ class ChatsListRepositoryImpl( private fun triggerUpdate(chatId: Long? = null) { if (chatId == null) { + invalidateAllModels = true invalidatedModels.addAll(cache.activeListPositions.keys) } else { invalidatedModels.add(chatId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt index 4b95d14d..dd059a11 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt @@ -1,12 +1,18 @@ package org.monogram.presentation.features.chats +import androidx.compose.runtime.Immutable import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.* import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.core.util.AppPreferences interface ChatListComponent { - val state: StateFlow + val uiState: StateFlow + val foldersState: StateFlow + val chatsState: StateFlow + val selectionState: StateFlow + val searchState: StateFlow + val appPreferences: AppPreferences fun onChatClicked(id: Long) @@ -52,26 +58,14 @@ interface ChatListComponent { fun updateScrollPosition(folderId: Int, index: Int, offset: Int) - data class State( - val chatsByFolder: Map> = emptyMap(), - val folders: List = emptyList(), - val selectedFolderId: Int = -1, + @Immutable + data class UiState( val currentUser: UserModel? = null, - val isLoadingByFolder: Map = emptyMap(), - val selectedChatIds: Set = emptySet(), - val isSearchActive: Boolean = false, - val searchQuery: String = "", - val searchResults: List = emptyList(), - val globalSearchResults: List = emptyList(), - val messageSearchResults: List = emptyList(), - val searchHistory: List = emptyList(), val connectionStatus: ConnectionStatus = ConnectionStatus.Connected, val isArchivePinned: Boolean = true, val isArchiveAlwaysVisible: Boolean = false, val isForwarding: Boolean = false, - val canLoadMoreMessages: Boolean = false, val instantViewUrl: String? = null, - val activeChatId: Long? = null, val isProxyEnabled: Boolean = false, val attachMenuBots: List = emptyList(), val botWebAppUrl: String? = null, @@ -80,10 +74,38 @@ interface ChatListComponent { val webAppBotId: Long? = null, val webAppBotName: String? = null, val webViewUrl: String? = null, - val updateState: UpdateState = UpdateState.Idle, + val updateState: UpdateState = UpdateState.Idle + ) + + @Immutable + data class FoldersState( + val chatsByFolder: Map> = emptyMap(), + val folders: List = emptyList(), + val selectedFolderId: Int = -1, + val isLoadingByFolder: Map = emptyMap(), val scrollPositions: Map> = emptyMap() - ) { - val chats: List get() = chatsByFolder[selectedFolderId] ?: emptyList() - val isLoading: Boolean get() = isLoadingByFolder[selectedFolderId] ?: false - } + ) + + data class ChatsState( + val chats: List = emptyList(), + val isLoading: Boolean = false + ) + + @Immutable + data class SelectionState( + val selectedChatIds: Set = emptySet(), + val activeChatId: Long? = null + ) + + @Immutable + data class SearchState( + val isSearchActive: Boolean = false, + val searchQuery: String = "", + val searchResults: List = emptyList(), + val globalSearchResults: List = emptyList(), + val messageSearchResults: List = emptyList(), + val recentUsers: List = emptyList(), + val recentOthers: List = emptyList(), + val canLoadMoreMessages: Boolean = false + ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt deleted file mode 100644 index 6a894216..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.monogram.presentation.features.chats - -import com.arkivanov.mvikotlin.core.store.Store - -interface ChatListStore : Store { - - sealed class Intent { - data class ChatClicked(val id: Long) : Intent() - data class ProfileClicked(val id: Long) : Intent() - data class MessageClicked(val chatId: Long, val messageId: Long) : Intent() - object SettingsClicked : Intent() - data class FolderClicked(val id: Int) : Intent() - data class LoadMore(val folderId: Int? = null) : Intent() - object LoadMoreMessages : Intent() - data class ChatLongClicked(val id: Long) : Intent() - object ClearSelection : Intent() - object RetryConnection : Intent() - object SearchToggle : Intent() - data class SearchQueryChange(val query: String) : Intent() - object ClearSearchHistory : Intent() - data class RemoveSearchHistoryItem(val chatId: Long) : Intent() - data class MuteSelected(val mute: Boolean) : Intent() - data class ArchiveSelected(val archive: Boolean) : Intent() - object DeleteSelected : Intent() - object ArchivePinToggle : Intent() - object ConfirmForwarding : Intent() - object NewChatClicked : Intent() - object ProxySettingsClicked : Intent() - data class OpenInstantView(val url: String) : Intent() - object DismissInstantView : Intent() - data class OpenWebApp(val url: String, val botUserId: Long, val botName: String) : Intent() - object DismissWebApp : Intent() - data class OpenWebView(val url: String) : Intent() - object DismissWebView : Intent() - object UpdateClicked : Intent() - data class UpdateScrollPosition(val folderId: Int, val index: Int, val offset: Int) : Intent() - data class UpdateState(val state: ChatListComponent.State) : Intent() - } - - sealed class Label { - data class ChatClicked(val id: Long) : Label() - data class ProfileClicked(val id: Long) : Label() - data class MessageClicked(val chatId: Long, val messageId: Long) : Label() - object SettingsClicked : Label() - object NewChatClicked : Label() - object ProxySettingsClicked : Label() - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt deleted file mode 100644 index 6f1e9485..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.monogram.presentation.features.chats - -import com.arkivanov.mvikotlin.core.store.Reducer -import com.arkivanov.mvikotlin.core.store.Store -import com.arkivanov.mvikotlin.core.store.StoreFactory -import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor -import org.monogram.presentation.features.chats.ChatListStore.Intent -import org.monogram.presentation.features.chats.ChatListStore.Label -import org.monogram.presentation.features.chats.chatList.DefaultChatListComponent - -class ChatListStoreFactory( - private val storeFactory: StoreFactory, - private val component: DefaultChatListComponent -) { - - fun create(): ChatListStore = - object : ChatListStore, Store by storeFactory.create( - name = "ChatListStore", - initialState = ChatListComponent.State(isForwarding = component.isForwarding), - executorFactory = ::ExecutorImpl, - reducer = ReducerImpl - ) {} - - private inner class ExecutorImpl : CoroutineExecutor() { - override fun executeIntent(intent: Intent) { - when (intent) { - is Intent.ChatClicked -> component.onChatClicked(intent.id) - is Intent.ProfileClicked -> component.onProfileClicked(intent.id) - is Intent.MessageClicked -> component.onMessageClicked(intent.chatId, intent.messageId) - Intent.SettingsClicked -> component.onSettingsClicked() - is Intent.FolderClicked -> component.onFolderClicked(intent.id) - is Intent.LoadMore -> component.loadMore(intent.folderId) - Intent.LoadMoreMessages -> component.loadMoreMessages() - is Intent.ChatLongClicked -> component.onChatLongClicked(id = intent.id) - Intent.ClearSelection -> component.clearSelection() - Intent.RetryConnection -> component.retryConnection() - Intent.SearchToggle -> component.onSearchToggle() - is Intent.SearchQueryChange -> component.onSearchQueryChange(intent.query) - Intent.ClearSearchHistory -> component.onClearSearchHistory() - is Intent.RemoveSearchHistoryItem -> component.onRemoveSearchHistoryItem(intent.chatId) - is Intent.MuteSelected -> component.onMuteSelected(intent.mute) - is Intent.ArchiveSelected -> component.onArchiveSelected(archive = intent.archive) - Intent.DeleteSelected -> component.onDeleteSelected() - Intent.ArchivePinToggle -> component.onArchivePinToggle() - Intent.ConfirmForwarding -> component.onConfirmForwarding() - Intent.NewChatClicked -> component.onNewChatClicked() - Intent.ProxySettingsClicked -> component.onProxySettingsClicked() - is Intent.OpenInstantView -> component.onOpenInstantView(intent.url) - Intent.DismissInstantView -> component.onDismissInstantView() - is Intent.OpenWebApp -> component.onOpenWebApp(intent.url, intent.botUserId, intent.botName) - Intent.DismissWebApp -> component.onDismissWebApp() - is Intent.OpenWebView -> component.onOpenWebView(intent.url) - Intent.DismissWebView -> component.onDismissWebView() - Intent.UpdateClicked -> component.onUpdateClicked() - is Intent.UpdateScrollPosition -> component.updateScrollPosition( - intent.folderId, - intent.index, - intent.offset - ) - - is Intent.UpdateState -> dispatch(Message.UpdateState(intent.state)) - } - } - } - - private object ReducerImpl : Reducer { - override fun ChatListComponent.State.reduce(msg: Message): ChatListComponent.State = - when (msg) { - is Message.UpdateState -> msg.state - } - } - - sealed class Message { - data class UpdateState(val state: ChatListComponent.State) : Message() - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index 882033d1..896aa2a8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -113,7 +113,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.koin.compose.koinInject -import org.monogram.domain.models.ChatType import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar @@ -140,7 +139,12 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatListContent(component: ChatListComponent) { - val state by component.state.collectAsState() + val uiState by component.uiState.collectAsState() + val foldersState by component.foldersState.collectAsState() + val chatsState by component.chatsState.collectAsState() + val selectionState by component.selectionState.collectAsState() + val searchState by component.searchState.collectAsState() + val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current @@ -164,7 +168,7 @@ fun ChatListContent(component: ChatListComponent) { adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled val isCustomBackHandlingEnabled = - state.isSearchActive || state.selectedChatIds.isNotEmpty() || state.selectedFolderId == -2 || state.isForwarding || state.instantViewUrl != null || state.webAppUrl != null || state.webViewUrl != null || showStatusMenu + searchState.isSearchActive || selectionState.selectedChatIds.isNotEmpty() || foldersState.selectedFolderId == -2 || uiState.isForwarding || uiState.instantViewUrl != null || uiState.webAppUrl != null || uiState.webViewUrl != null || showStatusMenu BackHandler(enabled = isCustomBackHandlingEnabled) { if (showStatusMenu) { @@ -175,26 +179,26 @@ fun ChatListContent(component: ChatListComponent) { } val pagerState = rememberPagerState( - initialPage = state.folders.indexOfFirst { it.id == state.selectedFolderId }.coerceAtLeast(0), - pageCount = { state.folders.size } + initialPage = foldersState.folders.indexOfFirst { it.id == foldersState.selectedFolderId }.coerceAtLeast(0), + pageCount = { foldersState.folders.size } ) - LaunchedEffect(state.selectedFolderId) { - val index = state.folders.indexOfFirst { it.id == state.selectedFolderId }.coerceAtLeast(0) + LaunchedEffect(foldersState.selectedFolderId) { + val index = foldersState.folders.indexOfFirst { it.id == foldersState.selectedFolderId }.coerceAtLeast(0) if (pagerState.currentPage != index) pagerState.animateScrollToPage(index) } LaunchedEffect(pagerState.currentPage) { - if (state.folders.isNotEmpty()) { - val folderId = state.folders[pagerState.currentPage].id - if (state.selectedFolderId != folderId && state.selectedFolderId != -2) { + if (foldersState.folders.isNotEmpty()) { + val folderId = foldersState.folders[pagerState.currentPage].id + if (foldersState.selectedFolderId != folderId && foldersState.selectedFolderId != -2) { component.onFolderClicked(folderId) } } } val density = LocalDensity.current - val tabsHeight = if (state.folders.size > 1) 56.dp else 10.dp + val tabsHeight = if (foldersState.folders.size > 1) 56.dp else 10.dp val archiveItemHeight = 78.dp val tabsHeightPx = with(density) { tabsHeight.toPx() } val archiveItemHeightPx = with(density) { archiveItemHeight.toPx() } @@ -208,10 +212,10 @@ fun ChatListContent(component: ChatListComponent) { var hasVibrated by remember { mutableStateOf(false) } var canRevealArchive by remember { mutableStateOf(true) } - val currentFolder = state.folders.getOrNull(pagerState.currentPage) + val currentFolder = foldersState.folders.getOrNull(pagerState.currentPage) val isMainFolder = currentFolder?.id == -1 - val isArchivePersistent = state.isArchivePinned && (state.isArchiveAlwaysVisible || isMainFolder) + val isArchivePersistent = uiState.isArchivePinned && (uiState.isArchiveAlwaysVisible || isMainFolder) val canShowArchive = isArchivePersistent || isMainFolder val lastArchivePersistent = remember { mutableStateOf(isArchivePersistent) } @@ -263,7 +267,7 @@ fun ChatListContent(component: ChatListComponent) { } } - val nestedScrollConnection = remember(isArchivePersistent, canShowArchive, state.isArchiveAlwaysVisible, tabsHeightPx) { + val nestedScrollConnection = remember(isArchivePersistent, canShowArchive, uiState.isArchiveAlwaysVisible, tabsHeightPx) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (source == NestedScrollSource.UserInput) { @@ -308,7 +312,7 @@ fun ChatListContent(component: ChatListComponent) { } var limit = 0f - if (isArchivePersistent && !state.isArchiveAlwaysVisible) { + if (isArchivePersistent && !uiState.isArchiveAlwaysVisible) { limit = -archiveItemHeightPx } @@ -399,19 +403,19 @@ fun ChatListContent(component: ChatListComponent) { val isFabExpanded by remember { derivedStateOf { headerOffsetPx > -10f } } - var cachedStatusEmojiPath by remember(state.currentUser?.id) { - mutableStateOf(state.currentUser?.statusEmojiPath) + var cachedStatusEmojiPath by remember(uiState.currentUser?.id) { + mutableStateOf(uiState.currentUser?.statusEmojiPath) } - LaunchedEffect(state.currentUser?.id, state.currentUser?.statusEmojiPath) { - val statusEmojiPath = state.currentUser?.statusEmojiPath + LaunchedEffect(uiState.currentUser?.id, uiState.currentUser?.statusEmojiPath) { + val statusEmojiPath = uiState.currentUser?.statusEmojiPath if (!statusEmojiPath.isNullOrBlank()) { cachedStatusEmojiPath = statusEmojiPath } } - val currentUser = remember(state.currentUser, cachedStatusEmojiPath) { - state.currentUser?.let { user -> + val currentUser = remember(uiState.currentUser, cachedStatusEmojiPath) { + uiState.currentUser?.let { user -> if (user.statusEmojiId != 0L && user.statusEmojiPath.isNullOrBlank() && !cachedStatusEmojiPath.isNullOrBlank()) { user.copy(statusEmojiPath = cachedStatusEmojiPath) } else { @@ -423,7 +427,7 @@ fun ChatListContent(component: ChatListComponent) { if (showAccountMenu) { AccountMenu( user = currentUser, - attachMenuBots = state.attachMenuBots, + attachMenuBots = uiState.attachMenuBots, onDismiss = { showAccountMenu = false }, onSavedMessagesClick = { currentUser?.id?.let { component.onChatClicked(it) } @@ -436,13 +440,13 @@ fun ChatListContent(component: ChatListComponent) { onProfileClick = { currentUser?.id?.let { component.onProfileClicked(it) } }, - updateState = state.updateState, + updateState = uiState.updateState, onUpdateClick = { component.onUpdateClicked() }, onBotClick = { bot -> component.onOpenWebApp( - url = state.botWebAppUrl ?: "", + url = uiState.botWebAppUrl ?: "", botUserId = bot.botUserId, - botName = state.botWebAppName ?: bot.name + botName = uiState.botWebAppName ?: bot.name ) } ) @@ -478,19 +482,19 @@ fun ChatListContent(component: ChatListComponent) { topBar = { Column(Modifier.fillMaxWidth()) { AnimatedContent( - targetState = state.selectedChatIds.isNotEmpty() && !state.isForwarding, + targetState = selectionState.selectedChatIds.isNotEmpty() && !uiState.isForwarding, label = "TopBarSelectionAnimation", transitionSpec = { fadeIn() togetherWith fadeOut() } ) { isSelectionMode -> if (isSelectionMode) { - val selectedChats = state.chats.filter { state.selectedChatIds.contains(it.id) } + val selectedChats = chatsState.chats.filter { selectionState.selectedChatIds.contains(it.id) } val canMarkUnread = selectedChats.any { !it.isMarkedAsUnread } val allPinned = selectedChats.isNotEmpty() && selectedChats.all { it.isPinned } val allMuted = selectedChats.isNotEmpty() && selectedChats.all { it.isMuted } - val isInArchive = state.selectedFolderId == -2 + val isInArchive = foldersState.selectedFolderId == -2 SelectionTopBar( - selectedCount = state.selectedChatIds.size, + selectedCount = selectionState.selectedChatIds.size, isInArchive = isInArchive, allPinned = allPinned, allMuted = allMuted, @@ -503,7 +507,7 @@ fun ChatListContent(component: ChatListComponent) { canMarkUnread = canMarkUnread ) } else { - if (state.isForwarding) { + if (uiState.isForwarding) { TopAppBar( title = { Column { @@ -512,11 +516,11 @@ fun ChatListContent(component: ChatListComponent) { fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium ) - if (state.selectedChatIds.isNotEmpty()) { + if (selectionState.selectedChatIds.isNotEmpty()) { Text( text = stringResource( R.string.chats_selected_format, - state.selectedChatIds.size + selectionState.selectedChatIds.size ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary @@ -530,7 +534,7 @@ fun ChatListContent(component: ChatListComponent) { } }, actions = { - if (state.selectedChatIds.isNotEmpty()) { + if (selectionState.selectedChatIds.isNotEmpty()) { IconButton(onClick = { component.onConfirmForwarding() }) { Icon( Icons.AutoMirrored.Rounded.Send, @@ -542,7 +546,7 @@ fun ChatListContent(component: ChatListComponent) { }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) - } else if (state.selectedFolderId == -2 && !state.isSearchActive) { + } else if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { TopAppBar( title = { Text( @@ -568,12 +572,12 @@ fun ChatListContent(component: ChatListComponent) { } else { ChatListTopBar( user = currentUser, - connectionStatus = state.connectionStatus, - isProxyEnabled = state.isProxyEnabled, + connectionStatus = uiState.connectionStatus, + isProxyEnabled = uiState.isProxyEnabled, onRetryConnection = { component.retryConnection() }, onProxySettingsClick = { component.onProxySettingsClicked() }, - isSearchActive = state.isSearchActive, - searchQuery = state.searchQuery, + isSearchActive = searchState.isSearchActive, + searchQuery = searchState.searchQuery, onSearchQueryChange = component::onSearchQueryChange, onSearchToggle = component::onSearchToggle, onStatusClick = { anchorBounds -> @@ -586,7 +590,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.connectionStatus == ConnectionStatus.Connecting || state.connectionStatus == ConnectionStatus.Updating || state.connectionStatus == ConnectionStatus.ConnectingToProxy) { + if (uiState.connectionStatus == ConnectionStatus.Connecting || uiState.connectionStatus == ConnectionStatus.Updating || uiState.connectionStatus == ConnectionStatus.ConnectingToProxy) { Column { LinearWavyProgressIndicator( modifier = Modifier @@ -598,7 +602,7 @@ fun ChatListContent(component: ChatListComponent) { } } - val isMainView = !state.isSearchActive && state.selectedFolderId != -2 + val isMainView = !searchState.isSearchActive && foldersState.selectedFolderId != -2 if (isMainView) { Box( @@ -656,7 +660,7 @@ fun ChatListContent(component: ChatListComponent) { } ) { ArchiveHeaderCard( - isPinned = state.isArchivePinned, + isPinned = uiState.isArchivePinned, onClick = { component.onFolderClicked(-2) }, onLongClick = { component.onArchivePinToggle() } ) @@ -664,14 +668,14 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.folders.size > 1) { + if (foldersState.folders.size > 1) { FolderTabs( modifier = Modifier, - folders = state.folders, + folders = foldersState.folders, pagerState = pagerState, onTabClick = { index -> if (pagerState.currentPage == index) { - val folderId = state.folders[index].id + val folderId = foldersState.folders[index].id scope.launch { scrollStates[folderId]?.animateScrollToItem(0) } @@ -695,7 +699,7 @@ fun ChatListContent(component: ChatListComponent) { floatingActionButton = { if (!isTablet) { AnimatedVisibility( - visible = !state.isSearchActive && state.selectedFolderId != -2 && !state.isForwarding, + visible = !searchState.isSearchActive && foldersState.selectedFolderId != -2 && !uiState.isForwarding, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { @@ -710,7 +714,7 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.isForwarding && state.selectedChatIds.isNotEmpty(), + visible = uiState.isForwarding && selectionState.selectedChatIds.isNotEmpty(), enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { @@ -737,32 +741,32 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .fillMaxSize() ) { - if (state.isSearchActive || state.selectedFolderId == -2) { + if (searchState.isSearchActive || foldersState.selectedFolderId == -2) { var showAllGlobal by remember { mutableStateOf(false) } var showAllMessages by remember { mutableStateOf(false) } val scrollState = rememberLazyListState( - initialFirstVisibleItemIndex = if (state.selectedFolderId == -2 && !state.isSearchActive) state.scrollPositions[-2]?.first ?: 0 else 0, - initialFirstVisibleItemScrollOffset = if (state.selectedFolderId == -2 && !state.isSearchActive) state.scrollPositions[-2]?.second ?: 0 else 0 + initialFirstVisibleItemIndex = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) foldersState.scrollPositions[-2]?.first ?: 0 else 0, + initialFirstVisibleItemScrollOffset = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) foldersState.scrollPositions[-2]?.second ?: 0 else 0 ) - if (state.selectedFolderId == -2 && !state.isSearchActive) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { scrollStates[-2] = scrollState } - val firstItemId = if (state.selectedFolderId == -2 && !state.isSearchActive) { - state.chatsByFolder[-2]?.firstOrNull()?.id + val firstItemId = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { + foldersState.chatsByFolder[-2]?.firstOrNull()?.id } else { null } LaunchedEffect(firstItemId) { - if (state.selectedFolderId == -2 && !state.isSearchActive && !scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive && !scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { scrollState.scrollToItem(0, 0) } } - if (state.selectedFolderId == -2 && !state.isSearchActive) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { DisposableEffect(Unit) { onDispose { component.updateScrollPosition(-2, scrollState.firstVisibleItemIndex, scrollState.firstVisibleItemScrollOffset) @@ -770,10 +774,10 @@ fun ChatListContent(component: ChatListComponent) { } } - val isArchivedView = state.selectedFolderId == -2 && !state.isSearchActive - val archivedChats = if (isArchivedView) state.chatsByFolder[-2] ?: emptyList() else emptyList() - val isArchivedLoading = if (isArchivedView) state.isLoadingByFolder[-2] ?: false else false - val hasArchivedLoadState = if (isArchivedView) state.isLoadingByFolder.containsKey(-2) else false + val isArchivedView = foldersState.selectedFolderId == -2 && !searchState.isSearchActive + val archivedChats = if (isArchivedView) chatsState.chats else emptyList() + val isArchivedLoading = if (isArchivedView) chatsState.isLoading else false + val hasArchivedLoadState = isArchivedView && (foldersState.isLoadingByFolder.containsKey(-2) || chatsState.chats.isNotEmpty()) val showArchivedShimmer = isArchivedView && archivedChats.isEmpty() && (isArchivedLoading || !hasArchivedLoadState) val shouldAnimateFirstArchiveTransition = firstFolderTransitionCompleted[-2] != true @@ -814,13 +818,8 @@ fun ChatListContent(component: ChatListComponent) { end = if (isTablet) 12.dp else 0.dp ), ) { - if (state.isSearchActive) { - if (state.searchQuery.isEmpty() && state.searchHistory.isNotEmpty()) { - val recentUsers = - state.searchHistory.filter { (it.type == ChatType.PRIVATE || it.type == ChatType.SECRET) && !it.isBot } - val recentOthers = - state.searchHistory.filter { it.type != ChatType.PRIVATE && it.type != ChatType.SECRET || it.isBot } - + if (searchState.isSearchActive) { + if (searchState.searchQuery.isEmpty() && (searchState.recentUsers.isNotEmpty() || searchState.recentOthers.isNotEmpty())) { item { Row( modifier = Modifier @@ -841,7 +840,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (recentUsers.isNotEmpty()) { + if (searchState.recentUsers.isNotEmpty()) { item { LazyRow( modifier = Modifier @@ -850,7 +849,7 @@ fun ChatListContent(component: ChatListComponent) { contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - itemsIndexed(items = recentUsers, key = { index, chat -> "recent_user_${chat.id}_$index" }) { _, chat -> + itemsIndexed(items = searchState.recentUsers, key = { index, chat -> "recent_user_${chat.id}_$index" }) { _, chat -> Column( modifier = Modifier .width(64.dp) @@ -908,18 +907,18 @@ fun ChatListContent(component: ChatListComponent) { } } - if (recentOthers.isNotEmpty()) { + if (searchState.recentOthers.isNotEmpty()) { itemsIndexed( - items = recentOthers, + items = searchState.recentOthers, key = { _, chat -> "recent_${chat.id}" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, + currentUserId = uiState.currentUser?.id, isSelected = false, onClick = { onChatClicked(chat.id) }, onLongClick = { component.onRemoveSearchHistoryItem(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -928,7 +927,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.searchResults.isNotEmpty()) { + if (searchState.searchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_chats), @@ -937,15 +936,15 @@ fun ChatListContent(component: ChatListComponent) { color = MaterialTheme.colorScheme.primary ) } - itemsIndexed(items = state.searchResults, key = { index, chat -> "search_${chat.id}_$index" }) { _, chat -> + itemsIndexed(items = searchState.searchResults, key = { index, chat -> "search_${chat.id}_$index" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -953,7 +952,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.globalSearchResults.isNotEmpty()) { + if (searchState.globalSearchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_global), @@ -964,24 +963,24 @@ fun ChatListContent(component: ChatListComponent) { } val globalToDisplay = - if (showAllGlobal) state.globalSearchResults else state.globalSearchResults.take(3) + if (showAllGlobal) searchState.globalSearchResults else searchState.globalSearchResults.take(3) itemsIndexed(items = globalToDisplay, key = { _, chat -> "global_${chat.id}" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos ) } - if (!showAllGlobal && state.globalSearchResults.size > 3) { + if (!showAllGlobal && searchState.globalSearchResults.size > 3) { item { Box( modifier = Modifier @@ -1000,7 +999,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.messageSearchResults.isNotEmpty()) { + if (searchState.messageSearchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_messages), @@ -1011,12 +1010,12 @@ fun ChatListContent(component: ChatListComponent) { } val messagesToDisplay = - if (showAllMessages) state.messageSearchResults else state.messageSearchResults.take(3) + if (showAllMessages) searchState.messageSearchResults else searchState.messageSearchResults.take(3) itemsIndexed( items = messagesToDisplay, key = { index, msg -> "msg_${msg.id}_$index" }) { index, msg -> - if (showAllMessages && index >= messagesToDisplay.lastIndex - 5 && state.canLoadMoreMessages) { + if (showAllMessages && index >= messagesToDisplay.lastIndex - 5 && searchState.canLoadMoreMessages) { LaunchedEffect(Unit) { component.loadMoreMessages() } } @@ -1027,7 +1026,7 @@ fun ChatListContent(component: ChatListComponent) { ) } - if (!showAllMessages && state.messageSearchResults.size > 3) { + if (!showAllMessages && searchState.messageSearchResults.size > 3) { item { Box( modifier = Modifier @@ -1056,11 +1055,11 @@ fun ChatListContent(component: ChatListComponent) { ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -1087,21 +1086,21 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 1 ) { page -> - val folderId = state.folders.getOrNull(page)?.id - ?: state.folders.firstOrNull { it.id == state.selectedFolderId }?.id + val folderId = foldersState.folders.getOrNull(page)?.id + ?: foldersState.folders.firstOrNull { it.id == foldersState.selectedFolderId }?.id if (folderId == null) { Box(modifier = Modifier.fillMaxSize()) return@HorizontalPager } - val folderChats = state.chatsByFolder[folderId] ?: emptyList() - val isFolderLoading = state.isLoadingByFolder[folderId] ?: false - val hasFolderLoadState = state.isLoadingByFolder.containsKey(folderId) + val folderChats = foldersState.chatsByFolder[folderId] ?: emptyList() + val isFolderLoading = foldersState.isLoadingByFolder[folderId] ?: false + val hasFolderLoadState = foldersState.isLoadingByFolder.containsKey(folderId) val showFolderShimmer = folderChats.isEmpty() && (isFolderLoading || !hasFolderLoadState) val shouldAnimateFirstFolderTransition = firstFolderTransitionCompleted[folderId] != true val scrollState = rememberLazyListState( - initialFirstVisibleItemIndex = state.scrollPositions[folderId]?.first ?: 0, - initialFirstVisibleItemScrollOffset = state.scrollPositions[folderId]?.second ?: 0 + initialFirstVisibleItemIndex = foldersState.scrollPositions[folderId]?.first ?: 0, + initialFirstVisibleItemScrollOffset = foldersState.scrollPositions[folderId]?.second ?: 0 ) scrollStates[folderId] = scrollState @@ -1134,7 +1133,7 @@ fun ChatListContent(component: ChatListComponent) { val isInitialLoad = remember(folderId) { mutableStateOf(true) } LaunchedEffect(folderChats) { if (isInitialLoad.value && folderChats.isNotEmpty()) { - if (state.scrollPositions[folderId] == null) { + if (foldersState.scrollPositions[folderId] == null) { scrollState.scrollToItem(0, 0) } isInitialLoad.value = false @@ -1187,11 +1186,11 @@ fun ChatListContent(component: ChatListComponent) { ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -1308,11 +1307,11 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.instantViewUrl != null, + visible = uiState.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - state.instantViewUrl?.let { url -> + uiState.instantViewUrl?.let { url -> InstantViewer( url = url, messageRepository = koinInject(), @@ -1324,13 +1323,13 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.webAppUrl != null || state.webAppBotId != null, + visible = uiState.webAppUrl != null || uiState.webAppBotId != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - val webAppUrl = state.webAppUrl - val botUserId = state.webAppBotId - val botName = state.webAppBotName + val webAppUrl = uiState.webAppUrl + val botUserId = uiState.webAppBotId + val botName = uiState.webAppBotName Log.d("MiniAppViewer", "webAppUrl: $webAppUrl, botUserId: $botUserId, botName: $botName") @@ -1349,7 +1348,7 @@ fun ChatListContent(component: ChatListComponent) { if (showDeleteChatsSheet) { ConfirmationSheet( icon = Icons.Rounded.Delete, - title = stringResource(R.string.delete_chats_title, state.selectedChatIds.size), + title = stringResource(R.string.delete_chats_title, selectionState.selectedChatIds.size), description = stringResource(R.string.delete_chats_confirmation), confirmText = stringResource(R.string.action_delete_chats), onConfirm = { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt index b1bef737..c2e63947 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt @@ -2,23 +2,19 @@ package org.monogram.presentation.features.chats.chatList import android.util.Log import com.arkivanov.decompose.value.Value -import com.arkivanov.mvikotlin.core.instancekeeper.getStore -import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow -import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatType import org.monogram.domain.models.UpdateState import org.monogram.domain.repository.* import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.ChatListStore -import org.monogram.presentation.features.chats.ChatListStoreFactory import org.monogram.presentation.root.AppComponentContext class DefaultChatListComponent( @@ -45,21 +41,17 @@ class DefaultChatListComponent( private val updateRepository: UpdateRepository = container.repositories.updateRepository override val appPreferences: AppPreferences = container.preferences.appPreferences - private val _state = MutableStateFlow( - ChatListComponent.State( - isForwarding = isForwarding, - isLoadingByFolder = mapOf(-1 to true) - ) - ) - - private val store = instanceKeeper.getStore { - ChatListStoreFactory( - storeFactory = DefaultStoreFactory(), - component = this - ).create() - } + private val _uiState = MutableStateFlow(ChatListComponent.UiState(isForwarding = isForwarding)) + private val _foldersState = MutableStateFlow(ChatListComponent.FoldersState(isLoadingByFolder = mapOf(-1 to true))) + private val _chatsState = MutableStateFlow(ChatListComponent.ChatsState()) + private val _selectionState = MutableStateFlow(ChatListComponent.SelectionState()) + private val _searchState = MutableStateFlow(ChatListComponent.SearchState()) - override val state: StateFlow = store.stateFlow + override val uiState: StateFlow = _uiState.asStateFlow() + override val foldersState: StateFlow = _foldersState.asStateFlow() + override val chatsState: StateFlow = _chatsState + override val selectionState: StateFlow = _selectionState.asStateFlow() + override val searchState: StateFlow = _searchState.asStateFlow() private val scope = componentScope private var searchJob: Job? = null @@ -68,13 +60,13 @@ class DefaultChatListComponent( init { activeChatId.subscribe { id -> - _state.update { it.copy(activeChatId = id) } + _selectionState.update { it.copy(activeChatId = id) } } repositoryUser.currentUserFlow .onEach { user -> if (user != null) { - _state.update { it.copy(currentUser = user) } + _uiState.update { it.copy(currentUser = user) } } } .launchIn(scope) @@ -95,70 +87,90 @@ class DefaultChatListComponent( }" ) } - _state.update { + _foldersState.update { val newChatsByFolder = it.chatsByFolder.toMutableMap() newChatsByFolder[update.folderId] = distinctList it.copy(chatsByFolder = newChatsByFolder) } + + if (update.folderId == _foldersState.value.selectedFolderId) { + _chatsState.update { + it.copy(chats = distinctList) + } + } } .launchIn(scope) chatFolderRepository.foldersFlow .onEach { folders -> - _state.update { it.copy(folders = folders) } + _foldersState.update { it.copy(folders = folders) } } .launchIn(scope) chatFolderRepository.folderLoadingFlow .onEach { update -> - _state.update { + _foldersState.update { val newLoadingByFolder = it.isLoadingByFolder.toMutableMap() newLoadingByFolder[update.folderId] = update.isLoading it.copy(isLoadingByFolder = newLoadingByFolder) } + if (update.folderId == _foldersState.value.selectedFolderId) { + _chatsState.update { + it.copy(isLoading = update.isLoading) + } + } } .launchIn(scope) chatListRepository.connectionStateFlow .onEach { status -> - _state.update { it.copy(connectionStatus = status) } + _uiState.update { it.copy(connectionStatus = status) } } .launchIn(scope) appPreferences.enabledProxyId .onEach { enabledProxyId -> - _state.update { it.copy(isProxyEnabled = enabledProxyId != null) } + _uiState.update { it.copy(isProxyEnabled = enabledProxyId != null) } } .launchIn(scope) chatOperationsRepository.isArchivePinned .onEach { isPinned -> - _state.update { it.copy(isArchivePinned = isPinned) } + _uiState.update { it.copy(isArchivePinned = isPinned) } } .launchIn(scope) chatOperationsRepository.isArchiveAlwaysVisible .onEach { alwaysVisible -> - _state.update { it.copy(isArchiveAlwaysVisible = alwaysVisible) } + _uiState.update { it.copy(isArchiveAlwaysVisible = alwaysVisible) } } .launchIn(scope) chatSearchRepository.searchHistory .onEach { history -> - _state.update { it.copy(searchHistory = history) } + _searchState.update { + it.copy( + recentUsers = history.filter { chat -> + (chat.type == ChatType.PRIVATE || chat.type == ChatType.SECRET) && !chat.isBot + }, + recentOthers = history.filter { chat -> + chat.type != ChatType.PRIVATE && chat.type != ChatType.SECRET || chat.isBot + } + ) + } } .launchIn(scope) attachMenuBotRepository.getAttachMenuBots() .onEach { bots -> - _state.update { it.copy(attachMenuBots = bots) } + _uiState.update { it.copy(attachMenuBots = bots) } bots.firstOrNull()?.let { bot -> if (bot.botUserId != 0L) { val botInfo = botRepository.getBotInfo(bot.botUserId) val menuButton = botInfo?.menuButton if (menuButton is BotMenuButtonModel.WebApp) { - _state.update { + _uiState.update { it.copy( botWebAppUrl = menuButton.url, botWebAppName = menuButton.text @@ -172,7 +184,7 @@ class DefaultChatListComponent( updateRepository.updateState .onEach { updateState -> - _state.update { it.copy(updateState = updateState) } + _uiState.update { it.copy(updateState = updateState) } } .launchIn(scope) @@ -180,12 +192,8 @@ class DefaultChatListComponent( updateRepository.checkForUpdates() } - _state.onEach { - store.accept(ChatListStore.Intent.UpdateState(it)) - }.launchIn(scope) - scope.launch(Dispatchers.IO) { - chatListRepository.selectFolder(_state.value.selectedFolderId) + chatListRepository.selectFolder(_foldersState.value.selectedFolderId) } } @@ -194,9 +202,9 @@ class DefaultChatListComponent( } override fun onFolderClicked(id: Int) { - if (_state.value.selectedFolderId == id) return + if (_foldersState.value.selectedFolderId == id) return - _state.update { + _foldersState.update { val loadingByFolder = it.isLoadingByFolder.toMutableMap() loadingByFolder[id] = true it.copy( @@ -205,17 +213,22 @@ class DefaultChatListComponent( ) } + _chatsState.value = ChatListComponent.ChatsState( + chats = _foldersState.value.chatsByFolder[id].orEmpty(), + isLoading = true + ) + scope.launch(Dispatchers.IO) { chatListRepository.selectFolder(id) } } override fun loadMore(folderId: Int?) { - val targetFolderId = folderId ?: _state.value.selectedFolderId - if (_state.value.isLoadingByFolder[targetFolderId] == true) return + val targetFolderId = folderId ?: _foldersState.value.selectedFolderId + if (_foldersState.value.isLoadingByFolder[targetFolderId] == true) return scope.launch(Dispatchers.IO) { - if (folderId != null && folderId != _state.value.selectedFolderId) { + if (folderId != null && folderId != _foldersState.value.selectedFolderId) { return@launch } chatListRepository.loadNextChunk(20) @@ -226,11 +239,11 @@ class DefaultChatListComponent( if (isFetchingMoreMessages || nextMessagesOffset.isEmpty()) return isFetchingMoreMessages = true - val query = _state.value.searchQuery + val query = _searchState.value.searchQuery scope.launch(Dispatchers.IO) { val result = chatSearchRepository.searchMessages(query, offset = nextMessagesOffset) nextMessagesOffset = result.nextOffset - _state.update { + _searchState.update { it.copy( messageSearchResults = it.messageSearchResults + result.messages, canLoadMoreMessages = nextMessagesOffset.isNotEmpty() @@ -241,12 +254,12 @@ class DefaultChatListComponent( } override fun onChatClicked(id: Long) { - if (_state.value.isForwarding) { + if (_uiState.value.isForwarding) { toggleSelection(id) - } else if (_state.value.selectedChatIds.isNotEmpty()) { + } else if (_selectionState.value.selectedChatIds.isNotEmpty()) { toggleSelection(id) } else { - if (_state.value.isSearchActive) { + if (_searchState.value.isSearchActive) { chatSearchRepository.addSearchChatId(id) } onSelect(id, null) @@ -258,12 +271,12 @@ class DefaultChatListComponent( } override fun onMessageClicked(chatId: Long, messageId: Long) { - if (_state.value.isForwarding) { + if (_uiState.value.isForwarding) { toggleSelection(chatId) - } else if (_state.value.selectedChatIds.isNotEmpty()) { + } else if (_selectionState.value.selectedChatIds.isNotEmpty()) { toggleSelection(chatId) } else { - if (_state.value.isSearchActive) { + if (_searchState.value.isSearchActive) { chatSearchRepository.addSearchChatId(chatId) } onSelect(chatId, messageId) @@ -275,7 +288,7 @@ class DefaultChatListComponent( } override fun clearSelection() { - _state.update { it.copy(selectedChatIds = emptySet()) } + _selectionState.update { it.copy(selectedChatIds = emptySet()) } } override fun onSettingsClicked() { @@ -283,9 +296,10 @@ class DefaultChatListComponent( } override fun onSearchToggle() { - _state.update { + val isSearchActive = !_searchState.value.isSearchActive + _searchState.update { it.copy( - isSearchActive = !it.isSearchActive, + isSearchActive = isSearchActive, searchQuery = "", searchResults = emptyList(), globalSearchResults = emptyList(), @@ -297,22 +311,23 @@ class DefaultChatListComponent( } override fun onSearchQueryChange(query: String) { - _state.update { it.copy(searchQuery = query) } + _searchState.update { it.copy(searchQuery = query) } searchJob?.cancel() searchJob = scope.launch(Dispatchers.IO) { delay(300) if (query.isNotEmpty()) { - if (_state.value.selectedFolderId == -2) { - val archivedChats = _state.value.chatsByFolder[-2].orEmpty() + if (_foldersState.value.selectedFolderId == -2) { + val archivedChats = _foldersState.value.chatsByFolder[-2].orEmpty() val trimmedQuery = query.trim() val archiveResults = archivedChats.filter { chat -> chat.title.contains(trimmedQuery, ignoreCase = true) || chat.lastMessageText.contains(trimmedQuery, ignoreCase = true) } - _state.update { + _searchState.update { it.copy( + searchQuery = query, searchResults = archiveResults, globalSearchResults = emptyList(), messageSearchResults = emptyList(), @@ -324,14 +339,14 @@ class DefaultChatListComponent( } val localResults = chatSearchRepository.searchChats(query) - _state.update { it.copy(searchResults = localResults) } + _searchState.update { it.copy(searchResults = localResults) } val globalResults = chatSearchRepository.searchPublicChats(query) - _state.update { it.copy(globalSearchResults = globalResults) } + _searchState.update { it.copy(globalSearchResults = globalResults) } val messageResults = chatSearchRepository.searchMessages(query) nextMessagesOffset = messageResults.nextOffset - _state.update { + _searchState.update { it.copy( messageSearchResults = messageResults.messages, canLoadMoreMessages = nextMessagesOffset.isNotEmpty() @@ -339,8 +354,9 @@ class DefaultChatListComponent( } } else { nextMessagesOffset = "" - _state.update { + _searchState.update { it.copy( + searchQuery = "", searchResults = emptyList(), globalSearchResults = emptyList(), messageSearchResults = emptyList(), @@ -352,7 +368,7 @@ class DefaultChatListComponent( } override fun onSetEmojiStatus(customEmojiId: Long, statusPath: String?) { - _state.update { state -> + _uiState.update { state -> val user = state.currentUser ?: return@update state state.copy( currentUser = user.copy( @@ -378,8 +394,8 @@ class DefaultChatListComponent( } override fun onMuteSelected(mute: Boolean) { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldMute = selectedChats.any { !it.isMuted } scope.launch(Dispatchers.IO) { @@ -389,7 +405,7 @@ class DefaultChatListComponent( } override fun onArchiveSelected(archive: Boolean) { - val selectedIds = _state.value.selectedChatIds + val selectedIds = _selectionState.value.selectedChatIds scope.launch(Dispatchers.IO) { chatOperationsRepository.toggleArchiveChats(selectedIds, archive) clearSelection() @@ -397,10 +413,10 @@ class DefaultChatListComponent( } override fun onPinSelected() { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldPin = selectedChats.any { !it.isPinned } - val folderId = _state.value.selectedFolderId + val folderId = _foldersState.value.selectedFolderId scope.launch(Dispatchers.IO) { chatOperationsRepository.togglePinChats(selectedIds, shouldPin, folderId) @@ -409,8 +425,8 @@ class DefaultChatListComponent( } override fun onToggleReadSelected() { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldMarkUnread = selectedChats.any { !it.isMarkedAsUnread } scope.launch(Dispatchers.IO) { @@ -420,7 +436,7 @@ class DefaultChatListComponent( } override fun onDeleteSelected() { - val selectedIds = _state.value.selectedChatIds + val selectedIds = _selectionState.value.selectedChatIds scope.launch(Dispatchers.IO) { chatOperationsRepository.deleteChats(selectedIds) clearSelection() @@ -428,12 +444,13 @@ class DefaultChatListComponent( } override fun onArchivePinToggle() { - chatOperationsRepository.setArchivePinned(!_state.value.isArchivePinned) + chatOperationsRepository.setArchivePinned(!_uiState.value.isArchivePinned) } override fun onConfirmForwarding() { - if (_state.value.selectedChatIds.isNotEmpty()) { - onConfirmForward(_state.value.selectedChatIds) + val selectedChatIds = _selectionState.value.selectedChatIds + if (selectedChatIds.isNotEmpty()) { + onConfirmForward(selectedChatIds) } } @@ -454,7 +471,7 @@ class DefaultChatListComponent( scope.launch(Dispatchers.IO) { chatFolderRepository.deleteFolder(folderId) - if (_state.value.selectedFolderId == folderId) { + if (_foldersState.value.selectedFolderId == folderId) { onFolderClicked(-1) } } @@ -466,15 +483,15 @@ class DefaultChatListComponent( } override fun onOpenInstantView(url: String) { - _state.update { it.copy(instantViewUrl = url) } + _uiState.update { it.copy(instantViewUrl = url) } } override fun onDismissInstantView() { - _state.update { it.copy(instantViewUrl = null) } + _uiState.update { it.copy(instantViewUrl = null) } } override fun onOpenWebApp(url: String, botUserId: Long, botName: String) { - _state.update { + _uiState.update { it.copy( webAppUrl = url, webAppBotId = botUserId, @@ -484,7 +501,7 @@ class DefaultChatListComponent( } override fun onDismissWebApp() { - _state.update { + _uiState.update { it.copy( webAppUrl = null, webAppBotId = null, @@ -494,15 +511,15 @@ class DefaultChatListComponent( } override fun onOpenWebView(url: String) { - _state.update { it.copy(webViewUrl = url) } + _uiState.update { it.copy(webViewUrl = url) } } override fun onDismissWebView() { - _state.update { it.copy(webViewUrl = null) } + _uiState.update { it.copy(webViewUrl = null) } } override fun onUpdateClicked() { - val currentState = _state.value.updateState + val currentState = _uiState.value.updateState when (currentState) { is UpdateState.UpdateAvailable -> { updateRepository.downloadUpdate() @@ -524,32 +541,32 @@ class DefaultChatListComponent( override fun handleBack(): Boolean { return when { - state.value.webViewUrl != null -> { + uiState.value.webViewUrl != null -> { onDismissWebView() true } - state.value.webAppUrl != null -> { + uiState.value.webAppUrl != null -> { onDismissWebApp() true } - state.value.instantViewUrl != null -> { + uiState.value.instantViewUrl != null -> { onDismissInstantView() true } - state.value.isSearchActive -> { + searchState.value.isSearchActive -> { onSearchToggle() true } - state.value.selectedChatIds.isNotEmpty() -> { + selectionState.value.selectedChatIds.isNotEmpty() -> { clearSelection() true } - state.value.selectedFolderId == -2 -> { + foldersState.value.selectedFolderId == -2 -> { onFolderClicked(-1) true } - state.value.isForwarding -> { + uiState.value.isForwarding -> { onSelect(0L, null) true } @@ -558,7 +575,7 @@ class DefaultChatListComponent( } override fun updateScrollPosition(folderId: Int, index: Int, offset: Int) { - _state.update { + _foldersState.update { val newPositions = it.scrollPositions.toMutableMap() newPositions[folderId] = index to offset it.copy(scrollPositions = newPositions) @@ -570,13 +587,12 @@ class DefaultChatListComponent( } private fun toggleSelection(id: Long) { - _state.update { state -> - val newSelection = if (state.selectedChatIds.contains(id)) { - state.selectedChatIds - id - } else { - state.selectedChatIds + id - } - state.copy(selectedChatIds = newSelection) + val currentSelection = _selectionState.value.selectedChatIds + val newSelection = if (currentSelection.contains(id)) { + currentSelection - id + } else { + currentSelection + id } + _selectionState.value = _selectionState.value.copy(selectedChatIds = newSelection) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 3e9c0d24..8d5d5383 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -343,9 +343,9 @@ private fun ChatListItemContent( } else { emptyMap() } + val spoilerLabel = stringResource(R.string.message_spoiler) val annotatedDraft = if (draftHasSpoiler) { buildAnnotatedString { - val spoilerLabel = stringResource(R.string.message_spoiler) append(spoilerLabel) addStyle( SpanStyle( @@ -382,11 +382,11 @@ private fun ChatListItemContent( entities = chat.lastMessageEntities, fontSize = fontSize ) + val spoilerLabel = stringResource(R.string.message_spoiler) val annotatedText = if (chat.lastMessageText.isNotEmpty()) { val hasSpoiler = chat.lastMessageEntities.any { it.type is MessageEntityType.Spoiler } if (hasSpoiler) { buildAnnotatedString { - val spoilerLabel = stringResource(R.string.message_spoiler) append(spoilerLabel) addStyle( SpanStyle( @@ -505,4 +505,4 @@ private fun ChatListItemStatus(chat: ChatModel) { ) } } -} \ No newline at end of file +} From 599a195872174358f8a4ee47502d5d3b275b7fc0 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:43:14 +0300 Subject: [PATCH 72/83] Keep member names for Monogram and TDLib packages in release builds This preserves readable stacktraces under R8 so crash diagnostics are easier without fully disabling minification. --- app/proguard-rules.pro | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 713132ff..fd955b62 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -4,6 +4,8 @@ -keepnames class org.monogram.** -keepnames class org.drinkless.tdlib.** +-keepclassmembernames class org.monogram.** { *; } +-keepclassmembernames class org.drinkless.tdlib.** { *; } -assumenosideeffects class android.util.Log { public static *** v(...); From f36ddff3c0e6a62694828cd03a5640a877902972 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:44:10 +0300 Subject: [PATCH 73/83] fix #237 #241 #238 #236 #234 --- .../java/org/monogram/data/di/TdLibClient.kt | 13 +- .../monogram/data/gateway/TdLibException.kt | 33 ++++ .../org/monogram/data/mapper/ProxyMapper.kt | 7 +- .../repository/ExternalProxyRepositoryImpl.kt | 48 ++++- .../monogram/data/repository/LinkParser.kt | 6 +- .../data/proxy/MtprotoSecretNormalizerTest.kt | 45 +++++ .../domain/proxy/MtprotoSecretNormalizer.kt | 43 +++++ .../repository/AppPreferencesProvider.kt | 6 +- .../repository/ExternalProxyRepository.kt | 7 + .../presentation/root/DefaultRootComponent.kt | 47 ++++- .../settings/proxy/ProxyComponent.kt | 170 ++++++++++++++---- .../settings/proxy/ProxyContent.kt | 35 +++- presentation/src/main/res/values/string.xml | 2 +- 13 files changed, 407 insertions(+), 55 deletions(-) create mode 100644 data/src/test/java/org/monogram/data/proxy/MtprotoSecretNormalizerTest.kt create mode 100644 domain/src/main/java/org/monogram/domain/proxy/MtprotoSecretNormalizer.kt diff --git a/data/src/main/java/org/monogram/data/di/TdLibClient.kt b/data/src/main/java/org/monogram/data/di/TdLibClient.kt index 95395530..3005026b 100644 --- a/data/src/main/java/org/monogram/data/di/TdLibClient.kt +++ b/data/src/main/java/org/monogram/data/di/TdLibClient.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import org.drinkless.tdlib.Client import org.drinkless.tdlib.TdApi import org.monogram.data.gateway.TdLibException +import org.monogram.data.gateway.isExpectedProxyFailure import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume @@ -63,7 +64,12 @@ internal class TdLibClient { fun send(function: TdApi.Function, callback: (TdApi.Object) -> Unit = {}) { client.send(function) { result -> if (result is TdApi.Error) { - if (result.code != 404) { + if (result.isExpectedProxyFailure()) { + Log.w( + TAG, + "Expected proxy error in send $function: ${result.code} ${result.message}" + ) + } else if (result.code != 404) { Log.e(TAG, "Error in send $function: ${result.code} ${result.message}") } else { Log.w(TAG, "Not found in send $function: ${result.message}") @@ -113,6 +119,11 @@ internal class TdLibClient { if (isExpectedUserFullInfoMiss) { Log.w(TAG, "User not found in sendSuspend $function: ${result.code} ${result.message}") + } else if (result.isExpectedProxyFailure()) { + Log.w( + TAG, + "Expected proxy error in sendSuspend $function: ${result.code} ${result.message}" + ) } else if (result.code != 404) { Log.e(TAG, "Error in sendSuspend $function: ${result.code} ${result.message}") } else { diff --git a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt index 3d18f839..3dc2f100 100644 --- a/data/src/main/java/org/monogram/data/gateway/TdLibException.kt +++ b/data/src/main/java/org/monogram/data/gateway/TdLibException.kt @@ -4,6 +4,39 @@ import org.drinkless.tdlib.TdApi class TdLibException(val error: TdApi.Error) : Exception(error.message) +private val proxyResolveHostErrors = listOf( + "failed to resolve host", + "no address associated with hostname" +) + +private val proxyConnectivityErrors = listOf( + "response hash mismatch", + "connection refused", + "network is unreachable", + "timed out" +) + +fun TdApi.Error.isExpectedProxyFailure(): Boolean { + val text = message.orEmpty().lowercase() + return proxyResolveHostErrors.any(text::contains) || proxyConnectivityErrors.any(text::contains) +} + +fun Throwable.isExpectedProxyFailure(): Boolean { + val tdError = (this as? TdLibException)?.error + return tdError?.isExpectedProxyFailure() == true +} + +fun Throwable.toProxyFailureMessage(): String? { + val text = (this as? TdLibException)?.error?.message?.lowercase() ?: return null + return when { + proxyResolveHostErrors.any(text::contains) -> "Proxy host can't be resolved" + text.contains("response hash mismatch") -> "Invalid MTProto secret" + text.contains("connection refused") -> "Proxy connection refused" + text.contains("network is unreachable") || text.contains("timed out") -> "Proxy is unreachable" + else -> null + } +} + fun Throwable.toUserMessage(defaultMessage: String = "Unknown error"): String { val tdMessage = (this as? TdLibException)?.error?.message.orEmpty() return tdMessage.ifEmpty { message ?: defaultMessage } diff --git a/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt b/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt index 87018d60..b8efba41 100644 --- a/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/ProxyMapper.kt @@ -3,6 +3,7 @@ package org.monogram.data.mapper import org.drinkless.tdlib.TdApi import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.proxy.MtprotoSecretNormalizer fun TdApi.AddedProxy.toDomain(): ProxyModel = ProxyModel( id = id, @@ -23,5 +24,9 @@ fun TdApi.ProxyType.toDomain(): ProxyTypeModel = when (this) { fun ProxyTypeModel.toApi(): TdApi.ProxyType = when (this) { is ProxyTypeModel.Socks5 -> TdApi.ProxyTypeSocks5(username, password) is ProxyTypeModel.Http -> TdApi.ProxyTypeHttp(username, password, httpOnly) - is ProxyTypeModel.Mtproto -> TdApi.ProxyTypeMtproto(secret) + is ProxyTypeModel.Mtproto -> { + val normalized = MtprotoSecretNormalizer.normalize(secret) + ?: throw IllegalArgumentException("Invalid MTProto proxy secret") + TdApi.ProxyTypeMtproto(normalized) + } } diff --git a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt index 3e622906..10fc6da7 100644 --- a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt @@ -1,12 +1,14 @@ package org.monogram.data.repository -import kotlinx.coroutines.* +import kotlinx.coroutines.withTimeoutOrNull import org.monogram.data.core.coRunCatching import org.monogram.data.datasource.remote.ProxyRemoteDataSource +import org.monogram.data.gateway.toProxyFailureMessage import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.ProxyTestResult class ExternalProxyRepositoryImpl( private val remote: ProxyRemoteDataSource, @@ -50,17 +52,51 @@ class ExternalProxyRepositoryImpl( }.getOrDefault(false) override suspend fun pingProxy(proxyId: Int): Long? = withTimeoutOrNull(10_000L) { + when (val result = pingProxyDetailed(proxyId)) { + is ProxyTestResult.Success -> result.ping + is ProxyTestResult.Failure -> null + } + } + + override suspend fun pingProxyDetailed(proxyId: Int): ProxyTestResult = + withTimeoutOrNull(10_000L) { coRunCatching { - val proxy = remote.getProxies().find { it.id == proxyId } ?: return@withTimeoutOrNull null + val proxy = remote.getProxies().find { it.id == proxyId } ?: return@coRunCatching null remote.pingProxy(proxy.server, proxy.port, proxy.type) - }.getOrNull() - } + }.fold( + onSuccess = { ping -> + if (ping == null) ProxyTestResult.Failure("Proxy is unreachable") + else ProxyTestResult.Success(ping) + }, + onFailure = { + ProxyTestResult.Failure(it.toProxyFailureMessage() ?: "Proxy is unreachable") + } + ) + } ?: ProxyTestResult.Failure("Proxy is unreachable") override suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long? = - withTimeoutOrNull(10_000L) { - coRunCatching { remote.testProxy(server, port, type) }.getOrNull() + when (val result = testProxyDetailed(server, port, type)) { + is ProxyTestResult.Success -> result.ping + is ProxyTestResult.Failure -> null } + override suspend fun testProxyDetailed( + server: String, + port: Int, + type: ProxyTypeModel + ): ProxyTestResult = + withTimeoutOrNull(10_000L) { + coRunCatching { remote.testProxy(server, port, type) } + .fold( + onSuccess = { ProxyTestResult.Success(it) }, + onFailure = { + ProxyTestResult.Failure( + it.toProxyFailureMessage() ?: "Proxy is unreachable" + ) + } + ) + } ?: ProxyTestResult.Failure("Proxy is unreachable") + override fun setPreferIpv6(enabled: Boolean) { appPreferences.setPreferIpv6(enabled) } diff --git a/data/src/main/java/org/monogram/data/repository/LinkParser.kt b/data/src/main/java/org/monogram/data/repository/LinkParser.kt index b7019054..6c77602a 100644 --- a/data/src/main/java/org/monogram/data/repository/LinkParser.kt +++ b/data/src/main/java/org/monogram/data/repository/LinkParser.kt @@ -3,6 +3,7 @@ package org.monogram.data.repository import androidx.core.net.toUri import org.monogram.data.core.coRunCatching import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.proxy.MtprotoSecretNormalizer class LinkParser { @@ -89,7 +90,10 @@ class LinkParser { val pass = uri.getQueryParameter("pass") val type = when { - secret != null -> ProxyTypeModel.Mtproto(secret) + secret != null -> { + val normalized = MtprotoSecretNormalizer.normalize(secret) ?: return null + ProxyTypeModel.Mtproto(normalized) + } isHttp -> ProxyTypeModel.Http(user ?: "", pass ?: "", false) else -> ProxyTypeModel.Socks5(user ?: "", pass ?: "") } diff --git a/data/src/test/java/org/monogram/data/proxy/MtprotoSecretNormalizerTest.kt b/data/src/test/java/org/monogram/data/proxy/MtprotoSecretNormalizerTest.kt new file mode 100644 index 00000000..19c42bea --- /dev/null +++ b/data/src/test/java/org/monogram/data/proxy/MtprotoSecretNormalizerTest.kt @@ -0,0 +1,45 @@ +package org.monogram.data.proxy + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.monogram.domain.proxy.MtprotoSecretNormalizer + +class MtprotoSecretNormalizerTest { + + @Test + fun `normalize keeps valid hex and lowercases it`() { + val normalized = MtprotoSecretNormalizer.normalize("A1B2c3D4") + assertEquals("a1b2c3d4", normalized) + } + + @Test + fun `normalize decodes base64url and converts to hex`() { + val normalized = MtprotoSecretNormalizer.normalize("7v0mvBQ9yQrMNLtAYp5lHI1wZXRyb3ZpY2gucg") + assertEquals("eefd26bc143dc90acc34bb40629e651c8d706574726f766963682e72", normalized) + } + + @Test + fun `normalize decodes base64 with padding and converts to hex`() { + val normalized = MtprotoSecretNormalizer.normalize("AAE=") + assertEquals("0001", normalized) + } + + @Test + fun `normalize rejects odd-length hex`() { + val normalized = MtprotoSecretNormalizer.normalize("abc") + assertNull(normalized) + } + + @Test + fun `normalize rejects invalid secret`() { + val normalized = MtprotoSecretNormalizer.normalize("not a secret !") + assertNull(normalized) + } + + @Test + fun `isValid returns false for blank input`() { + assertFalse(MtprotoSecretNormalizer.isValid(" ")) + } +} diff --git a/domain/src/main/java/org/monogram/domain/proxy/MtprotoSecretNormalizer.kt b/domain/src/main/java/org/monogram/domain/proxy/MtprotoSecretNormalizer.kt new file mode 100644 index 00000000..6daf6977 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/proxy/MtprotoSecretNormalizer.kt @@ -0,0 +1,43 @@ +package org.monogram.domain.proxy + +import java.util.Base64 + +object MtprotoSecretNormalizer { + private val hexRegex = Regex("^[0-9a-fA-F]+$") + + fun normalize(secret: String): String? { + val candidate = secret.trim() + if (candidate.isEmpty()) return null + + if (hexRegex.matches(candidate)) { + if (candidate.length % 2 != 0) return null + return candidate.lowercase() + } + + decodeBase64Like(candidate)?.let { decoded -> + if (decoded.isNotEmpty()) return decoded.toHexLowercase() + } + + return null + } + + fun isValid(secret: String): Boolean = normalize(secret) != null + + private fun decodeBase64Like(value: String): ByteArray? { + val padded = value.padEnd(value.length + ((4 - value.length % 4) % 4), '=') + return runCatching { Base64.getUrlDecoder().decode(padded) } + .recoverCatching { Base64.getDecoder().decode(padded) } + .getOrNull() + } + + private fun ByteArray.toHexLowercase(): String { + val hexDigits = "0123456789abcdef" + val chars = CharArray(size * 2) + forEachIndexed { index, byte -> + val value = byte.toInt() and 0xFF + chars[index * 2] = hexDigits[value ushr 4] + chars[index * 2 + 1] = hexDigits[value and 0x0F] + } + return String(chars) + } +} 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 5de4c8ce..e45ab9c9 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -41,11 +41,7 @@ data class ProxyNetworkRule( ) fun defaultProxyNetworkMode(networkType: ProxyNetworkType): ProxyNetworkMode { - return if (networkType == ProxyNetworkType.VPN) { - ProxyNetworkMode.DIRECT - } else { - ProxyNetworkMode.BEST_PROXY - } + return ProxyNetworkMode.BEST_PROXY } interface AppPreferencesProvider { diff --git a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt index 6678d0a5..af7d7be4 100644 --- a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt @@ -3,6 +3,11 @@ package org.monogram.domain.repository import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +sealed interface ProxyTestResult { + data class Success(val ping: Long) : ProxyTestResult + data class Failure(val message: String) : ProxyTestResult +} + interface ExternalProxyRepository { suspend fun getProxies(): List suspend fun addProxy(server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel? @@ -11,6 +16,8 @@ interface ExternalProxyRepository { suspend fun disableProxy(): Boolean suspend fun removeProxy(proxyId: Int): Boolean suspend fun pingProxy(proxyId: Int): Long? + suspend fun pingProxyDetailed(proxyId: Int): ProxyTestResult suspend fun testProxy(server: String, port: Int, type: ProxyTypeModel): Long? + suspend fun testProxyDetailed(server: String, port: Int, type: ProxyTypeModel): ProxyTestResult fun setPreferIpv6(enabled: Boolean) } 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 27d1790d..9af327cc 100644 --- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt @@ -4,12 +4,27 @@ package org.monogram.presentation.root import android.os.Parcelable import android.util.Log import com.arkivanov.decompose.DelicateDecomposeApi -import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.bringToFront +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.navigate +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.popWhile +import com.arkivanov.decompose.router.stack.push +import com.arkivanov.decompose.router.stack.replaceAll import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize @@ -17,7 +32,19 @@ import kotlinx.serialization.Serializable import org.monogram.domain.managers.PhoneManager import org.monogram.domain.models.MessageContent import org.monogram.domain.models.ProxyTypeModel -import org.monogram.domain.repository.* +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.AuthStep +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.LinkAction +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.UpdateRepository +import org.monogram.domain.repository.UserRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.core.util.coRunCatching @@ -28,7 +55,11 @@ import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent import org.monogram.presentation.features.chats.currentChat.components.VideoPlayerPool import org.monogram.presentation.features.chats.newChat.DefaultNewChatComponent import org.monogram.presentation.features.profile.DefaultProfileComponent -import org.monogram.presentation.features.profile.admin.* +import org.monogram.presentation.features.profile.admin.DefaultAdminManageComponent +import org.monogram.presentation.features.profile.admin.DefaultChatEditComponent +import org.monogram.presentation.features.profile.admin.DefaultChatPermissionsComponent +import org.monogram.presentation.features.profile.admin.DefaultMemberListComponent +import org.monogram.presentation.features.profile.admin.MemberListComponent import org.monogram.presentation.features.profile.logs.DefaultProfileLogsComponent import org.monogram.presentation.features.stickers.core.toUi import org.monogram.presentation.features.webview.DefaultWebViewComponent @@ -299,9 +330,13 @@ class DefaultRootComponent( override fun confirmProxy(server: String, port: Int, type: ProxyTypeModel) { scope.launch { - externalProxyRepository.addProxy(server, port, true, type) + val proxy = externalProxyRepository.addProxy(server, port, true, type) dismissProxyConfirm() - messageDisplayer.show("Proxy added and enabled") + if (proxy != null) { + messageDisplayer.show("Proxy added and enabled") + } else { + messageDisplayer.show("Failed to add proxy") + } } } diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt index 97e31afe..c0577d40 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray import org.json.JSONObject import org.monogram.domain.models.ProxyModel @@ -21,6 +20,7 @@ import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType import org.monogram.domain.repository.ProxySortMode +import org.monogram.domain.repository.ProxyTestResult import org.monogram.domain.repository.ProxyUnavailableFallback import org.monogram.domain.repository.defaultProxyNetworkMode import org.monogram.presentation.core.util.componentScope @@ -79,6 +79,8 @@ interface ProxyComponent { val proxyToEdit: ProxyModel? = null, val proxyToDelete: ProxyModel? = null, val testPing: Long? = null, + val testError: String? = null, + val proxyErrors: Map = emptyMap(), val isTesting: Boolean = false, val toastMessage: String? = null, val showClearOfflineConfirmation: Boolean = false, @@ -98,6 +100,17 @@ class DefaultProxyComponent( override val state: Value = _state private val scope = componentScope private var restoreAttempted = false + private var lastToastMessage: String? = null + private var lastToastAtMs: Long = 0L + + private fun showToastThrottled(message: String, throttleMs: Long = 1500L) { + val now = System.currentTimeMillis() + val isDuplicateTooSoon = lastToastMessage == message && (now - lastToastAtMs) < throttleMs + if (isDuplicateTooSoon) return + lastToastMessage = message + lastToastAtMs = now + _state.update { it.copy(toastMessage = message) } + } init { scope.launch { @@ -177,9 +190,10 @@ class DefaultProxyComponent( private suspend fun refreshProxies(shouldPing: Boolean = false) { _state.update { it.copy(isLoading = true) } - restoreUserProxiesIfNeeded() - val allProxies = externalProxyRepository.getProxies() + val restoredProxies = restoreUserProxiesIfNeeded() + val allProxies = externalProxyRepository.getProxies().ifEmpty { restoredProxies } _state.update { + val availableIds = allProxies.mapTo(HashSet()) { proxy -> proxy.id } it.copy( proxies = allProxies, visibleProxies = buildVisibleProxies( @@ -188,6 +202,7 @@ class DefaultProxyComponent( it.hideOfflineProxies, it.favoriteProxyId ), + proxyErrors = it.proxyErrors.filterKeys { id -> id in availableIds }, isLoading = false ) } @@ -269,17 +284,50 @@ class DefaultProxyComponent( } } - private suspend fun restoreUserProxiesIfNeeded() { - if (restoreAttempted) return - restoreAttempted = true + private fun upsertProxyLocally( + proxy: ProxyModel, + replaceId: Int? = null, + closeEditor: Boolean = false + ) { + _state.update { current -> + val existingId = replaceId ?: proxy.id + val withoutOld = current.proxies.filterNot { it.id == existingId || it.id == proxy.id } + val updatedProxies = withoutOld + proxy + val updatedErrors = current.proxyErrors.toMutableMap().apply { + remove(existingId) + remove(proxy.id) + } + current.copy( + proxies = updatedProxies, + visibleProxies = buildVisibleProxies( + updatedProxies, + current.proxySortMode, + current.hideOfflineProxies, + current.favoriteProxyId + ), + proxyErrors = updatedErrors, + isAddingProxy = false, + proxyToEdit = if (closeEditor) null else current.proxyToEdit + ) + } + } + + private suspend fun restoreUserProxiesIfNeeded(): List { + if (restoreAttempted) return emptyList() val backups = appPreferences.userProxyBackups.value - if (backups.isEmpty()) return + if (backups.isEmpty()) { + restoreAttempted = true + return emptyList() + } val existing = externalProxyRepository.getProxies() - if (existing.isNotEmpty()) return + if (existing.isNotEmpty()) { + restoreAttempted = true + return emptyList() + } - backups.mapNotNull { parseProxyBackup(it) }.forEach { backup -> + val restored = backups.mapNotNull { parseProxyBackup(it) }.mapNotNull { backup -> externalProxyRepository.addProxy( server = backup.server, port = backup.port, @@ -287,6 +335,8 @@ class DefaultProxyComponent( type = backup.type ) } + restoreAttempted = true + return restored } private fun addProxyToBackup(proxy: ProxyModel) { @@ -428,11 +478,27 @@ class DefaultProxyComponent( override fun onBackClicked() = onBack() override fun onAddProxyClicked() { - _state.update { it.copy(isAddingProxy = true, proxyToEdit = null, testPing = null, isTesting = false) } + _state.update { + it.copy( + isAddingProxy = true, + proxyToEdit = null, + testPing = null, + testError = null, + isTesting = false + ) + } } override fun onEditProxyClicked(proxy: ProxyModel) { - _state.update { it.copy(proxyToEdit = proxy, isAddingProxy = false, testPing = null, isTesting = false) } + _state.update { + it.copy( + proxyToEdit = proxy, + isAddingProxy = false, + testPing = null, + testError = null, + isTesting = false + ) + } } override fun onProxyClicked(proxy: ProxyModel) { @@ -576,21 +642,31 @@ class DefaultProxyComponent( private suspend fun performPingAll() { val allProxies = _state.value.proxies - val pings = coroutineScope { + val pingResults = coroutineScope { allProxies.map { proxy -> proxy.id to async { - withTimeoutOrNull(5000) { - externalProxyRepository.pingProxy(proxy.id) - } ?: -1L + externalProxyRepository.pingProxyDetailed(proxy.id) } }.associate { (id, job) -> id to job.await() } } val updatedProxies = _state.value.proxies.map { proxy -> - pings[proxy.id]?.let { proxy.copy(ping = it) } ?: proxy + when (val result = pingResults[proxy.id]) { + is ProxyTestResult.Success -> proxy.copy(ping = result.ping) + is ProxyTestResult.Failure -> proxy.copy(ping = -1L) + else -> proxy + } } _state.update { + val updatedErrors = it.proxyErrors.toMutableMap() + pingResults.forEach { (proxyId, result) -> + if (result is ProxyTestResult.Failure) { + updatedErrors[proxyId] = result.message + } else { + updatedErrors.remove(proxyId) + } + } it.copy( proxies = updatedProxies, visibleProxies = buildVisibleProxies( @@ -598,22 +674,26 @@ class DefaultProxyComponent( it.proxySortMode, it.hideOfflineProxies, it.favoriteProxyId - ) + ), + proxyErrors = updatedErrors ) } } override fun onPingProxy(proxyId: Int) { scope.launch { - val ping = withTimeoutOrNull(5000) { - externalProxyRepository.pingProxy(proxyId) - } ?: -1L + val result = externalProxyRepository.pingProxyDetailed(proxyId) + val ping = if (result is ProxyTestResult.Success) result.ping else -1L + val errorMessage = (result as? ProxyTestResult.Failure)?.message val updatedProxies = _state.value.proxies.map { if (it.id == proxyId) it.copy(ping = ping) else it } _state.update { + val updatedErrors = it.proxyErrors.toMutableMap() + if (errorMessage != null) updatedErrors[proxyId] = + errorMessage else updatedErrors.remove(proxyId) it.copy( proxies = updatedProxies, visibleProxies = buildVisibleProxies( @@ -621,19 +701,37 @@ class DefaultProxyComponent( it.proxySortMode, it.hideOfflineProxies, it.favoriteProxyId - ) + ), + proxyErrors = updatedErrors ) } } } override fun onTestProxy(server: String, port: Int, type: ProxyTypeModel) { - _state.update { it.copy(isTesting = true, testPing = null) } + _state.update { it.copy(isTesting = true, testPing = null, testError = null) } scope.launch { - val ping = withTimeoutOrNull(10000) { - externalProxyRepository.testProxy(server, port, type) - } ?: -1L - _state.update { it.copy(isTesting = false, testPing = ping) } + when (val result = externalProxyRepository.testProxyDetailed(server, port, type)) { + is ProxyTestResult.Success -> { + _state.update { + it.copy( + isTesting = false, + testPing = result.ping, + testError = null + ) + } + } + + is ProxyTestResult.Failure -> { + _state.update { + it.copy( + isTesting = false, + testPing = -1L, + testError = result.message + ) + } + } + } } } @@ -645,9 +743,10 @@ class DefaultProxyComponent( ProxyNetworkType.entries.forEach { networkType -> appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) } - _state.update { it.copy(isAddingProxy = false) } - refreshProxies(shouldPing = false) + upsertProxyLocally(proxy) onPingProxy(proxy.id) + } else { + showToastThrottled("Failed to add proxy") } } } @@ -666,9 +765,10 @@ class DefaultProxyComponent( appPreferences.setLastUsedProxyIdForNetwork(networkType, proxy.id) } } - _state.update { it.copy(proxyToEdit = null) } - refreshProxies(shouldPing = false) + upsertProxyLocally(proxy, replaceId = proxyId, closeEditor = true) onPingProxy(proxy.id) + } else { + showToastThrottled("Failed to save proxy") } } } @@ -701,7 +801,15 @@ class DefaultProxyComponent( } override fun onDismissAddEdit() { - _state.update { it.copy(isAddingProxy = false, proxyToEdit = null, testPing = null, isTesting = false) } + _state.update { + it.copy( + isAddingProxy = false, + proxyToEdit = null, + testPing = null, + testError = null, + isTesting = false + ) + } } override fun onAutoBestProxyToggled(enabled: Boolean) { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt index c4c80d95..bd6c53b0 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt @@ -129,6 +129,7 @@ import androidx.compose.ui.window.PopupProperties import com.arkivanov.decompose.extensions.compose.subscribeAsState import org.monogram.domain.models.ProxyModel import org.monogram.domain.models.ProxyTypeModel +import org.monogram.domain.proxy.MtprotoSecretNormalizer import org.monogram.domain.repository.ProxyNetworkMode import org.monogram.domain.repository.ProxyNetworkRule import org.monogram.domain.repository.ProxyNetworkType @@ -592,6 +593,7 @@ fun ProxyContent(component: ProxyComponent) { ) { ProxyItem( proxy = proxy, + errorMessage = state.proxyErrors[proxy.id], isFavorite = state.favoriteProxyId == proxy.id, position = position, onClick = { component.onProxyClicked(proxy) }, @@ -693,6 +695,7 @@ fun ProxyContent(component: ProxyComponent) { onDismiss = component::onDismissAddEdit, onTest = component::onTestProxy, testPing = state.testPing, + testError = state.testError, isTesting = state.isTesting, isFavorite = state.proxyToEdit?.id == state.favoriteProxyId, onToggleFavorite = { @@ -928,6 +931,7 @@ private fun proxyToDeepLink(proxy: ProxyModel): String { @Composable fun ProxyItem( proxy: ProxyModel, + errorMessage: String?, isFavorite: Boolean, position: ItemPosition, onClick: () -> Unit, @@ -1042,6 +1046,16 @@ fun ProxyItem( maxLines = 1 ) } + if (!errorMessage.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = errorMessage, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } Column(horizontalAlignment = Alignment.End) { @@ -1159,6 +1173,7 @@ fun ProxyAddEditSheet( onDismiss: () -> Unit, onTest: (String, Int, ProxyTypeModel) -> Unit, testPing: Long?, + testError: String?, isTesting: Boolean, isFavorite: Boolean, onToggleFavorite: () -> Unit, @@ -1201,15 +1216,18 @@ fun ProxyAddEditSheet( ) } - val currentProxyType = remember(type, secret, username, password) { + val normalizedMtprotoSecret = remember(secret) { MtprotoSecretNormalizer.normalize(secret) } + + val currentProxyType = remember(type, normalizedMtprotoSecret, secret, username, password) { when (type) { - "MTProto" -> ProxyTypeModel.Mtproto(secret) + "MTProto" -> ProxyTypeModel.Mtproto(normalizedMtprotoSecret ?: secret.trim()) "SOCKS5" -> ProxyTypeModel.Socks5(username, password) else -> ProxyTypeModel.Http(username, password, false) } } - val isInputValid = server.isNotBlank() && port.isNotBlank() && (type != "MTProto" || secret.isNotBlank()) + val isInputValid = + server.isNotBlank() && port.isNotBlank() && (type != "MTProto" || normalizedMtprotoSecret != null) ModalBottomSheet( onDismissRequest = onDismiss, @@ -1393,6 +1411,17 @@ fun ProxyAddEditSheet( } } + if (!testError.isNullOrBlank()) { + Text( + text = testError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 3f20c15c..33604687 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -550,7 +550,7 @@ Edit Proxy Server Address Port - Secret (Hex) + Secret Username (Optional) Password (Optional) Save From 6735e446a225475e57e6a483a2a04f4af66e8883 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:05:41 +0300 Subject: [PATCH 74/83] improve notification handling and background service reliability - filter out outgoing messages from notifications by tracking and verifying the current user's ID - refine chat mute logic to correctly handle default notification scope settings - optimize notification performance by pre-rendering messages with local data and preloading assets asynchronously - improve `FcmPushService` robustness with explicit wake lock management, JSON validation, and processing timeouts - fix `TdNotificationService` shutdown logic to ensure wake locks are released and monitoring jobs are cancelled - simplify `NotificationReplyReceiver` by removing redundant chat fetching and manual notification updates after a reply - enhance membership checks and sender resolution with improved caching and timeout handling --- .../monogram/data/di/TdNotificationManager.kt | 185 ++++++++++++------ .../monogram/data/service/FcmPushService.kt | 63 +++--- .../data/service/NotificationReplyReceiver.kt | 15 -- .../data/service/TdNotificationService.kt | 21 +- 4 files changed, 177 insertions(+), 107 deletions(-) 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 62f55b52..dad7bc6a 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -8,20 +8,36 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.graphics.* +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Shader +import android.graphics.Typeface import android.os.Build import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.StyleSpan import android.util.Log import android.util.LruCache -import androidx.core.app.* +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.toBitmap import com.google.firebase.messaging.FirebaseMessaging -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull import org.drinkless.tdlib.TdApi import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.NotificationSettingDao @@ -65,6 +81,9 @@ class TdNotificationManager( private val scopeNotificationsEnabled = ConcurrentHashMap() private val loadedScopeSettings = ConcurrentHashMap.newKeySet() + @Volatile + private var myUserId: Long = 0L + private enum class NotificationScopeKey { PRIVATE, GROUPS, @@ -106,6 +125,7 @@ class TdNotificationManager( if (authenticated) { loadedScopeSettings.clear() scopeNotificationsEnabled.clear() + refreshMyUserId() fetchScopeNotificationSettings() fetchInitialExceptions() updatePushRegistration() @@ -155,7 +175,13 @@ class TdNotificationManager( } is TdApi.UpdateOption -> { if (update.name == "is_authenticated" && (update.value as? TdApi.OptionValueBoolean)?.value == true) { + refreshMyUserId() updatePushRegistration() + } else if (update.name == "my_id") { + val id = (update.value as? TdApi.OptionValueInteger)?.value ?: 0L + if (id > 0L) { + myUserId = id + } } } else -> {} @@ -266,11 +292,11 @@ class TdNotificationManager( fun isChatMuted(chat: TdApi.Chat): Boolean { val cached = notificationSettingsCache[chat.id] val chatSettings = chat.notificationSettings - val muteFor = cached?.muteFor ?: chatSettings?.muteFor ?: return true - val useDefault = cached?.useDefault ?: chatSettings?.useDefaultMuteFor ?: return true + val muteFor = cached?.muteFor ?: chatSettings?.muteFor ?: 0 + val useDefault = cached?.useDefault ?: chatSettings?.useDefaultMuteFor ?: true return if (useDefault) { - val chatType = chat.type ?: return true + val chatType = chat.type ?: return muteFor > 0 val scopeKey = when (chatType) { is TdApi.ChatTypePrivate -> NotificationScopeKey.PRIVATE is TdApi.ChatTypeBasicGroup -> NotificationScopeKey.GROUPS @@ -281,11 +307,15 @@ class TdNotificationManager( else -> null } - if (scopeKey == null || !loadedScopeSettings.contains(scopeKey)) { - return true + if (scopeKey == null) { + return muteFor > 0 + } + + if (!loadedScopeSettings.contains(scopeKey)) { + return false } - val globalEnabled = scopeNotificationsEnabled[scopeKey] ?: false + val globalEnabled = scopeNotificationsEnabled[scopeKey] ?: true !globalEnabled } else { muteFor > 0 @@ -337,64 +367,58 @@ class TdNotificationManager( return } + if (senderId is TdApi.MessageSenderUser && senderId.userId != 0L && senderId.userId == myUserId) { + return + } + val lastId = lastMessageIds[message.chatId] if (lastId != null && message.id <= lastId) { return } lastMessageIds[message.chatId] = message.id - getChat(message.chatId) { chat -> - scope.launch { - val chatType = chat.type - if (chatType == null) { - Log.w(TAG, "Skipping notification for chat ${chat.id}: chat type is null") - return@launch - } - - val isMember = checkMembership(chat) - if (!isMember) { - Log.d(TAG, "Skipping notification for chat ${chat.id}: user is not a member") - return@launch - } + scope.launch { + val chat = getChatSuspend(message.chatId) ?: return@launch - if (isChatMuted(chat)) return@launch + val chatType = chat.type + if (chatType == null) { + Log.w(TAG, "Skipping notification for chat ${chat.id}: chat type is null") + return@launch + } - val contentText = - if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText(messageContent) + val isMember = withTimeoutOrNull(1_500L) { checkMembership(chat) } ?: true + if (!isMember) { + Log.d(TAG, "Skipping notification for chat ${chat.id}: user is not a member") + return@launch + } - if (contentText.isBlank()) return@launch + if (isChatMuted(chat)) return@launch - val timestamp = message.date.toLong() * 1000 + val contentText = + if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText( + messageContent + ) - val shouldDownloadAvatar = - !appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value + if (contentText.isBlank()) return@launch + + val timestamp = message.date.toLong() * 1000 + val shouldPreloadAvatar = + !appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value + + resolveSender(senderId, chat, true) { senderName, senderBitmap -> + appendMessageToNotification( + chatId = chat.id, + messageId = message.id, + chatType = chatType, + senderName = senderName, + senderBitmap = senderBitmap, + chatIcon = senderBitmap, + text = contentText, + timestamp = timestamp + ) - resolveSender(senderId, chat, !shouldDownloadAvatar) { senderName, senderBitmap -> - if (shouldDownloadAvatar) { - downloadAvatar(chat.photo, false) { chatIcon -> - appendMessageToNotification( - chatId = chat.id, - messageId = message.id, - chatType = chatType, - senderName = senderName, - senderBitmap = senderBitmap, - chatIcon = chatIcon ?: senderBitmap, - text = contentText, - timestamp = timestamp - ) - } - } else { - appendMessageToNotification( - chatId = chat.id, - messageId = message.id, - chatType = chatType, - senderName = senderName, - senderBitmap = senderBitmap, - chatIcon = senderBitmap, - text = contentText, - timestamp = timestamp - ) - } + if (shouldPreloadAvatar && senderBitmap == null) { + preloadNotificationAssets(senderId, chat) } } } @@ -781,6 +805,12 @@ class TdNotificationManager( } } + private suspend fun refreshMyUserId() { + myUserId = coRunCatching { + gateway.execute(TdApi.GetMe()).id + }.getOrDefault(myUserId) + } + private fun sanitizeSpoilers(formattedText: TdApi.FormattedText?): String { if (formattedText == null) return "" val text = formattedText.text.orEmpty() @@ -860,6 +890,28 @@ class TdNotificationManager( when (senderId) { is TdApi.MessageSenderUser -> { + if (onlyIfLocal) { + val user = userCache[senderId.userId] + if (user == null) { + callback(fallbackName, null) + return + } + + val fullName = listOf(user.firstName, user.lastName) + .filterNotNull() + .filter { it.isNotBlank() } + .joinToString(" ") + val name = + if (chat.type is TdApi.ChatTypePrivate) fallbackName else fullName.ifBlank { fallbackName } + val file = + user.profilePhoto?.small + ?: if (chat.type is TdApi.ChatTypePrivate) chat.photo?.small else null + downloadFile(file, true) { bitmap -> + callback(name, bitmap) + } + return + } + getUser(senderId.userId) { user -> val fullName = listOf(user.firstName, user.lastName) .filterNotNull() @@ -876,6 +928,20 @@ class TdNotificationManager( } is TdApi.MessageSenderChat -> { + if (onlyIfLocal) { + val senderChat = chatCache[senderId.chatId] + if (senderChat == null) { + callback(fallbackName, null) + return + } + + val name = senderChat.title?.takeIf { it.isNotBlank() } ?: fallbackName + downloadFile(senderChat.photo?.small, true) { bitmap -> + callback(name, bitmap) + } + return + } + getChat(senderId.chatId) { senderChat -> val name = senderChat.title?.takeIf { it.isNotBlank() } ?: fallbackName downloadFile(senderChat.photo?.small, onlyIfLocal) { bitmap -> @@ -892,12 +958,9 @@ class TdNotificationManager( } } - private fun downloadAvatar( - fileInfo: TdApi.ChatPhotoInfo?, - onlyIfLocal: Boolean = false, - callback: (Bitmap?) -> Unit - ) { - downloadFile(fileInfo?.small, onlyIfLocal, callback) + private fun preloadNotificationAssets(senderId: TdApi.MessageSender?, chat: TdApi.Chat) { + resolveSender(senderId, chat, false) { _, _ -> } + downloadFile(chat.photo?.small, false) { _ -> } } private fun downloadFile(file: TdApi.File?, onlyIfLocal: Boolean = false, callback: (Bitmap?) -> Unit) { diff --git a/data/src/main/java/org/monogram/data/service/FcmPushService.kt b/data/src/main/java/org/monogram/data/service/FcmPushService.kt index e310821e..272d3f76 100644 --- a/data/src/main/java/org/monogram/data/service/FcmPushService.kt +++ b/data/src/main/java/org/monogram/data/service/FcmPushService.kt @@ -4,7 +4,12 @@ import android.os.PowerManager import android.util.Log import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import org.drinkless.tdlib.TdApi import org.json.JSONObject import org.koin.android.ext.android.inject @@ -34,37 +39,43 @@ class FcmPushService : FirebaseMessagingService() { if (appPreferences.pushProvider.value != PushProvider.FCM) return val data = message.data - if (data.isNotEmpty()) { - val powerManager = getSystemService(POWER_SERVICE) as PowerManager - val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "monogram:FcmPushService") + if (data.isEmpty()) return - try { - val json = JSONObject() - for ((k, v) in data) { - json.put(k, v) + val powerManager = getSystemService(POWER_SERVICE) as? PowerManager ?: return + val wakeLock = + powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "monogram:FcmPushService") + .apply { + setReferenceCounted(false) } - val jsonPayload = json.toString() - wakeLock.acquire(10_000L) - scope.launch { - try { + try { + val json = JSONObject() + for ((k, v) in data) { + json.put(k, v) + } + val jsonPayload = json.toString() + if (jsonPayload.isBlank()) return + + wakeLock.acquire(10_000L) + scope.launch { + try { + withTimeout(8_000L) { gateway.execute(TdApi.ProcessPushNotification(jsonPayload)) - Log.d("FcmPushService", "ProcessPushNotification success") - delay(5000) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("FcmPushService", "Error processing push", e) - } finally { - if (wakeLock.isHeld) { - wakeLock.release() - } + } + Log.d("FcmPushService", "ProcessPushNotification success") + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("FcmPushService", "Error processing push", e) + } finally { + if (wakeLock.isHeld) { + wakeLock.release() } } - } catch (e: Exception) { - Log.e("FcmPushService", "Error processing push", e) - if (wakeLock.isHeld) { - wakeLock.release() - } + } + } catch (e: Exception) { + Log.e("FcmPushService", "Error preparing push payload", e) + if (wakeLock.isHeld) { + wakeLock.release() } } } diff --git a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt index adc8e7f1..ec33c595 100644 --- a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt +++ b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt @@ -10,13 +10,11 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.monogram.data.di.TdNotificationManager import org.monogram.data.gateway.TelegramGateway -import org.monogram.domain.repository.StringProvider class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { private val gateway: TelegramGateway by inject() private val notificationManager: TdNotificationManager by inject() - private val stringProvider: StringProvider by inject() override fun onReceive(context: Context, intent: Intent) { val chatId = intent.getLongExtra("chat_id", 0L) @@ -38,8 +36,6 @@ class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { runCatching { gateway.execute(actionTyping) } } - val chat = gateway.execute(TdApi.GetChat(chatId)) - val inputMessageContent = TdApi.InputMessageText().apply { this.text = TdApi.FormattedText(replyText, emptyArray()) this.clearDraft = true @@ -60,17 +56,6 @@ class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { if (notificationId != 0) { notificationManager.removeNotification(chatId, notificationId) } - - notificationManager.appendMessageToNotification( - chatId = chatId, - messageId = System.currentTimeMillis(), - chatType = chat.type, - senderName = stringProvider.getString("notification_person_me"), - senderBitmap = null, - chatIcon = null, - text = replyText, - timestamp = System.currentTimeMillis() - ) } catch (e: Exception) { e.printStackTrace() } diff --git a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt index ada8254f..40ba3df1 100644 --- a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt +++ b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt @@ -11,8 +11,15 @@ import android.os.IBinder import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.PushProvider @@ -38,7 +45,7 @@ class TdNotificationService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { - stopForegroundService() + stopForegroundService(userInitiated = true) return START_NOT_STICKY } @@ -159,9 +166,14 @@ class TdNotificationService : Service() { } } - private fun stopForegroundService() { + private fun stopForegroundService(userInitiated: Boolean = false) { isServiceRunning = false - appPreferences.setBackgroundServiceEnabled(false) + checkJob?.cancel() + checkJob = null + releaseWakeLock() + if (userInitiated) { + appPreferences.setBackgroundServiceEnabled(false) + } try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) @@ -191,7 +203,6 @@ class TdNotificationService : Service() { } if (isPowerSaving || !isWakeLockEnabled || isBatteryOptimization) { - delay(5000) releaseWakeLock() } else { if (isServiceRunning) { From d0d8b4c5b5e24b3160112ec0cd36f20a3e86c2ee Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:47:30 +0300 Subject: [PATCH 75/83] Implement UnifiedPush support (#244) #39 --- app/build.gradle.kts | 11 + app/src/main/AndroidManifest.xml | 6 + app/src/main/java/org/monogram/app/App.kt | 13 +- .../java/org/monogram/app/MainActivity.kt | 2 +- .../org/monogram/app/di/DistrManagerImpl.kt | 5 + data/build.gradle.kts | 1 + data/src/main/AndroidManifest.xml | 8 + .../monogram/data/chats/ChatModelFactory.kt | 37 ++- .../monogram/data/di/TdNotificationManager.kt | 221 +++++++++++---- .../java/org/monogram/data/di/dataModule.kt | 33 ++- .../notifications/NotificationMuteDecision.kt | 19 ++ .../notifications/NotificationMuteResolver.kt | 86 ++++++ .../notifications/NotificationScopeState.kt | 8 + .../org/monogram/data/push/PushSyncTrigger.kt | 62 +++++ .../monogram/data/push/UnifiedPushManager.kt | 169 +++++++++++ .../repository/PushDebugRepositoryImpl.kt | 139 ++++++++++ .../data/service/TdNotificationService.kt | 19 +- .../data/service/UnifiedPushService.kt | 80 ++++++ .../monogram/domain/managers/DistrManager.kt | 1 + .../repository/AppPreferencesProvider.kt | 2 +- .../domain/repository/PushDebugRepository.kt | 31 +++ gradle/libs.versions.toml | 2 + presentation/build.gradle.kts | 1 + .../presentation/core/util/AppPreferences.kt | 27 +- .../core/util/ContextExtensions.kt | 11 + .../monogram/presentation/di/AppContainer.kt | 53 +++- .../presentation/di/KoinAppContainer.kt | 53 +++- .../settings/debug/DebugComponent.kt | 27 +- .../settings/debug/DebugContent.kt | 262 ++++++++++++++++-- .../settings/debug/DefaultDebugComponent.kt | 44 +++ .../notifications/NotificationsComponent.kt | 21 +- .../notifications/NotificationsContent.kt | 88 +++++- presentation/src/main/res/values/string.xml | 7 + 33 files changed, 1438 insertions(+), 111 deletions(-) create mode 100644 data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt create mode 100644 data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt create mode 100644 data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt create mode 100644 data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt create mode 100644 data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt create mode 100644 data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt create mode 100644 data/src/main/java/org/monogram/data/service/UnifiedPushService.kt create mode 100644 domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff16f497..b319a197 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,6 +110,16 @@ androidComponents { } } +configurations.configureEach { + val tink = "com.google.crypto.tink:tink-android:1.21.0" + resolutionStrategy { + force(tink) + dependencySubstitution { + substitute(module("com.google.crypto.tink:tink")).using(module(tink)) + } + } +} + dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.androidx.compose) @@ -126,6 +136,7 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + implementation(libs.unifiedpush.connector) implementation(libs.maplibre.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d799dff8..f7cf541c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,6 +23,12 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> + + + + + + () val isGmsAvailable = distrManager.isGmsAvailable() val isFcmAvailable = distrManager.isFcmAvailable() + val isUnifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable() val prefs = get() - if (!(isGmsAvailable && isFcmAvailable) && prefs.pushProvider.value == PushProvider.FCM) { - prefs.setPushProvider(PushProvider.GMS_LESS) + val currentProvider = prefs.pushProvider.value + if (currentProvider == PushProvider.FCM && !(isGmsAvailable && isFcmAvailable)) { + val fallback = + if (isUnifiedPushAvailable) PushProvider.UNIFIED_PUSH else PushProvider.GMS_LESS + prefs.setPushProvider(fallback) + } else if (currentProvider == PushProvider.UNIFIED_PUSH && !isUnifiedPushAvailable) { + val fallback = + if (isGmsAvailable && isFcmAvailable) PushProvider.FCM else PushProvider.GMS_LESS + prefs.setPushProvider(fallback) } } diff --git a/app/src/main/java/org/monogram/app/MainActivity.kt b/app/src/main/java/org/monogram/app/MainActivity.kt index c051f5d3..163f28ef 100644 --- a/app/src/main/java/org/monogram/app/MainActivity.kt +++ b/app/src/main/java/org/monogram/app/MainActivity.kt @@ -107,7 +107,7 @@ class MainActivity : FragmentActivity() { } private fun startNotificationService() { - if (appPreferences.pushProvider.value == PushProvider.FCM) return + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) return val intent = Intent(this, TdNotificationService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(intent) diff --git a/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt b/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt index 3006cb30..0ea81ceb 100644 --- a/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt +++ b/app/src/main/java/org/monogram/app/di/DistrManagerImpl.kt @@ -6,6 +6,7 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.firebase.FirebaseApp import org.monogram.domain.managers.DistrManager +import org.unifiedpush.android.connector.UnifiedPush class DistrManagerImpl(private val context: Context) : DistrManager { override fun isGmsAvailable(): Boolean { @@ -16,6 +17,10 @@ class DistrManagerImpl(private val context: Context) : DistrManager { return FirebaseApp.getApps(context).isNotEmpty() } + override fun isUnifiedPushDistributorAvailable(): Boolean { + return UnifiedPush.getDistributors(context).isNotEmpty() + } + override fun isInstalledFromGooglePlay(): Boolean { val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 96b96568..c11bab91 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(libs.androidx.media3.datasource) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + implementation(libs.unifiedpush.connector) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml index 0be57ce1..8bf7ac1e 100644 --- a/data/src/main/AndroidManifest.xml +++ b/data/src/main/AndroidManifest.xml @@ -20,6 +20,14 @@ android:exported="false" /> + + + + + + 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 8bf1710a..ad8fa383 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatModelFactory.kt @@ -9,12 +9,19 @@ import org.monogram.core.DispatcherProvider import org.monogram.data.core.coRunCatching import org.monogram.data.db.dao.UserFullInfoDao import org.monogram.data.gateway.TelegramGateway -import org.monogram.data.mapper.* +import org.monogram.data.mapper.ChatMapper +import org.monogram.data.mapper.isForcedVerifiedChat +import org.monogram.data.mapper.isForcedVerifiedUser +import org.monogram.data.mapper.isSponsoredUser +import org.monogram.data.mapper.isValidFilePath import org.monogram.data.mapper.user.toEntity import org.monogram.data.mapper.user.toTdApi +import org.monogram.data.notifications.NotificationMuteResolver +import org.monogram.data.notifications.NotificationScopeState import org.monogram.domain.models.ChatModel import org.monogram.domain.models.UsernamesModel import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope import java.util.concurrent.ConcurrentHashMap class ChatModelFactory( @@ -27,6 +34,7 @@ class ChatModelFactory( private val typingManager: ChatTypingManager, private val appPreferences: AppPreferencesProvider, private val userFullInfoDao: UserFullInfoDao, + private val muteResolver: NotificationMuteResolver = NotificationMuteResolver(), private val triggerUpdate: (Long?) -> Unit, private val fetchUser: (Long) -> Unit ) { @@ -260,15 +268,24 @@ class ChatModelFactory( cache.usersCache[userId]?.firstName ?: run { fetchUser(userId); null } } - val isMuted = when { - chat.notificationSettings.muteFor > 0 -> true - chat.notificationSettings.useDefaultMuteFor -> when { - isChannel -> !appPreferences.channelsNotifications.value - isSupergroup || chat.type is TdApi.ChatTypeBasicGroup -> !appPreferences.groupsNotifications.value - else -> !appPreferences.privateChatsNotifications.value - } - else -> false - } + val scopeState = NotificationScopeState( + loadedScopes = setOf( + TdNotificationScope.PRIVATE_CHATS, + TdNotificationScope.GROUPS, + TdNotificationScope.CHANNELS + ), + enabledByScope = mapOf( + TdNotificationScope.PRIVATE_CHATS to appPreferences.privateChatsNotifications.value, + TdNotificationScope.GROUPS to appPreferences.groupsNotifications.value, + TdNotificationScope.CHANNELS to appPreferences.channelsNotifications.value + ) + ) + + val isMuted = muteResolver.resolve( + chat = chat, + cachedSettings = null, + scopeState = scopeState + ).isMuted return chatMapper.mapChatToModel( chat = chat, 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 dad7bc6a..f9b18ed7 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -44,6 +44,10 @@ import org.monogram.data.db.dao.NotificationSettingDao import org.monogram.data.db.model.NotificationSettingEntity import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.notifications.NotificationMuteDecision +import org.monogram.data.notifications.NotificationMuteResolver +import org.monogram.data.notifications.NotificationScopeState +import org.monogram.data.push.UnifiedPushManager import org.monogram.data.service.NotificationDismissReceiver import org.monogram.data.service.NotificationReadReceiver import org.monogram.data.service.NotificationReplyReceiver @@ -62,7 +66,9 @@ class TdNotificationManager( private val notificationSettingsRepository: NotificationSettingsRepository, private val notificationSettingDao: NotificationSettingDao, private val fileQueue: FileDownloadQueue, - private val stringProvider: StringProvider + private val stringProvider: StringProvider, + private val unifiedPushManager: UnifiedPushManager, + private val muteResolver: NotificationMuteResolver ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val notificationManager = NotificationManagerCompat.from(context) @@ -78,18 +84,12 @@ class TdNotificationManager( } private val activeDownloads = ConcurrentHashMap Unit>>() private val notificationSettingsCache = ConcurrentHashMap() - private val scopeNotificationsEnabled = ConcurrentHashMap() - private val loadedScopeSettings = ConcurrentHashMap.newKeySet() + private val scopeNotificationsEnabled = ConcurrentHashMap() + private val loadedScopeSettings = ConcurrentHashMap.newKeySet() @Volatile private var myUserId: Long = 0L - private enum class NotificationScopeKey { - PRIVATE, - GROUPS, - CHANNELS - } - companion object { private const val TAG = "TdNotificationManager" const val GROUP_CHATS = "group_chats" @@ -136,7 +136,15 @@ class TdNotificationManager( scope.launch { gateway.updates.collect { update -> when (update) { - is TdApi.UpdateNewMessage -> handleNewMessage(update.message) + is TdApi.UpdateNewMessage -> { + val senderDebug = senderIdToDebug(update.message.senderId) + Log.d( + TAG, + "UpdateNewMessage chatId=${update.message.chatId} messageId=${update.message.id} " + + "outgoing=${update.message.isOutgoing} sender=$senderDebug" + ) + handleNewMessage(update.message) + } is TdApi.UpdateUser -> userCache[update.user.id] = update.user is TdApi.UpdateFile -> { val file = update.file @@ -194,6 +202,18 @@ class TdNotificationManager( updatePushRegistration() } } + + scope.launch { + unifiedPushManager.endpoint.collect { + if (appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH && !it.isNullOrBlank()) { + Log.d( + TAG, + "UnifiedPush endpoint update observed, refreshing TDLib registration" + ) + updatePushRegistration() + } + } + } } private fun updateChatNotificationSettings(chatId: Long, settings: TdApi.ChatNotificationSettings) { @@ -214,6 +234,7 @@ class TdNotificationManager( when (appPreferences.pushProvider.value) { PushProvider.FCM -> { coRunCatching { + unifiedPushManager.unregister() val token = FirebaseMessaging.getInstance().token.await() gateway.execute( TdApi.RegisterDevice( @@ -221,17 +242,42 @@ class TdNotificationManager( longArrayOf() ) ) + Log.d(TAG, "RegisterDevice success for FCM") }.onFailure { Log.e(TAG, "FCM token registration failed", it) } } + PushProvider.UNIFIED_PUSH -> { + coRunCatching { + unifiedPushManager.ensureRegistered() + val endpoint = unifiedPushManager.endpoint.value + if (endpoint.isNullOrBlank()) { + Log.w(TAG, "UnifiedPush endpoint is not available yet") + return@coRunCatching + } + + gateway.execute( + TdApi.RegisterDevice( + TdApi.DeviceTokenSimplePush(endpoint), + longArrayOf() + ) + ) + Log.d( + TAG, + "RegisterDevice success for UnifiedPush endpoint=${endpoint.take(120)}" + ) + }.onFailure { Log.e(TAG, "UnifiedPush registration failed", it) } + } + PushProvider.GMS_LESS -> { coRunCatching { + unifiedPushManager.unregister() gateway.execute( TdApi.RegisterDevice( TdApi.DeviceTokenFirebaseCloudMessaging("", false), longArrayOf() ) ) + Log.d(TAG, "RegisterDevice success for GMS-less fallback") }.onFailure { Log.e(TAG, "GMS-less token registration failed", it) } } } @@ -269,57 +315,42 @@ class TdNotificationManager( if (!gateway.isAuthenticated.value) return val scopes = listOf( - NotificationScopeKey.PRIVATE to TdNotificationScope.PRIVATE_CHATS, - NotificationScopeKey.GROUPS to TdNotificationScope.GROUPS, - NotificationScopeKey.CHANNELS to TdNotificationScope.CHANNELS + TdNotificationScope.PRIVATE_CHATS, + TdNotificationScope.GROUPS, + TdNotificationScope.CHANNELS ) - scopes.forEach { (key, scope) -> + scopes.forEach { scope -> val enabled = coRunCatching { notificationSettingsRepository.getNotificationSettings(scope) } .getOrDefault(false) - scopeNotificationsEnabled[key] = enabled - loadedScopeSettings.add(key) + scopeNotificationsEnabled[scope] = enabled + loadedScopeSettings.add(scope) + + when (scope) { + TdNotificationScope.PRIVATE_CHATS -> appPreferences.setPrivateChatsNotifications( + enabled + ) - when (key) { - NotificationScopeKey.PRIVATE -> appPreferences.setPrivateChatsNotifications(enabled) - NotificationScopeKey.GROUPS -> appPreferences.setGroupsNotifications(enabled) - NotificationScopeKey.CHANNELS -> appPreferences.setChannelsNotifications(enabled) + TdNotificationScope.GROUPS -> appPreferences.setGroupsNotifications(enabled) + TdNotificationScope.CHANNELS -> appPreferences.setChannelsNotifications(enabled) } } } fun isChatMuted(chat: TdApi.Chat): Boolean { - val cached = notificationSettingsCache[chat.id] - val chatSettings = chat.notificationSettings - val muteFor = cached?.muteFor ?: chatSettings?.muteFor ?: 0 - val useDefault = cached?.useDefault ?: chatSettings?.useDefaultMuteFor ?: true - - return if (useDefault) { - val chatType = chat.type ?: return muteFor > 0 - val scopeKey = when (chatType) { - is TdApi.ChatTypePrivate -> NotificationScopeKey.PRIVATE - is TdApi.ChatTypeBasicGroup -> NotificationScopeKey.GROUPS - is TdApi.ChatTypeSupergroup -> { - if (chatType.isChannel) NotificationScopeKey.CHANNELS else NotificationScopeKey.GROUPS - } - - else -> null - } - - if (scopeKey == null) { - return muteFor > 0 - } - - if (!loadedScopeSettings.contains(scopeKey)) { - return false - } + return resolveMuteDecision(chat).isMuted + } - val globalEnabled = scopeNotificationsEnabled[scopeKey] ?: true - !globalEnabled - } else { - muteFor > 0 - } + private fun resolveMuteDecision(chat: TdApi.Chat): NotificationMuteDecision { + return muteResolver.resolve( + chat = chat, + cachedSettings = notificationSettingsCache[chat.id], + scopeState = NotificationScopeState( + loadedScopes = loadedScopeSettings.toSet(), + enabledByScope = scopeNotificationsEnabled.toMap() + ) + ) } fun clearHistory(chatId: Long) { @@ -353,7 +384,19 @@ class TdNotificationManager( } private fun handleNewMessage(message: TdApi.Message) { - if (message.isOutgoing) return + Log.d( + TAG, + "handleNewMessage enter chatId=${message.chatId} messageId=${message.id} outgoing=${message.isOutgoing} " + + "content=${message.content?.javaClass?.simpleName ?: "null"}" + ) + + if (message.isOutgoing) { + Log.d( + TAG, + "Skip notification: outgoing message, chatId=${message.chatId}, messageId=${message.id}" + ) + return + } val messageContent = message.content if (messageContent == null) { @@ -368,17 +411,32 @@ class TdNotificationManager( } if (senderId is TdApi.MessageSenderUser && senderId.userId != 0L && senderId.userId == myUserId) { + Log.d( + TAG, + "Skip notification: sender is self, chatId=${message.chatId}, messageId=${message.id}" + ) return } val lastId = lastMessageIds[message.chatId] if (lastId != null && message.id <= lastId) { + Log.d( + TAG, + "Skip notification: stale/duplicate message, chatId=${message.chatId}, messageId=${message.id}, lastId=$lastId" + ) return } lastMessageIds[message.chatId] = message.id scope.launch { - val chat = getChatSuspend(message.chatId) ?: return@launch + val chat = getChatSuspend(message.chatId) + if (chat == null) { + Log.d( + TAG, + "Skip notification: chat unavailable, chatId=${message.chatId}, messageId=${message.id}" + ) + return@launch + } val chatType = chat.type if (chatType == null) { @@ -392,20 +450,50 @@ class TdNotificationManager( return@launch } - if (isChatMuted(chat)) return@launch + val muteDecision = resolveMuteDecision(chat) + if (muteDecision.isMuted) { + Log.d( + TAG, + "Skip notification: muted reason=${muteDecision.reason} scope=${muteDecision.scope} " + + "muteFor=${muteDecision.muteFor} useDefault=${muteDecision.useDefault} " + + "chatId=${chat.id} messageId=${message.id}" + ) + return@launch + } val contentText = if (appPreferences.showSenderOnly.value) stringProvider.getString("notification_new_message") else getMessageText( messageContent ) - if (contentText.isBlank()) return@launch + if (contentText.isBlank()) { + Log.d( + TAG, + "Skip notification: empty content text, chatId=${chat.id}, messageId=${message.id}" + ) + return@launch + } val timestamp = message.date.toLong() * 1000 val shouldPreloadAvatar = !appPreferences.isPowerSavingMode.value && !appPreferences.batteryOptimizationEnabled.value resolveSender(senderId, chat, true) { senderName, senderBitmap -> + Log.d( + TAG, + "Resolved sender for notification chatId=${chat.id} messageId=${message.id} " + + "senderName=$senderName hasBitmap=${senderBitmap != null}" + ) + + Log.d( + TAG, + "Append notification chatId=${chat.id} messageId=${message.id} " + + "chatType=${chatType.javaClass.simpleName} text=${ + previewText( + contentText + ) + }" + ) appendMessageToNotification( chatId = chat.id, messageId = message.id, @@ -465,9 +553,16 @@ class TdNotificationManager( text: String, timestamp: Long ) { - if (text.isBlank()) return + if (text.isBlank()) { + Log.d( + TAG, + "Skip appendMessageToNotification: blank text, chatId=$chatId, messageId=$messageId" + ) + return + } if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "Skip appendMessageToNotification: missing POST_NOTIFICATIONS permission") return } @@ -500,6 +595,11 @@ class TdNotificationManager( history.removeAt(0) } + Log.d( + TAG, + "Notification history updated chatId=$chatId size=${history.size} notificationId=${chatId.toInt()}" + ) + val notificationId = chatId.toInt() activeNotifications.getOrPut(chatId) { ConcurrentHashMap.newKeySet() }.add(notificationId) @@ -628,9 +728,22 @@ class TdNotificationManager( } notificationManager.notify(notificationId, builder.build()) + Log.d(TAG, "Notification posted chatId=$chatId notificationId=$notificationId") updateSummary() } + private fun senderIdToDebug(senderId: TdApi.MessageSender?): String = when (senderId) { + null -> "null" + is TdApi.MessageSenderUser -> "user:${senderId.userId}" + is TdApi.MessageSenderChat -> "chat:${senderId.chatId}" + else -> senderId.javaClass.simpleName + } + + private fun previewText(text: String, max: Int = 80): String { + val normalized = text.replace('\n', ' ').trim() + return if (normalized.length <= max) normalized else normalized.take(max) + "..." + } + private fun getCircularBitmap(bitmap: Bitmap): Bitmap { val size = min(bitmap.width, bitmap.height) 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 d1c19d29..15c3ad88 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -81,6 +81,9 @@ import org.monogram.data.mapper.WebPageMapper import org.monogram.data.mapper.message.MessageContentMapper import org.monogram.data.mapper.message.MessagePersistenceMapper import org.monogram.data.mapper.message.MessageSenderResolver +import org.monogram.data.notifications.NotificationMuteResolver +import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.UnifiedPushManager import org.monogram.data.repository.AttachMenuBotRepositoryImpl import org.monogram.data.repository.AuthRepositoryImpl import org.monogram.data.repository.BotRepositoryImpl @@ -100,6 +103,7 @@ import org.monogram.data.repository.PollRepositoryImpl import org.monogram.data.repository.PremiumRepositoryImpl import org.monogram.data.repository.PrivacyRepositoryImpl import org.monogram.data.repository.ProfilePhotoRepositoryImpl +import org.monogram.data.repository.PushDebugRepositoryImpl import org.monogram.data.repository.SessionRepositoryImpl import org.monogram.data.repository.SponsorRepositoryImpl import org.monogram.data.repository.StickerRepositoryImpl @@ -140,6 +144,7 @@ import org.monogram.domain.repository.PollRepository import org.monogram.domain.repository.PremiumRepository import org.monogram.domain.repository.PrivacyRepository import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.PushDebugRepository import org.monogram.domain.repository.SessionRepository import org.monogram.domain.repository.SponsorRepository import org.monogram.domain.repository.StickerRepository @@ -485,6 +490,10 @@ val dataModule = module { ) } + single { PushSyncTrigger(connectionManager = get(), gateway = get()) } + single { UnifiedPushManager(androidContext()) } + single { NotificationMuteResolver() } + single { ChatsListRepositoryImpl( remoteDataSource = get(), @@ -796,5 +805,27 @@ val dataModule = module { ) } - single(createdAtStart = true) { TdNotificationManager(androidContext(), get(), get(), get(), get(), get(), get()) } + single { + PushDebugRepositoryImpl( + context = androidContext(), + appPreferences = get(), + unifiedPushManager = get(), + pushSyncTrigger = get(), + scope = get() + ) + } + + single(createdAtStart = true) { + TdNotificationManager( + androidContext(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } } diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt b/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt new file mode 100644 index 00000000..e669fe35 --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/NotificationMuteDecision.kt @@ -0,0 +1,19 @@ +package org.monogram.data.notifications + +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope + +data class NotificationMuteDecision( + val isMuted: Boolean, + val scope: TdNotificationScope?, + val reason: Reason, + val muteFor: Int, + val useDefault: Boolean +) { + enum class Reason { + CHAT_MUTED, + SCOPE_DISABLED, + NOT_MUTED, + SCOPE_NOT_LOADED, + UNKNOWN_SCOPE + } +} diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt b/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt new file mode 100644 index 00000000..6e80a158 --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/NotificationMuteResolver.kt @@ -0,0 +1,86 @@ +package org.monogram.data.notifications + +import org.drinkless.tdlib.TdApi +import org.monogram.data.db.model.NotificationSettingEntity +import org.monogram.data.notifications.NotificationMuteDecision.Reason +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope + +class NotificationMuteResolver { + + fun resolve( + chat: TdApi.Chat, + cachedSettings: NotificationSettingEntity?, + scopeState: NotificationScopeState + ): NotificationMuteDecision { + val muteFor = cachedSettings?.muteFor ?: chat.notificationSettings?.muteFor ?: 0 + val useDefault = + cachedSettings?.useDefault ?: chat.notificationSettings?.useDefaultMuteFor ?: true + + if (!useDefault) { + return if (muteFor > 0) { + NotificationMuteDecision( + isMuted = true, + scope = null, + reason = Reason.CHAT_MUTED, + muteFor = muteFor, + useDefault = false + ) + } else { + NotificationMuteDecision( + isMuted = false, + scope = null, + reason = Reason.NOT_MUTED, + muteFor = muteFor, + useDefault = false + ) + } + } + + val scope = resolveScope(chat.type) + if (scope == null) { + return NotificationMuteDecision( + isMuted = muteFor > 0, + scope = null, + reason = if (muteFor > 0) Reason.CHAT_MUTED else Reason.UNKNOWN_SCOPE, + muteFor = muteFor, + useDefault = true + ) + } + + if (!scopeState.loadedScopes.contains(scope)) { + return NotificationMuteDecision( + isMuted = false, + scope = scope, + reason = Reason.SCOPE_NOT_LOADED, + muteFor = muteFor, + useDefault = true + ) + } + + val enabled = scopeState.enabledByScope[scope] ?: true + return if (enabled) { + NotificationMuteDecision( + isMuted = false, + scope = scope, + reason = Reason.NOT_MUTED, + muteFor = muteFor, + useDefault = true + ) + } else { + NotificationMuteDecision( + isMuted = true, + scope = scope, + reason = Reason.SCOPE_DISABLED, + muteFor = muteFor, + useDefault = true + ) + } + } + + private fun resolveScope(chatType: TdApi.ChatType?): TdNotificationScope? = when (chatType) { + is TdApi.ChatTypePrivate -> TdNotificationScope.PRIVATE_CHATS + is TdApi.ChatTypeBasicGroup -> TdNotificationScope.GROUPS + is TdApi.ChatTypeSupergroup -> if (chatType.isChannel) TdNotificationScope.CHANNELS else TdNotificationScope.GROUPS + else -> null + } +} diff --git a/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt b/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt new file mode 100644 index 00000000..b80dc9ba --- /dev/null +++ b/data/src/main/java/org/monogram/data/notifications/NotificationScopeState.kt @@ -0,0 +1,8 @@ +package org.monogram.data.notifications + +import org.monogram.domain.repository.NotificationSettingsRepository.TdNotificationScope + +data class NotificationScopeState( + val loadedScopes: Set, + val enabledByScope: Map +) diff --git a/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt new file mode 100644 index 00000000..33b1cb4f --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/PushSyncTrigger.kt @@ -0,0 +1,62 @@ +package org.monogram.data.push + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.drinkless.tdlib.TdApi +import org.monogram.data.gateway.TelegramGateway +import org.monogram.data.infra.ConnectionManager +import java.util.concurrent.atomic.AtomicLong + +class PushSyncTrigger( + private val connectionManager: ConnectionManager, + private val gateway: TelegramGateway +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val lastSyncAt = AtomicLong(0L) + + fun requestSync(reason: String) { + val now = System.currentTimeMillis() + val previous = lastSyncAt.get() + if (now - previous < MIN_SYNC_GAP_MS) { + Log.d(TAG, "Skip push sync by rate limit: reason=$reason") + return + } + if (!lastSyncAt.compareAndSet(previous, now)) { + Log.d(TAG, "Skip push sync by CAS race: reason=$reason") + return + } + + scope.launch { + if (!gateway.isAuthenticated.value) { + Log.d(TAG, "Skip push sync: not authenticated, reason=$reason") + return@launch + } + + Log.d(TAG, "Triggering TDLib sync from push: reason=$reason") + connectionManager.retryConnection() + + delay(PUSH_SYNC_DELAY_MS) + + val me = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + runCatching { gateway.execute(TdApi.GetMe()) }.getOrNull() + } + if (me != null) { + Log.d(TAG, "Push sync probe success: me=${me.id}") + } else { + Log.w(TAG, "Push sync probe failed (GetMe timeout/error)") + } + } + } + + private companion object { + const val TAG = "PushSyncTrigger" + const val MIN_SYNC_GAP_MS = 1500L + const val PUSH_SYNC_DELAY_MS = 350L + const val REQUEST_TIMEOUT_MS = 5000L + } +} diff --git a/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt new file mode 100644 index 00000000..1cb5b33b --- /dev/null +++ b/data/src/main/java/org/monogram/data/push/UnifiedPushManager.kt @@ -0,0 +1,169 @@ +package org.monogram.data.push + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.ResolvedDistributor + +class UnifiedPushManager( + private val context: Context +) { + enum class Status { + IDLE, + REGISTERING, + REGISTERED, + FAILED, + UNREGISTERED + } + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val _endpoint = MutableStateFlow(loadEndpoint()) + val endpoint: StateFlow = _endpoint.asStateFlow() + + private val _status = MutableStateFlow(loadStatus()) + val status: StateFlow = _status.asStateFlow() + + fun isDistributorAvailable(): Boolean = UnifiedPush.getDistributors(context).isNotEmpty() + + fun getDistributors(): List = UnifiedPush.getDistributors(context) + + fun getSavedDistributor(): String? = UnifiedPush.getSavedDistributor(context) + + fun getAckDistributor(): String? = UnifiedPush.getAckDistributor(context) + + fun currentDistributor(): String? { + val distributors = getDistributors() + val ack = getAckDistributor() + if (!ack.isNullOrBlank() && distributors.contains(ack)) { + Log.d(TAG, "Select distributor from ack: $ack") + return ack + } + + val saved = getSavedDistributor() + if (!saved.isNullOrBlank() && distributors.contains(saved)) { + Log.d(TAG, "Select distributor from saved: $saved") + return saved + } + + val resolved = UnifiedPush.resolveDefaultDistributor(context) + if (resolved is ResolvedDistributor.Found && distributors.contains(resolved.packageName)) { + Log.d(TAG, "Select distributor from default resolver: ${resolved.packageName}") + return resolved.packageName + } + + val first = distributors.firstOrNull() + if (first != null) { + Log.d(TAG, "Select distributor fallback first installed: $first") + } + return first + } + + fun ensureRegistered(force: Boolean = false): Boolean { + val distributor = currentDistributor() ?: return false + val endpointKnown = !_endpoint.value.isNullOrBlank() + if (!force && endpointKnown && _status.value == Status.REGISTERED && !shouldRefreshRegistration()) { + Log.d(TAG, "Skip re-register: endpoint already known and fresh") + return true + } + + _status.value = Status.REGISTERING + markRegistrationAttempt() + Log.d( + TAG, + "Request UnifiedPush register: distributor=$distributor force=$force endpointKnown=$endpointKnown" + ) + + return runCatching { + UnifiedPush.saveDistributor(context, distributor) + UnifiedPush.register(context, INSTANCE_ID) + }.onFailure { + _status.value = Status.FAILED + Log.e(TAG, "Failed to request UnifiedPush registration", it) + }.isSuccess + } + + fun unregister() { + runCatching { + UnifiedPush.unregister(context, INSTANCE_ID) + }.onFailure { + Log.e(TAG, "Failed to request UnifiedPush unregister", it) + } + clearEndpoint() + _status.value = Status.UNREGISTERED + } + + fun onNewEndpoint(endpoint: PushEndpoint) { + onNewEndpoint(endpoint.url) + } + + fun onNewEndpoint(endpoint: String?) { + val value = endpoint?.trim().orEmpty() + if (value.isEmpty()) { + _status.value = Status.FAILED + return + } + + prefs.edit() + .putString(KEY_ENDPOINT, value) + .putLong(KEY_LAST_REGISTERED_AT, System.currentTimeMillis()) + .apply() + _endpoint.value = value + _status.value = Status.REGISTERED + Log.d(TAG, "UnifiedPush endpoint saved: ${value.take(140)}") + } + + fun onRegistrationFailed(reason: FailedReason?) { + _status.value = Status.FAILED + if (reason != null) { + Log.w(TAG, "UnifiedPush registration failed: $reason") + } + } + + fun onTempUnavailable() { + _status.value = Status.FAILED + Log.w(TAG, "UnifiedPush distributor temporarily unavailable") + } + + fun onUnregistered() { + clearEndpoint() + _status.value = Status.UNREGISTERED + } + + fun shouldRefreshRegistration(): Boolean { + val last = prefs.getLong(KEY_LAST_REGISTERED_AT, 0L) + if (last <= 0L) return true + return System.currentTimeMillis() - last >= REFRESH_INTERVAL_MS + } + + private fun clearEndpoint() { + prefs.edit().remove(KEY_ENDPOINT).apply() + _endpoint.value = null + } + + private fun markRegistrationAttempt() { + prefs.edit().putLong(KEY_LAST_REGISTER_ATTEMPT_AT, System.currentTimeMillis()).apply() + } + + private fun loadEndpoint(): String? = + prefs.getString(KEY_ENDPOINT, null)?.takeIf { it.isNotBlank() } + + private fun loadStatus(): Status { + return if (_endpoint.value.isNullOrBlank()) Status.IDLE else Status.REGISTERED + } + + private companion object { + const val TAG = "UnifiedPushManager" + const val PREFS_NAME = "unified_push_state" + const val KEY_ENDPOINT = "endpoint" + const val KEY_LAST_REGISTERED_AT = "last_registered_at" + const val KEY_LAST_REGISTER_ATTEMPT_AT = "last_register_attempt_at" + const val REFRESH_INTERVAL_MS = 24L * 60L * 60L * 1000L + const val INSTANCE_ID = "monogram_default" + } +} diff --git a/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt new file mode 100644 index 00000000..fc7b06ec --- /dev/null +++ b/data/src/main/java/org/monogram/data/repository/PushDebugRepositoryImpl.kt @@ -0,0 +1,139 @@ +package org.monogram.data.repository + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.UnifiedPushManager +import org.monogram.data.service.TdNotificationService +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.PushDebugRepository +import org.monogram.domain.repository.PushDiagnostics +import org.monogram.domain.repository.UnifiedPushDebugStatus + +class PushDebugRepositoryImpl( + private val context: Context, + private val appPreferences: AppPreferencesProvider, + private val unifiedPushManager: UnifiedPushManager, + private val pushSyncTrigger: PushSyncTrigger, + private val scope: CoroutineScope +) : PushDebugRepository { + + private val _diagnostics = MutableStateFlow(PushDiagnostics()) + override val diagnostics: StateFlow = _diagnostics + + init { + val prefsFlow = combine( + appPreferences.pushProvider, + appPreferences.backgroundServiceEnabled, + appPreferences.hideForegroundNotification, + appPreferences.isPowerSavingMode, + appPreferences.isWakeLockEnabled + ) { provider, bgEnabled, hideForeground, powerSaving, wakeLock -> + PrefSnapshot(provider, bgEnabled, hideForeground, powerSaving, wakeLock) + } + + scope.launch { + combine( + prefsFlow, + appPreferences.batteryOptimizationEnabled, + TdNotificationService.isRunningFlow, + unifiedPushManager.status, + unifiedPushManager.endpoint + ) { prefs, batteryOpt, serviceRunning, unifiedStatus, endpoint -> + PushDiagnostics( + pushProvider = prefs.pushProvider, + backgroundServiceEnabled = prefs.backgroundServiceEnabled, + hideForegroundNotification = prefs.hideForegroundNotification, + isPowerSavingMode = prefs.isPowerSavingMode, + isWakeLockEnabled = prefs.isWakeLockEnabled, + batteryOptimizationEnabled = batteryOpt, + isTdNotificationServiceRunning = serviceRunning, + unifiedPushStatus = when (unifiedStatus) { + UnifiedPushManager.Status.IDLE -> UnifiedPushDebugStatus.IDLE + UnifiedPushManager.Status.REGISTERING -> UnifiedPushDebugStatus.REGISTERING + UnifiedPushManager.Status.REGISTERED -> UnifiedPushDebugStatus.REGISTERED + UnifiedPushManager.Status.FAILED -> UnifiedPushDebugStatus.FAILED + UnifiedPushManager.Status.UNREGISTERED -> UnifiedPushDebugStatus.UNREGISTERED + }, + unifiedPushEndpoint = endpoint, + unifiedPushSavedDistributor = unifiedPushManager.getSavedDistributor(), + unifiedPushAckDistributor = unifiedPushManager.getAckDistributor(), + unifiedPushDistributorsCount = unifiedPushManager.getDistributors().size + ) + }.collect { + _diagnostics.value = it + } + } + } + + override fun triggerTestPush() { + ensureDebugChannel() + + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + val pendingIntent = PendingIntent.getActivity( + context, + 9110, + launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val builder = NotificationCompat.Builder(context, DEBUG_CHANNEL_ID) + .setSmallIcon(org.monogram.data.R.drawable.message_outline) + .setContentTitle("MonoGram Debug Push") + .setContentText("Synthetic push signal delivered") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + ) { + NotificationManagerCompat.from(context).notify(DEBUG_NOTIFICATION_ID, builder.build()) + } + + pushSyncTrigger.requestSync("debug_test_push") + } + + private fun ensureDebugChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = context.getSystemService(NotificationManager::class.java) ?: return + val channel = NotificationChannel( + DEBUG_CHANNEL_ID, + "Debug Push", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Debug notifications for push diagnostics" + setShowBadge(false) + } + manager.createNotificationChannel(channel) + } + + private data class PrefSnapshot( + val pushProvider: org.monogram.domain.repository.PushProvider, + val backgroundServiceEnabled: Boolean, + val hideForegroundNotification: Boolean, + val isPowerSavingMode: Boolean, + val isWakeLockEnabled: Boolean + ) + + private companion object { + const val DEBUG_CHANNEL_ID = "debug_push_channel" + const val DEBUG_NOTIFICATION_ID = 9110 + } +} diff --git a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt index 40ba3df1..cd02c8a2 100644 --- a/data/src/main/java/org/monogram/data/service/TdNotificationService.kt +++ b/data/src/main/java/org/monogram/data/service/TdNotificationService.kt @@ -17,6 +17,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -39,6 +42,9 @@ class TdNotificationService : Service() { const val FOREGROUND_ID = 999 const val ACTION_STOP = "org.monogram.data.service.ACTION_STOP" private const val CHECK_INTERVAL = 60_000L // 1 minute + + private val _isRunningFlow = MutableStateFlow(false) + val isRunningFlow: StateFlow = _isRunningFlow.asStateFlow() } override fun onBind(intent: Intent?): IBinder? = null @@ -51,11 +57,12 @@ class TdNotificationService : Service() { if (!isServiceRunning) { isServiceRunning = true + _isRunningFlow.value = true // Call startForeground as soon as possible to satisfy // startForegroundService() timing requirements on Android 8+. startForegroundNotification() - if (appPreferences.pushProvider.value == PushProvider.FCM) { + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) { stopForegroundService() return START_NOT_STICKY } @@ -63,7 +70,7 @@ class TdNotificationService : Service() { acquireWakeLock() startListeningUpdates() startPeriodicCheck() - } else if (appPreferences.pushProvider.value == PushProvider.FCM) { + } else if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) { stopForegroundService() return START_NOT_STICKY } @@ -71,7 +78,7 @@ class TdNotificationService : Service() { } private fun acquireWakeLock() { - if (appPreferences.pushProvider.value == PushProvider.FCM) return + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS) return if (appPreferences.isPowerSavingMode.value) return if (!appPreferences.isWakeLockEnabled.value) return @@ -168,6 +175,7 @@ class TdNotificationService : Service() { private fun stopForegroundService(userInitiated: Boolean = false) { isServiceRunning = false + _isRunningFlow.value = false checkJob?.cancel() checkJob = null releaseWakeLock() @@ -197,7 +205,7 @@ class TdNotificationService : Service() { ) { powerSaving, wakeLockEnabled, batteryOptimization, pushProvider -> Quadruple(powerSaving, wakeLockEnabled, batteryOptimization, pushProvider) }.collect { (isPowerSaving, isWakeLockEnabled, isBatteryOptimization, pushProvider) -> - if (pushProvider == PushProvider.FCM) { + if (pushProvider != PushProvider.GMS_LESS) { stopForegroundService() return@collect } @@ -217,7 +225,7 @@ class TdNotificationService : Service() { checkJob?.cancel() checkJob = serviceScope.launch { while (isActive) { - if (appPreferences.pushProvider.value == PushProvider.FCM || (!appPreferences.backgroundServiceEnabled.value && appPreferences.pushProvider.value == PushProvider.GMS_LESS)) { + if (appPreferences.pushProvider.value != PushProvider.GMS_LESS || !appPreferences.backgroundServiceEnabled.value) { stopForegroundService() break } @@ -257,6 +265,7 @@ class TdNotificationService : Service() { override fun onDestroy() { super.onDestroy() isServiceRunning = false + _isRunningFlow.value = false serviceScope.cancel() releaseWakeLock() } diff --git a/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt new file mode 100644 index 00000000..15244aa4 --- /dev/null +++ b/data/src/main/java/org/monogram/data/service/UnifiedPushService.kt @@ -0,0 +1,80 @@ +package org.monogram.data.service + +import android.util.Log +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.monogram.data.push.PushSyncTrigger +import org.monogram.data.push.UnifiedPushManager +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.PushProvider +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +class UnifiedPushService : PushService(), KoinComponent { + private val unifiedPushManager: UnifiedPushManager by inject() + private val pushSyncTrigger: PushSyncTrigger by inject() + private val appPreferences: AppPreferencesProvider by inject() + + override fun onMessage(message: PushMessage, instance: String) { + val payload = + runCatching { message.content.toString(Charsets.UTF_8) }.getOrDefault("") + Log.d( + TAG, + "onMessage instance=$instance decrypted=${message.decrypted} bytes=${message.content.size} payload=${ + payload.take( + 96 + ) + }" + ) + + if (!isUnifiedPushSelected()) { + Log.d(TAG, "Ignore UnifiedPush message: provider is not UnifiedPush") + return + } + + val reason = + if (payload.startsWith("version=")) "unified_push_telegram_simple" else "unified_push_message" + pushSyncTrigger.requestSync(reason) + } + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + Log.d( + TAG, + "onNewEndpoint instance=$instance temporary=${endpoint.temporary} hasPubKeySet=${endpoint.pubKeySet != null} url=${ + endpoint.url.take( + 120 + ) + }" + ) + + unifiedPushManager.onNewEndpoint(endpoint) + + if (!isUnifiedPushSelected()) return + pushSyncTrigger.requestSync("unified_push_new_endpoint") + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.w(TAG, "onRegistrationFailed instance=$instance reason=$reason") + unifiedPushManager.onRegistrationFailed(reason) + } + + override fun onUnregistered(instance: String) { + Log.w(TAG, "onUnregistered instance=$instance") + unifiedPushManager.onUnregistered() + } + + override fun onTempUnavailable(instance: String) { + unifiedPushManager.onTempUnavailable() + Log.w(TAG, "UnifiedPush temp unavailable for instance=$instance") + } + + private fun isUnifiedPushSelected(): Boolean { + return appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH + } + + private companion object { + const val TAG = "UnifiedPushService" + } +} diff --git a/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt b/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt index b40ef808..a0a0842a 100644 --- a/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt +++ b/domain/src/main/java/org/monogram/domain/managers/DistrManager.kt @@ -3,5 +3,6 @@ package org.monogram.domain.managers interface DistrManager { fun isGmsAvailable(): Boolean fun isFcmAvailable(): Boolean + fun isUnifiedPushDistributorAvailable(): Boolean fun isInstalledFromGooglePlay(): Boolean } \ No newline at end of file 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 e45ab9c9..d42dce35 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -3,7 +3,7 @@ package org.monogram.domain.repository import kotlinx.coroutines.flow.StateFlow enum class PushProvider { - FCM, GMS_LESS + FCM, UNIFIED_PUSH, GMS_LESS } enum class ProxyNetworkType { diff --git a/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt new file mode 100644 index 00000000..856d2f55 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/repository/PushDebugRepository.kt @@ -0,0 +1,31 @@ +package org.monogram.domain.repository + +import kotlinx.coroutines.flow.StateFlow + +enum class UnifiedPushDebugStatus { + IDLE, + REGISTERING, + REGISTERED, + FAILED, + UNREGISTERED +} + +data class PushDiagnostics( + val pushProvider: PushProvider = PushProvider.FCM, + val backgroundServiceEnabled: Boolean = true, + val hideForegroundNotification: Boolean = false, + val isPowerSavingMode: Boolean = false, + val isWakeLockEnabled: Boolean = true, + val batteryOptimizationEnabled: Boolean = false, + val isTdNotificationServiceRunning: Boolean = false, + val unifiedPushStatus: UnifiedPushDebugStatus = UnifiedPushDebugStatus.IDLE, + val unifiedPushEndpoint: String? = null, + val unifiedPushSavedDistributor: String? = null, + val unifiedPushAckDistributor: String? = null, + val unifiedPushDistributorsCount: Int = 0 +) + +interface PushDebugRepository { + val diagnostics: StateFlow + fun triggerTestPush() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76cc6af3..c7997585 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ playServices-ossLicenses = "17.5.0" zxing = "3.5.4" junit = "4.13.2" libphonenumber = "9.0.27" +unifiedpush-connector = "3.3.2" [libraries] # AndroidX Activity @@ -140,6 +141,7 @@ maplibre-compose = { module = "io.github.rallista:maplibre-compose", version.ref zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } junit = { group = "junit", name = "junit", version.ref = "junit" } libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "libphonenumber" } +unifiedpush-connector = { group = "org.unifiedpush.android", name = "connector", version.ref = "unifiedpush-connector" } [bundles] androidx-camera = [ diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index af285278..9a485ecd 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { implementation(libs.maplibre.compose) implementation(libs.play.services.oss.licenses) implementation(libs.play.services.location) + implementation(libs.unifiedpush.connector) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) 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 5b666ca0..6af73078 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 @@ -296,8 +296,7 @@ class AppPreferences( private val _showSenderOnly = MutableStateFlow(prefs.getBoolean(KEY_SHOW_SENDER_ONLY, false)) override val showSenderOnly: StateFlow = _showSenderOnly - private val _pushProvider = - MutableStateFlow(PushProvider.entries[prefs.getInt(KEY_PUSH_PROVIDER, PushProvider.FCM.ordinal)]) + private val _pushProvider = MutableStateFlow(loadPushProvider()) override val pushProvider: StateFlow = _pushProvider private val _isArchivePinned = MutableStateFlow(prefs.getBoolean(KEY_IS_ARCHIVE_PINNED, true)) @@ -893,7 +892,10 @@ class AppPreferences( } override fun setPushProvider(provider: PushProvider) { - prefs.edit().putInt(KEY_PUSH_PROVIDER, provider.ordinal).apply() + prefs.edit() + .putString(KEY_PUSH_PROVIDER_NAME, provider.name) + .putInt(KEY_PUSH_PROVIDER, provider.ordinal) + .apply() _pushProvider.value = provider } @@ -1144,6 +1146,24 @@ class AppPreferences( _isSupportViewed.value = viewed } + private fun loadPushProvider(): PushProvider { + val byName = prefs.getString(KEY_PUSH_PROVIDER_NAME, null) + ?.let { stored -> PushProvider.entries.firstOrNull { it.name == stored } } + if (byName != null) { + return byName + } + + val legacyOrdinal = prefs.getInt(KEY_PUSH_PROVIDER, PushProvider.FCM.ordinal) + val migrated = when (legacyOrdinal) { + 0 -> PushProvider.FCM + 1 -> PushProvider.GMS_LESS + else -> PushProvider.entries.getOrNull(legacyOrdinal) ?: PushProvider.FCM + } + + prefs.edit().putString(KEY_PUSH_PROVIDER_NAME, migrated.name).apply() + return migrated + } + companion object { private const val KEY_FONT_SIZE = "font_size" private const val KEY_LETTER_SPACING = "letter_spacing" @@ -1226,6 +1246,7 @@ class AppPreferences( private const val KEY_REPEAT_NOTIFICATIONS = "repeat_notifications" private const val KEY_SHOW_SENDER_ONLY = "show_sender_only" private const val KEY_PUSH_PROVIDER = "push_provider" + private const val KEY_PUSH_PROVIDER_NAME = "push_provider_name" private const val KEY_IS_ARCHIVE_PINNED = "is_archive_pinned" private const val KEY_IS_ARCHIVE_ALWAYS_VISIBLE = "is_archive_always_visible" diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt b/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt new file mode 100644 index 00000000..5b6309ec --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/util/ContextExtensions.kt @@ -0,0 +1,11 @@ +package org.monogram.presentation.core.util + +import android.content.Context +import android.content.ContextWrapper +import androidx.activity.ComponentActivity + +fun Context.findActivity(): ComponentActivity? = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} 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 dc356e6f..f5187e46 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt @@ -4,8 +4,56 @@ import coil3.ImageLoader import kotlinx.coroutines.CoroutineScope import org.monogram.core.DispatcherProvider import org.monogram.core.Logger -import org.monogram.domain.managers.* -import org.monogram.domain.repository.* +import org.monogram.domain.managers.AssetsManager +import org.monogram.domain.managers.ClipManager +import org.monogram.domain.managers.DistrManager +import org.monogram.domain.managers.DomainManager +import org.monogram.domain.managers.PhoneManager +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.AttachMenuBotRepository +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ChatCreationRepository +import org.monogram.domain.repository.ChatEventLogRepository +import org.monogram.domain.repository.ChatFolderRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +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.EditorSnippetProvider +import org.monogram.domain.repository.EmojiRepository +import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.FileRepository +import org.monogram.domain.repository.ForumTopicsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.InlineBotRepository +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.MessageAiRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.NetworkStatisticsRepository +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PremiumRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.PushDebugRepository +import org.monogram.domain.repository.SessionRepository +import org.monogram.domain.repository.SponsorRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.UpdateRepository +import org.monogram.domain.repository.UserProfileEditRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WallpaperRepository +import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache @@ -64,6 +112,7 @@ interface RepositoriesContainer { val gifRepository: GifRepository val emojiRepository: EmojiRepository val updateRepository: UpdateRepository + val pushDebugRepository: PushDebugRepository } interface UtilsContainer { 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 50f44bd9..b8291666 100644 --- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt @@ -5,8 +5,56 @@ import kotlinx.coroutines.CoroutineScope import org.koin.core.Koin import org.monogram.core.DispatcherProvider import org.monogram.core.Logger -import org.monogram.domain.managers.* -import org.monogram.domain.repository.* +import org.monogram.domain.managers.AssetsManager +import org.monogram.domain.managers.ClipManager +import org.monogram.domain.managers.DistrManager +import org.monogram.domain.managers.DomainManager +import org.monogram.domain.managers.PhoneManager +import org.monogram.domain.repository.AppPreferencesProvider +import org.monogram.domain.repository.AttachMenuBotRepository +import org.monogram.domain.repository.AuthRepository +import org.monogram.domain.repository.BotPreferencesProvider +import org.monogram.domain.repository.BotRepository +import org.monogram.domain.repository.CacheProvider +import org.monogram.domain.repository.ChatCreationRepository +import org.monogram.domain.repository.ChatEventLogRepository +import org.monogram.domain.repository.ChatFolderRepository +import org.monogram.domain.repository.ChatInfoRepository +import org.monogram.domain.repository.ChatListRepository +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.EditorSnippetProvider +import org.monogram.domain.repository.EmojiRepository +import org.monogram.domain.repository.ExternalNavigator +import org.monogram.domain.repository.ExternalProxyRepository +import org.monogram.domain.repository.FileRepository +import org.monogram.domain.repository.ForumTopicsRepository +import org.monogram.domain.repository.GifRepository +import org.monogram.domain.repository.InlineBotRepository +import org.monogram.domain.repository.LinkHandlerRepository +import org.monogram.domain.repository.LocationRepository +import org.monogram.domain.repository.MessageAiRepository +import org.monogram.domain.repository.MessageDisplayer +import org.monogram.domain.repository.MessageRepository +import org.monogram.domain.repository.NetworkStatisticsRepository +import org.monogram.domain.repository.NotificationSettingsRepository +import org.monogram.domain.repository.PaymentRepository +import org.monogram.domain.repository.PremiumRepository +import org.monogram.domain.repository.PrivacyRepository +import org.monogram.domain.repository.ProfilePhotoRepository +import org.monogram.domain.repository.PushDebugRepository +import org.monogram.domain.repository.SessionRepository +import org.monogram.domain.repository.SponsorRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.StorageRepository +import org.monogram.domain.repository.StringProvider +import org.monogram.domain.repository.UpdateRepository +import org.monogram.domain.repository.UserProfileEditRepository +import org.monogram.domain.repository.UserRepository +import org.monogram.domain.repository.WallpaperRepository +import org.monogram.domain.repository.WebAppRepository import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.IDownloadUtils import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache @@ -65,6 +113,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 updateRepository: UpdateRepository by lazy { koin.get() } + override val pushDebugRepository: PushDebugRepository by lazy { koin.get() } } class KoinUtilsContainer(private val koin: Koin) : UtilsContainer { diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt index 85238d0c..7d5c0433 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugComponent.kt @@ -1,12 +1,37 @@ package org.monogram.presentation.settings.debug +import com.arkivanov.decompose.value.Value +import org.monogram.domain.repository.PushProvider +import org.monogram.domain.repository.UnifiedPushDebugStatus + interface DebugComponent { + val state: Value + fun onBackClicked() fun onCrashClicked() fun onShowSponsorSheetClicked() fun onForceSponsorSyncClicked() + fun onTestPushClicked() fun onDropDatabasesClicked() fun onDropCachePrefsClicked() fun onDropPrefsClicked() fun onDropDatabaseCacheClicked() -} \ No newline at end of file + + data class State( + val pushProvider: PushProvider = PushProvider.FCM, + val backgroundServiceEnabled: Boolean = true, + val hideForegroundNotification: Boolean = false, + val isPowerSavingMode: Boolean = false, + val isWakeLockEnabled: Boolean = true, + val batteryOptimizationEnabled: Boolean = false, + val isTdNotificationServiceRunning: Boolean = false, + val unifiedPushStatus: UnifiedPushDebugStatus = UnifiedPushDebugStatus.IDLE, + val unifiedPushEndpoint: String? = null, + val unifiedPushSavedDistributor: String? = null, + val unifiedPushAckDistributor: String? = null, + val unifiedPushDistributorsCount: Int = 0, + val isGmsAvailable: Boolean = false, + val isFcmAvailable: Boolean = false, + val isUnifiedPushDistributorAvailable: Boolean = false + ) +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt index 7384ee7f..b7cd2f97 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DebugContent.kt @@ -1,26 +1,74 @@ package org.monogram.presentation.settings.debug -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +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.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape 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.BatteryAlert +import androidx.compose.material.icons.rounded.BatterySaver +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Cloud +import androidx.compose.material.icons.rounded.CloudDone +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material.icons.rounded.DoneAll +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.Hub +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.NotificationAdd +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Power +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.monogram.domain.repository.PushProvider +import org.monogram.domain.repository.UnifiedPushDebugStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SectionHeader import org.monogram.presentation.core.ui.SettingsItem @OptIn(ExperimentalMaterial3Api::class) @Composable fun DebugContent(component: DebugComponent) { + val state by component.state.subscribeAsState() var isSponsorSheetVisible by remember { mutableStateOf(false) } if (isSponsorSheetVisible) { @@ -85,10 +133,20 @@ fun DebugContent(component: DebugComponent) { Scaffold( topBar = { TopAppBar( - title = { Text("Debug", fontWeight = FontWeight.Bold) }, + title = { + Text( + text = stringResource(R.string.debug_title), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + }, navigationIcon = { IconButton(onClick = component::onBackClicked) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) } } ) @@ -98,9 +156,162 @@ fun DebugContent(component: DebugComponent) { modifier = Modifier .fillMaxSize() .padding(padding), - contentPadding = PaddingValues(16.dp) + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { + SectionHeader(stringResource(R.string.debug_section_push_diagnostics)) + SettingsItem( + icon = Icons.Rounded.Notifications, + title = "Push provider", + subtitle = when (state.pushProvider) { + PushProvider.FCM -> "FCM" + PushProvider.UNIFIED_PUSH -> "UnifiedPush" + PushProvider.GMS_LESS -> "GMS-less" + }, + iconBackgroundColor = Color(0xFF4CAF50), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Sync, + title = "Push service running", + subtitle = state.isTdNotificationServiceRunning.toUiToggle(), + iconBackgroundColor = Color(0xFF00ACC1), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.NotificationAdd, + title = "Test Push", + subtitle = "Show local notification and trigger sync", + iconBackgroundColor = Color(0xFF009688), + position = ItemPosition.BOTTOM, + onClick = component::onTestPushClicked + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_runtime_flags)) + SettingsItem( + icon = Icons.Rounded.Settings, + title = "Keep-alive enabled", + subtitle = state.backgroundServiceEnabled.toUiToggle(), + iconBackgroundColor = Color(0xFF607D8B), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.BatteryAlert, + title = "Power saving mode", + subtitle = state.isPowerSavingMode.toUiToggle(), + iconBackgroundColor = Color(0xFFFF9800), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Power, + title = "Wake lock", + subtitle = state.isWakeLockEnabled.toUiToggle(), + iconBackgroundColor = Color(0xFF3F51B5), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.BatterySaver, + title = "Battery optimization", + subtitle = state.batteryOptimizationEnabled.toUiToggle(), + iconBackgroundColor = Color(0xFF8BC34A), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.VisibilityOff, + title = "Hide foreground notification", + subtitle = state.hideForegroundNotification.toUiToggle(), + iconBackgroundColor = Color(0xFF9E9E9E), + position = ItemPosition.BOTTOM, + onClick = { } + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_push_environment)) + SettingsItem( + icon = Icons.Rounded.CloudDone, + title = "GMS available", + subtitle = state.isGmsAvailable.toUiToggle(), + iconBackgroundColor = Color(0xFF4285F4), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Cloud, + title = "FCM available", + subtitle = state.isFcmAvailable.toUiToggle(), + iconBackgroundColor = Color(0xFF1E88E5), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Hub, + title = "UnifiedPush distributor", + subtitle = state.isUnifiedPushDistributorAvailable.toUiToggle(), + iconBackgroundColor = Color(0xFF7E57C2), + position = ItemPosition.BOTTOM, + onClick = { } + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_unifiedpush_details)) + SettingsItem( + icon = Icons.Rounded.Info, + title = "UnifiedPush status", + subtitle = state.unifiedPushStatus.toUiText(), + iconBackgroundColor = Color(0xFF3949AB), + position = ItemPosition.TOP, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Link, + title = "UnifiedPush endpoint", + subtitle = state.unifiedPushEndpoint?.takeIf { it.isNotBlank() } + ?: "Not registered", + iconBackgroundColor = Color(0xFF5C6BC0), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.Bookmark, + title = "Saved distributor", + subtitle = state.unifiedPushSavedDistributor?.takeIf { it.isNotBlank() } + ?: "None", + iconBackgroundColor = Color(0xFF6A1B9A), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.DoneAll, + title = "Ack distributor", + subtitle = state.unifiedPushAckDistributor?.takeIf { it.isNotBlank() } + ?: "None", + iconBackgroundColor = Color(0xFF512DA8), + position = ItemPosition.MIDDLE, + onClick = { } + ) + SettingsItem( + icon = Icons.Rounded.FilterList, + title = "Distributors count", + subtitle = state.unifiedPushDistributorsCount.toString(), + iconBackgroundColor = Color(0xFF9575CD), + position = ItemPosition.BOTTOM, + onClick = { } + ) + } + + item { + SectionHeader(stringResource(R.string.debug_section_sponsor)) SettingsItem( icon = Icons.Rounded.Favorite, title = stringResource(R.string.debug_sponsor_sheet_title), @@ -109,28 +320,27 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.TOP, onClick = { isSponsorSheetVisible = true } ) - } - item { SettingsItem( icon = Icons.Rounded.Sync, title = stringResource(R.string.debug_force_sponsor_sync_title), subtitle = stringResource(R.string.debug_force_sponsor_sync_subtitle), iconBackgroundColor = Color(0xFF00ACC1), - position = ItemPosition.MIDDLE, + position = ItemPosition.BOTTOM, onClick = component::onForceSponsorSyncClicked ) } + item { + SectionHeader(stringResource(R.string.debug_section_danger_zone)) SettingsItem( icon = Icons.Rounded.BugReport, title = "Crash App", subtitle = "Trigger a manual RuntimeException", iconBackgroundColor = Color.Red, - position = ItemPosition.MIDDLE, + position = ItemPosition.TOP, onClick = component::onCrashClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Storage, title = "Drop Databases", @@ -139,8 +349,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropDatabasesClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Storage, title = "Drop Cache Database", @@ -149,8 +358,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropDatabaseCacheClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.DeleteSweep, title = "Drop Cache", @@ -159,8 +367,7 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.MIDDLE, onClick = component::onDropCachePrefsClicked ) - } - item { + SettingsItem( icon = Icons.Rounded.Delete, title = "Drop Prefs", @@ -169,7 +376,24 @@ fun DebugContent(component: DebugComponent) { position = ItemPosition.BOTTOM, onClick = component::onDropPrefsClicked ) + + Spacer(modifier = Modifier.height(padding.calculateBottomPadding())) } } } } + +@Composable +private fun Boolean.toUiToggle(): String = if (this) { + stringResource(R.string.on_label) +} else { + stringResource(R.string.off_label) +} + +private fun UnifiedPushDebugStatus.toUiText(): String = when (this) { + UnifiedPushDebugStatus.IDLE -> "Idle" + UnifiedPushDebugStatus.REGISTERING -> "Registering" + UnifiedPushDebugStatus.REGISTERED -> "Registered" + UnifiedPushDebugStatus.FAILED -> "Failed" + UnifiedPushDebugStatus.UNREGISTERED -> "Unregistered" +} diff --git a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt index 484ca8e5..74e182f8 100644 --- a/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/settings/debug/DefaultDebugComponent.kt @@ -1,5 +1,11 @@ package org.monogram.presentation.settings.debug +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.root.AppComponentContext import java.io.File @@ -11,7 +17,40 @@ class DefaultDebugComponent( private val messageDisplayer = container.utils.messageDisplayer() private val assetsManager = container.utils.assetsManager() private val externalNavigator = container.utils.externalNavigator() + private val distrManager = container.utils.distrManager() + private val pushDebugRepository = container.repositories.pushDebugRepository private val sponsorRepository = container.repositories.sponsorRepository + private val scope = componentScope + + private val _state = MutableValue( + DebugComponent.State( + isGmsAvailable = distrManager.isGmsAvailable(), + isFcmAvailable = distrManager.isFcmAvailable(), + isUnifiedPushDistributorAvailable = distrManager.isUnifiedPushDistributorAvailable() + ) + ) + override val state: Value = _state + + init { + pushDebugRepository.diagnostics.onEach { diagnostics -> + _state.update { + it.copy( + pushProvider = diagnostics.pushProvider, + backgroundServiceEnabled = diagnostics.backgroundServiceEnabled, + hideForegroundNotification = diagnostics.hideForegroundNotification, + isPowerSavingMode = diagnostics.isPowerSavingMode, + isWakeLockEnabled = diagnostics.isWakeLockEnabled, + batteryOptimizationEnabled = diagnostics.batteryOptimizationEnabled, + isTdNotificationServiceRunning = diagnostics.isTdNotificationServiceRunning, + unifiedPushStatus = diagnostics.unifiedPushStatus, + unifiedPushEndpoint = diagnostics.unifiedPushEndpoint, + unifiedPushSavedDistributor = diagnostics.unifiedPushSavedDistributor, + unifiedPushAckDistributor = diagnostics.unifiedPushAckDistributor, + unifiedPushDistributorsCount = diagnostics.unifiedPushDistributorsCount + ) + } + }.launchIn(scope) + } override fun onBackClicked() { onBack() @@ -30,6 +69,11 @@ class DefaultDebugComponent( messageDisplayer.show("Sponsor sync started") } + override fun onTestPushClicked() { + pushDebugRepository.triggerTestPush() + messageDisplayer.show("Debug push dispatched") + } + override fun onDropDatabasesClicked() { messageDisplayer.show("Dropping databases and restarting...") assetsManager.getDatabasePath("monogram_db").delete() diff --git a/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/notifications/NotificationsComponent.kt index 075ba5bf..126d03cf 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 @@ -1,7 +1,11 @@ package org.monogram.presentation.settings.notifications import android.os.Parcelable -import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.update @@ -61,6 +65,7 @@ interface NotificationsComponent { val showSenderOnly: Boolean = false, val pushProvider: PushProvider = PushProvider.FCM, val isGmsAvailable: Boolean = false, + val isUnifiedPushAvailable: Boolean = false, val privateExceptions: List? = null, val groupExceptions: List? = null, val channelExceptions: List? = null @@ -163,7 +168,12 @@ class DefaultNotificationsComponent( _state.update { it.copy(pushProvider = value) } }.launchIn(scope) - _state.update { it.copy(isGmsAvailable = distrManager.isGmsAvailable()) } + _state.update { + it.copy( + isGmsAvailable = distrManager.isGmsAvailable() && distrManager.isFcmAvailable(), + isUnifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable() + ) + } syncSettings() } @@ -296,7 +306,12 @@ class DefaultNotificationsComponent( onPriorityChanged(1) onRepeatNotificationsChanged(0) onShowSenderOnlyToggled(false) - onPushProviderChanged(if (_state.value.isGmsAvailable) PushProvider.FCM else PushProvider.GMS_LESS) + val defaultProvider = when { + _state.value.isGmsAvailable -> PushProvider.FCM + _state.value.isUnifiedPushAvailable -> PushProvider.UNIFIED_PUSH + else -> PushProvider.GMS_LESS + } + onPushProviderChanged(defaultProvider) } override fun onExceptionClicked(scope: TdNotificationScope) { 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 15ab5036..0eef28c1 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 @@ -1,19 +1,59 @@ package org.monogram.presentation.settings.notifications +import android.widget.Toast import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.VolumeUp -import androidx.compose.material.icons.rounded.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.Campaign +import androidx.compose.material.icons.rounded.ChatBubbleOutline +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PersonAdd +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.Sync +import androidx.compose.material.icons.rounded.Vibration +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -29,6 +69,8 @@ import org.monogram.presentation.core.ui.ExpressiveDefaults import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.core.ui.SettingsItem import org.monogram.presentation.core.ui.SettingsSwitchTile +import org.monogram.presentation.core.util.findActivity +import org.unifiedpush.android.connector.UnifiedPush @Composable fun NotificationsContent(component: NotificationsComponent) { @@ -44,6 +86,7 @@ fun NotificationsContent(component: NotificationsComponent) { @Composable private fun NotificationsMainContent(component: NotificationsComponent) { val state by component.state.subscribeAsState() + val context = LocalContext.current var showVibrationSheet by remember { mutableStateOf(false) } var showPrioritySheet by remember { mutableStateOf(false) } var showRepeatSheet by remember { mutableStateOf(false) } @@ -196,6 +239,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) { title = stringResource(R.string.push_provider_title), subtitle = when (state.pushProvider) { PushProvider.FCM -> stringResource(R.string.push_provider_fcm) + PushProvider.UNIFIED_PUSH -> stringResource(R.string.push_provider_unified) PushProvider.GMS_LESS -> stringResource(R.string.push_provider_gms_less) }, iconBackgroundColor = Color(0xFF4CAF50), @@ -209,6 +253,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) { checked = state.backgroundServiceEnabled, iconColor = Color(0xFF607D8B), position = ItemPosition.MIDDLE, + enabled = state.pushProvider == PushProvider.GMS_LESS, onCheckedChange = component::onBackgroundServiceToggled ) SettingsSwitchTile( @@ -218,6 +263,7 @@ private fun NotificationsMainContent(component: NotificationsComponent) { checked = state.hideForegroundNotification, iconColor = Color(0xFF9E9E9E), position = ItemPosition.BOTTOM, + enabled = state.pushProvider == PushProvider.GMS_LESS, onCheckedChange = component::onHideForegroundNotificationToggled ) } @@ -349,6 +395,9 @@ private fun NotificationsMainContent(component: NotificationsComponent) { if (state.isGmsAvailable) { options.add(PushProvider.FCM.name to stringResource(R.string.push_provider_fcm)) } + if (state.isUnifiedPushAvailable) { + options.add(PushProvider.UNIFIED_PUSH.name to stringResource(R.string.push_provider_unified)) + } options.add(PushProvider.GMS_LESS.name to stringResource(R.string.push_provider_gms_less)) NotificationOptionSheet( @@ -356,8 +405,35 @@ private fun NotificationsMainContent(component: NotificationsComponent) { options = options, selectedOption = state.pushProvider.name, onOptionSelected = { - component.onPushProviderChanged(PushProvider.valueOf(it)) - showPushProviderSheet = false + val selected = PushProvider.valueOf(it) + if (selected != PushProvider.UNIFIED_PUSH) { + component.onPushProviderChanged(selected) + showPushProviderSheet = false + return@NotificationOptionSheet + } + + val activity = context.findActivity() + if (activity == null) { + Toast.makeText( + context, + "Cannot select UnifiedPush without active activity", + Toast.LENGTH_SHORT + ).show() + return@NotificationOptionSheet + } + + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success -> + if (success) { + component.onPushProviderChanged(PushProvider.UNIFIED_PUSH) + showPushProviderSheet = false + } else { + Toast.makeText( + context, + "UnifiedPush distributor not selected", + Toast.LENGTH_SHORT + ).show() + } + } }, onDismiss = { showPushProviderSheet = false } ) diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 33604687..dd863ca3 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -381,6 +381,12 @@ Open sponsor info bottom sheet Force sponsor sync Fetch sponsor IDs from channel now + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone Log Out Disconnect from account @@ -612,6 +618,7 @@ Every 1 hour Every %1$d hours Firebase Cloud Messaging + UnifiedPush (Simple Push) GMS-less (Background Service) From 0a85e18985bace9e2fa2a952660db176f8b66bfe Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:24:47 +0300 Subject: [PATCH 76/83] prioritize push provider fallback order Resolve provider fallback through a single FCM -> UnifiedPush -> GMS-less priority function so unavailable providers downgrade predictably without unintended jumps. --- app/src/main/java/org/monogram/app/App.kt | 33 ++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/monogram/app/App.kt b/app/src/main/java/org/monogram/app/App.kt index 70071294..07c6cba9 100644 --- a/app/src/main/java/org/monogram/app/App.kt +++ b/app/src/main/java/org/monogram/app/App.kt @@ -83,20 +83,29 @@ class App : Application(), SingletonImageLoader.Factory { private fun checkPushAvailability() { val distrManager = get() - val isGmsAvailable = distrManager.isGmsAvailable() - val isFcmAvailable = distrManager.isFcmAvailable() - val isUnifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable() - val prefs = get() val currentProvider = prefs.pushProvider.value - if (currentProvider == PushProvider.FCM && !(isGmsAvailable && isFcmAvailable)) { - val fallback = - if (isUnifiedPushAvailable) PushProvider.UNIFIED_PUSH else PushProvider.GMS_LESS - prefs.setPushProvider(fallback) - } else if (currentProvider == PushProvider.UNIFIED_PUSH && !isUnifiedPushAvailable) { - val fallback = - if (isGmsAvailable && isFcmAvailable) PushProvider.FCM else PushProvider.GMS_LESS - prefs.setPushProvider(fallback) + val bestAvailable = resolveBestAvailablePushProvider(distrManager) + + val shouldFallback = when (currentProvider) { + PushProvider.FCM -> bestAvailable != PushProvider.FCM + PushProvider.UNIFIED_PUSH -> !distrManager.isUnifiedPushDistributorAvailable() + PushProvider.GMS_LESS -> false + } + + if (shouldFallback && currentProvider != bestAvailable) { + prefs.setPushProvider(bestAvailable) + } + } + + private fun resolveBestAvailablePushProvider(distrManager: DistrManager): PushProvider { + val fcmAvailable = distrManager.isGmsAvailable() && distrManager.isFcmAvailable() + val unifiedPushAvailable = distrManager.isUnifiedPushDistributorAvailable() + + return when { + fcmAvailable -> PushProvider.FCM + unifiedPushAvailable -> PushProvider.UNIFIED_PUSH + else -> PushProvider.GMS_LESS } } From 31a31af67be05f7fd4b4689fbbe774c2abc88c11 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:30:49 +0300 Subject: [PATCH 77/83] harden notification building against NPEs Replace cached MessagingStyle objects with plain history entries and add safe PendingIntent/action builders with a fallback notification path so notification rendering failures no longer crash the app. --- .../monogram/data/di/TdNotificationManager.kt | 369 +++++++++++------- 1 file changed, 232 insertions(+), 137 deletions(-) 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 f9b18ed7..cd3fbb0c 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -28,8 +28,6 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.graphics.createBitmap -import androidx.core.graphics.drawable.IconCompat -import androidx.core.graphics.drawable.toBitmap import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -74,7 +72,7 @@ class TdNotificationManager( private val notificationManager = NotificationManagerCompat.from(context) private val userCache = ConcurrentHashMap() private val chatCache = ConcurrentHashMap() - private val messagesHistory = ConcurrentHashMap>>() + private val messagesHistory = ConcurrentHashMap>() private val lastMessageIds = ConcurrentHashMap() private val activeNotifications = ConcurrentHashMap>() private val bitmapCache = object : LruCache(5 * 1024 * 1024) { @@ -104,6 +102,13 @@ class TdNotificationManager( const val KEY_TEXT_REPLY = "key_text_reply" } + private data class NotificationHistoryEntry( + val messageId: Long, + val senderName: String, + val text: String, + val timestamp: Long + ) + init { createNotificationChannels() loadSettingsFromDb() @@ -359,7 +364,7 @@ class TdNotificationManager( activeNotifications.remove(chatId)?.forEach { notificationId -> notificationManager.cancel(notificationId) } - notificationManager.cancel(chatId.toInt()) + notificationManager.cancel(notificationIdForChat(chatId)) updateSummary() } @@ -367,13 +372,13 @@ class TdNotificationManager( activeNotifications[chatId]?.remove(notificationId) notificationManager.cancel(notificationId) - if (notificationId == chatId.toInt()) { + if (notificationId == notificationIdForChat(chatId)) { messagesHistory.remove(chatId) activeNotifications.remove(chatId) } else { val history = messagesHistory[chatId] if (history != null) { - history.removeAll { it.first == notificationId.toLong() } + history.removeAll { it.messageId == notificationId.toLong() } if (history.isEmpty()) { messagesHistory.remove(chatId) activeNotifications.remove(chatId) @@ -573,102 +578,46 @@ class TdNotificationManager( else -> CHANNEL_OTHER } - val personBuilder = Person.Builder() - .setName(senderName) - .setKey(senderName) - - if (senderBitmap != null) { - personBuilder.setIcon(IconCompat.createWithBitmap(getCircularBitmap(senderBitmap))) - } - - val sender = personBuilder.build() - - val styleMessage = NotificationCompat.MessagingStyle.Message( - text, - timestamp, - sender - ) - val history = messagesHistory.getOrPut(chatId) { mutableListOf() } - history.add(messageId to styleMessage) + history.add( + NotificationHistoryEntry( + messageId = messageId, + senderName = senderName, + text = text, + timestamp = timestamp + ) + ) if (history.size > 10) { history.removeAt(0) } + val notificationId = notificationIdForChat(chatId) Log.d( TAG, - "Notification history updated chatId=$chatId size=${history.size} notificationId=${chatId.toInt()}" + "Notification history updated chatId=$chatId size=${history.size} notificationId=$notificationId" ) - val notificationId = chatId.toInt() activeNotifications.getOrPut(chatId) { ConcurrentHashMap.newKeySet() }.add(notificationId) - val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - putExtra("chat_id", chatId) - } - val pendingIntent = PendingIntent.getActivity( - context, - notificationId, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val dismissIntent = Intent(context, NotificationDismissReceiver::class.java).apply { - putExtra("chat_id", chatId) - putExtra("notification_id", notificationId) - } - val dismissPendingIntent = PendingIntent.getBroadcast( - context, - notificationId, - dismissIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel(stringProvider.getString("menu_reply")) - .build() - - val replyIntent = Intent(context, NotificationReplyReceiver::class.java).apply { - putExtra("chat_id", chatId) - putExtra("notification_id", notificationId) - } - val replyPendingIntent = PendingIntent.getBroadcast( - context, - notificationId, - replyIntent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - val readIntent = Intent(context, NotificationReadReceiver::class.java).apply { - putExtra("chat_id", chatId) - putExtra("notification_id", notificationId) - } - val readPendingIntent = PendingIntent.getBroadcast( - context, - notificationId, - readIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - - val replyAction = NotificationCompat.Action.Builder( - android.R.drawable.ic_menu_send, - stringProvider.getString("menu_reply"), - replyPendingIntent - ).addRemoteInput(remoteInput).build() - - val readAction = NotificationCompat.Action.Builder( - android.R.drawable.ic_menu_view, - stringProvider.getString("action_mark_as_read"), - readPendingIntent - ).build() - + val pendingIntent = buildContentPendingIntent(chatId, notificationId) + val dismissPendingIntent = buildDismissPendingIntent(chatId, notificationId) + val replyAction = buildReplyAction(chatId, notificationId) + val readAction = buildReadAction(chatId, notificationId) val myself = Person.Builder().setName(stringProvider.getString("notification_person_me")).build() val messagingStyle = NotificationCompat.MessagingStyle(myself) - history.forEach { (_, msg) -> - messagingStyle.addMessage(msg) + history.forEach { entry -> + val person = Person.Builder() + .setName(entry.senderName) + .setKey(entry.senderName) + .build() + messagingStyle.addMessage( + NotificationCompat.MessagingStyle.Message( + entry.text, + entry.timestamp, + person + ) + ) } val isGroup = chatType !is TdApi.ChatTypePrivate @@ -684,52 +633,210 @@ class TdNotificationManager( 2 -> NotificationCompat.PRIORITY_HIGH else -> NotificationCompat.PRIORITY_DEFAULT } + val posted = runCatching { + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(org.monogram.data.R.drawable.message_outline) + .setStyle(messagingStyle) + .setPriority(priority) + .setGroup(GROUP_CHATS) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setShortcutId(chatId.toString()) + .setLocusId(androidx.core.content.LocusIdCompat(chatId.toString())) + .setOnlyAlertOnce(true) + + pendingIntent?.let { builder.setContentIntent(it) } + dismissPendingIntent?.let { builder.setDeleteIntent(it) } + replyAction?.let { builder.addAction(it) } + readAction?.let { builder.addAction(it) } + + builder.setContentTitle(chatTitle) + builder.setContentText(text) + + if (appPreferences.inAppSounds.value) { + builder.setDefaults(NotificationCompat.DEFAULT_SOUND) + } else { + builder.setSilent(true) + } - val builder = NotificationCompat.Builder(context, channelId) - .setSmallIcon(org.monogram.data.R.drawable.message_outline) - .setStyle(messagingStyle) - .setPriority(priority) - .setContentIntent(pendingIntent) - .setDeleteIntent(dismissPendingIntent) - .addAction(replyAction) - .addAction(readAction) - .setGroup(GROUP_CHATS) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setAutoCancel(true) - .setShortcutId(chatId.toString()) - .setLocusId(androidx.core.content.LocusIdCompat(chatId.toString())) - .setOnlyAlertOnce(true) + if (appPreferences.inAppVibrate.value) { + when (appPreferences.notificationVibrationPattern.value) { + "short" -> builder.setVibrate(longArrayOf(0, 100, 50, 100)) + "long" -> builder.setVibrate(longArrayOf(0, 500, 200, 500)) + "disabled" -> builder.setVibrate(longArrayOf(0)) + else -> builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE) + } + } - builder.setContentTitle(chatTitle) - builder.setContentText(text) + if (!appPreferences.inAppPreview.value) { + builder.setContentText(stringProvider.getString("notification_new_message")) + } - if (appPreferences.inAppSounds.value) { - builder.setDefaults(NotificationCompat.DEFAULT_SOUND) - } else { - builder.setSilent(true) + if (chatIcon != null) { + runCatching { builder.setLargeIcon(getCircularBitmap(chatIcon)) } + .onFailure { Log.w(TAG, "Failed to set large icon for notification", it) } + } + + notificationManager.notify(notificationId, builder.build()) + true + }.onFailure { + Log.e(TAG, "Failed to build rich notification, falling back", it) + }.getOrDefault(false) + + if (!posted) { + postFallbackNotification( + chatId = chatId, + chatType = chatType, + title = chatTitle, + text = text, + channelId = channelId, + notificationId = notificationId, + pendingIntent = pendingIntent, + dismissPendingIntent = dismissPendingIntent + ) } - if (appPreferences.inAppVibrate.value) { - when (appPreferences.notificationVibrationPattern.value) { - "short" -> builder.setVibrate(longArrayOf(0, 100, 50, 100)) - "long" -> builder.setVibrate(longArrayOf(0, 500, 200, 500)) - "disabled" -> builder.setVibrate(longArrayOf(0)) - else -> builder.setDefaults(NotificationCompat.DEFAULT_VIBRATE) - } + Log.d(TAG, "Notification posted chatId=$chatId notificationId=$notificationId") + updateSummary() + } + + private fun buildContentPendingIntent(chatId: Long, notificationId: Int): PendingIntent? { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("chat_id", chatId) + } ?: return null + + return runCatching { + PendingIntent.getActivity( + context, + notificationId, + launchIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.onFailure { + Log.w(TAG, "Failed to create content PendingIntent", it) + }.getOrNull() + } + + private fun buildDismissPendingIntent(chatId: Long, notificationId: Int): PendingIntent? { + val dismissIntent = Intent(context, NotificationDismissReceiver::class.java).apply { + putExtra("chat_id", chatId) + putExtra("notification_id", notificationId) } - if (!appPreferences.inAppPreview.value) { - builder.setContentText(stringProvider.getString("notification_new_message")) + return runCatching { + PendingIntent.getBroadcast( + context, + notificationId, + dismissIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.onFailure { + Log.w(TAG, "Failed to create dismiss PendingIntent", it) + }.getOrNull() + } + + private fun buildReplyAction(chatId: Long, notificationId: Int): NotificationCompat.Action? { + val replyIntent = Intent(context, NotificationReplyReceiver::class.java).apply { + putExtra("chat_id", chatId) + putExtra("notification_id", notificationId) } - if (chatIcon != null) { - Log.d("TdNotificationManager", "Setting chat icon to $chatTitle") - builder.setLargeIcon(getCircularBitmap(chatIcon)) + val replyPendingIntent = runCatching { + PendingIntent.getBroadcast( + context, + notificationId, + replyIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.getOrNull() ?: return null + + val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel(stringProvider.getString("menu_reply")) + .build() + + return runCatching { + NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_send, + stringProvider.getString("menu_reply"), + replyPendingIntent + ).addRemoteInput(remoteInput).build() + }.onFailure { + Log.w(TAG, "Failed to build reply action", it) + }.getOrNull() + } + + private fun buildReadAction(chatId: Long, notificationId: Int): NotificationCompat.Action? { + val readIntent = Intent(context, NotificationReadReceiver::class.java).apply { + putExtra("chat_id", chatId) + putExtra("notification_id", notificationId) } - notificationManager.notify(notificationId, builder.build()) - Log.d(TAG, "Notification posted chatId=$chatId notificationId=$notificationId") - updateSummary() + val readPendingIntent = runCatching { + PendingIntent.getBroadcast( + context, + notificationId, + readIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + }.getOrNull() ?: return null + + return runCatching { + NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_view, + stringProvider.getString("action_mark_as_read"), + readPendingIntent + ).build() + }.onFailure { + Log.w(TAG, "Failed to build read action", it) + }.getOrNull() + } + + private fun postFallbackNotification( + chatId: Long, + chatType: TdApi.ChatType, + title: String, + text: String, + channelId: String, + notificationId: Int, + pendingIntent: PendingIntent?, + dismissPendingIntent: PendingIntent? + ) { + runCatching { + val builder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(org.monogram.data.R.drawable.message_outline) + .setContentTitle(title) + .setContentText(if (appPreferences.inAppPreview.value) text else stringProvider.getString("notification_new_message")) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(GROUP_CHATS) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority( + when (appPreferences.notificationPriority.value) { + 0 -> NotificationCompat.PRIORITY_LOW + 2 -> NotificationCompat.PRIORITY_HIGH + else -> NotificationCompat.PRIORITY_DEFAULT + } + ) + + pendingIntent?.let { builder.setContentIntent(it) } + dismissPendingIntent?.let { builder.setDeleteIntent(it) } + + if (chatType !is TdApi.ChatTypePrivate) { + builder.setSubText(stringProvider.getString("notification_group_chats")) + } + + notificationManager.notify(notificationId, builder.build()) + Log.w(TAG, "Fallback notification posted chatId=$chatId notificationId=$notificationId") + }.onFailure { + Log.e(TAG, "Fallback notification failed chatId=$chatId notificationId=$notificationId", it) + } + } + + private fun notificationIdForChat(chatId: Long): Int { + val hash = (chatId xor (chatId ushr 32)).toInt() + return if (hash == SUMMARY_ID) SUMMARY_ID + 1 else hash } private fun senderIdToDebug(senderId: TdApi.MessageSender?): String = when (senderId) { @@ -782,7 +889,7 @@ class TdNotificationManager( } val allMessages = messagesHistory.flatMap { (chatId, messages) -> - messages.map { (_, message) -> + messages.map { message -> Triple(chatId, message, message.timestamp) } }.sortedByDescending { it.third } @@ -792,7 +899,7 @@ class TdNotificationManager( allMessages.take(5).forEach { (chatId, message, _) -> val chat = chatCache[chatId] - val senderName = message.person?.name ?: stringProvider.getString("unknown_user") + val senderName = message.senderName.ifBlank { stringProvider.getString("unknown_user") } val chatTitle = chat?.title ?: senderName val sb = SpannableStringBuilder() @@ -822,18 +929,6 @@ class TdNotificationManager( .setOnlyAlertOnce(true) .setContentTitle(summaryTitle) - val latestMessageTriple = allMessages.firstOrNull() - if (latestMessageTriple != null) { - val (_, message, _) = latestMessageTriple - val iconCompat = message.person?.icon - if (iconCompat != null) { - val drawable = iconCompat.loadDrawable(context) - if (drawable != null) { - builder.setLargeIcon(drawable.toBitmap()) - } - } - } - notificationManager.notify(SUMMARY_ID, builder.build()) } From 7120f834c9455a55ce338cd198641b4182e4b579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bu=C4=9Fra=20=C3=87a=C4=9Flar?= Date: Mon, 13 Apr 2026 11:37:10 +0300 Subject: [PATCH 78/83] Feat: Add complete Turkish (tr) localization (#239) --- README_TR.md | 215 ++ app/src/main/res/values-tr/strings.xml | 44 + .../src/main/res/values-tr/strings.xml | 2008 +++++++++++++++++ 3 files changed, 2267 insertions(+) create mode 100644 README_TR.md create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 presentation/src/main/res/values-tr/strings.xml diff --git a/README_TR.md b/README_TR.md new file mode 100644 index 00000000..eff88781 --- /dev/null +++ b/README_TR.md @@ -0,0 +1,215 @@ +

+
+
MonoGram + +
+ MonoGram +
+

+ +

+ + + + + + + + + + + + +

+ +**Bu dokümanı diğer dillerde okuyun:** [English](README.md), [Русский](README_RU.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) + +--- + +**MonoGram**, Android için modern, yıldırım hızında ve zarif bir resmi olmayan Telegram istemcisidir. **Jetpack Compose** ve **Material Design 3** ile sıfırdan inşa edilen uygulama, resmi **TDLib** altyapısıyla desteklenen yerleşik (native) ve akıcı bir deneyim sunar. + +> [!IMPORTANT] +> MonoGram şu an **aktif geliştirme** aşamasındadır. Sık güncellemeler, mimari değişiklikler ve nadiren de olsa hatalar (bug) bekleyebilirsiniz. + +Projeyi [**Boosty**](https://boosty.to/monogram) üzerinden destekleyebilirsiniz. + +--- + +## Ekran Görüntüleri + +
+ +| | | | | +|:---:|:---:|:---:|:---:| +| Ekran Görüntüsü 1 | Ekran Görüntüsü 2 | Ekran Görüntüsü 3 | Ekran Görüntüsü 4 | + +
+ +--- + +## Öne Çıkan Özellikler + +- **Bağımsız İstemci** — Android için Telegram'ın bir çatalı (fork) değildir. MonoGram, tamamen sıfırdan bağımsız bir proje olarak inşa edilmiştir. +- **Material Design 3** — Telefonlar, tabletler ve katlanabilir cihazlarda harika görünen, estetik ve uyarlanabilir kullanıcı arayüzü. +- **Güvenli** — Yerleşik biyometrik kilitleme ve şifrelenmiş yerel depolama. +- **Zengin Medya Deneyimi** — ExoPlayer ve Coil 3 ile yüksek performanslı medya oynatma. +- **Hızlı ve Verimli** — Kotlin Coroutines ile desteklenen, performans için optimize edilmiş yapı. +- **Temiz Mimari (Clean Architecture)** — Domain, Data ve Presentation katmanları ile sorumlulukların net bir şekilde ayrılması. +- **MVI Deseni** — MVIKotlin kullanılarak sağlanan öngörülebilir durum yönetimi. +- **NFT veya Kripto Yok** — MonoGram; Telegram tarafından dayatılan ve bir mesajlaşma uygulamasının kapsamı dışında gördüğümüz NFT tanıtımları, hediyeleri veya benzeri özellikleri asla içermeyecektir. + +--- + +## Başlangıç + +Projeyi yerel ortamınızda kurmak için bu adımları izleyin. + +### Ön Koşullar + +- **Android Studio**: Ladybug veya daha yeni bir sürüm (önerilir). +- **JDK**: Java 17 veya daha yeni bir sürüm. + +### 1. Depoyu Klonlayın + +```bash +git clone --recurse-submodules https://github.com/monogram-android/monogram.git +cd monogram +``` +### 2. Telegram API Anahtarlarını Yapılandırın + +Telegram sunucularına bağlanmak için kendi API kimlik bilgilerinize ihtiyacınız vardır. + +1. [my.telegram.org](https://my.telegram.org/) adresinde oturum açın. +2. **API development tools (API geliştirme araçları)** bölümüne gidin. +3. `App api_id` ve `App api_hash` değerlerinizi almak için yeni bir uygulama oluşturun. +4. Projenin kök dizininde (eğer yoksa) `local.properties` adlı bir dosya oluşturun. +5. Aşağıdaki satırları dosyaya ekleyin: + +```properties +API_ID=12345678 +API_HASH=your_api_hash_here +``` +### 3. Anlık Bildirimleri (Push Notifications) Yapılandırın + +1. [Firebase konsolunda](https://console.firebase.google.com) oturum açın. +2. Yeni bir proje oluşturun. +3. İhtiyacınız olan `applicationId` değerine sahip yeni bir uygulama ekleyin. Farklı ID'lere sahip birden fazla uygulamanız varsa, birden fazla Firebase uygulaması oluşturun. **Varsayılan olarak, hata ayıklama (debug) ve yayınlama (release) sürümlerinin `applicationId` değerleri farklıdır.** +4. `google-services.json` dosyasını indirin ve **app** modülünün kök dizinine kopyalayın (`monogram/app/google-services.json`). Birden fazla uygulama oluşturduysanız, yalnızca en güncel yapılandırmayı kopyalayın. +5. **Cloud Messaging** bölümüne gidin. +6. **Manage service accounts** (Hizmet hesaplarını yönet) seçeneğine tıklayın. +7. Açılan pencerenin üst kısmındaki **Keys** (Anahtarlar) sekmesini seçin. +8. **Add key** (Anahtar ekle) seçeneğine tıklayın ve **JSON** opsiyonunu seçin. Dosyanın indirilmesini bekleyin. +9. Uygulama ID'nizi aldığınız Telegram API sayfasına geri dönün. +10. FCM kimlik bilgileri (FCM credentials) bölümünün yanındaki **Update** (Güncelle) butonuna tıklayın. +11. Açılan sayfada hizmet hesabı (service account) JSON dosyasını yükleyin. + +### 4. İlk Kurulum: libvpx Derlemesi + +Animasyonların çalışması için libvpx'in derlenmiş olması gerekir. Bu işlem, Gradle derlemesini başlatmadan önce yapılmalıdır; aksi takdirde derleme hatalarıyla karşılaşırsınız. + +1. Çalışma dizininizi `presentation/src/main/cpp` olarak değiştirin. +2. `build.sh` dosyası içerisine kendi `ANDROID_NDK_HOME` yolunuzu ekleyin. +3. `build.sh` dosyasını çalıştırın ve işlemin tamamlanmasını bekleyin. + +### 5. Derleyin ve Çalıştırın + +1. Projeyi **Android Studio** ile açın. +2. `TdApi.java` (TDLib sarmalayıcısı) dosyasının doğru şekilde indekslenebilmesi için IDE indeksleme limitlerini artırın. **Android Studio** veya **IntelliJ IDEA** içerisinde, **Help (Yardım) → Edit Custom Properties...** (Özel Özellikleri Düzenle) yolunu izleyin, aşağıdaki satırları yapıştırın ve istenirse IDE'yi yeniden başlatın: + +```properties +# Kb cinsinden boyut +idea.max.intellisense.filesize=20480 +# Kb cinsinden boyut +idea.max.content.load.filesize=20480 +``` + +3. Gradle senkronizasyonunu (Sync) yapın. +4. `app` çalıştırma yapılandırmasını (run configuration) seçin. +5. Bir cihaz bağlayın veya bir emülatör başlatın. +6. **Run** (Çalıştır) butonuna tıklayın. + +--- + +## TDLib Derlemesi + +Eğer TDLib'i kaynaktan derlemeniz gerekirse, öncelikle gerekli bağımlılıkları kurun. Debian/Ubuntu tabanlı dağıtımlar için: + +```bash +sudo apt-get update +sudo apt-get install build-essential git curl wget php perl gperf unzip zip default-jdk cmake +``` + +Ardından projenin kök dizininden derleme betiğini (script) çalıştırın: + +```bash +./build-tdlib.sh +``` + +--- + +## Katkıda Bulunma + +Katkılarınızı memnuniyetle karşılıyoruz! İster hataları gidermek, ister dokümantasyonu iyileştirmek veya yeni özellikler önermek olsun, her türlü katkıya açığız. + +1. **Sorunları (Issues) Kontrol Edin** — Açık sorunlara göz atın veya fikirlerinizi tartışmak için yeni bir sorun kaydı oluşturun. +2. **`develop` Dalında Çalışın** — Kendi dalınızı (branch) `develop` üzerinden oluşturun ve çalışmalarınızı bu dalı temel alarak sürdürün. +3. **Fork & Branch** — Depoyu (repo) çatallayın (fork) ve bir özellik dalı (feature branch) oluşturun. +4. **Kod Stili** — Mevcut Kotlin kod yazım stiline ve Temiz Mimari (Clean Architecture) yönergelerine uyun. +5. **PR Gönderin** — Değişikliklerinizin net bir açıklamasını içeren bir Çekme İsteğini (Pull Request) `develop` dalına açın. + +> [!IMPORTANT] +> - [Telegram API Hizmet Şartlarına](https://core.telegram.org/api/terms) uyun. +> - Kodunuzun tüm kontrollerden ve testlerden geçtiğinden emin olun. + +### Hata Bildirme ve Özellik Önerileri + +- **Hatalar (Bugs)** — Bir sorun kaydı (issue) açın ve başlıkta `[Bug]` etiketini kullanın (Örn: `[Bug] Uygulama başlangıçta çöküyor`). Ayrıca, bilinen tüm hatalara [**Hata Takipçisi**](https://github.com/orgs/monogram-android/projects/3/views/1) üzerinden göz atabilirsiniz. +- **Özellik İstekleri** — `[Feature]` etiketini içeren bir sorun kaydı açın (Örn: `[Feature] Planlanmış mesaj desteği`). Mevcut özellik isteklerini [**Özellik Panosu**](https://github.com/orgs/monogram-android/projects/5/views/1) üzerinden inceleyebilirsiniz. + +--- + +## Çeviriler + +MonoGram topluluk tarafından yapılan çevirileri memnuniyetle karşılar! Kendi dilinizle katkıda bulunmak için metin kaynak (strings resource) dosyasını düzenleyebilirsiniz. + +Kaynak metinler [`presentation/src/main/res/values/string.xml`](https://github.com/monogram-android/monogram/blob/develop/presentation/src/main/res/values/string.xml) adresinde yer almaktadır. Yeni bir dil eklemek için, ilgili dile ait bir `values-/string.xml` dosyası oluşturun (örneğin Almanca için `values-de/string.xml`) ve metinleri orada çevirin. Çevirinizi içeren bir Çekme İsteği (PR) açın, biz de onu projeye dahil edelim. + +--- + +## Teknoloji Yığını + +MonoGram, en güncel Android geliştirme araçlarından ve kütüphanelerinden yararlanır: + +| Kategori | Kütüphaneler | +|:---|:---| +| **Dil** | [Kotlin](https://kotlinlang.org/) | +| **Kullanıcı Arayüzü (UI)** | [Jetpack Compose](https://developer.android.com/jetpack/compose) (Material 3) | +| **Mimari** | [Decompose](https://github.com/arkivanov/Decompose) (Navigasyon), [MVIKotlin](https://github.com/arkivanov/MVIKotlin) | +| **Bağımlılık Enjeksiyonu (DI)** | [Koin](https://insert-koin.io/) | +| **Asenkron İşlemler** | Coroutines & Flow | +| **Telegram Çekirdeği** | [TDLib](https://core.telegram.org/tdlib) (Telegram Database Library) | +| **Görsel Yükleme** | [Coil 3](https://coil-kt.github.io/coil/) | +| **Medya** | Media3 (ExoPlayer) | +| **Haritalar** | [MapLibre](https://maplibre.org/) | +| **Yerel Veritabanı** | Room | + +--- + +## Proje Yapısı + +Proje, sorumlulukların ayrılmasını ve ölçeklenebilirliği sağlamak amacıyla çok modüllü (multi-module) bir yapı izlemektedir: + +| Modül | Açıklama | +|:---|:---| +| **:app** | Ana Android uygulama modülü. | +| **:domain** | İş mantığı (business logic), kullanım durumları (use cases) ve depo (repository) arayüzlerini içeren saf Kotlin modülü. | +| **:data** | Depo (repository) uygulamaları, veri kaynakları ve TDLib entegrasyonu. | +| **:presentation** | Kullanıcı arayüzü (UI) bileşenleri, ekranlar ve görünüm modelleri (MVI Store'ları). | +| **:core** | Modüller genelinde kullanılan ortak yardımcı sınıflar ve uzantılar (extensions). | +| **:baselineprofile** | Uygulama başlangıcı ve performans optimizasyonu için Baseline Profilleri. | + +--- + +## Lisans + +Bu proje [**GNU General Public License v3.0**](LICENSE) (GNU Genel Kamu Lisansı v3.0) kapsamında lisanslanmıştır. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..430890e8 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,44 @@ + + + MonoGram Kilidini Aç + Biyometrik verilerinizi kullanarak giriş yapın + Şifre kullan + Şifreyi Girin + Mesajlarınız korunuyor + Geçersiz şifre + Biyometrik Kilidi Aç + + + Proxy Detayları + Bu proxy sunucusunu ekleyin ve bağlanın + Sunucu + Port + Tür + Bilinmiyor + İptal + Bağlan + + + Katıl + Kanal + Grup + + %d üye + %d üye + + + + Bir ayar seçin + Mesajlaşmaya başlamak için bir sohbet seçin + + + Hata Günlüğü + Panoya kopyalandı + Hata Günlüğünü Paylaş + Paylaş + Kopyala + Uygulamayı Yeniden Başlat + Hata Detayları + Bir şeyler ters gitti. Bu sorunu geliştiricilere bildirmek için lütfen aşağıdaki günlükleri paylaşın veya kopyalayın. + + diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..f0a97342 --- /dev/null +++ b/presentation/src/main/res/values-tr/strings.xml @@ -0,0 +1,2008 @@ + + +Onay + Bu cihazı yetkilendirmek istiyor musunuz? + Evet, giriş yap + İptal + + QR Tara + Cihazlar + Cihaz bağla + + Bu cihaz + Giriş istekleri + Aktif oturumlar + Giriş girişimi + Onaylanmamış + Oturumu sonlandır + + Geri git + Tarayıcıyı kapat + QR Tarayıcı simgesi + + + Telegram'a bağlanılıyor… + + + Hakkında + Geri + MonoGram + Sürüm %1$s + Hizmet Şartları + Şartlar ve koşulları okuyun + Açık Kaynak Lisansları + MonoGram\'da kullanılan yazılımlar + GitHub + Kaynak kodunu görüntüle + TDLib Sürümü + %1$s (%2$s) + Topluluk + Telegram Sohbeti + Özellikleri tartışmak ve yardım almak için topluluğumuza katılın + Telegram Kanalı + En son haberler ve duyurulardan haberdar olun + MonoGram\'ı Destekle + Geliştirmeye destek olun ve projenin devam etmesini sağlayın + Geliştiriciler + Geliştirici + Simge ve Logo Tasarımcısı + MonoGram, Material Design 3 ile oluşturulmuş gayriresmi bir Telegram istemcisidir + © 2026 MonoGram + + + Güncellemeleri Denetle + Denetleniyor... + Güncelleme Mevcut: %1$s + Sürümünüz güncel + Güncelleme Hazır + Güncelleme Hatası + Yeni sürümü denetlemek için dokunun + Sunucuya bağlanılıyor + İndirilebilir yeni bir sürüm var + En son sürümü kullanıyorsunuz + Güncellemeyi yüklemek için dokunun + Güncelleme İndiriliyor... + %% %1$d + %1$s Yenilikleri + Güncellemeyi İndir + İptal + Yükleniyor... + + + Telefonunuz + Doğrulama + Şifre + Proxy Ayarları + Telefon Numaranız + Lütfen ülke kodunuzu onaylayın ve telefon numaranızı girin. + Ülke + Kod + Telefon Numarası + 000 00 00 + Devam Et + Ülke Seçin + Bilinmeyen ülke + Ülke veya kod ara... + Kodu diğer cihazınızdaki Telegram uygulamasına gönderdik. + Kodu SMS ile gönderdik. + Sizi kod için arıyoruz. + Kodu %1$s adresine gönderdik. + Doğrulama kodunu gönderdik. + Onayla + Kodu %1$s içinde tekrar gönder + SMS ile tekrar gönder + Arama ile tekrar gönder + Kodu tekrar gönder + Yanlış numara? + İki Adımlı Doğrulama + Hesabınız ek bir şifre ile korunuyor. + Şifre + Kilidi Aç + Yapıştır + Kimlik Doğrulama Hatası + Kapat + + + Lütfen bekleyin + Çok mu uzun sürdü? + Bağlantıyı Sıfırla + + + İlet... + + %1$d sohbet seçildi + %1$d sohbet seçildi + + Gönder + Arşivlenmiş Sohbetler + Yeni Sohbet + Son Aramalar + Tümünü Temizle + Sohbetler ve kişiler + Genel arama + Mesajlar + Daha fazla göster + Görüşme ara… + Ağ bekleniyor… + Bağlanıyor… + Güncelleniyor… + Proxy\'ye bağlanılıyor… + Proxy etkin + Proxy + Forum + Spoiler + Taslak: + Henüz gönderi yok + Henüz mesaj yok + Sabitlenmiş + Bahsetmeler + Ana listeden gizlenenler + Henüz sohbet yok + Yeni bir sohbet başlatın + Mini Uygulama + + + MonoGram Alpha + Hesap Ekle + Başka bir hesapla giriş yap + Profilim + Profilinizi görüntüleyin + Kayıtlı Mesajlar + Bulut depolama alanı + Ayarlar + Uygulama yapılandırması + Güncelleme Mevcut + Yeni sürüm %1$s mevcut + Güncelleme indiriliyor... %% %1$d + Güncelleme yüklenmeye hazır + Yardım ve Geri Bildirim + SSS ve destek + Gizlilik Politikası + Android için MonoGram Alpha v%1$s + Bilinmeyen Kullanıcı + Bilgi yok + Hesapları göster + + + Mesaj ara... + Temizle + Sessiz + Doğrulanmış + Sponsor + Sesi Aç + Sessize Al + Reklamları Filtrele + Kanalı Beyaz Listeye Al + Bağlantıyı Kopyala + Geçmişi Temizle + Sohbeti Sil + Rapor Et + KATIL + En alta kaydır + Bu konu kapatıldı + Ek Dosya + + + Mesaj silinsin mi? + %1$d mesaj silinsin mi? + Bu mesajı silmek istediğinizden emin misiniz? + Bu mesajları silmek istediğinizden emin misiniz? + Herkes için sil + Benim için sil + + Neden rapor ediyorsunuz? + Raporunuz anonimdir. Güvenliği sağlamak için sohbet geçmişini inceleyeceğiz. + Rapor detayları + Sorunu açıklayın… + Raporu Gönder + Spam + İstenmeyen ticari içerik veya dolandırıcılık + Şiddet + Şiddet tehdidi veya övülmesi + Pornografi + Uygunsuz medya veya müstehcen dil + Çocuk güvenliği + Reşit olmayanlara zarar veren içerik + Telif Hakkı + Başkasının fikri mülkiyetini kullanma + Taklit + Başka biri veya bir bot gibi davranma + Yasadışı uyuşturucular + Yasaklı maddelerin tanıtımı veya satışı + Gizlilik ihlali + Özel iletişim bilgileri veya adres paylaşımı + İlgisiz konum + Bu mekanla ilgili olmayan içerik + Diğer + Şartlarımızı ihlal eden başka bir durum + + Kullanıcıyı Kısıtla + Mesaj Gönder + Medya Gönder + Çıkartma ve GIF Gönder + Anket Gönder + Bağlantıları Önizle + Mesajları Sabitle + Sohbet Bilgisini Değiştir + Kısıtlama bitişi + Süresiz + Tarih Seç + Saat Seç + Kısıtla + + Yanıtla + Kopyala + Sabitle + Sabitlemeyi Kaldır + İlet + Seç + Daha Fazla + Sil + Yorumları Görüntüle + İndirilenlere Kaydet + Cocoon + Özet + Çevir + Telegram Cocoon ile oluşturuldu + Orijinal Metni Göster + Kullanıcıyı Kısıtla + Düzenlendi + Okundu + Görüntüleme + + + Bilinmiyor + + %1$d üye + %1$d üye + + %1$s, %2$d çevrimiçi + Henüz eklenmedi + Arama henüz eklenmedi + Paylaşım henüz eklenmedi + Engelleme henüz eklenmedi + Silme henüz eklenmedi + İstatistikler + Gelir + Paylaş + Düzenle + Kullanıcıyı Engelle + Ayrıl + + Mesaj + Katıl + Rapor Et + QR Kodu + Ekle + Kişisel Fotoğraf + Bu fotoğraf sadece sizin tarafınızdan görülebilir + Mini Uygulamayı Aç + Botun web uygulamasını başlat + Şartları Kabul Et + Botun hizmet şartlarını inceleyin ve kabul edin + Bot İzinleri + Bu bot için izinleri yönetin + Kullanıcı Adı + Bağlantı + Davet Bağlantısı + Bot Bilgisi + Açıklama + Hakkında + Doğum Tarihi + Konum + Çalışma Saatleri + Son Eylemler + Sohbet olay günlüğünü görüntüle + Detaylı sohbet istatistiklerini görüntüle + Sohbet gelir istatistiklerini görüntüle + Sizi rehberine ekledi + Sizi rehberine eklemedi + Rehberinizde kayıtlı + Rehberinizde kayıtlı değil + Profil hikayeleri + Profilinizden hikaye yayınlayabilirsiniz + Sohbet arka planı + Özel arka planlar ayarlayabilirsiniz + Sesli ve görüntülü mesajlar + Sesli ve görüntülü mesaj gönderme kısıtlandı + Yavaş mod + Üyeler her %1$s içinde bir mesaj gönderebilir + Korumalı içerik + İletme ve kaydetme kısıtlandı + Gizli iletmeler + İletilen mesajlar profil bağlantısını gizler + Sohbet İstatistikleri + %1$d yönetici + %1$d kısıtlı + %1$d yasaklı + Bilgi + Bildirimler + Mesajları otomatik sil + Ayarlar + Kaydet + Spam + Şiddet + Pornografi + Çocuk İstismarı + Telif Hakkı + Gelir + Diğer + Kapat + Bu Mini Uygulamayı başlatarak Hizmet Şartlarını ve Gizlilik Politikasını kabul etmiş olursunuz. Bot, temel profil bilgilerinize erişebilecektir. + Kabul Et ve Başlat + + + QR Kodu + Paylaş + MonoGram ücretsiz ve açık kaynaklı bir projedir. Desteğiniz projenin devam etmesine ve yeni özellikler geliştirmemize yardımcı olur. + Kalp simgeli sponsor rozeti, Gelişmiş destek seviyesi veya 150 RUB (yaklaşık 1,96$) tutarındaki katkılar için geçerlidir. + Boosty üzerinden destekle + Belki daha sonra + Profili Düzenle + Maskelemek için basılı tutun + Görmek için basılı tutun, kopyalamak için tıklayın + ID Numaranız + Adınızı, biyografinizi ve profil fotoğrafınızı değiştirin + t.me bağlantılarını etkinleştir + Telegram bağlantılarını uygulama içinde aç + Genel + Sohbet Ayarları + Temalar, yazı boyutu, video oynatıcı + Gizlilik ve Güvenlik + Uygulama kilidi, aktif oturumlar, gizlilik + Bildirimler ve Sesler + Mesajlar, gruplar, aramalar + Veri ve Depolama + Ağ kullanımı, otomatik indirme + Güç Tasarrufu + Pil kullanım ayarları + Sohbet Klasörleri + Sohbetlerinizi düzenleyin + Çıkartmalar ve Emoji + Çıkartma ve emoji paketlerini yönetin + Bağlı cihazlar + Dil + İngilizce + Proxy Ayarları + MTProto, SOCKS5, HTTP + Telegram Premium + Özel özelliklerin kilidini açın + Projeyi geliştirmemize yardım edin + MonoGram Destekçisi + Bu kullanıcı projeyi destekliyor ve gelişmesine yardımcı oluyor + MonoGram sürümü ve bilgisi + Hata Ayıklama + Hata ayıklama seçenekleri + Sponsor sayfasını göster + Sponsor bilgi sayfasını aç + Sponsor senkronizasyonunu zorla + Sponsor ID\'lerini şimdi kanaldan çek + Oturumu Kapat + Hesap bağlantısını kes + + + Kullanıcı Adları + %1$d sn sonra tekrar deneyin + Aktif Kullanıcı Adları + Devre Dışı Kullanıcı Adları + Koleksiyonluk Kullanıcı Adları + Tamam + Tamam + Başlangıç Saatini Seçin + Bitiş Saatini Seçin + Çalışma Saatleri + Çalışma Günleri + Zaman Aralığı + Başlangıç + Bitiş + İşletme Konumu + Adres + Konumu Onayla + Profili Düzenle + Ad (Zorunlu) + Soyad (İsteğe bağlı) + Biyografi + Yaş, meslek veya şehir gibi detaylar. Örnek: İstanbul\'dan 23 yaşında bir tasarımcı. + Kullanıcı Adı + Telegram\'da bir kullanıcı adı seçebilirsiniz. Seçerseniz, insanlar sizi bu kullanıcı adıyla bulabilir ve telefon numaranıza ihtiyaç duymadan sizinle iletişim kurabilirler. + Doğum Gününüz + Doğum Günü + Telegram İşletme + Bağlı Kanal ID\'si + İşletme Biyografisi + İşletme Adresi + Coğrafi Konum + Çalışma Saatleri + Premium kullanıcı olarak bir kanala bağlantı ekleyebilir ve profilinize işletme detaylarını set edebilirsiniz. + Ayarlanmadı + (%1$d gün) + Pzt + Sal + Çar + Per + Cum + Cmt + Paz + + + Gizlilik ve Güvenlik + Gizlilik + Engellenen Kullanıcılar + %1$d kullanıcı + Hiçbiri + Telefon Numarası + Son Görülme ve Çevrimiçi + Profil Fotoğrafları + İletilen Mesajlar + Aramalar + Gruplar ve Kanallar + Güvenlik + Uygulama Kilidi + Açık + Kapalı + Biyometrik Kilidi Aç + Kilidi açmak için parmak izi veya yüz tanıma kullanın + Aktif Oturumlar + Giriş yapılmış cihazlarınızı yönetin + Hassas İçerik + Filtrelemeyi devre dışı bırak + Tüm cihazlarınızdaki genel kanallarda hassas medyaları göster. + Gelişmiş + Hesabımı Sil + %1$s boyunca yoksam + Hesabı Şimdi Sil + Hesabınızı ve tüm verileri kalıcı olarak silin + Şu kadar süre aktif olmazsam sil... + Hesabı Sil + Hesabınızı silmek istediğinizden emin misiniz? Bu işlem kalıcıdır ve geri alınamaz. + Herkes + Kişilerim + Hiç kimse + 1 ay + 3 ay + 6 ay + 1 yıl + 1,5 yıl + 2 yıl + %1$d ay + %1$d gün + Kullanıcı silme isteği + + + Proxy Ayarları + Pingleri Yenile + Ekle + Bağlantı + Akıllı Geçiş + Otomatik olarak en hızlı proxy\'yi kullan + IPv6\'yı Tercih Et + Mümkün olduğunda IPv6 kullan + Proxy\'yi Devre Dışı Bırak + Doğrudan bağlı + Doğrudan bağlantıya geç + Ağ Kuralları + Her ağ türü için proxy çalışma şeklini seçin + Wi-Fi + Mobil veri + VPN + Diğer ağlar + Doğrudan + En iyi proxy + Son kullanılan + Belirli proxy + Bu ağda her zaman doğrudan bağlan + Bu ağdaki en hızlı proxy\'yi seç + Bu ağda kullanılan son proxy\'yi tekrar kullan + Bu ağda her zaman seçilen bir proxy\'yi kullan + Özel: %1$s:%2$d + Liste Davranışı + Proxy\'leri sırala + Proxy listesinin nasıl sıralanacağını seçin + Önce aktifler + En düşük ping + Sunucu adı + Proxy türü + Durum + Seçili proxy kullanılamıyorsa + Belirli/son kullanılan modlar için yedek davranış + En iyi proxy\'ye geç + Doğrudan bağlantıya geç + Mevcut durumu koru + Çevrimdışı proxy\'leri gizle + Sadece mevcut veya denetlenmemiş proxy\'leri göster + Proxy\'leri dışa aktar + Proxy\'leri içe aktar + Proxy listesi dışa aktarıldı + Proxy listesi dışa aktarılamadı + İçe aktarma dosyası okunamadı + Favorilere ekle + Favorilerden çıkar + Bağlantı olarak kopyala + Proxy\'yi düzenle + Proxy\'yi sil + Proxy bağlantısı kopyalandı + Listeyi Yenile + En son topluluk proxy\'lerini getir + Proxy\'leriniz + Çevrimdışıları Temizle + Tümünü Kaldır + Çevrimdışı Proxy\'leri Sil + Bu işlem şu an çevrimdışı olan tüm proxy\'leri kaldıracaktır. Devam edilsin mi? + Tüm Proxy\'leri Sil + Bu işlem uygulamadaki tüm yapılandırılmış proxy\'leri kaldıracaktır. Devam edilsin mi? + Proxy eklenmedi + Filtrelerle eşleşen proxy yok + Proxy\'yi Sil + %1$s proxy\'sini silmek istediğinizden emin misiniz? + Yeni Proxy + Proxy\'yi Düzenle + Sunucu Adresi + Port + Secret (Hex) + Kullanıcı Adı (İsteğe bağlı) + Şifre (İsteğe bağlı) + Kaydet + Test Et + Test Sonucu + Sil + Denetleniyor... + Çevrimdışı + %1$dms + + + Kullanıcı Adlarınız + Daha fazla seçenek + + + Bildirimler ve Sesler + Mesaj Bildirimleri + Özel Sohbetler + Gruplar + Kanallar + Bildirim Ayarları + Titreşim + Öncelik + Bildirimleri Tekrarla + Sadece Göndereni Göster + Bildirimlerde mesaj içeriğini gizle + Push Servisi + Push Sağlayıcı + Keep-Alive Servisi + Güvenilir bildirimler için uygulamayı arka planda çalışır durumda tut + Ön Plan Bildirimini Gizle + Servis başladıktan sonra bildirimi gizle. Sistemin servisi durdurmasına neden olabilir. + Uygulama İçi Bildirimler + Uygulama İçi Sesler + Uygulama İçi Titreşim + Uygulama İçi Önizleme + Etkinlikler + Kişi Telegram\'a Katıldı + Sabitlenmiş Mesajlar + Tüm Bildirimleri Sıfırla + Tüm kişiler ve gruplar için özel bildirim ayarlarını geri al + Titreşim Deseni + Bildirim Önceliği + %1$s, %2$d istisna + Varsayılan + Kısa + Uzun + Devre Dışı + Düşük + Varsayılan + Yüksek + Asla + Her %1$d dakikada bir + Her 1 saatte bir + Her %1$d saatte bir + Firebase Cloud Messaging + GMS-less (Arka Plan Servisi) + + + Sohbet Ayarları + Görünüm + Mesaj metni boyutu + Mesaj harf aralığı + Balon yuvarlaklığı + Çıkartma boyutu + Sohbet Duvar Kağıdı + Duvar Kağıdını Sıfırla + Duvar Kağıdı Yükle + Emoji Stili + Tema + Gece Modu + Sistem + Açık + Koyu + Planlı + Otomatik + Mevcut davranış + Kullanılan: %1$s + Parlaklık eşiği: %1$d%% + Ekran parlaklığı bu seviyenin altına düştüğünde koyu temaya geç. + Dinamik Renkler + Dinamik Renkler + Uygulama teması için sistem renklerini kullan + Veri ve Depolama + Fotoğrafları Sıkıştır + Göndermeden önce fotoğraf boyutunu küçült + Videoları Sıkıştır + Göndermeden önce video boyutunu küçült + Video Oynatıcı + Hareketleri Etkinleştir + Ses ve parlaklığı kontrol etmek için kaydırın + Sarmak için Çift Dokun + İleri/geri sarmak için video kenarlarına çift dokunun + Sarma Süresi + Yakınlaştırmayı Etkinleştir + Video oynatıcıda parmakla yakınlaştırın + Sohbet Listesi + Arşivlenenleri Sabitle + Arşivlenmiş sohbetleri listenin başında tut + Sabit Arşivi Her Zaman Göster + Kaydırırken bile sabitlenmiş arşivi görünür tut + Bağlantı Önizlemelerini Göster + Mesajlardaki bağlantılar için önizleme göster + Geriye Sürükle + Geri gitmek için sol kenardan kaydırın + Tablet Arayüzü + Tabletlerde bölünmüş ekran düzenini kullan + İki satırlı + Üç satırlı + Fotoğrafları Göster + Sohbet listesinde profil fotoğraflarını göster + Deneysel + Kanallar için AdBlock + Kanallardaki sponsorlu gönderileri gizle + Son Medyalar + Son Çıkartmaları Temizle + Son kullanılan tüm çıkartmaları kaldır + Son Emojileri Temizle + Son kullanılan tüm emojileri kaldır + Emoji Paketini Kaldır + %1$s paketi kaldırılsın mı? + Bu işlem, indirilmiş emoji paketini cihazınızdan silecektir. Daha sonra tekrar indirebilirsiniz. + Özel temayı düzenle + Seçildi + Apple + Twitter + Windows + Catmoji + Noto + Sistem + + + Veri ve Depolama + Disk ve ağ kullanımı + Depolama Kullanımı + Yerel önbelleğinizi yönetin + Ağ Kullanımı + Gönderilen ve alınan verileri görüntüleyin + Otomatik medya indirme + Mobil veri kullanılırken + Wi-Fi bağlıyken + Dolaşımdayken + Dosyaları otomatik indir + Gelen dosyaları otomatik olarak indir + Çıkartmaları otomatik indir + Çıkartmaları otomatik olarak indir + Video mesajları otomatik indir + Video mesajları otomatik olarak indir + Medyayı otomatik oynat + GIF\'ler + Sohbet listesinde ve sohbetlerde GIF\'leri otomatik oynat + Videolar + Sohbetlerde videoları otomatik oynat + Etkin + Devre Dışı + + + Güç Tasarrufu + Pil + Güç Tasarrufu Modu + Pil tasarrufu için arka plan etkinliklerini ve animasyonları azaltır + Pil Kullanımını Optimize Et + Arka plan çalışmasını agresif bir şekilde kısıtla ve uyandırma kilitlerini serbest bırak + Uyandırma Kilidi (Wake Lock) + Arka plan görevleri için işlemciyi uyanık tut. Pil tasarrufu için devre dışı bırakın + Animasyonlar + Sohbet Animasyonları + Pil tasarrufu için sohbet animasyonlarını devre dışı bırak + Arka Plan + Bunu devre dışı bırakmak pil kullanımını azaltır ancak arka plan bildirimlerini geciktirebilir + + + Çıkartmalar ve Emoji + Çıkartmalar + Emoji + Son Çıkartmalar + Çıkartma Setleri + Arşivlenmiş Çıkartmalar + Kendi çıkartmalarını ekle + @Stickers botunu kullanarak kendi setlerini oluştur + Yüklü çıkartma seti yok + \"%1$s\" için çıkartma bulunamadı + Son Emojiler + Emoji Paketleri + Arşivlenmiş Emojiler + Kendi emojini ekle + @Stickers botunu kullanarak kendi paketlerini oluştur + Son Emojileri Temizle + Son kullanılan tüm emojileri kaldır + Yüklü emoji paketi yok + \"%1$s\" için emoji bulunamadı + Paket ara + Ara + + %1$d çıkartma + %1$d çıkartma + + + %1$d emoji + %1$d emoji + + Maskeler + Özel Emojiler + Resmî + Bağlantı panoya kopyalandı + + + Ağ Kullanımı + İstatistikleri Sıfırla + Ağ İstatistikleri + Ne kadar veri kullandığınızı takip edin. Devre dışı bırakmak disk alanı kullanımını azaltabilir. + Ağ İstatistikleri Devre Dışı + Ağ kullanımı takibi şu an kapalı. Mobil, Wi-Fi ve dolaşım ağlarında ne kadar veri kullandığınızı görmek için yukarıdaki anahtarı kullanarak etkinleştirin. + Toplam Kullanım + Gönderilen + Alınan + Genel Bakış + Uygulama Kullanımı + Kaydedilmiş kullanım verisi yok + İstatistik mevcut değil + Mobil + Wi-Fi + Dolaşım + Diğer + + + Depolama Kullanımı + Tüm Önbelleği Temizle • %1$s + Tüm sohbetlerdeki fotoğrafları, videoları, belgeleri, çıkartmaları ve GIF\'leri içerir. + Önbellek Sınırı + Önbelleği Otomatik Temizle + Depolama Optimize Edici + Arka planda depolama optimizasyonu + Detaylı Kullanım + Depolama Temiz + Önbelleğe alınmış dosya bulunamadı. + Önbelleği Temizle + \"%1$s\" için önbelleği temizlemek istediğinizden emin misiniz? Bu işlem %2$s alan boşaltacaktır. + Tüm Önbelleği Temizle + Tüm sohbetlerdeki önbelleğe alınmış medyalar silinecektir. Emin misiniz? + Sınırsız + Asla + Her Gün + Her Hafta + Her Ay + Toplam Kullanılan + %1$d dosya + %1$d dosya + + + Beni %1$s\'e kimler ekleyebilir? + Beni kimler arayabilir? + Son görülme zamanımı kimler görebilir? + Profil fotoğraflarımı kimler görebilir? + Biyografimi kimler görebilir? + Mesajlarım iletildiğinde profilime kimler bağlantı ekleyebilir? + Beni gruplara ve kanallara kimler ekleyebilir? + Telefon numaramı kimler görebilir? + Beni numaramla kimler bulabilir? + Numaranızı rehberine ekleyen kullanıcılar, ancak yukarıdaki ayar izin veriyorsa numaranızı görebilirler. + İstisnalar ekle + Her Zaman İzin Ver + Asla İzin Verme + %1$d kullanıcı/sohbet + (silindi) + Sohbet üyeleri + Telefon Numarası Araması + Engellenen kullanıcı yok + Engellenen kullanıcılar sizinle iletişime geçemez ve son görülme zamanınızı göremez. + Engeli Kaldır + Kullanıcıyı Engelle + Kullanıcı ara + Kullanıcı bulunamadı + + + Şifreyi Değiştir + Şifre Koy + Uygulamanız şu an bir şifre ile korunuyor. Değiştirmek için yenisini girin. + Uygulamayı kilitlemek ve gizliliğinizi korumak için 4 haneli bir şifre girin. + Şifre + Mevcut Şifre + Şifreyi Kaydet + Şifreyi Kapat + Şifreyi Doğrula + Değiştirmeden veya kapatmadan önce mevcut şifrenizi girin. + Hatalı şifre. + + + Anahtar Kelime Ekle + AdBlock\'u Etkinleştir + Beyaz Listedeki Kanallar + %1$d kanala izin verildi + Temel kelimeleri yükle + Varlıklardan yaygın reklam kelimelerini içe aktar + Tüm kelimeleri kopyala + Mevcut listeyi panoya kopyala + Tüm kelimeleri temizle + Listedeki tüm anahtar kelimeleri kaldır + Gönderileri gizlemek için anahtar kelimeler + Anahtar kelime eklenmedi + Filtreleme için kelime eklemek için + butonuna dokunun + Anahtar Kelime Ekle + Kanal gönderilerini filtrelemek için virgül veya yeni satır ile ayrılmış kelimeler girin. + örn: #reklam, ad, sponsorlu + Listeye Ekle + Bu kanallardaki gönderiler filtrelenmeyecektir + Beyaz listeye alınmış kanal yok + Kaldır + + + son görülme yakınlarda + son görülme az önce + + son görülme %1$d dakika önce + son görülme %1$d dakika önce + + son görülme %1$s + son görülme dün %1$s + son görülme %1$s + son görülme bir hafta içinde + son görülme bir ay içinde + son görülme çok uzun zaman önce + çevrimiçi + çevrimdışı + bot + Kullanıcı Adı + %1$d çıkartma + Arşivlendi + Ekle + Arşivden Çıkar + Arşivle + Seti kaldır + + + Yeni Mesaj + Üye Ekle + Yeni Grup + Yeni Kanal + %1$d kişi + %1$d / 200000 + %1$d seçildi + Kişilerde ara... + Kişi bulunamadı + \"%1$s\" için sonuç bulunamadı + Son görülme zamanına göre sıralandı + Yeni Grup + Yeni Kanal + Destek + Kanallar, mesajlarınızı sınırsız kitlelere yayınlamak için kullanılan bir araçtır. + Kanal Detayları + Kanal Adı + Açıklama (isteğe bağlı) + Mesajları Otomatik Sil + Kapalı + 1 gün + 2 gün + 3 gün + 4 gün + 5 gün + 6 gün + 1 hafta + 2 hafta + 3 hafta + 1 ay + 2 ay + 3 ay + 4 ay + 5 ay + 6 ay + 1 yıl + Kanalınız için isteğe bağlı bir açıklama ekleyebilirsiniz. Bağlantıya sahip olan herkes kanalınıza katılabilir. + Yeni grubunuz için bir ad ve isteğe bağlı bir fotoğraf ekleyin. + Grup Detayları + Grup Adı + Lütfen bir grup adı girin + Lütfen bir kanal adı girin + Profili Aç + İsmi Düzenle + Kişiyi Sil + Kişiyi Düzenle + Ad + Soyad + Kişi silinsin mi? + %1$s kişisini rehberinizden silmek istediğinizden emin misiniz? + Bu grupta gönderilen yeni mesajları belirli bir süre sonra otomatik olarak silin. + Fotoğraf ekle + Fotoğrafı değiştir + + + İzinler Gerekli + En iyi deneyimi sunmak için MonoGram'ın aşağıdaki izinlere ihtiyacı vardır. + Bildirimler + Yeni mesajlardan haberdar olun + İzin Ver + Telefon Durumu + Daha iyi bir kullanıcı deneyimi için cihaz durumlarını yönetin + Pil Optimizasyonu + Arka planda güvenilir çalışma sağlayın + Devre Dışı Bırak + Kamera + Fotoğraf çekin ve video mesajlar kaydedin + Mikrofon + Sesli ve görüntülü mesajlar kaydedin + Konum + Konumunuzu paylaşın ve yakındaki kullanıcıları görün + + + Seçimi temizle + Sabitle + + + Bot Komutları + Bota göndermek için bir komut seçin + + + Sohbet Listesi + Konata Izumi + Kısa boylu değilim, sadece yoğunlaştırılmış harikalığım! 🍫 Ayrıca, profesyonel bir uykucu olmaya karar verdim 😴 + 12:45 + Kagami Hiiragi + Ödevi unutma! Yarın sabah teslim edilmesi gerekiyor ve oldukça zor. + 11:20 + + + Önizleme + Ben + Kısa boylu değilim, sadece yoğunlaştırılmış harikalığım! 🍫\nAyrıca, profesyonel bir uykucu olmaya karar verdim 😴 + En üst rafa yetişemediğin her seferde aynı şeyi söylüyorsun... 🙄\nŞuna bir bak: bu + Beni ifşa etmeyi bırak! 😤✨\nGizli silahımı kullanacağım: %100 saf tembellik + Süper etkili oldu! 😵‍💫 + Bugün + Konata + + %1$d abone + Konu + + + Kaydı Sil + < İptal etmek için kaydırın + Kaydı Gönder + Kaydı Kilitle + Yukarı kaydırın + + + Açıklama ekle… + Mesaj + Mesaj gönderimine izin verilmiyor + + + Fotoğraf + Video + Çıkartma + Sesli mesaj + Görüntülü mesaj + GIF + Konum + Mesaj + + + Yanıtlamayı iptal et + Mesajı Düzenle + Düzenlemeyi iptal et + %1$d öğeyi ekle + Medyayı gönder + İptal + Kopyala + Yapıştır + Görseli yapıştır + Kes + Tümünü seç + Uygula + Bitti + Yenile + Tam ekran düzenleyici + Düzenleyici + Sessiz gönder + Mesajı zamanla + Zamanlanmış mesajlar + Zamanlanmış mesajlar (%1$d) + Henüz zamanlanmış mesaj yok + Toplam zamanlanan: %1$d + Sonraki gönderim: %1$s + Düzenlenebilir: %1$d + Kimlik: %1$d + Düzenle + Gönder + Sil + Yönet + + Sınırlı medya erişimi etkin + Yalnızca seçilen fotoğraf ve videolar görünür. + Ekler + Diğer kaynaklar + Tümü + Fotoğraflar + Videolar + Tüm klasörler + Ekran görüntüleri + %1$d seçildi + Eklenmeye hazır + Medya erişimine izin ver + Sohbette dosya eklemek için fotoğraf ve videolara erişim izni verin. + Erişim izni ver + + %1$d karakter • %2$d biçim bloğu + %1$d biçim bloğu + Telegram\'daki gibi zengin biçimlendirme uygulamak için metni seçin + %1$d/%2$d + Geri Al + Yinele + Önizleme + Düzenle + Markdown: açık + Markdown: kapalı + A+ + A- + Snippet\'lar + Snippet olarak kaydet + Snippet başlığı + Henüz snippet yok + Bul + Değiştir + Tümünü değiştir + %1$d / %2$d eşleşme + Eşleşme yok + %1$d kelime + ~%1$d dk okuma + Taslak otomatik kaydedildi + Kopyala + Kes + Yapıştır + AI + AI düzenleyici + Çevir + Stille + Düzelt + Orijinal + Sonuç + Değişiklikler + Sonucu uygula + Metni çevir + Stili uygula + Metni düzelt + Hedef dil + Dil seçin + Dil bulunamadı + Resmî + Kısa + Kabile + Kurumsal + Dini + Viking + Zen + Emoji ekle + İşleniyor... + AI kullanmak için metin girin + Çok fazla AI isteği. Telegram Premium gerekebilir. + AI işleme başarısız oldu + Ekle + Önceki + Sonraki + + Kalın + İtalik + Altı Çizili + Üstü Çizili + Spoiler + Kod + Sabit Aralıklı + Bağlantı + Bahsetme + Emoji + Temizle + Bağlantı ekle + URL + Kod dili + Dil (örn. kotlin) + + + Komutlar + + + yazıyor + görüntülü mesaj kaydediyor + sesli mesaj kaydediyor + fotoğraf gönderiyor + video gönderiyor + dosya gönderiyor + çıkartma seçiyor + oyun oynuyor + Biri + ve + ve %d kişi daha + yazıyorlar + + %d kişi yazıyor + %d kişi yazıyor + + + +Fotoğraflar + Videolar + Belgeler + Çıkartmalar + Müzik + Sesli Mesajlar + Görüntülü Mesajlar + Diğer Dosyalar + Diğer / Önbellek + Sohbet %d + + + Aramalar + + + Yeni mesajlar bekleniyor + Durdur + Arka Plan Servisi + Uygulamanın arka planda çalıştığına dair bildirim + Yeni mesaj + Ben + %2$d sohbetten %1$d mesaj + %1$d sohbet + Sohbetler + Diğer + Özel sohbetler + Özel görüşmelerden gelen bildirimler + Gruplar + Gruplardan gelen bildirimler + Kanallar + Kanallardan gelen bildirimler + Diğer + Diğer bildirimler + + + 📷 Fotoğraf + 📹 Video + 🎤 Sesli mesaj + 🧩 Çıkartma + 📎 Belge + 🎵 Müzik + GIF + 🎬 Görüntülü mesaj + 👤 Kişi + 📊 Anket + 📍 Konum + 📞 Arama + 🎮 Oyun + 💳 Fatura + 🎬 Hikaye + 📌 Sabitlenmiş mesaj + Mesaj + Bot + Çevrimiçi + Çevrimdışı + Son görülme az önce + Son görülme %d dakika önce + Son görülme %d dakika önce + Son görülme %s + Son görülme dün %s + Son görülme %s + Son görülme yakınlarda + Son görülme bir hafta içinde + Son görülme bir ay içinde + + + Sabitlenmiş Mesajlar + Sabitlenmiş Mesaj + Tüm sabitlenenleri göster + Sabitlemeyi kaldır + Kapat + + %d mesaj + %d mesaj + + + + Görüntülü mesaj + GIF + Belge + Anket: %s + Çıkartma %s + + + Görünüm + Kırp + Filtreler + Çiz + Metin + Silgi + + + Orijinal + Siyah Beyaz + Sepya + Eskitme + Soğuk + Sıcak + Polaroid + Ters Çevir + + + Kaydet + İptal + Geri Al + Yinele + Kapat + Sıfırla + Sola Döndür + Sağa Döndür + Metin Ekle + Metni Düzenle + Uygula + Sil + + + Boyut + Yakınlaştırma + Bir şeyler yazın... + Düzenlemeye başlamak için bir araç seçin + + + Değişiklikler atılsın mı? + Kaydedilmemiş değişiklikleriniz var. Bunları iptal etmek istediğinizden emin misiniz? + Vazgeç ve At + + + Uygula + Bitti + İptal + Düşük + + + Görünüm + Kes + Filtreler + Metin + Sıkıştır + + + Sesi Aç + Sesi Kapat + Metin Katmanı Ekle + Video Kalitesi + Tahmini Bit Hızı: %1$d kbps + + + Orijinal + Siyah Beyaz + Sepya + Eskitme + Soğuk + Sıcak + Polaroid + Ters Çevir + + + Dosya bulunamadı + Video dosyası eksik + Değişiklikler atılsın mı? + Kaydedilmemiş değişiklikleriniz var. Bunları iptal etmek istediğinizden emin misiniz? + Vazgeç ve At + + + Yükleniyor… + Web Görünümü + Kapat + Diğer seçenekler + + + Seçenekler + Geri + İleri + Yenile + Eylemler + Ayarlar + Kopyala + Bağlantı kopyalandı + Paylaş + Bağlantıyı şununla paylaş: + Tarayıcıda aç + Sayfada bul + Masaüstü sitesi + Reklamları engelle + Metin boyutu: %%1$d + + + Güvenli + Güvenli değil + Güvenlik Bilgileri + Güvenli Olmayan Bağlantı + Bu siteyle olan bağlantınız şifrelenmiştir ve güvenlidir. + Bu siteyle olan bağlantınız güvenli değil. Parola veya kredi kartı gibi hassas bilgilerinizi girmemelisiniz; çünkü bu bilgiler saldırganlar tarafından çalınabilir. + Şu tarafa verildi: + Şu tarafça verildi: + Şu tarihe kadar geçerli: + Bilinmiyor + + + Sayfada bul… + Önceki + Sonraki + Aramayı Kapat + + + Tartışma + Kanal + Haritaları Aç + Yol Tarifi + Şununla navigasyon yap: + Şununla aç: + Tarayıcı / Diğer + + Medya + Üyeler + Dosyalar + Müzik + Ses + Bağlantılar + GIF\'ler + Üye bulunamadı + Medya bulunamadı + Müzik dosyası bulunamadı + Sesli mesaj bulunamadı + Dosya bulunamadı + Bağlantı bulunamadı + GIF bulunamadı + + BOT + DOLANDIRICI + SAHTE + Resmî olmayan uygulama kullanıyor + Bu hesap resmî olmayan bir Telegram istemcisi kullanıyor + Bot doğrulaması + Üçüncü taraf bir bot tarafından doğrulandı + Kapalı + ID + + + İstatistikler Analiz Ediliyor... + Genel Bakış + Üyeler + Mesajlar + İzleyiciler + Aktif Göndericiler + Üye Büyümesi + Yeni Üyeler + Mesaj İçeriği + Eylemler + Günlük Aktivite + Haftalık Aktivite + En Aktif Saatler + Kaynağa Göre Görüntülenme + Kaynağa Göre Yeni Üyeler + Diller + En Çok Gönderenler + mesaj + Ort. karakter: %1$d + En Aktif Yöneticiler + eylem + Silinen: %1$d | Yasaklanan: %2$d + En Çok Davet Edenler + davet + Eklenen üyeler + Aboneler + Bildirimler Açık + Ort. Mesaj Görüntülenme + Ort. Mesaj Paylaşımı + Ort. Tepkiler + Büyüme + Yeni Aboneler + Saate Göre Görüntülenme + Mesaj Etkileşimleri + Hızlı Görünüm Etkileşimleri + Mesaj Tepkileri + Son Etkileşimler + Mesaj + Hikaye + Gönderi Kimliği + Daha Az Göster + Tümünü Göster (%1$d) + + Gelir + Kullanılabilir Bakiye + Toplam Bakiye + Döviz Kuru + Gelir Büyümesi + Saatlik Gelir + Analizler Yüklendi + + Yakınlaştır + Grafik Oluşturuluyor... + Değişim yok + önceki döneme göre + Bilinmeyen İstatistik Türü + Veri sınıfı: %1$s + + Kişi: %1$s + Mekan: %1$s + Anket: %1$s + Servis mesajı + + + Geri + Seçenekler + 10 saniye geri sar + 10 saniye ileri sar + -%1$ds + +%1$ds + Küçük resim %1$d + %1$d / %2$d + Orijinal yükleniyor... + + + İndir + Videoyu İndir + Görseli Kopyala + Metni Kopyala + Bağlantıyı Kopyala + Zaman Bilgisiyle Kopyala + İlet + GIF\'lere Kaydet + Galeriye Kaydet + Panoya Kopyala + Yeniden Başlat + Duraklat + Oynat + Kilidi Aç + + + Ayarlar + Oynatma Hızı + Ölçeklendirme Modu + Sığdır + Yakınlaştır + Ekranı Döndür + Pencere İçinde Pencere (PiP) + Ekran Görüntüsü Al + Videoyu Döngüye Al + Sesi Kapat + Altyazılar + Kontrolleri Kilitle + Kalite + Video Kalitesi + Otomatik + Yüksek Çözünürlük + Normal + + + Oynat + Duraklat + Geri sar + İleri sar + + + Çıkartmalar + Emojiler + GIF\'ler + + + Son Kullanılanlar + Çıkartmalar + Çıkartma ara + + + Son Kullanılanlar + Standart Emojiler + Özel Emojiler + Emojiler + Emoji ara + + + Son Kullanılanlar ve Kaydedilenler + GIF bulunamadı + GIF ara + + + Geri + Temizle + Son Kullanılanlar + + + Galeriye kaydedildi + QR Paylaş + Gönderilirken hata oluştu: %1$s + + + Kanalı Düzenle + Grubu Düzenle + Kanal Adı + Grup Adı + Açıklama + Ayarlar + Genel Kanal + Genel Grup + Otomatik Çeviri + Konular + Yönetim + Kanalı Sil + Grubu Sil + + + Ara... + Yöneticiler + Aboneler + Üyeler + Kara Liste + Sonuç bulunamadı + Henüz üye yok + + + Yönetici Yetkileri + Özel Başlık + Bu başlık, sohbetteki tüm üyeler tarafından görülebilir + Bu yönetici neler yapabilir? + Sohbeti Yönet + Mesaj Gönder + Mesajları Düzenle + Mesajları Sil + Üyeleri Kısıtla + Kullanıcı Davet Et + Konuları Yönet + Görüntülü Sohbetleri Yönet + Hikaye Paylaş + Hikayeleri Düzenle + Hikayeleri Sil + Yeni Yönetici Ekle + Anonim Kal + + + İzinler + Bu grubun üyeleri neler yapabilir? + Üye Ekle + + + Geri + Kaydet + Temizle + Ara + Ekle + Düzenle + Kullanıcı adı + Filtreler + + + Son Etkinlikler + %d etkinlik yüklendi + Yakın zamanda gerçekleşen bir etkinlik bulunamadı + Filtreleri değiştirmeyi deneyin + Henüz uygulanmadı + Kullanıcı kimliği kopyalandı + + + Eylemleri Filtrele + Sıfırla + Uygula + Eylem Türleri + Kullanıcıya Göre + Ara… + Kullanıcı bulunamadı + \"%s\" için sonuç yok + + + Düzenlemeler + Silmeler + Sabitlemeler + Katılmalar + Ayrılmalar + Davetler + Yetkilendirmeler + Kısıtlamalar + Bilgiler + Ayarlar + Bağlantılar + Video + + + bir mesaj düzenledi + bir mesaj sildi + bir mesaj sabitledi + bir mesajın sabitlemesini kaldırdı + sohbete katıldı + sohbetten ayrıldı + %s kullanıcısını davet etti + %s için izinleri değiştirdi + %s için kısıtlamaları değiştirdi + sohbet adını \"%s\" olarak değiştirdi + sohbet açıklamasını değiştirdi + kullanıcı adını @%s olarak değiştirdi + sohbet fotoğrafını değiştirdi + davet bağlantısını düzenledi + davet bağlantısını iptal etti + davet bağlantısını sildi + görüntülü sohbet başlattı + görüntülü sohbeti bitirdi + bir eylem gerçekleştirdi: %s + + + Orijinal mesaj: + Yeni mesaj: + Silinen mesaj: + Sabitlenen mesaj: + Sabitlemesi kaldırılan mesaj: + Bitiş: %s + Kalıcı olarak kısıtlandı + Eski + Yeni + Eski sohbet fotoğrafı + Yeni sohbet fotoğrafı + Kaynak + Hedef + İzin değişiklikleri: + Mevcut izinler: + + + Mesajlar + Medya + Çıkartmalar + Bağlantılar + Anketler + Davet + Sabitleme + Bilgi + + Yönetici + Sahip + Kısıtlı + Yasaklı + Üye + + + Fotoğraf + Video + GIF + Çıkartma + Dosya + Müzik + Sesli mesaj + Görüntülü mesaj + Kişi + Anket + Konum + Mekan + Desteklenmeyen mesaj + + + %1$02d:%2$02d + + + %1$.1fB + %1$.1fM + + + Yorum yap + %1$d yorum + %1$.1fB yorum + %1$.1fM yorum + + + Sonuçlar + Anonim + Genel + Test + Anket + • Çoklu Seçim + Oyumu Geri Çek + Anketi Kapat + + %d oy + %d oy + + + + Daha fazla + Oy ver + Açıklama + + + Oylayanlar + Henüz oy veren yok + Kapat + + + Fotoğraf + Video + Çıkartma + Sesli mesaj + Görüntülü mesaj + GIF + Mesaj + + + Sohbet Klasörleri + Geri + Yeni Klasör + Oluştur + Klasörü Düzenle + Kaydet + Varsayılan Klasörler + Özel Klasörler + Sil + Farklı sohbet grupları için klasörler oluşturun ve bunlar arasında hızlıca geçiş yapın. + Özel klasör yok + Oluşturmak için + düğmesine dokunun + Tüm sohbetler + %1$d sohbet + Yukarı Taşı + Aşağı Taşı + Klasör Adı + Simge Seç + Dahil Edilen Sohbetler + Sohbet ara… + İptal + + + Telegram Premium + Geri + Aylık %s karşılığında abone ol + Özel özelliklerin kilidini aç + + +İki Kat Limitler + %1$d kanala kadar, %2$d sohbet klasörü, %3$d sabitleme, %4$d genel bağlantı ve daha fazlası. + Sesi Metne Dönüştürme + Yanındaki düğmeye dokunarak herhangi bir sesli mesajın metin dökümünü okuyun. + Daha Hızlı İndirme + Medya ve dosyaların indirilme hızındaki tüm limitler kalkıyor. + Gerçek Zamanlı Çeviri + Tüm sohbetleri tek bir dokunuşla gerçek zamanlı olarak çevirin. + Hareketli Emojiler + Yüzlerce paketteki hareketli emojileri mesajlarınıza dahil edin. + Gelişmiş Sohbet Yönetimi + Varsayılan klasörü ayarlama, otomatik arşivleme ve yeni sohbetleri gizleme araçları. + Reklam Yok + Genel kanallarda bazen gösterilen reklamlar artık karşınıza çıkmayacak. + Sonsuz Tepki + Binlerce emoji ile tepki verin; her mesajda 3 taneye kadar kullanın. + Premium Rozeti + Adınızın yanında Telegram Premium abonesi olduğunuzu gösteren özel bir rozet. + Emoji Durumları + Adınızın yanında görünecek binlerce emoji arasından seçim yapın. + Premium Uygulama Simgeleri + Ana ekranınız için çeşitli Telegram uygulama simgeleri arasından seçim yapın. + + + Yenile + Bağlantıyı kopyala + Tarayıcıda aç + Ana Ekrana Ekle + + + Mini Uygulama Kapatılsın mı? + Kaydedilmemiş değişiklikleriniz var. Kapatmak istediğinizden emin misiniz? + Kapat + İptal + İzin İsteği + İzin Ver + Reddet + Biyometri + Kişiyi Paylaş + %1$s uygulamasının telefon numaranıza erişmesine izin verilsin mi? + Mesajlara İzin Ver + %1$s uygulamasının size mesaj göndermesine izin verilsin mi? + Dosyayı İndir + Bu dosya indirilsin mi? + %1$s indirilsin mi? + Biyometrik Kimlik Doğrulama + Devam etmek için kimliğinizi doğrulayın + Bu botun konumunuza erişmesine izin verilsin mi? + Bot İzinleri + Hizmet Koşulları + Bu Mini Uygulamayı başlatarak Hizmet Koşullarını ve Gizlilik Politikasını kabul etmiş olursunuz. Bot, temel profil bilgilerinize erişebilecektir. + Kabul Et ve Başlat + + + Makalede ara… + Geri + Temizle + Hızlı Görünüm + Daha fazla + %d görüntülenme + Bağlantıyı kopyala + Tarayıcıda aç + Ara + Metin Boyutu + Videoyu oynat + Animasyonu oynat + Ses + Bilinmeyen Sanatçı + Oynat + + Daralt + Genişlet + Harita: %1$s, %2$s + + + Profili Paylaş + Monogram profil bağlantınızı paylaşın + Bağlantıyı Kopyala + Monogram profil bağlantınızı panoya kopyalayın + + + Tema Düzenleyici + Palet Modu + Hangi paleti düzenleyeceğinizi seçin. Düzenleyici, uygulamada şu an aktif olan palet üzerinden açılır. + Açık + Koyu + Düzenlenen: %1$s paleti + Uygulamada şu an aktif: %1$s + Vurgu + Vurgu rengi sadece %1$s paletini günceller. + Hex vurgusu + Uygula + İptal + Seç + Kaydet + Yükle + Her İkisi + Hex + monogram-tema.json + + Tema dosyası kaydedildi + Kaydedilemedi + Tema yüklendi + Geçersiz dosya + Yüklenemedi + + Tema Kaynağı + Uygulama renkleri için tam olarak bir kaynak seçin. "Özel", düzenlenebilir paletlerinizi; "Monet" ise Android dinamik renklerini kullanır. AMOLED seçeneği sadece koyu arka planları etkiler. + Özel tema + Kendi Açık/Koyu paletlerinizi, vurgu renklerinizi, hazır ayarlarınızı ve manuel rol renklerinizi kullanın. + Monet + Duvar kağıdına dayalı sistem tarafından oluşturulan Material You renklerini kullanın (Android 12+). + AMOLED koyu + OLED ekranlarda parlamayı azaltmak ve güç tasarrufu sağlamak için koyu yüzeylerde tam siyahı zorunlu kılın. + + Hazır Temalar + Her hazır ayarın Açık ve Koyu varyantları mevcuttur. Anında uygulamak için Açık, Koyu veya Her İkisi seçeneğine dokunun. + Manuel Renkler (%1$s) + %1$s önizleme + Özet metin + Eylem + %1$s seçin + Renk Tonu %1$d° + Doygunluk %%%1$d + Parlaklık %%%1$d + Alfa (Şeffaflık) %%%1$d + + Birincil + İkincil + Üçüncül + Arka Plan + Yüzey + Birincil Kap + İkincil Kap + Üçüncül Kap + Yüzey Varyantı + Kenarlık + + A B + A K + A AP + K B + K K + K AP + + Mavi + Yeşil + Turuncu + Gül Kurusu + Çivit Mavisi + Turkuaz + + Klasik + Net okunabilirlik ve nötr yüzeylerle dengelenmiş mavi. + + Orman + Sakin kontrast ve yumuşak kap tonlarıyla doğal yeşiller. + + Okyanus + Taze açık ve derin koyu mod ile serin turkuaz-mavi gradyan hissi. + + Gün Batımı + Yüksek ön plan netliği ile sıcak turuncu ve mercan vurgu seti. + + Grafit + Mavi-gri vurgular ve güçlü yapıya sahip nötr gri tonlamalı taban. + + Nane + Nazik kaplarla taze nane ve cam göbeği kombinasyonu. + + Yakut + Güçlü eylem odaklılık için nötr kaplara sahip derin kırmızı vurgu. + + Lavanta Grisi + Kontrollü doygunluk ve temiz kontrasta sahip mat mor-gri şema. + + Kum + Çok yumuşak görsel gürültü için ayarlanmış bej ve kehribar paleti. + + Arktik + Yüksek okunabilirlik ve keskin sınırlarla buzlu mavi-beyaz hissi. + + Zümrüt + Temiz yüzeyler ve modern kontrasta sahip canlı yeşil paleti. + + Bakır + Her iki modda da metni net tutan sıcak bakır-turuncu tema. + + Sakura + Yumuşak kaplar ve güçlü vurgularla nazik pembe-macenta tonları. + + Nord + Sakin ve profesyonel bir görsel dengeye sahip soğuk kuzey mavileri. + + + Kamera ve mikrofon izinleri gerekiyor + İşleniyor… + İşleme hatası: %1$s + Kaydediciyi kapat + Kamerayı değiştir + Kaydı bitir + KAYIT + HAZIR + Süre: %1$s + Yakınlaştırma: %1$.1fx (aralık %2$.1fx - %3$.1fx) + İptal + + Geçmiş Temizlensin mi? + Sohbet geçmişini temizlemek istediğinizden emin misiniz? Bu işlem geri alınamaz. + Geçmişi Temizle + Sohbet Silinsin mi? + Bu sohbeti silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. + Sohbeti Sil + Sohbetten Ayrıl? + Bu sohbetten ayrılmak istediğinizden emin misiniz? + Ayrıl + Kanalı Sil? + Grubu Sil? + Bu kanalı silmek istediğinizden emin misiniz? Tüm mesajlar ve medya içerikleri kaybolacaktır. + Bu grubu silmek istediğinizden emin misiniz? Tüm mesajlar ve medya içerikleri kaybolacaktır. + %1$d sohbet silinsin mi? + Seçilen sohbetleri silmek istediğinizden emin misiniz? + Sohbetleri Sil + Kullanıcı Engellensin mi? + Bu kullanıcıyı engellemek istediğinizden emin misiniz? Bu kullanıcı size mesaj gönderemeyecek. + Engelle + Engeli Kaldır? + Bu kullanıcının engelini kaldırmak istediğinizden emin misiniz? Size tekrar mesaj gönderebilecekler. + Mesaj Sabitlemesi Kaldırılsın mı? + Bu mesajın sabitlemesini kaldırmak istediğinizden emin misiniz? + Sabitlemeyi Kaldır + Son kullanılan çıkartmaları temizlemek istediğinizden emin misiniz? + Çıkartmaları Temizle + Son kullanılan emojileri temizlemek istediğinizden emin misiniz? + Emojileri Temizle + Kişilere ekle + Kişilerden çıkar + Okundu olarak işaretle + Okunmadı olarak işaretle + Düzenle + Yeniden Sırala + Sil + Geçersiz onay kodu + Geçersiz şifre + Beklenmedik bir hata oluştu + From 463a15e4565d29b6b5ccf663b4deaa74c913c2cb Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:40:53 +0300 Subject: [PATCH 79/83] complete Turkish locale wiring and missing translations --- README.md | 2 +- README_ES.md | 2 +- README_KOR.md | 2 +- README_RU.md | 2 +- README_TR.md | 2 +- README_UR.md | 2 +- app/src/main/res/xml/locales_config.xml | 1 + presentation/src/main/res/values-tr/strings.xml | 8 ++++++++ 8 files changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ed38bf06..8921eda8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ -**Read this in other languages:** [Русский](README_RU.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) +**Read this in other languages:** [Русский](README_RU.md), [Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) --- diff --git a/README_ES.md b/README_ES.md index 58e33ae5..f7945627 100644 --- a/README_ES.md +++ b/README_ES.md @@ -23,7 +23,7 @@ **Si deseas leer este documento en otro lenguaje:** [Русский](README_RU.md), -[한국어](README_KOR.md), [اُردو](README_UR.md), [English](README.md) +[Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [English](README.md) --- diff --git a/README_KOR.md b/README_KOR.md index 85f653ae..c9f2d748 100644 --- a/README_KOR.md +++ b/README_KOR.md @@ -22,7 +22,7 @@ -**다른 언어로 읽기:** [English](README.md), [Русский](README_RU.md), [Español](README_ES.md) +**다른 언어로 읽기:** [English](README.md), [Русский](README_RU.md), [Türkçe](README_TR.md), [Español](README_ES.md) --- diff --git a/README_RU.md b/README_RU.md index 8d5552cd..8e845b68 100644 --- a/README_RU.md +++ b/README_RU.md @@ -22,7 +22,7 @@ -**Читать на других языках:** [English](README.md), [한국어](README_KOR.md), [اُردو](README_UR.md), +**Читать на других языках:** [English](README.md), [Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) --- diff --git a/README_TR.md b/README_TR.md index eff88781..72b57e65 100644 --- a/README_TR.md +++ b/README_TR.md @@ -22,7 +22,7 @@ -**Bu dokümanı diğer dillerde okuyun:** [English](README.md), [Русский](README_RU.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) +**Bu dokümanı diğer dillerde okuyun:** [English](README.md), [Русский](README_RU.md), [Türkçe](README_TR.md), [한국어](README_KOR.md), [اُردو](README_UR.md), [Español](README_ES.md) --- diff --git a/README_UR.md b/README_UR.md index 6515691e..1693a698 100644 --- a/README_UR.md +++ b/README_UR.md @@ -22,7 +22,7 @@ -**اسے دوسری زبانوں میں پڑھیں:** [English](README.md), [Русский](README_RU.md)، [한국어](README_KOR.md), +**اسے دوسری زبانوں میں پڑھیں:** [English](README.md), [Русский](README_RU.md)، [Türkçe](README_TR.md), [한국어](README_KOR.md), [Español](README_ES.md) --- diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 392ba817..f58296dc 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -7,5 +7,6 @@ + diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml index f0a97342..6175a349 100644 --- a/presentation/src/main/res/values-tr/strings.xml +++ b/presentation/src/main/res/values-tr/strings.xml @@ -235,6 +235,7 @@ Sabitle Sabitlemeyi Kaldır İlet + +1 Seç Daha Fazla Sil @@ -377,6 +378,12 @@ Sponsor bilgi sayfasını aç Sponsor senkronizasyonunu zorla Sponsor ID\'lerini şimdi kanaldan çek + Push Tanılama + Çalışma Zamanı Bayrakları + Push Ortamı + UnifiedPush Ayrıntıları + Sponsor + Tehlike Bölgesi Oturumu Kapat Hesap bağlantısını kes @@ -595,6 +602,7 @@ Her 1 saatte bir Her %1$d saatte bir Firebase Cloud Messaging + UnifiedPush (Simple Push) GMS-less (Arka Plan Servisi) From 7df1c212eca355595b00399c2173edad19ae512c Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:06:27 +0300 Subject: [PATCH 80/83] Escape apostrophes and remove revenue_title Escape single quotes in Turkish string resources (startup_connecting and permissions_required_description) to avoid formatting/parsing issues. Remove the obsolete revenue_title entry from presentation/src/main/res/values-tr/strings.xml. --- presentation/src/main/res/values-tr/strings.xml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml index 6175a349..ad969e2b 100644 --- a/presentation/src/main/res/values-tr/strings.xml +++ b/presentation/src/main/res/values-tr/strings.xml @@ -21,7 +21,7 @@ QR Tarayıcı simgesi - Telegram'a bağlanılıyor… + Telegram\'a bağlanılıyor… Hakkında @@ -326,7 +326,6 @@ Pornografi Çocuk İstismarı Telif Hakkı - Gelir Diğer Kapat Bu Mini Uygulamayı başlatarak Hizmet Şartlarını ve Gizlilik Politikasını kabul etmiş olursunuz. Bot, temel profil bilgilerinize erişebilecektir. @@ -937,7 +936,7 @@ İzinler Gerekli - En iyi deneyimi sunmak için MonoGram'ın aşağıdaki izinlere ihtiyacı vardır. + En iyi deneyimi sunmak için MonoGram\'ın aşağıdaki izinlere ihtiyacı vardır. Bildirimler Yeni mesajlardan haberdar olun İzin Ver From 0587350ecacfdf602c752b894f0fe465248dc490 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:24:24 +0300 Subject: [PATCH 81/83] Add collectors for scope notification prefs --- .../monogram/data/di/TdNotificationManager.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 cd3fbb0c..e8ef359a 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -208,6 +208,24 @@ class TdNotificationManager( } } + scope.launch { + appPreferences.privateChatsNotifications.collect { enabled -> + updateScopePreferenceState(TdNotificationScope.PRIVATE_CHATS, enabled) + } + } + + scope.launch { + appPreferences.groupsNotifications.collect { enabled -> + updateScopePreferenceState(TdNotificationScope.GROUPS, enabled) + } + } + + scope.launch { + appPreferences.channelsNotifications.collect { enabled -> + updateScopePreferenceState(TdNotificationScope.CHANNELS, enabled) + } + } + scope.launch { unifiedPushManager.endpoint.collect { if (appPreferences.pushProvider.value == PushProvider.UNIFIED_PUSH && !it.isNullOrBlank()) { @@ -233,6 +251,12 @@ class TdNotificationManager( } } + private fun updateScopePreferenceState(scope: TdNotificationScope, enabled: Boolean) { + if (!gateway.isAuthenticated.value) return + scopeNotificationsEnabled[scope] = enabled + loadedScopeSettings.add(scope) + } + private suspend fun updatePushRegistration() { if (!gateway.isAuthenticated.value) return From 2f932116427142da70cb4b7e1620b3891b82a9b4 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:16:28 +0300 Subject: [PATCH 82/83] fixed compile issues --- app/src/main/java/org/monogram/app/App.kt | 15 -------- .../monogram/data/di/TdNotificationManager.kt | 12 ------ .../data/service/NotificationReplyReceiver.kt | 2 - .../repository/AppPreferencesProvider.kt | 38 ------------------- 4 files changed, 67 deletions(-) diff --git a/app/src/main/java/org/monogram/app/App.kt b/app/src/main/java/org/monogram/app/App.kt index 57400b17..07c6cba9 100644 --- a/app/src/main/java/org/monogram/app/App.kt +++ b/app/src/main/java/org/monogram/app/App.kt @@ -124,21 +124,6 @@ class App : Application(), SingletonImageLoader.Factory { } } - private fun trimInMemoryCaches(reason: String) { - if (!::container.isInitialized) return - runCatching { - get().clearDataCaches(reason) - }.onFailure { error -> - Log.w(TAG, "Failed to clear data caches for $reason", error) - } - - runCatching { - get().memoryCache?.clear() - }.onFailure { error -> - Log.w(TAG, "Failed to clear Coil memory cache for $reason", error) - } - } - override fun newImageLoader(context: PlatformContext): ImageLoader { return get() } 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 62f8ad07..e8ef359a 100644 --- a/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt +++ b/data/src/main/java/org/monogram/data/di/TdNotificationManager.kt @@ -447,18 +447,6 @@ class TdNotificationManager( return } - val messageContent = message.content - if (messageContent == null) { - Log.w(TAG, "Skipping notification for message ${message.id}: content is null") - return - } - - val senderId = message.senderId - if (senderId == null) { - Log.w(TAG, "Skipping notification for message ${message.id}: senderId is null") - return - } - val lastId = lastMessageIds[message.chatId] if (lastId != null && message.id <= lastId) { Log.d( diff --git a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt index 84f0dcc0..ec33c595 100644 --- a/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt +++ b/data/src/main/java/org/monogram/data/service/NotificationReplyReceiver.kt @@ -10,13 +10,11 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.monogram.data.di.TdNotificationManager import org.monogram.data.gateway.TelegramGateway -import org.monogram.domain.repository.StringProvider class NotificationReplyReceiver : BroadcastReceiver(), KoinComponent { private val gateway: TelegramGateway by inject() private val notificationManager: TdNotificationManager by inject() - private val stringProvider: StringProvider by inject() override fun onReceive(context: Context, intent: Intent) { val chatId = intent.getLongExtra("chat_id", 0L) 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 72b4ce2f..7449b32c 100644 --- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt +++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt @@ -40,44 +40,6 @@ data class ProxyNetworkRule( val lastUsedProxyId: Int? = null ) -fun defaultProxyNetworkMode(networkType: ProxyNetworkType): ProxyNetworkMode { - return ProxyNetworkMode.BEST_PROXY -} - -enum class ProxyNetworkType { - WIFI, - MOBILE, - VPN, - OTHER -} - -enum class ProxyNetworkMode { - DIRECT, - BEST_PROXY, - LAST_USED, - SPECIFIC_PROXY -} - -enum class ProxySortMode { - ACTIVE_FIRST, - LOWEST_PING, - SERVER_NAME, - PROXY_TYPE, - STATUS -} - -enum class ProxyUnavailableFallback { - BEST_PROXY, - DIRECT, - KEEP_CURRENT -} - -data class ProxyNetworkRule( - val mode: ProxyNetworkMode, - val specificProxyId: Int? = null, - val lastUsedProxyId: Int? = null -) - fun defaultProxyNetworkMode(networkType: ProxyNetworkType): ProxyNetworkMode { return if (networkType == ProxyNetworkType.VPN) { ProxyNetworkMode.DIRECT From 7c87d223dd708391a2f943186010f6a11fc33f8e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:17:09 +0300 Subject: [PATCH 83/83] bump app version to 0.0.8 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b319a197..214870b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "org.monogram" minSdk = 25 targetSdk = 36 - versionCode = 7 - versionName = "0.0.7" + versionCode = 8 + versionName = "0.0.8" } splits {