diff --git a/app/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt b/app/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt index 89b94b5981d..fbca5ab541e 100644 --- a/app/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt +++ b/app/src/main/kotlin/com/wire/android/di/ViewModelScoped.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.lifecycle.ViewModel import com.sebaslogen.resaca.KeyInScopeResolver import com.sebaslogen.resaca.hilt.hiltViewModelScoped +import kotlin.time.Duration /** * Common assisted factory contract for scoped ViewModels that receive [ScopedArgs]. @@ -44,10 +45,10 @@ interface AssistedViewModelFactory { @Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") @Composable inline fun > - hiltViewModelScoped(arguments: R): S where T : ViewModel, T : S = when { + hiltViewModelScoped(arguments: R, clearDelay: Duration? = null): S where T : ViewModel, T : S = when { LocalInspectionMode.current -> ViewModelScopedPreviews.firstNotNullOf { it as? S } espresso -> ViewModelScopedPreviews.firstNotNullOf { it as? S } - else -> hiltViewModelScoped(key = arguments.key?.toString()) { factory -> + else -> hiltViewModelScoped(key = arguments.key?.toString(), clearDelay = clearDelay) { factory -> factory.create(arguments) } } @@ -58,6 +59,7 @@ inline fun , + clearDelay: Duration? = null, ): S where T : ViewModel, T : S = when { LocalInspectionMode.current -> ViewModelScopedPreviews.firstNotNullOf { it as? S } espresso -> ViewModelScopedPreviews.firstNotNullOf { it as? S } @@ -65,7 +67,11 @@ inline fun (key = scopedKey, keyInScopeResolver = keyInScopeResolver) { factory -> + hiltViewModelScoped( + key = scopedKey, + keyInScopeResolver = keyInScopeResolver, + clearDelay = clearDelay + ) { factory -> factory.create(arguments) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/ConversationAssetPathsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/ConversationAssetPathsViewModel.kt new file mode 100644 index 00000000000..57884c835f2 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/ConversationAssetPathsViewModel.kt @@ -0,0 +1,122 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.messages.item + +import androidx.compose.runtime.mutableStateMapOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.data.asset.AssetTransferStatus +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase +import com.wire.kalium.logic.feature.asset.MessageAssetResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface ConversationAssetPathsViewModel { + fun localAssetPath(messageId: String): String? = null + fun localAssetPath( + conversationId: ConversationId, + messageId: String, + assetStatus: AssetTransferStatus?, + downloadIfNeeded: Boolean = false, + ): String? = null + + fun resolveIfNeeded( + conversationId: ConversationId, + messageId: String, + transferStatus: AssetTransferStatus, + downloadIfNeeded: Boolean = false, + ) {} +} + +object ConversationAssetPathsViewModelPreview : ConversationAssetPathsViewModel + +@HiltViewModel +class ConversationAssetPathsViewModelImpl @Inject constructor( + private val getMessageAsset: GetMessageAssetUseCase, + private val dispatchers: DispatcherProvider, +) : ViewModel(), ConversationAssetPathsViewModel { + + private val localAssetPaths = mutableStateMapOf() + private val resolvingJobs = mutableMapOf() + + override fun localAssetPath(messageId: String): String? = localAssetPaths[messageId] + + override fun localAssetPath( + conversationId: ConversationId, + messageId: String, + assetStatus: AssetTransferStatus?, + downloadIfNeeded: Boolean, + ): String? = localAssetPaths[messageId].also { path -> + if (path == null && resolvingJobs[messageId]?.isActive != true) { + resolveIfNeeded( + conversationId = conversationId, + messageId = messageId, + transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, + downloadIfNeeded = downloadIfNeeded, + ) + } + } + + override fun resolveIfNeeded( + conversationId: ConversationId, + messageId: String, + transferStatus: AssetTransferStatus, + downloadIfNeeded: Boolean + ) { + val shouldResolve = when { + downloadIfNeeded -> + transferStatus == AssetTransferStatus.NOT_DOWNLOADED || + transferStatus == AssetTransferStatus.DOWNLOAD_IN_PROGRESS || + transferStatus == AssetTransferStatus.SAVED_INTERNALLY || + transferStatus == AssetTransferStatus.UPLOADED + + else -> transferStatus == AssetTransferStatus.SAVED_INTERNALLY + } + + if (!shouldResolve || localAssetPaths[messageId] != null || resolvingJobs[messageId]?.isActive == true) { + return + } + + resolvingJobs[messageId] = viewModelScope.launch(dispatchers.io()) { + try { + when (val result = getMessageAsset(conversationId, messageId).await()) { + is MessageAssetResult.Success -> { + val resolvedPath = result.decodedAssetPath.toString() + withContext(dispatchers.main()) { + localAssetPaths[messageId] = resolvedPath + } + } + + is MessageAssetResult.Failure -> Unit + } + } finally { + withContext(dispatchers.main()) { + if (resolvingJobs[messageId] === this@launch) { + resolvingJobs.remove(messageId) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index d4a29c21549..5ecf1daebb0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -24,14 +24,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import com.wire.android.ui.home.conversations.LocalAssetLocalPathKeyInScopeResolver +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.wire.android.R -import com.wire.android.di.hiltViewModelScoped import com.wire.android.media.audiomessage.AudioMessageArgs import com.wire.android.model.Clickable import com.wire.android.ui.common.applyIf @@ -78,6 +77,11 @@ internal fun UIMessage.Regular.MessageContentAndStatus( conversationDetailsData: ConversationDetailsData, accent: Accent = Accent.Unknown, ) { + val conversationAssetPathsViewModel: ConversationAssetPathsViewModel = when { + LocalInspectionMode.current -> ConversationAssetPathsViewModelPreview + else -> hiltViewModel(key = message.conversationId.toString()) + } + val onAssetClickable = remember(message) { Clickable(enabled = isAvailable, onClick = { onAssetClicked(header.messageId) @@ -115,7 +119,8 @@ internal fun UIMessage.Regular.MessageContentAndStatus( onLinkClick = onLinkClicked, onReplyClick = onReplyClickable, messageStyle = messageStyle, - accent = accent + accent = accent, + conversationAssetPathsViewModel = conversationAssetPathsViewModel ) if (!messageStyle.isBubble()) { if (messageContent is PartialDeliverable && messageContent.deliveryStatus.hasAnyFailures) { @@ -161,85 +166,41 @@ private fun MessageContent( onOpenProfile: (String) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, - accent: Accent + accent: Accent, + conversationAssetPathsViewModel: ConversationAssetPathsViewModel ) { when (messageContent) { is UIMessageContent.ImageMessage -> { - val args = AssetLocalPathArgs(message.conversationId, message.header.messageId) - val keyInScopeResolver = LocalAssetLocalPathKeyInScopeResolver.current - val viewModel: AssetLocalPathViewModel = - if (keyInScopeResolver != null && keyInScopeResolver(args.key)) { - hiltViewModelScoped< - AssetLocalPathViewModelImpl, - AssetLocalPathViewModel, - AssetLocalPathArgs, - AssetLocalPathViewModelImpl.Factory, - >( - arguments = args, - keyInScopeResolver = keyInScopeResolver, - ) - } else { - hiltViewModelScoped< - AssetLocalPathViewModelImpl, - AssetLocalPathViewModel, - AssetLocalPathArgs, - AssetLocalPathViewModelImpl.Factory, - >( - args - ) - } - LaunchedEffect(assetStatus) { - viewModel.resolveIfNeeded( - transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, - downloadIfNeeded = true - ) - } + val messageId = message.header.messageId + val localAssetPath = conversationAssetPathsViewModel.localAssetPath( + conversationId = message.conversationId, + messageId = messageId, + assetStatus = assetStatus, + downloadIfNeeded = true + ) MessageImage( imgParams = messageContent.params, transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, onImageClick = onImageClick, messageStyle = messageStyle, - assetPath = viewModel.localAssetPath?.toPath(normalize = true) + assetPath = localAssetPath?.toPath(normalize = true) ) } is UIMessageContent.VideoMessage -> { - val args = AssetLocalPathArgs(message.conversationId, message.header.messageId) - val keyInScopeResolver = LocalAssetLocalPathKeyInScopeResolver.current - val viewModel: AssetLocalPathViewModel = - if (keyInScopeResolver != null && keyInScopeResolver(args.key)) { - hiltViewModelScoped< - AssetLocalPathViewModelImpl, - AssetLocalPathViewModel, - AssetLocalPathArgs, - AssetLocalPathViewModelImpl.Factory, - >( - arguments = args, - keyInScopeResolver = keyInScopeResolver, - ) - } else { - hiltViewModelScoped< - AssetLocalPathViewModelImpl, - AssetLocalPathViewModel, - AssetLocalPathArgs, - AssetLocalPathViewModelImpl.Factory, - >( - args - ) - } - LaunchedEffect(assetStatus) { - viewModel.resolveIfNeeded( - transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, - downloadIfNeeded = false - ) - } + val messageId = message.header.messageId + val localAssetPath = conversationAssetPathsViewModel.localAssetPath( + conversationId = message.conversationId, + messageId = messageId, + assetStatus = assetStatus + ) VideoMessage( assetSize = messageContent.assetSizeInBytes, assetName = messageContent.assetName, assetExtension = messageContent.assetExtension, - assetDataPath = viewModel.localAssetPath, + assetDataPath = localAssetPath, params = messageContent.params, duration = messageContent.duration, transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, @@ -333,41 +294,18 @@ private fun MessageContent( } is UIMessageContent.AssetMessage -> { - val args = AssetLocalPathArgs(message.conversationId, message.header.messageId) - val keyInScopeResolver = LocalAssetLocalPathKeyInScopeResolver.current - val viewModel: AssetLocalPathViewModel = - if (keyInScopeResolver != null && keyInScopeResolver(args.key)) { - hiltViewModelScoped< - AssetLocalPathViewModelImpl, - AssetLocalPathViewModel, - AssetLocalPathArgs, - AssetLocalPathViewModelImpl.Factory, - >( - arguments = args, - keyInScopeResolver = keyInScopeResolver, - ) - } else { - hiltViewModelScoped< - AssetLocalPathViewModelImpl, - AssetLocalPathViewModel, - AssetLocalPathArgs, - AssetLocalPathViewModelImpl.Factory, - >( - args - ) - } - LaunchedEffect(assetStatus) { - viewModel.resolveIfNeeded( - transferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, - downloadIfNeeded = false - ) - } + val messageId = message.header.messageId + val localAssetPath = conversationAssetPathsViewModel.localAssetPath( + conversationId = message.conversationId, + messageId = messageId, + assetStatus = assetStatus + ) MessageAsset( assetName = messageContent.assetName, assetExtension = messageContent.assetExtension, assetSizeInBytes = messageContent.assetSizeInBytes, - assetDataPath = viewModel.localAssetPath, + assetDataPath = localAssetPath, assetTransferStatus = assetStatus ?: AssetTransferStatus.NOT_DOWNLOADED, onAssetClick = onAssetClick, messageStyle = messageStyle diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt index 2a4ce70d561..d53987364d2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/MessageTypes.kt @@ -28,7 +28,10 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -251,7 +254,13 @@ fun MessageImage( shape = RoundedCornerShape(dimensions().messageAssetBorderRadius) ) } - .wrapContentSize() + .applyIf(messageStyle.isBubble()) { + fillMaxWidth() + .height(imageSize.height) + } + .applyIf(!messageStyle.isBubble()) { + requiredSize(imageSize) + } .combinedClickable( enabled = onImageClick.enabled, onClick = onImageClick.onClick, @@ -293,23 +302,41 @@ private fun MessageImageOverlay( transferStatus: AssetTransferStatus, messageStyle: MessageStyle, ) { + if (hasImageSource && transferStatus.shouldHideLoadingWhenSourceExists()) { + return + } + when (transferStatus) { - UPLOAD_IN_PROGRESS, DOWNLOAD_IN_PROGRESS -> { + UPLOAD_IN_PROGRESS -> { ImageMessageInProgress( size = size, - isDownloading = transferStatus == DOWNLOAD_IN_PROGRESS, + isDownloading = false, + color = colorsScheme().onScrim, + ) + } + + DOWNLOAD_IN_PROGRESS -> { + ImageMessageInProgress( + size = size, + isDownloading = true, color = colorsScheme().onScrim, ) } AssetTransferStatus.NOT_DOWNLOADED -> { - if (!hasImageSource) { - ImageMessageInProgress( - size = size, - isDownloading = true, - color = messageStyle.textColor(), - ) - } + ImageMessageInProgress( + size = size, + isDownloading = true, + color = messageStyle.textColor(), + ) + } + + AssetTransferStatus.SAVED_INTERNALLY, AssetTransferStatus.UPLOADED -> { + ImageMessageInProgress( + size = size, + isDownloading = true, + color = messageStyle.textColor(), + ) } NOT_FOUND -> { @@ -332,6 +359,16 @@ private fun MessageImageOverlay( } } +private fun AssetTransferStatus.shouldHideLoadingWhenSourceExists(): Boolean = + when (this) { + DOWNLOAD_IN_PROGRESS, + AssetTransferStatus.NOT_DOWNLOADED, + AssetTransferStatus.SAVED_INTERNALLY, + AssetTransferStatus.UPLOADED -> true + + else -> false + } + @Composable fun MediaAssetImage( asset: ImageAsset.Remote?, @@ -340,7 +377,7 @@ fun MediaAssetImage( messageStyle: MessageStyle, onImageClick: Clickable, modifier: Modifier = Modifier, - assetPath: Path? = null + assetPath: Path? = null, ) { Box( modifier @@ -355,21 +392,10 @@ fun MediaAssetImage( color = MaterialTheme.wireColorScheme.secondaryButtonDisabledOutline, shape = RoundedCornerShape(dimensions().messageAssetBorderRadius) ) - .wrapContentSize() + .requiredSize(size) .clickable(onImageClick) ) { when { - // Trying to upload the asset - transferStatus == DOWNLOAD_IN_PROGRESS -> { - ImageMessageInProgress( - size = size, - isDownloading = true, - showText = false, - color = messageStyle.textColor(), - modifier = Modifier.align(Alignment.Center) - ) - } - LocalInspectionMode.current -> { // preview DisplayableImageMessage( imageData = mockedPrivateAsset(), @@ -390,6 +416,17 @@ fun MediaAssetImage( ) } + // Download in progress and no available image source yet. + transferStatus == DOWNLOAD_IN_PROGRESS -> { + ImageMessageInProgress( + size = size, + isDownloading = true, + showText = false, + color = messageStyle.textColor(), + modifier = Modifier.align(Alignment.Center) + ) + } + // Show error placeholder transferStatus == FAILED_DOWNLOAD -> { ImageMessageFailed( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/item/ConversationAssetPathsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/item/ConversationAssetPathsViewModelTest.kt new file mode 100644 index 00000000000..0f48908ca48 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/item/ConversationAssetPathsViewModelTest.kt @@ -0,0 +1,283 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.messages.item + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.TestDispatcherProvider +import com.wire.kalium.logic.data.asset.AssetTransferStatus +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.asset.GetMessageAssetUseCase +import com.wire.kalium.logic.feature.asset.MessageAssetResult +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +class ConversationAssetPathsViewModelTest { + + @Test + fun givenUploadedStatus_whenResolveIfNeededWithDownloadIfNeeded_thenPathIsResolved() = runTest { + // given + val expectedPath = "/local/path/image.jpg" + val (arrangement, viewModel) = Arrangement() + .withGetMessageAssetSuccess(expectedPath) + .arrange() + + // when + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + transferStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + + // then + assertEquals(expectedPath, viewModel.localAssetPath(arrangement.messageId)) + coVerify(exactly = 1) { arrangement.getMessageAsset(any(), any()) } + } + + @Test + fun givenNoCachedPath_whenLocalAssetPathCalledWithDownloadIfNeeded_thenPathIsResolved() = runTest { + // given + val expectedPath = "/local/path/image.jpg" + val (arrangement, viewModel) = Arrangement() + .withGetMessageAssetSuccess(expectedPath) + .arrange() + + // when + val path = viewModel.localAssetPath( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + assetStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + + // then + assertNull(path) + assertEquals(expectedPath, viewModel.localAssetPath(arrangement.messageId)) + coVerify(exactly = 1) { arrangement.getMessageAsset(any(), any()) } + } + + @Test + fun givenCachedPath_whenLocalAssetPathCalled_thenGetAssetIsNotCalledAgain() = runTest { + // given + val expectedPath = "/local/path/image.jpg" + val (arrangement, viewModel) = Arrangement() + .withGetMessageAssetSuccess(expectedPath) + .arrange() + + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + transferStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + + // when + val path = viewModel.localAssetPath( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + assetStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + + // then + assertEquals(expectedPath, path) + coVerify(exactly = 1) { arrangement.getMessageAsset(any(), any()) } + } + + @Test + fun givenDownloadInProgressStatus_whenResolveIfNeededWithDownloadIfNeeded_thenPathIsResolved() = runTest { + // given + val expectedPath = "/local/path/image.jpg" + val (arrangement, viewModel) = Arrangement() + .withGetMessageAssetSuccess(expectedPath) + .arrange() + + // when + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + transferStatus = AssetTransferStatus.DOWNLOAD_IN_PROGRESS, + downloadIfNeeded = true + ) + + // then + assertEquals(expectedPath, viewModel.localAssetPath(arrangement.messageId)) + coVerify(exactly = 1) { arrangement.getMessageAsset(any(), any()) } + } + + @Test + fun givenPathAlreadyResolved_whenResolveIfNeededCalledAgain_thenGetAssetIsNotCalledAgain() = runTest { + // given + val expectedPath = "/local/path/image.jpg" + val (arrangement, viewModel) = Arrangement() + .withGetMessageAssetSuccess(expectedPath) + .arrange() + + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + transferStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + assertEquals(expectedPath, viewModel.localAssetPath(arrangement.messageId)) + + // when + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + transferStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + + // then + coVerify(exactly = 1) { arrangement.getMessageAsset(any(), any()) } + } + + @Test + fun givenTwoMessageIds_whenResolveIfNeeded_thenPathsAreResolvedSeparately() = runTest { + // given + val firstMessageId = "message-id-1" + val secondMessageId = "message-id-2" + val firstPath = "/local/path/first.jpg" + val secondPath = "/local/path/second.jpg" + val (arrangement, viewModel) = Arrangement() + .withGetMessageAssetSuccessForMessage(firstMessageId, firstPath) + .withGetMessageAssetSuccessForMessage(secondMessageId, secondPath) + .arrange() + + // when + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = firstMessageId, + transferStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = secondMessageId, + transferStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + + // then + assertEquals(firstPath, viewModel.localAssetPath(firstMessageId)) + assertEquals(secondPath, viewModel.localAssetPath(secondMessageId)) + } + + @Test + fun givenGetAssetFails_whenResolveIfNeeded_thenLocalPathRemainsNull() = runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withGetMessageAssetFailure() + .arrange() + + // when + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + transferStatus = AssetTransferStatus.UPLOADED, + downloadIfNeeded = true + ) + + // then + assertNull(viewModel.localAssetPath(arrangement.messageId)) + } + + @Test + fun givenUploadInProgressStatus_whenResolveIfNeeded_thenPathIsNotResolved() = runTest { + // given + val (arrangement, viewModel) = Arrangement() + .arrange() + + // when + viewModel.resolveIfNeeded( + conversationId = arrangement.conversationId, + messageId = arrangement.messageId, + transferStatus = AssetTransferStatus.UPLOAD_IN_PROGRESS, + downloadIfNeeded = true + ) + + // then + assertNull(viewModel.localAssetPath(arrangement.messageId)) + coVerify(exactly = 0) { arrangement.getMessageAsset(any(), any()) } + } + + private class Arrangement { + + @MockK + lateinit var getMessageAsset: GetMessageAssetUseCase + + val conversationId = ConversationId("conv-value", "conv-domain") + val messageId = "test-message-id" + private val dispatchers = TestDispatcherProvider() + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + fun withGetMessageAssetSuccess(path: String) = apply { + coEvery { getMessageAsset(any(), any()) } returns CompletableDeferred( + MessageAssetResult.Success( + decodedAssetPath = path.toPath(normalize = true), + assetSize = 1024L, + assetName = "image.jpg" + ) + ) + } + + fun withGetMessageAssetSuccessForMessage(messageId: String, path: String) = apply { + coEvery { getMessageAsset(any(), messageId) } returns CompletableDeferred( + MessageAssetResult.Success( + decodedAssetPath = path.toPath(normalize = true), + assetSize = 1024L, + assetName = "image.jpg" + ) + ) + } + + fun withGetMessageAssetFailure() = apply { + coEvery { getMessageAsset(any(), any()) } returns CompletableDeferred( + MessageAssetResult.Failure( + coreFailure = com.wire.kalium.common.error.CoreFailure.Unknown(null), + isRetryNeeded = false + ) + ) + } + + fun arrange(): Pair { + val viewModel = ConversationAssetPathsViewModelImpl( + getMessageAsset = getMessageAsset, + dispatchers = dispatchers + ) + return this to viewModel + } + } +}