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 356a3549..a7385306 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatCache.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatCache.kt @@ -3,6 +3,7 @@ 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 { @@ -424,11 +425,14 @@ class ChatCache : ChatsCacheDataSource, UserCacheDataSource { ) clientData = "mc:${entity.memberCount};oc:${entity.onlineCount}" } - if (entity.photoId != 0) { - fileCache[entity.photoId] = TdApi.File().apply { - id = entity.photoId - local = TdApi.LocalFile().apply { - this.path = entity.avatarPath.orEmpty() + 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 + } } } } 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 a199e115..04eb3b01 100644 --- a/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt +++ b/data/src/main/java/org/monogram/data/chats/ChatFileManager.kt @@ -1,12 +1,13 @@ package org.monogram.data.chats -import org.monogram.data.core.coRunCatching 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 +import org.monogram.data.infra.FileUpdateHandler import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -15,6 +16,7 @@ class ChatFileManager( private val gateway: TelegramGateway, private val dispatchers: DispatcherProvider, private val fileQueue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler, scopeProvider: ScopeProvider, private val onUpdate: () -> Unit ) { @@ -23,13 +25,11 @@ class ChatFileManager( private val downloadingFiles: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val loadingEmojis: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val filePaths = ConcurrentHashMap() - private val emojiPathsCache = ConcurrentHashMap() - private val fileIdToEmojiId = ConcurrentHashMap() private val chatPhotoIds = ConcurrentHashMap() private val trackedFileIds = Collections.newSetFromMap(ConcurrentHashMap()) fun getFilePath(fileId: Int): String? = filePaths[fileId] - fun getEmojiPath(emojiId: Long): String? = emojiPathsCache[emojiId] + fun getEmojiPath(emojiId: Long): String? = fileUpdateHandler.customEmojiPaths[emojiId] fun getChatIdByPhotoId(fileId: Int): Long? = chatPhotoIds[fileId] fun registerChatPhoto(fileId: Int, chatId: Long) { @@ -51,8 +51,8 @@ class ChatFileManager( private fun handleFileUpdated(fileId: Int, path: String): Boolean { if (path.isEmpty()) return false var updated = false - fileIdToEmojiId[fileId]?.let { emojiId -> - emojiPathsCache[emojiId] = path + fileUpdateHandler.fileIdToCustomEmojiId[fileId]?.let { emojiId -> + fileUpdateHandler.customEmojiPaths[emojiId] = path updated = true } if (chatPhotoIds.containsKey(fileId)) updated = true @@ -74,7 +74,7 @@ class ChatFileManager( } fun loadEmoji(emojiId: Long) { - if (emojiId == 0L || emojiPathsCache.containsKey(emojiId)) return + if (emojiId == 0L || fileUpdateHandler.customEmojiPaths.containsKey(emojiId)) return if (loadingEmojis.add(emojiId)) { scope.launch(dispatchers.io) { coRunCatching { @@ -82,9 +82,9 @@ class ChatFileManager( val sticker = result.stickers.firstOrNull() ?: return@launch val file = sticker.sticker val path = file.local.path.ifEmpty { filePaths[file.id] ?: "" } - fileIdToEmojiId[file.id] = emojiId + fileUpdateHandler.fileIdToCustomEmojiId[file.id] = emojiId if (path.isNotEmpty()) { - emojiPathsCache[emojiId] = path + fileUpdateHandler.customEmojiPaths[emojiId] = path onUpdate() } else { downloadFile(file.id, 32) 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 11632a7f..5911c70a 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -12,6 +12,7 @@ import org.monogram.data.chats.ChatCache import org.monogram.data.gateway.TdLibException import org.monogram.data.gateway.TelegramGateway import org.monogram.data.infra.FileDownloadQueue +import org.monogram.data.infra.FileUpdateHandler import org.monogram.data.mapper.MessageMapper import org.monogram.data.mapper.toApi import org.monogram.domain.models.* @@ -28,6 +29,7 @@ class TdMessageRemoteDataSource( private val cache: ChatCache, private val pollRepository: PollRepository, private val fileDownloadQueue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler, private val dispatcherProvider: DispatcherProvider, scopeProvider: ScopeProvider ) : MessageRemoteDataSource { @@ -69,8 +71,6 @@ class TdMessageRemoteDataSource( enum class DownloadType { VIDEO, GIF, STICKER, VIDEO_NOTE, DEFAULT } private val fileIdToMessageMap = fileDownloadQueue.registry.fileIdToMessageMap - val customEmojiPaths = ConcurrentHashMap() - val fileIdToCustomEmojiId = ConcurrentHashMap() private val messageUpdateJobs = ConcurrentHashMap, Job>() private val lastProgressMap = ConcurrentHashMap() private val lastDownloadActiveMap = ConcurrentHashMap() @@ -1357,8 +1357,8 @@ class TdMessageRemoteDataSource( if (isDC) { fileDownloadQueue.notifyDownloadComplete(file.id) lastProgressMap.remove(file.id) - fileIdToCustomEmojiId[file.id]?.let { customEmojiId -> - customEmojiPaths[customEmojiId] = file.local?.path ?: "" + fileUpdateHandler.fileIdToCustomEmojiId[file.id]?.let { customEmojiId -> + fileUpdateHandler.customEmojiPaths[customEmojiId] = file.local?.path ?: "" } val entries = fileIdToMessageMap[file.id] 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 ec4a9b83..93d924e8 100644 --- a/data/src/main/java/org/monogram/data/di/dataModule.kt +++ b/data/src/main/java/org/monogram/data/di/dataModule.kt @@ -288,8 +288,7 @@ val dataModule = module { gateway = get(), userRepository = get(), chatInfoRepository = get(), - customEmojiPaths = get().customEmojiPaths, - fileIdToCustomEmojiId = get().fileIdToCustomEmojiId, + fileUpdateHandler = get(), fileApi = get(), appPreferences = get(), cache = get(), @@ -329,6 +328,7 @@ val dataModule = module { chatFolderDao = get(), userFullInfoDao = get(), fileQueue = get(), + fileUpdateHandler = get(), stringProvider = get() ) } @@ -421,6 +421,7 @@ val dataModule = module { cache = get(), pollRepository = get(), fileDownloadQueue = get(), + fileUpdateHandler = get(), dispatcherProvider = get(), scopeProvider = get() ) 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 16e43099..0139f67a 100644 --- a/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/MessageMapper.kt @@ -10,6 +10,7 @@ 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.domain.models.* import org.monogram.domain.repository.AppPreferencesProvider import org.monogram.domain.repository.ChatInfoRepository @@ -22,14 +23,15 @@ class MessageMapper( private val gateway: TelegramGateway, private val userRepository: UserRepository, private val chatInfoRepository: ChatInfoRepository, - private val customEmojiPaths: ConcurrentHashMap, - private val fileIdToCustomEmojiId: ConcurrentHashMap, + private val fileUpdateHandler: FileUpdateHandler, private val fileApi: MessageFileApi, private val appPreferences: AppPreferencesProvider, private val cache: ChatCache, scopeProvider: ScopeProvider ) { val scope = scopeProvider.appScope + private val customEmojiPaths = fileUpdateHandler.customEmojiPaths + private val fileIdToCustomEmojiId = fileUpdateHandler.fileIdToCustomEmojiId private data class SenderUserSnapshot( val name: String, @@ -125,12 +127,14 @@ class MessageMapper( } private fun resolveCachedPath(fileId: Int, storedPath: String?): String? { - val fromCache = fileId.takeIf { it != 0 } - ?.let { cache.fileCache[it]?.local?.path } + val fromStored = storedPath + ?.takeIf { it.isNotBlank() } ?.takeIf { isValidPath(it) } - if (fromCache != null) return fromCache + if (fromStored != null) return fromStored - return storedPath?.takeIf { it.isNotBlank() }?.takeIf { isValidPath(it) } + return fileId.takeIf { it != 0 } + ?.let { cache.fileCache[it]?.local?.path } + ?.takeIf { isValidPath(it) } } private fun registerCachedFile(fileId: Int, chatId: Long, messageId: Long) { 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 7c22ce6f..980e9888 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -23,6 +23,7 @@ import org.monogram.data.gateway.TelegramGateway 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.mapper.ChatMapper import org.monogram.data.mapper.MessageMapper import org.monogram.domain.models.ChatModel @@ -53,6 +54,7 @@ class ChatsListRepositoryImpl( private val chatFolderDao: ChatFolderDao, private val userFullInfoDao: UserFullInfoDao, private val fileQueue: FileDownloadQueue, + private val fileUpdateHandler: FileUpdateHandler, private val stringProvider: StringProvider ) : ChatListRepository, ChatFolderRepository, @@ -96,6 +98,7 @@ class ChatsListRepositoryImpl( dispatchers = dispatchers, scopeProvider = scopeProvider, fileQueue = fileQueue, + fileUpdateHandler = fileUpdateHandler, onUpdate = { triggerUpdate() refreshActiveForumTopics() diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/ImageCacheKeys.kt b/presentation/src/main/java/org/monogram/presentation/core/util/ImageCacheKeys.kt new file mode 100644 index 00000000..56515dbe --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/core/util/ImageCacheKeys.kt @@ -0,0 +1,39 @@ +package org.monogram.presentation.core.util + +import java.io.File + +fun fileCacheKey(path: String?): String? { + if (path.isNullOrBlank()) return null + val file = File(path) + if (!file.exists()) return null + return "${file.absolutePath}:${file.lastModified()}:${file.length()}" +} + +fun miniThumbnailCacheKey(data: ByteArray): String { + return "mini:${data.contentHashCode()}:${data.size}" +} + +fun mediaCacheKey(data: Any?): String? { + return when (data) { + null -> null + is ByteArray -> miniThumbnailCacheKey(data) + is File -> "${data.absolutePath}:${data.lastModified()}:${data.length()}" + is String -> { + when { + data.startsWith("http://", ignoreCase = true) || + data.startsWith("https://", ignoreCase = true) || + data.startsWith("content:", ignoreCase = true) || + data.startsWith("file:", ignoreCase = true) -> data + + else -> fileCacheKey(data) ?: data + } + } + + else -> data.toString() + } +} + +fun namespacedCacheKey(namespace: String, data: Any?): String? { + val key = mediaCacheKey(data) ?: return null + return "$namespace:$key" +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt index cf84f114..467f106c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/AlphaVideoPlayer.kt @@ -61,6 +61,7 @@ import kotlinx.coroutines.isActive import org.monogram.domain.repository.PlayerDataSourceFactory import org.monogram.presentation.core.util.LocalVideoPlayerPool import org.monogram.presentation.core.util.getMimeType +import org.monogram.presentation.core.util.namespacedCacheKey import java.io.File import java.io.FileNotFoundException import java.util.concurrent.ArrayBlockingQueue @@ -252,15 +253,20 @@ fun VideoStickerPlayer( exit = fadeOut(tween(250)), modifier = Modifier.fillMaxSize() ) { + val thumbnailCacheKey = remember(currentPath, thumbnailData, fileId) { + namespacedCacheKey("video_sticker_thumb:$fileId", thumbnailData ?: currentPath) + } AsyncImage( model = ImageRequest.Builder(context) .data(thumbnailData ?: currentPath) .apply { + thumbnailCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } if (thumbnailData == null) { decoderFactory(VideoFrameDecoder.Factory()) videoFrameMillis(0) - memoryCacheKey(currentPath) - diskCacheKey(currentPath) } } .crossfade(false) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/CompactMediaMosaic.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/CompactMediaMosaic.kt index d5fff389..0983f907 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/CompactMediaMosaic.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/CompactMediaMosaic.kt @@ -31,10 +31,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState 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.channels.formatDuration import org.monogram.presentation.features.chats.currentChat.components.channels.formatViews @@ -291,8 +294,12 @@ fun PhotoItem( contentScale: ContentScale = ContentScale.Crop, downloadUtils: IDownloadUtils ) { + val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(photo.path) } val hasPath = !stablePath.isNullOrBlank() + val photoCacheKey = remember(stablePath, photo.fileId) { + namespacedCacheKey("mosaic_photo:${photo.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(photo.path) { @@ -328,7 +335,18 @@ fun PhotoItem( ) { resolved -> if (resolved && !stablePath.isNullOrBlank()) { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + photoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() @@ -415,8 +433,15 @@ fun VideoItem( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { + val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(video.path) } val hasPath = !stablePath.isNullOrBlank() + val videoCacheKey = remember(stablePath, video.fileId) { + namespacedCacheKey("mosaic_video:${video.fileId}", stablePath) + } + val videoMiniCacheKey = remember(video.minithumbnail, video.fileId) { + video.minithumbnail?.let { namespacedCacheKey("mosaic_video_mini:${video.fileId}", it) } + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(video.path) { @@ -476,7 +501,18 @@ fun VideoItem( } else { if (hasPath) { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + videoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() @@ -495,7 +531,17 @@ fun VideoItem( } else { if (video.minithumbnail != null) { Image( - painter = rememberAsyncImagePainter(video.minithumbnail), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(video.minithumbnail) + .apply { + videoMiniCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() @@ -606,6 +652,7 @@ fun VideoNoteItem( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { + val context = LocalContext.current var stablePath by remember(msg.id) { mutableStateOf(videoNote.path) } !stablePath.isNullOrBlank() var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } @@ -659,8 +706,22 @@ fun VideoNoteItem( ) } else { val model = videoNote.thumbnail ?: path + val videoNoteCacheKey = remember(model, videoNote.fileId) { + namespacedCacheKey("mosaic_video_note:${videoNote.fileId}", model) + } Image( - painter = rememberAsyncImagePainter(model), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(model) + .apply { + videoNoteCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() 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 23a73a7b..de3f2ab3 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 @@ -30,10 +30,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendingState 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.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType @@ -83,6 +86,9 @@ fun ChannelGifMessageBubble( var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val gifCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("channel_gif:${content.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(content.path) { @@ -191,7 +197,18 @@ fun ChannelGifMessageBubble( } } else { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + gifCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = content.caption, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit 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 407f32d1..ce99f3c9 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 @@ -34,6 +34,7 @@ import coil3.request.crossfade import org.monogram.domain.models.MessageContent 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.* @@ -86,6 +87,9 @@ fun ChannelPhotoMessageBubble( var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val photoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("channel_photo:${content.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } var isFullImageReady by remember(msg.id) { mutableStateOf(false) } val mediaAlpha by animateFloatAsState( @@ -203,6 +207,12 @@ fun ChannelPhotoMessageBubble( AsyncImage( model = ImageRequest.Builder(context) .data(stablePath) + .apply { + photoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = content.caption, 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 258b492e..aec89204 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 @@ -37,9 +37,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent 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.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType @@ -100,6 +103,12 @@ fun ChannelVideoMessageBubble( var stablePath by remember(msg.id) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val videoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("channel_video:${content.fileId}", stablePath) + } + val videoMiniCacheKey = remember(content.minithumbnail, content.fileId) { + content.minithumbnail?.let { namespacedCacheKey("channel_video_mini:${content.fileId}", it) } + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } val hasCaption = content.caption.isNotEmpty() @@ -233,7 +242,18 @@ fun ChannelVideoMessageBubble( } else { if (hasPath) { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + videoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit @@ -241,7 +261,17 @@ fun ChannelVideoMessageBubble( } else { if (content.minithumbnail != null) { Image( - painter = rememberAsyncImagePainter(content.minithumbnail), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(content.minithumbnail) + .apply { + videoMiniCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() 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 104c0f2c..7a85ad4a 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 @@ -15,8 +15,6 @@ import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Edit 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.ui.Alignment import androidx.compose.ui.Modifier @@ -27,14 +25,18 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.media3.common.util.UnstableApi import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade import org.monogram.domain.models.MessageContent 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.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType @@ -66,12 +68,16 @@ fun GifMessageBubble( downloadUtils: IDownloadUtils, isAnyViewerOpen: Boolean = false ) { + val context = LocalContext.current val cornerRadius = 18.dp val smallCorner = 4.dp val tailCorner = 2.dp var stablePath by remember(msg.id) { mutableStateOf(content.path) } !stablePath.isNullOrBlank() + val gifCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("chat_gif:${content.fileId}", stablePath) + } var isAutoDownloadSuppressed by remember(msg.id) { mutableStateOf(false) } LaunchedEffect(content.path) { @@ -199,7 +205,18 @@ fun GifMessageBubble( ) } else { Image( - painter = rememberAsyncImagePainter(stablePath), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(stablePath) + .apply { + gifCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .crossfade(true) + .build() + ), contentDescription = content.caption, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LinkPreview.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LinkPreview.kt index e8ab1b63..08ab90b8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LinkPreview.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/LinkPreview.kt @@ -24,6 +24,7 @@ import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade import org.monogram.domain.models.WebPage +import org.monogram.presentation.core.util.namespacedCacheKey import org.monogram.presentation.features.viewers.extractYouTubeId @Composable @@ -209,6 +210,8 @@ private fun LinkPreviewTextContent( private fun LinkPreviewSmallImage(webPage: WebPage) { val photo = webPage.photo val context = LocalContext.current + val modelData = remember(photo) { photo?.path ?: photo?.minithumbnail } + val cacheKey = remember(modelData) { namespacedCacheKey("link_preview_small", modelData) } Box( modifier = Modifier @@ -218,7 +221,13 @@ private fun LinkPreviewSmallImage(webPage: WebPage) { ) { AsyncImage( model = ImageRequest.Builder(context) - .data(photo?.path ?: photo?.minithumbnail) + .data(modelData) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = null, @@ -262,10 +271,17 @@ private fun LinkPreviewLargeMedia( photo?.path ?: photo?.minithumbnail ?: video?.path } } + val cacheKey = remember(modelData) { namespacedCacheKey("link_preview_large", modelData) } AsyncImage( model = ImageRequest.Builder(context) .data(modelData) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = null, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MediaLoadingComponents.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MediaLoadingComponents.kt index 3fd2aaf8..2ad6b268 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MediaLoadingComponents.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/MediaLoadingComponents.kt @@ -10,12 +10,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -23,9 +20,12 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import org.monogram.presentation.core.util.namespacedCacheKey @Composable fun MediaLoadingBackground( @@ -34,6 +34,10 @@ fun MediaLoadingBackground( modifier: Modifier = Modifier, previewBlur: Dp = 10.dp ) { + val context = LocalContext.current + val previewCacheKey = remember(previewData) { + namespacedCacheKey("media_loading_preview", previewData) + } val pulse = rememberInfiniteTransition(label = "MediaLoadingPulse") val pulseAlpha = pulse.animateFloat( initialValue = 0.06f, @@ -53,7 +57,17 @@ fun MediaLoadingBackground( ) { if (previewData != null) { Image( - painter = rememberAsyncImagePainter(previewData), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(previewData) + .apply { + previewCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() 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 661743f6..6166c0bd 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 @@ -9,11 +9,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -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.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +32,7 @@ import coil3.request.crossfade import org.monogram.domain.models.MessageContent 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 @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -71,6 +68,9 @@ fun PhotoMessageBubble( var stablePath by remember(msg.id, content.fileId) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val photoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("chat_photo:${content.fileId}", stablePath) + } var isFullImageReady by remember(msg.id, content.fileId) { mutableStateOf(false) } val mediaAlpha by animateFloatAsState( targetValue = if (hasPath && isFullImageReady) 1f else 0f, @@ -222,6 +222,12 @@ fun PhotoMessageBubble( AsyncImage( model = ImageRequest.Builder(context) .data(stablePath) + .apply { + photoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = content.caption, 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 0e254b95..e30c3d73 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 @@ -15,9 +15,6 @@ 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.material3.CircularWavyProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,6 +38,7 @@ import coil3.request.crossfade import org.monogram.domain.models.MessageContent 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.VideoStickerPlayer import org.monogram.presentation.features.chats.currentChat.components.VideoType @@ -78,6 +76,12 @@ fun VideoMessageBubble( val context = LocalContext.current var stablePath by remember(msg.id, content.fileId) { mutableStateOf(content.path) } val hasPath = !stablePath.isNullOrBlank() + val videoCacheKey = remember(stablePath, content.fileId) { + namespacedCacheKey("chat_video:${content.fileId}", stablePath) + } + val videoMiniCacheKey = remember(content.minithumbnail, content.fileId) { + content.minithumbnail?.let { namespacedCacheKey("chat_video_mini:${content.fileId}", it) } + } var isAutoDownloadSuppressed by remember(msg.id, content.fileId) { mutableStateOf(false) } LaunchedEffect(content.path, content.fileId) { @@ -246,6 +250,12 @@ fun VideoMessageBubble( painter = rememberAsyncImagePainter( model = ImageRequest.Builder(context) .data(stablePath) + .apply { + videoCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build() ), @@ -256,7 +266,17 @@ fun VideoMessageBubble( } else { if (content.minithumbnail != null) { Image( - painter = rememberAsyncImagePainter(content.minithumbnail), + painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) + .data(content.minithumbnail) + .apply { + videoMiniCacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } + .build() + ), contentDescription = null, modifier = Modifier .fillMaxSize() diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InlineBotResults.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InlineBotResults.kt index b2ac0264..58535a8f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InlineBotResults.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InlineBotResults.kt @@ -17,8 +17,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.* -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +36,7 @@ import org.monogram.domain.models.InlineQueryResultModel import org.monogram.domain.models.MessageContent import org.monogram.domain.repository.InlineBotResultsModel import org.monogram.presentation.R +import org.monogram.presentation.core.util.namespacedCacheKey private enum class InlineResultsMode { Loading, @@ -335,9 +334,16 @@ private fun rememberMediaModel(result: InlineQueryResultModel): Any? { return remember(contentPath, result.thumbUrl) { val data = if (!contentPath.isNullOrBlank()) contentPath else result.thumbUrl if (data == null) return@remember null + val cacheKey = namespacedCacheKey("inline_result:${result.id}", data) ImageRequest.Builder(context) .data(data) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build() } diff --git a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt index 3c3d17a6..75fbf26c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/stickers/ui/view/StickerImage.kt @@ -2,12 +2,14 @@ package org.monogram.presentation.features.stickers.ui.view import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil3.compose.SubcomposeAsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import org.monogram.presentation.core.util.namespacedCacheKey @Composable fun StickerImage( @@ -29,9 +31,17 @@ fun StickerImage( return } + val cacheKey = remember(path) { namespacedCacheKey("sticker", path) } + SubcomposeAsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(path) + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .crossfade(true) .build(), contentDescription = null,