Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand All @@ -81,4 +83,5 @@ fun AssetContent.toUiModel(progress: Float?) = MultipartAttachmentUi(
progress = progress,
contentHash = null,
contentUrl = null,
isEditSupported = false,
)
Original file line number Diff line number Diff line change
@@ -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) }
Copy link
Member

@ohassine ohassine Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coroutineScope is created with Dispatchers.Main, this launch will run refreshAsset(assetId)(that is calling fileSystem.delete() fileSystem.exists()) on the UI thread.

Copy link
Contributor Author

@sbakhtiarov sbakhtiarov Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, but I will take care of this in the usecase to keep the helper clean from implementation details of asset refresh.

},
)

/**
* Editable files. Could be updated frequently.
* - Refresh on first display.
* - Refresh every 30 sec to update preview if currently visible.
*/
@VisibleForTesting(otherwise = PRIVATE)

Check warning on line 70 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L70

Added line #L70 was not covered by tests
val visibleEditableAssets: ExpiringMap<String, Unit> = expiringMap(
expirationMs = EDITABLE_CONTENT_EXPIRY_MS,
onExpired = { assetId ->
coroutineScope.launch { refreshAsset(assetId) }

Check warning on line 74 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L74

Added line #L74 was not covered by tests

// Re-add to schedule next refresh
visibleEditableAssets[assetId] = Unit

Check warning on line 77 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L77

Added line #L77 was not covered by tests
}
)

private fun expiringMap(expirationMs: Long, onExpired: (String) -> Unit) = ExpiringMap<String, Unit>(
scope = coroutineScope,
expirationMs = expirationMs,
delegate = mutableMapOf(),
onEntryExpired = { key, _ -> onExpired(key) },
currentTime = currentTime,
)

fun onAttachmentsVisible(attachments: List<MessageAttachment>) {
attachments.forEach {
onAttachmentVisible(it.toUiModel())
}
}

fun onAttachmentsHidden(attachments: List<MessageAttachment>) {
attachments.forEach {
onAttachmentHidden(it.toUiModel())

Check warning on line 97 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L96-L97

Added lines #L96 - L97 were not covered by tests
}
}

fun onAttachmentVisible(attachment: MultipartAttachmentUi) {
if (attachment.isEditSupported && featureFlags.collaboraIntegration) {

if (visibleEditableAssets.contains(attachment.uuid)) return

visibleEditableAssets[attachment.uuid] = Unit

Check warning on line 106 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L106

Added line #L106 was not covered by tests

coroutineScope.launch {
refreshAsset(attachment.uuid)

Check warning on line 109 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L108-L109

Added lines #L108 - L109 were not covered by tests
}
} 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

Check warning on line 126 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L125-L126

Added lines #L125 - L126 were not covered by tests
}
}
}
}
}

fun onAttachmentHidden(attachment: MultipartAttachmentUi) {
if (attachment.isEditSupported) {
visibleEditableAssets.remove(attachment.uuid)

Check warning on line 135 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L135

Added line #L135 was not covered by tests
}
}

fun refresh(uuid: String) {
coroutineScope.launch {
refreshAsset(uuid)

Check warning on line 141 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L140-L141

Added lines #L140 - L141 were not covered by tests
}
}

fun close() {
coroutineScope.cancel()

Check warning on line 146 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/CellAssetRefreshHelper.kt#L146

Added line #L146 was not covered by tests
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
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
Expand All @@ -50,7 +50,7 @@
* Uses [MultipartAttachmentsViewModel] to handle preview image loading and interactions handling.
*/
@Composable
fun MultipartAttachmentsView(

Check failure on line 53 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-android&issues=AZsIZLpH5KBf2gL6d_wJ&open=AZsIZLpH5KBf2gL6d_wJ&pullRequest=4460
conversationId: ConversationId,
attachments: List<MessageAttachment>,
messageStyle: MessageStyle,
Expand All @@ -66,6 +66,14 @@
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 = {
Expand All @@ -80,7 +88,14 @@
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 ->
Expand Down Expand Up @@ -112,9 +127,6 @@
}
}
}
LaunchedEffect(attachments) {
attachments.onEach { viewModel.refreshAssetState(it.toUiModel()) }
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,27 @@
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<MessageAttachment>
): List<MultipartAttachmentGroup> {
Expand Down Expand Up @@ -87,59 +86,48 @@
data class Media(val attachments: List<MultipartAttachmentUi>) : MultipartAttachmentGroup
data class Files(val attachments: List<MultipartAttachmentUi>) : MultipartAttachmentGroup
}

fun onAttachmentsVisible(attachments: List<MessageAttachment>)
fun onAttachmentsHidden(attachments: List<MessageAttachment>)
}

@Suppress("EmptyFunctionBlock")
object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel {
override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) {}
override fun refreshAssetState(attachment: MultipartAttachmentUi) {}
override fun onAttachmentsVisible(attachments: List<MessageAttachment>) {}

Check failure on line 97 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-android&issues=AZsIZLrn5KBf2gL6d_wK&open=AZsIZLrn5KBf2gL6d_wK&pullRequest=4460
override fun onAttachmentsHidden(attachments: List<MessageAttachment>) {}

Check failure on line 98 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-android&issues=AZsIZLrn5KBf2gL6d_wL&open=AZsIZLrn5KBf2gL6d_wL&pullRequest=4460

Check warning on line 98 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt#L97-L98

Added lines #L97 - L98 were not covered by tests
}

@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<String, Unit>(
scope = viewModelScope,
expirationMs = DEFAULT_CONTENT_URL_EXPIRY_MS,
delegate = mutableMapOf(),
onEntryExpired = { key, _ ->
viewModelScope.launch { refreshAsset(key) }
}
)

private val uploadProgress = mutableStateMapOf<String, Float>()

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<MessageAttachment>) {
refreshHelper.onAttachmentsVisible(attachments)

Check warning on line 126 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt#L126

Added line #L126 was not covered by tests
}

viewModelScope.launch { refreshAsset(attachment.uuid) }
override fun onAttachmentsHidden(attachments: List<MessageAttachment>) {
refreshHelper.onAttachmentsHidden(attachments)

Check warning on line 130 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt#L130

Added line #L130 was not covered by tests
}

private fun openLocalFile(attachment: MultipartAttachmentUi) {
Expand Down Expand Up @@ -186,6 +174,19 @@
}
}

private fun openOnlineEditor(attachmentUuid: String) = viewModelScope.launch {
getEditorUrl(attachmentUuid)
.onSuccess { url ->

Check warning on line 179 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt#L177-L179

Added lines #L177 - L179 were not covered by tests
if (url != null) {
onlineEditor.open(url)

Check warning on line 181 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt#L181

Added line #L181 was not covered by tests
}
}
}

override fun onCleared() {
refreshHelper.close()

Check warning on line 187 in app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt#L187

Added line #L187 was not covered by tests
}

private fun MessageAttachment.toUiModel() =
toUiModel(uploadProgress[assetId()])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading