diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index ce080517f9f..3b815686e28 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -66,6 +66,7 @@ import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase import com.wire.kalium.cells.domain.usecase.GetUserNameUseCase import com.wire.kalium.cells.domain.usecase.offline.DeleteOfflineFileUseCase import com.wire.kalium.cells.domain.usecase.offline.GetOfflineFileUseCase +import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesByConversationUseCase import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase import com.wire.kalium.cells.paginatedConversationsFlowUseCase @@ -278,6 +279,11 @@ class CellsModule { @Provides fun provideObserveOfflineFilesUseCase(cellsScope: CellsScope): ObserveOfflineFilesUseCase = cellsScope.observeOfflineFiles + @ViewModelScoped + @Provides + fun provideObserveOfflineFilesByConversationUseCase(cellsScope: CellsScope): ObserveOfflineFilesByConversationUseCase = + cellsScope.observeOfflineFilesByConversation + @ViewModelScoped @Provides fun provideGetOfflineFileUseCase(cellsScope: CellsScope): GetOfflineFileUseCase = cellsScope.getOfflineFile diff --git a/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt b/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt index 48eb23aba01..f036b8449b1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/multipart/MultipartAttachmentUi.kt @@ -40,18 +40,19 @@ data class MultipartAttachmentUi( val transferStatus: AssetTransferStatus, val progress: Float? = null, val isEditSupported: Boolean = false, + val isAvailableOffline: Boolean = false, ) enum class AssetSource { CELL, ASSET_STORAGE } -fun MessageAttachment.toUiModel(progress: Float? = null) = when (this) { - is AssetContent -> this.toUiModel(progress) - is CellAssetContent -> this.toUiModel(progress) +fun MessageAttachment.toUiModel(progress: Float? = null, isAvailableOffline: Boolean = false) = when (this) { + is AssetContent -> this.toUiModel(progress, isAvailableOffline) + is CellAssetContent -> this.toUiModel(progress, isAvailableOffline) } -fun CellAssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( +fun CellAssetContent.toUiModel(progress: Float?, isAvailableOffline: Boolean = false) = MultipartAttachmentUi( uuid = this.id, source = AssetSource.CELL, fileName = this.assetPath?.substringAfterLast("/"), @@ -67,9 +68,10 @@ fun CellAssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( progress = progress, contentHash = contentHash, isEditSupported = isEditSupported, + isAvailableOffline = isAvailableOffline, ) -fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( +fun AssetContent.toUiModel(progress: Float?, isAvailableOffline: Boolean = false) = MultipartAttachmentUi( uuid = this.remoteData.assetId, source = AssetSource.ASSET_STORAGE, fileName = this.name, @@ -84,4 +86,5 @@ fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( contentHash = null, contentUrl = null, isEditSupported = false, + isAvailableOffline = isAvailableOffline, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt index e306a561dff..1f959257ca9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt @@ -25,19 +25,21 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import coil3.decode.Decoder import coil3.request.ImageRequest import coil3.request.crossfade +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.multipart.MultipartAttachmentUi -import com.wire.android.ui.common.multipart.toUiModel import com.wire.android.ui.home.conversations.messages.item.MessageStyle import com.wire.android.ui.home.conversations.model.messagetypes.multipart.grid.AssetGridPreview import com.wire.android.ui.home.conversations.model.messagetypes.multipart.standalone.AssetPreview @@ -62,10 +64,16 @@ fun MultipartAttachmentsView( else -> hiltViewModel(key = conversationId.value) } ) { + // Collect to trigger recomposition when offline availability changes. + val offlineAttachmentIds by viewModel.offlineAttachmentIds.collectAsStateWithLifecycle() // TODO I found out that empty attachments list is not handled here and it shows empty message with no information if (attachments.size == 1) { - attachments.first().toUiModel().let { + val attachment = attachments.first() + val item = remember(attachment, offlineAttachmentIds) { + viewModel.mapAttachment(attachment) + } + item.let { AssetPreview( modifier = modifier .onVisibilityChanged { visible -> @@ -86,7 +94,9 @@ fun MultipartAttachmentsView( ) } } else { - val groups = viewModel.mapAttachments(attachments) + val groups = remember(attachments, offlineAttachmentIds) { + viewModel.mapAttachments(attachments = attachments) + } Column( modifier = modifier diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt index ac1aafa85c8..f0921741482 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt @@ -18,8 +18,10 @@ package com.wire.android.ui.home.conversations.model.messagetypes.multipart import androidx.compose.runtime.mutableStateMapOf +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.app.navArgs import com.wire.android.appLogger import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.domain.model.AttachmentFileType.IMAGE @@ -28,10 +30,12 @@ import com.wire.android.feature.cells.domain.model.AttachmentFileType.VIDEO import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.ui.common.multipart.MultipartAttachmentUi import com.wire.android.ui.common.multipart.toUiModel +import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.util.FileManager import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesByConversationUseCase import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.KaliumFileSystem @@ -42,36 +46,55 @@ import com.wire.kalium.logic.data.message.MessageAttachment import com.wire.kalium.logic.featureFlags.KaliumConfigs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import okio.Path.Companion.toPath import javax.inject.Inject interface MultipartAttachmentsViewModel { + val offlineAttachmentIds: StateFlow> fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) + fun mapAttachment(attachment: MessageAttachment): MultipartAttachmentUi { + val isAvailableOffline = attachment.assetId() in offlineAttachmentIds.value + return attachment.toUiModel(isAvailableOffline = isAvailableOffline) + } + fun mapAttachments( - attachments: List + attachments: List, ): List { + val offlineIds = offlineAttachmentIds.value val result = mutableListOf() var group: MultipartAttachmentGroup? = null attachments.forEach { + val isAvailableOffline = it.assetId() in offlineIds if (it.isMediaAttachment()) { group = when (group) { - null -> MultipartAttachmentGroup.Media(listOf(it.toUiModel())) - is MultipartAttachmentGroup.Media -> group.copy(group.attachments + it.toUiModel()) + null -> MultipartAttachmentGroup.Media(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline))) + is MultipartAttachmentGroup.Media -> { + val newAttachment = it.toUiModel(isAvailableOffline = isAvailableOffline) + group.copy(attachments = group.attachments + newAttachment) + } else -> { result.add(group) - MultipartAttachmentGroup.Media(listOf(it.toUiModel())) + MultipartAttachmentGroup.Media(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline))) } } } else { group = when (group) { - null -> MultipartAttachmentGroup.Files(listOf(it.toUiModel())) - is MultipartAttachmentGroup.Files -> group.copy(group.attachments + it.toUiModel()) + null -> MultipartAttachmentGroup.Files(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline))) + is MultipartAttachmentGroup.Files -> { + val newAttachment = it.toUiModel(isAvailableOffline = isAvailableOffline) + group.copy(attachments = group.attachments + newAttachment) + } else -> { result.add(group) - MultipartAttachmentGroup.Files(listOf(it.toUiModel())) + MultipartAttachmentGroup.Files(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline))) } } } @@ -95,13 +118,16 @@ interface MultipartAttachmentsViewModel { @Suppress("EmptyFunctionBlock") object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel { + override val offlineAttachmentIds: StateFlow> = MutableStateFlow(emptySet()) override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) {} override fun onAttachmentsVisible(attachments: List) {} override fun onAttachmentsHidden(attachments: List) {} } +@Suppress("LongParameterList") @HiltViewModel class MultipartAttachmentsViewModelImpl @Inject constructor( + savedStateHandle: SavedStateHandle, private val refreshHelper: CellAssetRefreshHelper, private val download: DownloadCellFileUseCase, private val getEditorUrl: GetEditorUrlUseCase, @@ -110,9 +136,14 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( private val kaliumFileSystem: KaliumFileSystem, private val featureFlags: KaliumConfigs, private val getWireCellsConfig: GetWireCellConfigurationUseCase, + observeOfflineFilesByConversation: ObserveOfflineFilesByConversationUseCase, ) : ViewModel(), MultipartAttachmentsViewModel { + private val conversationId = savedStateHandle.navArgs().conversationId private val uploadProgress = mutableStateMapOf() + override val offlineAttachmentIds: StateFlow> = observeOfflineFilesByConversation(conversationId) + .map { offlineFiles -> offlineFiles.mapTo(mutableSetOf()) { it.id } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) private var isCollaboraEnabled: Boolean = false @@ -120,7 +151,10 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( loadWireCellConfig() } - override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) { + override fun onClick( + attachment: MultipartAttachmentUi, + openInImageViewer: (String) -> Unit, + ) { when { attachment.isImage() && !attachment.fileNotFound() -> openInImageViewer(attachment.uuid) attachment.isEditSupported && isCollaboraEnabled && featureFlags.collaboraIntegration -> @@ -174,7 +208,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( download( assetId = attachment.uuid, - conversationId = null, // TODO to replace with real conversation id in next PR + conversationId = conversationId.value, outFilePath = path, assetSize = attachment.assetSize ?: 0, ) { progress -> @@ -208,7 +242,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( } } -private fun MessageAttachment.assetId() = +internal fun MessageAttachment.assetId() = when (this) { is AssetContent -> remoteData.assetId is CellAssetContent -> id diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/grid/AssetGridPreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/grid/AssetGridPreview.kt index e2764c41859..b60849448a0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/grid/AssetGridPreview.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/grid/AssetGridPreview.kt @@ -28,11 +28,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable 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.res.painterResource +import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.colorsScheme @@ -92,6 +95,20 @@ internal fun AssetGridPreview( } } + if (item.isAvailableOffline) { + Icon( + modifier = Modifier + .padding( + end = dimensions().spacing6x, + top = dimensions().spacing6x + ) + .align(Alignment.TopEnd), + painter = painterResource(R.drawable.ic_downloaded), + contentDescription = null, + tint = colorsScheme().secondaryText, + ) + } + item.progress?.let { CircularProgressIndicator( modifier = Modifier diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/AssetPreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/AssetPreview.kt index 15f2098244d..a6b46b4d354 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/AssetPreview.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/AssetPreview.kt @@ -22,12 +22,17 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp +import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.ui.common.applyIf import com.wire.android.ui.common.colorsScheme @@ -76,6 +81,19 @@ fun AssetPreview( item.isEditSupported -> EditableAssetPreview(item, messageStyle) else -> FileAssetPreview(item, messageStyle) } + if (item.isAvailableOffline) { + Icon( + modifier = Modifier + .padding( + end = dimensions().spacing6x, + top = dimensions().spacing6x + ) + .align(Alignment.TopEnd), + painter = painterResource(R.drawable.ic_downloaded), + contentDescription = null, + tint = colorsScheme().secondaryText, + ) + } } else { AssetNotAvailablePreview(messageStyle = messageStyle) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt index f23b308cf30..4a36f4ce106 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt @@ -17,26 +17,35 @@ */ package com.wire.android.ui.home.conversations.model.messagetypes.multipart +import androidx.lifecycle.SavedStateHandle +import com.ramcosta.composedestinations.generated.app.navargs.toSavedStateHandle import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.ui.common.multipart.AssetSource import com.wire.android.ui.common.multipart.MultipartAttachmentUi +import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.util.FileManager import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase +import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesByConversationUseCase import com.wire.kalium.common.functional.right import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.KaliumFileSystem +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.CellAssetContent +import com.wire.kalium.cells.domain.usecase.offline.OfflineFileInfo import com.wire.kalium.logic.featureFlags.KaliumConfigs import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -137,6 +146,35 @@ class MultipartAttachmentsViewModelTest { ) } + @Test + fun `with offline attachment id when mapped then attachment is marked as available offline`() = runTest { + val (_, viewModel) = Arrangement() + .withOfflineAttachmentId("asset_1") + .arrange() + + advanceUntilIdle() + + val result = viewModel.mapAttachments( + listOf(testAssetContent.copy(id = "asset_1", mimeType = "application/pdf")), + ) + + assertEquals( + listOf( + MultipartAttachmentsViewModel.MultipartAttachmentGroup.Files( + attachments = listOf( + testAttachmentUi.copy( + uuid = "asset_1", + mimeType = "application/pdf", + assetType = AttachmentFileType.PDF, + isAvailableOffline = true, + ), + ) + ) + ), + result + ) + } + @Test fun `with image attachment when clicked then image opened in internal viewer`() = runTest { val (_, viewModel) = Arrangement() @@ -230,6 +268,10 @@ class MultipartAttachmentsViewModelTest { MockKAnnotations.init(this) } + private val savedStateHandle: SavedStateHandle = ConversationNavArgs( + conversationId = testConversationId, + ).toSavedStateHandle() + @MockK lateinit var refreshHelper: CellAssetRefreshHelper @@ -251,17 +293,38 @@ class MultipartAttachmentsViewModelTest { @MockK lateinit var getWireCellsConfig: GetWireCellConfigurationUseCase + @MockK + lateinit var observeOfflineFilesByConversation: ObserveOfflineFilesByConversationUseCase + val kaliumFileSystem: KaliumFileSystem = FakeKaliumFileSystem() + private var offlineFiles: List = emptyList() + + fun withOfflineAttachmentId(assetId: String) = apply { + offlineFiles = listOf( + OfflineFileInfo( + id = assetId, + conversationId = testConversationId.value, + name = "file", + owner = "owner", + localPath = "local/path", + size = 1L, + downloadedAt = 1L, + ) + ) + } - suspend fun arrange(): Pair { + fun arrange(): Pair { coEvery { refreshHelper.refresh(any()) } returns Unit coEvery { fileManager.openWithExternalApp(any(), any(), any(), any()) } returns Unit coEvery { fileManager.openUrlWithExternalApp(any(), any(), any()) } returns Unit coEvery { download(any(), any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() coEvery { getWireCellsConfig() } returns null + every { observeOfflineFilesByConversation(testConversationId) } returns flowOf(offlineFiles) + every { observeOfflineFilesByConversation(ConversationId("other-conversation", "test-domain")) } returns flowOf(offlineFiles) return this to MultipartAttachmentsViewModelImpl( + savedStateHandle = savedStateHandle, refreshHelper = refreshHelper, download = download, fileManager = fileManager, @@ -270,11 +333,14 @@ class MultipartAttachmentsViewModelTest { kaliumFileSystem = kaliumFileSystem, featureFlags = kaliumConfigs, getWireCellsConfig = getWireCellsConfig, + observeOfflineFilesByConversation = observeOfflineFilesByConversation, ) } } private companion object { + val testConversationId = ConversationId("test-conversation-id", "test-domain") + val testAssetContent = CellAssetContent( id = "assetId1", versionId = "1", diff --git a/kalium b/kalium index 227ebac8faa..192dc557579 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 227ebac8faac06c08139cfdf5062f26c38974bf9 +Subproject commit 192dc5575795d5efd1dbd2a6297771a149adfbbc