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 2ad2524f57..2a02606d57 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 @@ -20,6 +20,7 @@ package com.wire.android.di.accountScoped import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic import com.wire.android.feature.cells.util.FileNameResolver +import com.wire.android.ui.home.conversations.model.messagetypes.multipart.CellAssetRefreshHelper import com.wire.kalium.cells.CellsScope import com.wire.kalium.cells.domain.CellUploadManager import com.wire.kalium.cells.domain.usecase.AddAttachmentDraftUseCase @@ -56,6 +57,7 @@ import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.paginatedFilesFlowUseCase import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.featureFlags.KaliumConfigs import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -205,4 +207,11 @@ class CellsModule { @Provides fun provideGetNodeVersionsUseCase(cellsScope: CellsScope): GetNodeVersionsUseCase = cellsScope.getNodeVersions + + @ViewModelScoped + @Provides + fun provideRefreshHelper(cellsScope: CellsScope, kaliumConfigs: KaliumConfigs): CellAssetRefreshHelper = CellAssetRefreshHelper( + refreshAsset = cellsScope.refreshAsset, + featureFlags = kaliumConfigs + ) } 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 a86649a91f..a96181ddbb 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 @@ -39,6 +39,7 @@ data class MultipartAttachmentUi( val metadata: AssetMetadata? = null, val transferStatus: AssetTransferStatus, val progress: Float? = null, + val isEditSupported: Boolean = false, ) enum class AssetSource { @@ -65,6 +66,7 @@ fun CellAssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( transferStatus = this.transferStatus, progress = progress, contentHash = contentHash, + isEditSupported = isEditSupported, ) fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( @@ -81,4 +83,5 @@ fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi( progress = progress, contentHash = null, contentUrl = null, + isEditSupported = false, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt new file mode 100644 index 0000000000..d2f82e7325 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt @@ -0,0 +1,148 @@ +/* + * Wire + * Copyright (C) 2025 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.model.messagetypes.multipart + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import com.wire.android.ui.common.multipart.MultipartAttachmentUi +import com.wire.android.ui.common.multipart.toUiModel +import com.wire.android.util.ExpiringMap +import com.wire.kalium.cells.domain.usecase.RefreshCellAssetStateUseCase +import com.wire.kalium.common.functional.onSuccess +import com.wire.kalium.logic.data.message.MessageAttachment +import com.wire.kalium.logic.featureFlags.KaliumConfigs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +class CellAssetRefreshHelper( + private val refreshAsset: RefreshCellAssetStateUseCase, + private val featureFlags: KaliumConfigs, + private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), + private val currentTime: () -> Long = { System.currentTimeMillis() }, +) { + + private companion object Companion { + // Default refresh rate for not editable asset if URL expiration is missing + val DEFAULT_CONTENT_URL_EXPIRY_MS = 1.hours.inWholeMilliseconds + + // Refresh rate for editable assets + val EDITABLE_CONTENT_EXPIRY_MS = 30.seconds.inWholeMilliseconds + } + + /** + * Regular files. + * - Refresh on first display. + * - Refresh when content URL expires (unlikely). + */ + @VisibleForTesting(otherwise = PRIVATE) + val regularAssets = expiringMap( + expirationMs = DEFAULT_CONTENT_URL_EXPIRY_MS, + onExpired = { assetId -> + coroutineScope.launch { refreshAsset(assetId) } + }, + ) + +/** + * Editable files. Could be updated frequently. + * - Refresh on first display. + * - Refresh every 30 sec to update preview if currently visible. + */ + @VisibleForTesting(otherwise = PRIVATE) + val visibleEditableAssets: ExpiringMap = expiringMap( + expirationMs = EDITABLE_CONTENT_EXPIRY_MS, + onExpired = { assetId -> + coroutineScope.launch { refreshAsset(assetId) } + + // Re-add to schedule next refresh + visibleEditableAssets[assetId] = Unit + } + ) + + private fun expiringMap(expirationMs: Long, onExpired: (String) -> Unit) = ExpiringMap( + scope = coroutineScope, + expirationMs = expirationMs, + delegate = mutableMapOf(), + onEntryExpired = { key, _ -> onExpired(key) }, + currentTime = currentTime, + ) + + fun onAttachmentsVisible(attachments: List) { + attachments.forEach { + onAttachmentVisible(it.toUiModel()) + } + } + + fun onAttachmentsHidden(attachments: List) { + attachments.forEach { + onAttachmentHidden(it.toUiModel()) + } + } + + fun onAttachmentVisible(attachment: MultipartAttachmentUi) { + if (attachment.isEditSupported && featureFlags.collaboraIntegration) { + + if (visibleEditableAssets.contains(attachment.uuid)) return + + visibleEditableAssets[attachment.uuid] = Unit + + coroutineScope.launch { + refreshAsset(attachment.uuid) + } + } else { + + if (regularAssets.contains(attachment.uuid)) return + + if (attachment.contentUrlExpiresAt != null) { + regularAssets.putWithExpireAt(attachment.uuid, Unit, attachment.contentUrlExpiresAt) + } else { + regularAssets[attachment.uuid] = Unit + } + + coroutineScope.launch { + refreshAsset(attachment.uuid) + .onSuccess { node -> + if (node.supportedEditors.isNotEmpty()) { + regularAssets.remove(attachment.uuid) + visibleEditableAssets[attachment.uuid] = Unit + } + } + } + } + } + + fun onAttachmentHidden(attachment: MultipartAttachmentUi) { + if (attachment.isEditSupported) { + visibleEditableAssets.remove(attachment.uuid) + } + } + + fun refresh(uuid: String) { + coroutineScope.launch { + refreshAsset(uuid) + } + } + + fun close() { + coroutineScope.cancel() + } +} 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 520f5cb0fa..bc38b260ba 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,8 +25,8 @@ 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.LaunchedEffect 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 @@ -66,6 +66,14 @@ fun MultipartAttachmentsView( if (attachments.size == 1) { attachments.first().toUiModel().let { AssetPreview( + modifier = modifier + .onVisibilityChanged { visible -> + if (visible) { + viewModel.onAttachmentsVisible(attachments) + } else { + viewModel.onAttachmentsHidden(attachments) + } + }, item = it, messageStyle = messageStyle, onClick = { @@ -80,7 +88,14 @@ fun MultipartAttachmentsView( val groups = viewModel.mapAttachments(attachments) Column( - modifier = modifier, + modifier = modifier + .onVisibilityChanged { visible -> + if (visible) { + viewModel.onAttachmentsVisible(attachments) + } else { + viewModel.onAttachmentsHidden(attachments) + } + }, verticalArrangement = Arrangement.spacedBy(dimensions().spacing8x) ) { groups.forEach { group -> @@ -112,9 +127,6 @@ fun MultipartAttachmentsView( } } } - LaunchedEffect(attachments) { - attachments.onEach { viewModel.refreshAssetState(it.toUiModel()) } - } } @Composable 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 cca82a4a39..fbc075b71a 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 @@ -25,28 +25,27 @@ import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.domain.model.AttachmentFileType.IMAGE import com.wire.android.feature.cells.domain.model.AttachmentFileType.PDF import com.wire.android.feature.cells.domain.model.AttachmentFileType.VIDEO -import com.wire.android.ui.common.multipart.AssetSource +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.util.ExpiringMap import com.wire.android.util.FileManager import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase -import com.wire.kalium.cells.domain.usecase.RefreshCellAssetStateUseCase +import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase +import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.data.message.AssetContent import com.wire.kalium.logic.data.message.CellAssetContent 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.launch import okio.Path.Companion.toPath import javax.inject.Inject -import kotlin.time.Duration.Companion.hours interface MultipartAttachmentsViewModel { fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) - fun refreshAssetState(attachment: MultipartAttachmentUi) fun mapAttachments( attachments: List ): List { @@ -87,59 +86,48 @@ interface MultipartAttachmentsViewModel { data class Media(val attachments: List) : MultipartAttachmentGroup data class Files(val attachments: List) : MultipartAttachmentGroup } + + fun onAttachmentsVisible(attachments: List) + fun onAttachmentsHidden(attachments: List) } @Suppress("EmptyFunctionBlock") object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel { override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) {} - override fun refreshAssetState(attachment: MultipartAttachmentUi) {} + override fun onAttachmentsVisible(attachments: List) {} + override fun onAttachmentsHidden(attachments: List) {} } @HiltViewModel class MultipartAttachmentsViewModelImpl @Inject constructor( - private val refreshAsset: RefreshCellAssetStateUseCase, + private val refreshHelper: CellAssetRefreshHelper, private val download: DownloadCellFileUseCase, + private val getEditorUrl: GetEditorUrlUseCase, + private val onlineEditor: OnlineEditor, private val fileManager: FileManager, private val kaliumFileSystem: KaliumFileSystem, + private val featureFlags: KaliumConfigs, ) : ViewModel(), MultipartAttachmentsViewModel { - private companion object { - val DEFAULT_CONTENT_URL_EXPIRY_MS = 1.hours.inWholeMilliseconds - } - - private val refreshed = ExpiringMap( - scope = viewModelScope, - expirationMs = DEFAULT_CONTENT_URL_EXPIRY_MS, - delegate = mutableMapOf(), - onEntryExpired = { key, _ -> - viewModelScope.launch { refreshAsset(key) } - } - ) - private val uploadProgress = mutableStateMapOf() override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) { when { attachment.isImage() && !attachment.fileNotFound() -> openInImageViewer(attachment.uuid) - attachment.fileNotFound() -> { refreshAssetState(attachment) } + attachment.isEditSupported && featureFlags.collaboraIntegration -> openOnlineEditor(attachment.uuid) + attachment.fileNotFound() -> { refreshHelper.refresh(attachment.uuid) } attachment.localFileAvailable() -> openLocalFile(attachment) attachment.canOpenWithUrl() -> openUrl(attachment) else -> downloadAsset(attachment) } } - override fun refreshAssetState(attachment: MultipartAttachmentUi) { - - if (attachment.source != AssetSource.CELL) return - if (refreshed.contains(attachment.uuid)) return - - if (attachment.contentUrlExpiresAt != null) { - refreshed.putWithExpireAt(attachment.uuid, Unit, attachment.contentUrlExpiresAt) - } else { - refreshed.put(attachment.uuid, Unit) - } + override fun onAttachmentsVisible(attachments: List) { + refreshHelper.onAttachmentsVisible(attachments) + } - viewModelScope.launch { refreshAsset(attachment.uuid) } + override fun onAttachmentsHidden(attachments: List) { + refreshHelper.onAttachmentsHidden(attachments) } private fun openLocalFile(attachment: MultipartAttachmentUi) { @@ -186,6 +174,19 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( } } + private fun openOnlineEditor(attachmentUuid: String) = viewModelScope.launch { + getEditorUrl(attachmentUuid) + .onSuccess { url -> + if (url != null) { + onlineEditor.open(url) + } + } + } + + override fun onCleared() { + refreshHelper.close() + } + private fun MessageAttachment.toUiModel() = toUiModel(uploadProgress[assetId()]) } 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 f26e9c3eb7..15f2098244 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 @@ -73,6 +73,7 @@ fun AssetPreview( item.assetType == AttachmentFileType.IMAGE -> ImageAssetPreview(item, messageStyle) item.assetType == AttachmentFileType.VIDEO -> VideoAssetPreview(item, messageStyle) item.assetType == AttachmentFileType.PDF && !showWithPreview -> PdfAssetPreview(item, messageStyle) + item.isEditSupported -> EditableAssetPreview(item, messageStyle) else -> FileAssetPreview(item, messageStyle) } } else { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/EditableAssetPreview.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/EditableAssetPreview.kt new file mode 100644 index 0000000000..8c16c2b278 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/standalone/EditableAssetPreview.kt @@ -0,0 +1,168 @@ +/* + * Wire + * Copyright (C) 2025 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.model.messagetypes.multipart.standalone + +import android.graphics.drawable.Drawable +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import coil.compose.AsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.wire.android.ui.common.applyIf +import com.wire.android.ui.common.attachmentdraft.ui.FileHeaderView +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.progress.WireLinearProgressIndicator +import com.wire.android.ui.home.conversations.messages.item.MessageStyle +import com.wire.android.ui.home.conversations.messages.item.textColor +import com.wire.android.ui.home.conversations.model.messagetypes.multipart.transferProgressColor +import com.wire.android.ui.theme.wireTypography +import com.wire.kalium.logic.util.fileExtension + +/** + * Show preview for files which support online editing option. + */ +@Composable +internal fun EditableAssetPreview( + item: MultipartAttachmentUi, + messageStyle: MessageStyle, +) { + Column( + modifier = Modifier + .applyIf(messageStyle == MessageStyle.BUBBLE_SELF) { + background(colorsScheme().selfBubble.secondary) + } + .applyIf(messageStyle == MessageStyle.BUBBLE_OTHER) { + background(colorsScheme().otherBubble.secondary) + } + .applyIf(messageStyle == MessageStyle.NORMAL) { + background( + color = colorsScheme().surface, + shape = RoundedCornerShape(dimensions().messageAttachmentCornerSize) + ) + border( + width = dimensions().spacing1x, + color = colorsScheme().outline, + shape = RoundedCornerShape(dimensions().messageAttachmentCornerSize) + ) + } + .clip(RoundedCornerShape(dimensions().messageAttachmentCornerSize)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + FileHeaderView( + modifier = Modifier.padding( + start = dimensions().spacing8x, + top = dimensions().spacing8x, + end = dimensions().spacing8x + ), + extension = item.fileName?.fileExtension() ?: item.mimeType.substringAfter("/"), + size = item.assetSize, + messageStyle = messageStyle + ) + + item.fileName?.let { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = dimensions().spacing8x, end = dimensions().spacing8x), + text = it, + style = MaterialTheme.wireTypography.body02, + color = messageStyle.textColor(), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Box( + modifier = Modifier + .background( + color = colorsScheme().outline, + shape = RoundedCornerShape(dimensions().messageAttachmentCornerSize) + ) + .clip(RoundedCornerShape(dimensions().messageAttachmentCornerSize)), + contentAlignment = Alignment.Center + ) { + + item.previewUrl?.let { + + // Remember recent drawable to use as placeholder to avoid blink on update + var drawable by remember { mutableStateOf(null) } + + val request = ImageRequest.Builder(LocalContext.current) + .diskCacheKey(item.contentHash) + .memoryCacheKey(item.contentHash) + .placeholderMemoryCacheKey(item.contentHash) + .diskCachePolicy(CachePolicy.ENABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .placeholder(drawable) + .crossfade(true) + .data(item.previewUrl) + .build() + + AsyncImage( + modifier = Modifier + .fillMaxSize() + .sizeIn(maxHeight = dimensions().messageDocumentPreviewMaxHeight), + model = request, + contentDescription = null, + alignment = Alignment.TopStart, + contentScale = ContentScale.FillWidth, + onSuccess = { result -> + drawable = result.result.drawable + } + ) + } + + // Download progress + item.progress?.let { + WireLinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart), + progress = { item.progress }, + color = transferProgressColor(item.transferStatus), + trackColor = Color.Transparent, + ) + } + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelperTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelperTest.kt new file mode 100644 index 0000000000..ff153b66cf --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelperTest.kt @@ -0,0 +1,209 @@ +/* + * Wire + * Copyright (C) 2025 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.model.messagetypes.multipart + +import com.wire.kalium.cells.domain.model.CellNode +import com.wire.kalium.cells.domain.usecase.RefreshCellAssetStateUseCase +import com.wire.kalium.common.functional.right +import com.wire.kalium.logic.data.asset.AssetTransferStatus +import com.wire.kalium.logic.data.message.CellAssetContent +import com.wire.kalium.logic.featureFlags.KaliumConfigs +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.minutes + +class CellAssetRefreshHelperTest { + + // + // Tests for regular attachments + // + + @Test + fun `with attachment with url expiration available when displayed first time then refresh scheduled with url expiration`() = runTest { + + val (_, refreshHelper) = Arrangement().arrange(this) + + val expiration = currentTime + 300 + + refreshHelper.onAttachmentsVisible( + listOf( + cellAsset.copy( + contentUrlExpiresAt = expiration + ) + ) + ) + + assertTrue(refreshHelper.regularAssets.contains(cellAsset.id)) + + advanceTimeBy(301) + + assertFalse(refreshHelper.regularAssets.contains(cellAsset.id)) + } + + @Test + fun `with attachment with no url expiration available when displayed first time then refresh scheduled with default expiration`() = runTest { + + val (_, refreshHelper) = Arrangement().arrange(this) + + refreshHelper.onAttachmentsVisible( + listOf( + cellAsset.copy( + contentUrlExpiresAt = null + ) + ) + ) + + assertTrue(refreshHelper.regularAssets.contains(cellAsset.id)) + + advanceTimeBy(59.minutes.inWholeMilliseconds) + + assertTrue(refreshHelper.regularAssets.contains(cellAsset.id)) + + advanceTimeBy(2.minutes.inWholeMilliseconds) + + assertFalse(refreshHelper.regularAssets.contains(cellAsset.id)) + } + + @Test + fun `with attachment when displayed first time then refresh called`() = runTest(UnconfinedTestDispatcher()) { + + var count = 0 + + val (_, refreshHelper) = Arrangement() + .withRefreshAsset { + count++ + testNode.right() + } + .arrange(this) + + refreshHelper.onAttachmentsVisible( + listOf(cellAsset) + ) + + assertEquals(1, count) + } + + @Test + fun `with attachment when displayed second time then no refresh called`() = runTest(UnconfinedTestDispatcher()) { + + var count = 0 + + val (_, refreshHelper) = Arrangement() + .withRefreshAsset { + count++ + testNode.right() + } + .arrange(this) + + refreshHelper.regularAssets[cellAsset.id] = Unit + + refreshHelper.onAttachmentsVisible( + listOf(cellAsset) + ) + + assertEquals(0, count) + } + + @Test + fun `with attachment when expire then refresh is called`() = runTest(UnconfinedTestDispatcher()) { + + var count = 0 + + val (_, refreshHelper) = Arrangement() + .withRefreshAsset { + count++ + testNode.right() + } + .arrange(this) + + val expiration = currentTime + 300 + + refreshHelper.onAttachmentsVisible( + listOf( + cellAsset.copy( + contentUrlExpiresAt = expiration + ) + ) + ) + + assertTrue(refreshHelper.regularAssets.contains(cellAsset.id)) + assertEquals(1, count) // First refresh when added + + advanceTimeBy(301) + + assertFalse(refreshHelper.regularAssets.contains(cellAsset.id)) + assertEquals(2, count) // Second refresh when expired + } + + // + // Tests for editable attachments + // + + // + // TODO: Refresh reschedule for editable assets causes infinite reschedule - expire loop in test scope. + // + + private inner class Arrangement { + + val featureFlags = KaliumConfigs( + collaboraIntegration = true + ) + + var refreshAsset = RefreshCellAssetStateUseCase { testNode.right() } + + fun withRefreshAsset(useCase: RefreshCellAssetStateUseCase) = apply { + refreshAsset = useCase + } + + fun arrange(scope: TestScope) = this to CellAssetRefreshHelper( + refreshAsset = refreshAsset, + featureFlags = featureFlags, + coroutineScope = scope, + currentTime = { scope.currentTime } + ) + } +} + +private val testNode = CellNode( + uuid = "uuid", + versionId = "versionId", + path = "path", + modified = 0, + size = 0, + eTag = "eTag", + type = "type", + isRecycled = false, + isDraft = false +) + +private val cellAsset = CellAssetContent( + id = "assetId1", + versionId = "v1", + mimeType = "image/png", + assetPath = null, + assetSize = 1024, + metadata = null, + transferStatus = AssetTransferStatus.UPLOADED +) 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 87eaa1f8e4..ab814499f7 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 @@ -18,17 +18,18 @@ package com.wire.android.ui.home.conversations.model.messagetypes.multipart 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.util.FileManager -import com.wire.kalium.cells.domain.model.CellNode import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase -import com.wire.kalium.cells.domain.usecase.RefreshCellAssetStateUseCase +import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase 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.message.CellAssetContent +import com.wire.kalium.logic.featureFlags.KaliumConfigs import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -162,7 +163,7 @@ class MultipartAttachmentsViewModelTest { ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } - coVerify(exactly = 1) { arrangement.refreshAsset(testAttachmentUi.uuid) } + coVerify(exactly = 1) { arrangement.refreshHelper.refresh(testAttachmentUi.uuid) } } @Test @@ -181,7 +182,7 @@ class MultipartAttachmentsViewModelTest { ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } - coVerify(exactly = 1) { arrangement.refreshAsset(testAttachmentUi.uuid) } + coVerify(exactly = 1) { arrangement.refreshHelper.refresh(testAttachmentUi.uuid) } } @Test @@ -229,28 +230,40 @@ class MultipartAttachmentsViewModelTest { } @MockK - lateinit var refreshAsset: RefreshCellAssetStateUseCase + lateinit var refreshHelper: CellAssetRefreshHelper @MockK lateinit var download: DownloadCellFileUseCase + @MockK + lateinit var getEditorUrl: GetEditorUrlUseCase + + @MockK + lateinit var onlineEditor: OnlineEditor + @MockK lateinit var fileManager: FileManager + @MockK + lateinit var kaliumConfigs: KaliumConfigs + val kaliumFileSystem: KaliumFileSystem = FakeKaliumFileSystem() suspend fun arrange(): Pair { - coEvery { refreshAsset(any()) } returns CellNode("uuid", "id", "path").right() + 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()) } returns Unit.right() return this to MultipartAttachmentsViewModelImpl( - refreshAsset = refreshAsset, + refreshHelper = refreshHelper, download = download, fileManager = fileManager, + getEditorUrl = getEditorUrl, + onlineEditor = onlineEditor, kaliumFileSystem = kaliumFileSystem, + featureFlags = kaliumConfigs, ) } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 2bc150f40b..5dd75639ea 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -81,6 +81,7 @@ data class WireDimensions( val messageImageMaxWidth: Dp, val messageImageMinHeight: Dp, val messageImageMaxHeight: Dp, + val messageDocumentPreviewMaxHeight: Dp, val messageVisualMaxFractionWidth: Float, val messageVisualMaxFractionHeight: Float, val bubbleMessageMaxFractionWidth: Float, @@ -279,6 +280,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( messageImageMaxWidth = 310.dp, messageImageMinHeight = 220.dp, messageImageMaxHeight = 310.dp, + messageDocumentPreviewMaxHeight = 410.dp, messageVisualMaxFractionWidth = 0.7F, messageVisualMaxFractionHeight = 0.4F, bubbleMessageMaxFractionWidth = 0.75F, diff --git a/core/ui-common/src/main/res/drawable/ic_edit_file.xml b/core/ui-common/src/main/res/drawable/ic_edit_file.xml new file mode 100644 index 0000000000..d64d5101be --- /dev/null +++ b/core/ui-common/src/main/res/drawable/ic_edit_file.xml @@ -0,0 +1,30 @@ + + + + +