From 15042f200e0a5f707063411aff113d9a0fe2bb56 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 17:15:29 +0200 Subject: [PATCH 01/20] feat: display offline files --- .../android/di/accountScoped/CellsModule.kt | 10 ++ .../feature/cells/ui/AllFilesScreen.kt | 55 +++++++--- .../feature/cells/ui/CellFileActionsMenu.kt | 19 ++++ .../android/feature/cells/ui/CellViewModel.kt | 100 ++++++++++++++++-- .../cells/ui/ConversationFilesScreen.kt | 44 +++++--- .../cells/ui/OfflineFileDownloadController.kt | 4 + .../feature/cells/ui/common/OfflineBanner.kt | 79 ++++++++++++++ .../feature/cells/ui/model/CellNodeUi.kt | 2 + .../cells/ui/model/NodeBottomSheetAction.kt | 3 +- .../main/res/drawable/ic_cross_in_circle.xml | 30 ++++++ .../cells/src/main/res/drawable/ic_open.xml | 24 +++++ .../src/main/res/drawable/ic_wifi_signal.xml | 28 +++++ .../cells/src/main/res/values/strings.xml | 2 + kalium | 2 +- 14 files changed, 364 insertions(+), 38 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/common/OfflineBanner.kt create mode 100644 features/cells/src/main/res/drawable/ic_cross_in_circle.xml create mode 100644 features/cells/src/main/res/drawable/ic_open.xml create mode 100644 features/cells/src/main/res/drawable/ic_wifi_signal.xml 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 f710aae5bf9..fa4370b406b 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 @@ -62,6 +62,8 @@ import com.wire.kalium.cells.domain.usecase.publiclink.SetPublicLinkExpirationUs import com.wire.kalium.cells.domain.usecase.publiclink.UpdatePublicLinkPasswordUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase +import com.wire.kalium.cells.domain.usecase.GetConversationNamesUseCase +import com.wire.kalium.cells.domain.usecase.GetUserNamesUseCase 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.ObserveOfflineFilesUseCase @@ -279,4 +281,12 @@ class CellsModule { @ViewModelScoped @Provides fun provideGetOfflineFileUseCase(cellsScope: CellsScope): GetOfflineFileUseCase = cellsScope.getOfflineFile + + @ViewModelScoped + @Provides + fun provideGetConversationNamesUseCase(cellsScope: CellsScope): GetConversationNamesUseCase = cellsScope.getConversationNames + + @ViewModelScoped + @Provides + fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNamesUseCase = cellsScope.getUserNames } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index 3dc9c3ec67f..18474f9e9a3 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt @@ -17,11 +17,13 @@ */ package com.wire.android.feature.cells.ui +import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -30,6 +32,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTa import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator @@ -48,26 +51,52 @@ fun AllFilesScreen( ) { val pagingListItems = viewModel.nodesFlow.collectAsLazyPagingItems() + val isOnline by viewModel.isOnline.collectAsState() WireScaffold( modifier = modifier, topBar = { Column { - SearchTopBar( - modifier = Modifier, - isSearchActive = false, - searchBarHint = stringResource(R.string.search_label), - searchQueryTextState = rememberTextFieldState(), - onTap = { - navigator.navigate( - NavigationCommand( - SearchScreenDestination( - screenType = DriveSearchScreenType.DRIVE, + AnimatedContent(isOnline) { + if (it) { + SearchTopBar( + modifier = Modifier, + isSearchActive = false, + searchBarHint = stringResource(R.string.search_label), + searchQueryTextState = rememberTextFieldState(), + onTap = { + navigator.navigate( + NavigationCommand( + SearchScreenDestination( + screenType = DriveSearchScreenType.DRIVE, + ) + ) ) - ) + }, ) - }, - ) + } else { + OfflineBanner() + } + } +// if (isOnline) { +// SearchTopBar( +// modifier = Modifier, +// isSearchActive = false, +// searchBarHint = stringResource(R.string.search_label), +// searchQueryTextState = rememberTextFieldState(), +// onTap = { +// navigator.navigate( +// NavigationCommand( +// SearchScreenDestination( +// screenType = DriveSearchScreenType.DRIVE, +// ) +// ) +// ) +// }, +// ) +// } else { +// OfflineBanner() +// } } }, ) { innerPadding -> diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt index 8e604ec5323..a698296aa02 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt @@ -35,7 +35,20 @@ class CellFileActionsMenu @Inject constructor( isAllFiles: Boolean, isSearching: Boolean, isCollaboraEnabled: Boolean, + isOnline: Boolean = true, ): List { + if (!isOnline) { + return buildList { + val canOpenOffline = cellNode is CellNodeUi.Folder || + (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) + if (canOpenOffline) { + add(NodeBottomSheetAction.OPEN) + } + if (cellNode is CellNodeUi.File && cellNode.isAvailableOffline) { + add(NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS) + } + } + } return when { isRecycleBin -> recycleBinActions() @@ -79,6 +92,8 @@ class CellFileActionsMenu @Inject constructor( else -> { + add(NodeBottomSheetAction.OPEN) + if (cellNode.localFileAvailable()) { add(NodeBottomSheetAction.SHARE) } @@ -92,6 +107,8 @@ class CellFileActionsMenu @Inject constructor( ) } } + } else { + add(NodeBottomSheetAction.OPEN) } add(NodeBottomSheetAction.PUBLIC_LINK) @@ -131,6 +148,7 @@ class CellFileActionsMenu @Inject constructor( internal sealed interface MenuActionResult internal data class Action(val action: CellViewAction) : MenuActionResult + internal data class Open(val node: CellNodeUi) : MenuActionResult internal data class Share(val node: CellNodeUi.File) : MenuActionResult internal data class Edit(val node: CellNodeUi) : MenuActionResult internal data class CancelLoading(val node: CellNodeUi) : MenuActionResult @@ -146,6 +164,7 @@ class CellFileActionsMenu @Inject constructor( onResult: (MenuActionResult) -> Unit, ) { val result = when (action) { + NodeBottomSheetAction.OPEN -> Open(node) NodeBottomSheetAction.SHARE -> { if (node is CellNodeUi.File) { Share(node) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index c2267803fb1..9b6cbfa02e9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -34,6 +34,7 @@ import com.wire.android.feature.cells.ui.model.NodeBottomSheetAction import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.model.canOpenWithUrl import com.wire.android.feature.cells.ui.model.localFileAvailable +import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.model.toUiModel import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.feature.cells.ui.search.SearchNavArgs @@ -45,18 +46,23 @@ import com.wire.kalium.cells.data.FileFilters import com.wire.kalium.cells.data.SortingSpec import com.wire.kalium.cells.domain.model.Node import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase +import com.wire.kalium.cells.domain.usecase.GetConversationNamesUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase +import com.wire.kalium.cells.domain.usecase.GetUserNamesUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase import com.wire.kalium.cells.domain.usecase.IsAtLeastOneCellAvailableUseCase import com.wire.kalium.cells.domain.usecase.RestoreNodeFromRecycleBinUseCase 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.ObserveOfflineFilesUseCase +import com.wire.kalium.cells.domain.usecase.offline.OfflineFileInfo import com.wire.kalium.common.functional.fold import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.featureConfig.CollaboraEdition +import com.wire.kalium.network.NetworkState +import com.wire.kalium.network.NetworkStateObserver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -72,7 +78,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -99,6 +108,9 @@ class CellViewModel @Inject constructor( private val observeOfflineFiles: ObserveOfflineFilesUseCase, private val deleteOfflineFile: DeleteOfflineFileUseCase, private val getOfflineFile: GetOfflineFileUseCase, + private val networkStateObserver: NetworkStateObserver, + private val getConversationNames: GetConversationNamesUseCase, + private val getUserNames: GetUserNamesUseCase, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -147,6 +159,18 @@ class CellViewModel @Inject constructor( } ) + val isOnline: StateFlow = networkStateObserver.observeNetworkState() + .map { it is NetworkState.ConnectedWithInternet } + .transformLatest { online -> + if (online) { + emit(true) + } else { + delay(OFFLINE_TRANSITION_DELAY_MS) + emit(false) + } + } + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + private var isCollaboraEnabled: Boolean = false init { @@ -219,15 +243,49 @@ class CellViewModel @Inject constructor( } }.shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) - internal val nodesFlow = cellAvailableFlow.flatMapLatest { cellAvailable -> - if (!cellAvailable || searchNavArgs != null) { - flowOf(emptyData) - } else { - sharedNodesFlow + private val offlineNodesFlow: Flow> = + combine( + observeOfflineFiles(), + sharedPathCache.openLoadStates, + offlineFileDownloadController.downloadProgresses, + ) { offlineFiles, openLoadStates, downloadProgresses -> + val conversationNames = getConversationNames() + val userNames = getUserNames() + val rootConversationId = navArgs.conversationId?.substringBefore("/") + val filtered = if (rootConversationId != null) { + offlineFiles.filter { it.conversationId == rootConversationId } + } else { + offlineFiles + } + PagingData.from( + data = filtered.map { info -> + info.toCellNodeUi( + conversationName = info.conversationId?.let { conversationNames[it] }, + userName = info.owner.ifEmpty { null }?.let { userNames[it] }, + openLoadState = openLoadStates[info.id], + downloadProgress = downloadProgresses[info.id], + ) + }, + sourceLoadStates = LoadStates( + refresh = LoadState.NotLoading(true), + prepend = LoadState.NotLoading(true), + append = LoadState.NotLoading(true), + ) + ) + } + + internal val nodesFlow = combine(cellAvailableFlow, isOnline) { cellAvailable, online -> + cellAvailable to online + }.flatMapLatest { (cellAvailable, online) -> + when { + !cellAvailable || searchNavArgs != null -> flowOf(emptyData) + !online -> offlineNodesFlow + else -> sharedNodesFlow } } fun onPullToRefresh() { + if (!isOnline.value) return _isPullToRefresh.value = true refreshNodes() } @@ -357,6 +415,7 @@ class CellViewModel @Inject constructor( isSearching = searchNavArgs?.screenType == DriveSearchScreenType.SHARED_DRIVE || searchNavArgs?.screenType == DriveSearchScreenType.DRIVE, isCollaboraEnabled = isCollaboraEnabled, + isOnline = isOnline.value, ) _menu.emit(MenuOptions(cellNode, menuItems)) @@ -371,6 +430,7 @@ class CellViewModel @Inject constructor( ) { result -> when (result) { is CellFileActionsMenu.Action -> sendAction(result.action) + is CellFileActionsMenu.Open -> sendIntent(CellViewIntent.OnItemClick(result.node)) is CellFileActionsMenu.Edit -> editNode(result.node.uuid) is CellFileActionsMenu.Share -> shareFile(result.node) is CellFileActionsMenu.CancelLoading -> cancelDownload(result.node.uuid) @@ -384,7 +444,9 @@ class CellViewModel @Inject constructor( private fun makeAvailableOffline(node: CellNodeUi.File) { offlineFileDownloadController.start( scope = viewModelScope, - cellNode = node, + cellNode = node.copy( + conversationId = navArgs.conversationId + ), onSuccess = { _ -> sendAction(ShowOfflineFileSaved) }, onError = { sendAction(ShowError(it)) }, ) @@ -506,6 +568,31 @@ class CellViewModel @Inject constructor( isCollaboraEnabled = config?.collabora != CollaboraEdition.NO } + private fun OfflineFileInfo.toCellNodeUi( + conversationName: String? = null, + userName: String? = null, + openLoadState: OpenLoadState? = null, + downloadProgress: Float? = null, + ): CellNodeUi.File { + val extension = name.substringAfterLast('.', "") + return CellNodeUi.File( + uuid = id, + name = name, + mimeType = "", + assetType = AttachmentFileType.fromExtension(extension), + size = size, + localPath = localPath, + ownerUserId = owner.ifEmpty { null }, + userName = userName, + userHandle = null, + conversationName = conversationName, + modifiedTime = modifiedTime, + isAvailableOffline = true, + openLoadState = openLoadState, + downloadProgress = downloadProgress, + ) + } + companion object { private val emptyData: PagingData = PagingData.empty( LoadStates( @@ -560,3 +647,4 @@ data class MenuOptions( ) private const val RESTORE_DELAY_MS = 300L +private const val OFFLINE_TRANSITION_DELAY_MS = 2_000L diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index 3cba2524e53..2d9f6a0442b 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -56,6 +57,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.SearchScree import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet @@ -103,6 +105,8 @@ fun ConversationFilesScreen( animatedVisibilityScope: AnimatedVisibilityScope, viewModel: CellViewModel = hiltViewModel(), ) { + val isOnline by viewModel.isOnline.collectAsState() + ConversationFilesScreenContent( animatedVisibilityScope = animatedVisibilityScope, navigator = navigator, @@ -112,6 +116,7 @@ fun ConversationFilesScreen( pagingListItems = viewModel.nodesFlow.collectAsLazyPagingItems(), menu = viewModel.menu, isSearchResult = false, + isOnline = isOnline, isRestoreInProgress = viewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRefreshing = viewModel.isPullToRefresh.collectAsState(), @@ -146,6 +151,7 @@ internal fun ConversationFilesScreenContent( screenTitle: String? = null, isRecycleBin: Boolean = false, isRestoreInProgress: Boolean = false, + isOnline: Boolean = true, breadcrumbs: Array? = emptyArray(), fileReadyFlow: Flow = emptyFlow(), ) { @@ -232,23 +238,27 @@ internal fun ConversationFilesScreenContent( } ) - SearchTopBar( - modifier = Modifier - .sharedElement( - sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), - animatedVisibilityScope = animatedVisibilityScope - ), - isSearchActive = false, - searchBarHint = stringResource(R.string.search_label), - searchQueryTextState = TextFieldState(), - onTap = { - currentNodeUuid?.let { - navigator.navigate( - NavigationCommand(SearchScreenDestination(conversationId = it)) - ) - } - }, - ) + if (isOnline) { + SearchTopBar( + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), + animatedVisibilityScope = animatedVisibilityScope + ), + isSearchActive = false, + searchBarHint = stringResource(R.string.search_label), + searchQueryTextState = TextFieldState(), + onTap = { + currentNodeUuid?.let { + navigator.navigate( + NavigationCommand(SearchScreenDestination(conversationId = it)) + ) + } + }, + ) + } else { + OfflineBanner() + } } }, floatingActionButton = { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt index 41a20e507f5..32a156fd0ab 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt @@ -111,6 +111,8 @@ class OfflineFileDownloadController @Inject constructor( localPath = existingPath, size = cellNode.size, downloadedAt = System.currentTimeMillis(), + conversationId = cellNode.conversationId, + modifiedTime = cellNode.modifiedTime, ) ) onSuccess(existingPath) @@ -155,6 +157,8 @@ class OfflineFileDownloadController @Inject constructor( localPath = filePath.toString(), size = cellNode.size, downloadedAt = System.currentTimeMillis(), + conversationId = cellNode.conversationId, + modifiedTime = cellNode.modifiedTime, ) ) onSuccess(filePath.toString()) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/common/OfflineBanner.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/common/OfflineBanner.kt new file mode 100644 index 00000000000..589482bf5fb --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/common/OfflineBanner.kt @@ -0,0 +1,79 @@ +/* + * 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.feature.cells.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.wire.android.feature.cells.R +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireTypography + +@Composable +internal fun OfflineBanner(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(dimensions().spacing12x), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_wifi_signal), + modifier = Modifier + .width(dimensions().spacing14x) + .height(dimensions().spacing14x) + .align(Alignment.CenterVertically), + contentDescription = null, + tint = colorsScheme().onBackground + ) + Text( + modifier = Modifier.padding(start = dimensions().spacing6x), + text = stringResource(R.string.offline_banner_message), + style = MaterialTheme.wireTypography.body01, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@MultipleThemePreviews +@Composable +private fun PreviewOfflineBanner() { + WireTheme { + OfflineBanner() + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt index d7051b7f8b1..5fc8b0e2395 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt @@ -85,6 +85,7 @@ sealed class CellNodeUi { internal override val openLoadState: OpenLoadState? = null, override val downloadProgress: Float? = null, override val isAvailableOffline: Boolean = false, + val conversationId: String? = null, ) : CellNodeUi() } @@ -107,6 +108,7 @@ internal fun Node.File.toUiModel( userHandle = userHandle, ownerUserId = ownerUserId, conversationName = conversationName, + conversationId = conversationId, publicLinkId = publicLinkId, modifiedTime = formattedModifiedTime(), tags = tags, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt index 10f63ff52c4..39771462c78 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt @@ -24,6 +24,7 @@ enum class NodeBottomSheetAction( val icon: Int, val isHighlighted: Boolean = false ) { + OPEN(R.string.open_label, R.drawable.ic_open), SHARE(R.string.share_label, R.drawable.ic_share), PUBLIC_LINK(R.string.public_link, R.drawable.ic_link), ADD_REMOVE_TAGS(R.string.add_remove_tags_label, R.drawable.ic_tags), @@ -37,5 +38,5 @@ enum class NodeBottomSheetAction( CANCEL_LOADING(R.string.cancel_loading_label, com.wire.android.ui.common.R.drawable.ic_close, true), CANCEL_DOWNLOAD(R.string.cancel_download_label, com.wire.android.ui.common.R.drawable.ic_close, true), MAKE_AVAILABLE_OFFLINE(R.string.make_available_offline_label, R.drawable.ic_save), - REMOVE_OFFLINE_ACCESS(R.string.remove_offline_access_label, R.drawable.ic_save, true), + REMOVE_OFFLINE_ACCESS(R.string.remove_offline_access_label, R.drawable.ic_cross_in_circle, true), } diff --git a/features/cells/src/main/res/drawable/ic_cross_in_circle.xml b/features/cells/src/main/res/drawable/ic_cross_in_circle.xml new file mode 100644 index 00000000000..dd2786d0845 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cross_in_circle.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_open.xml b/features/cells/src/main/res/drawable/ic_open.xml new file mode 100644 index 00000000000..c0bd6a871a3 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_open.xml @@ -0,0 +1,24 @@ + + + + diff --git a/features/cells/src/main/res/drawable/ic_wifi_signal.xml b/features/cells/src/main/res/drawable/ic_wifi_signal.xml new file mode 100644 index 00000000000..26356772174 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_wifi_signal.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 5cc6a00a466..a0cee253cd3 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -85,8 +85,10 @@ Make available offline Remove offline access File saved for offline use + You\'re offline and can see only saved files \"%1$s\" ready to open Open + Open Unable to create folder. Please try again Move to folder Move Here diff --git a/kalium b/kalium index e854de386df..d0e86c776ff 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit e854de386df9e1d3b3d26e5a01132fdd86d3842d +Subproject commit d0e86c776ffaf3e4f93c84f9aea432096faa4470 From 0d713bcfb7248c907e2293df336df72bf4388214 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 08:59:09 +0200 Subject: [PATCH 02/20] feat: display offline files --- .../android/di/accountScoped/CellsModule.kt | 8 ++++---- .../android/feature/cells/ui/CellViewModel.kt | 14 ++++++------- .../feature/cells/ui/CellViewModelTest.kt | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) 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 fa4370b406b..7360c6b1393 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 @@ -62,8 +62,8 @@ import com.wire.kalium.cells.domain.usecase.publiclink.SetPublicLinkExpirationUs import com.wire.kalium.cells.domain.usecase.publiclink.UpdatePublicLinkPasswordUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase -import com.wire.kalium.cells.domain.usecase.GetConversationNamesUseCase -import com.wire.kalium.cells.domain.usecase.GetUserNamesUseCase +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.ObserveOfflineFilesUseCase @@ -284,9 +284,9 @@ class CellsModule { @ViewModelScoped @Provides - fun provideGetConversationNamesUseCase(cellsScope: CellsScope): GetConversationNamesUseCase = cellsScope.getConversationNames + fun provideGetConversationNamesUseCase(cellsScope: CellsScope): GetConversationNameUseCase = cellsScope.getConversationNames @ViewModelScoped @Provides - fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNamesUseCase = cellsScope.getUserNames + fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNameUseCase = cellsScope.getUserNames } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 9b6cbfa02e9..fb195d484bf 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -46,10 +46,10 @@ import com.wire.kalium.cells.data.FileFilters import com.wire.kalium.cells.data.SortingSpec import com.wire.kalium.cells.domain.model.Node import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase -import com.wire.kalium.cells.domain.usecase.GetConversationNamesUseCase +import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase -import com.wire.kalium.cells.domain.usecase.GetUserNamesUseCase +import com.wire.kalium.cells.domain.usecase.GetUserNameUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase import com.wire.kalium.cells.domain.usecase.IsAtLeastOneCellAvailableUseCase import com.wire.kalium.cells.domain.usecase.RestoreNodeFromRecycleBinUseCase @@ -109,8 +109,8 @@ class CellViewModel @Inject constructor( private val deleteOfflineFile: DeleteOfflineFileUseCase, private val getOfflineFile: GetOfflineFileUseCase, private val networkStateObserver: NetworkStateObserver, - private val getConversationNames: GetConversationNamesUseCase, - private val getUserNames: GetUserNamesUseCase, + private val getConversationName: GetConversationNameUseCase, + private val getUserName: GetUserNameUseCase, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -249,8 +249,6 @@ class CellViewModel @Inject constructor( sharedPathCache.openLoadStates, offlineFileDownloadController.downloadProgresses, ) { offlineFiles, openLoadStates, downloadProgresses -> - val conversationNames = getConversationNames() - val userNames = getUserNames() val rootConversationId = navArgs.conversationId?.substringBefore("/") val filtered = if (rootConversationId != null) { offlineFiles.filter { it.conversationId == rootConversationId } @@ -260,8 +258,8 @@ class CellViewModel @Inject constructor( PagingData.from( data = filtered.map { info -> info.toCellNodeUi( - conversationName = info.conversationId?.let { conversationNames[it] }, - userName = info.owner.ifEmpty { null }?.let { userNames[it] }, + conversationName = info.conversationId?.let { getConversationName(it) }, + userName = info.owner.ifEmpty { null }?.let { getUserName(it) }, openLoadState = openLoadStates[info.id], downloadProgress = downloadProgresses[info.id], ) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index 7bda85948ef..aa615bb8728 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -35,6 +35,8 @@ import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase import com.wire.kalium.cells.domain.usecase.GetWireCellConfigurationUseCase +import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase +import com.wire.kalium.cells.domain.usecase.GetUserNameUseCase import com.wire.kalium.cells.domain.usecase.IsAtLeastOneCellAvailableUseCase import com.wire.kalium.cells.domain.usecase.RestoreNodeFromRecycleBinUseCase import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase @@ -42,6 +44,9 @@ 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.ObserveOfflineFilesUseCase import com.wire.kalium.common.functional.right +import com.wire.kalium.network.NetworkState +import com.wire.kalium.network.NetworkStateObserver +import kotlinx.coroutines.flow.MutableStateFlow import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -306,6 +311,15 @@ class CellViewModelTest { @MockK lateinit var getOfflineFile: GetOfflineFileUseCase + @MockK + lateinit var networkStateObserver: NetworkStateObserver + + @MockK + lateinit var getConversationNames: GetConversationNameUseCase + + @MockK + lateinit var getUserNames: GetUserNameUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) @@ -322,6 +336,9 @@ class CellViewModelTest { every { observeOfflineFiles() } returns emptyFlow() coEvery { getOfflineFile(any()) } returns null + every { networkStateObserver.observeNetworkState() } returns MutableStateFlow(NetworkState.ConnectedWithInternet) + coEvery { getConversationNames(any()) } returns null + coEvery { getUserNames(any()) } returns null coEvery { getCellFilesPagedUseCase.invoke(any(), any(), any(), any()) } returns flowOf( PagingData.from( @@ -408,6 +425,9 @@ class CellViewModelTest { observeOfflineFiles = observeOfflineFiles, deleteOfflineFile = deleteOfflineFile, getOfflineFile = getOfflineFile, + networkStateObserver = networkStateObserver, + getConversationName = getConversationNames, + getUserName = getUserNames, ) } } From a334c17841df545b71ee2621549009904f469fdd Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 10:19:37 +0200 Subject: [PATCH 03/20] feat: display offline files --- .../multipart/MultipartAttachmentsViewModel.kt | 7 ++++++- .../com/wire/android/feature/cells/ui/CellScreenContent.kt | 6 +++--- .../com/wire/android/feature/cells/ui/CellViewModel.kt | 2 +- .../feature/cells/ui/OfflineFileDownloadController.kt | 5 +++-- .../android/feature/cells/ui/OpenFileDownloadController.kt | 1 + 5 files changed, 14 insertions(+), 7 deletions(-) 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 abff1e8ad23..c4a043c2c87 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 @@ -125,7 +125,11 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( attachment.isImage() && !attachment.fileNotFound() -> openInImageViewer(attachment.uuid) attachment.isEditSupported && isCollaboraEnabled && featureFlags.collaboraIntegration -> openOnlineEditor(attachment.uuid) - attachment.fileNotFound() -> { refreshHelper.refresh(attachment.uuid) } + + attachment.fileNotFound() -> { + refreshHelper.refresh(attachment.uuid) + } + attachment.localFileAvailable() -> openLocalFile(attachment) attachment.canOpenWithUrl() -> openUrl(attachment) else -> downloadAsset(attachment) @@ -170,6 +174,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( download( assetId = attachment.uuid, + conversationId = null, // TODO to replace with real conversation id in next PR outFilePath = path, assetSize = attachment.assetSize ?: 0, ) { progress -> diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 4a71aab73ed..dbf50a083ee 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -217,6 +217,7 @@ internal fun CellScreenContent( } ) } + val offlineFileSavedToastDescription = stringResource(R.string.offline_file_saved_message) HandleActions(actionsFlow) { action -> when (action) { @@ -245,11 +246,10 @@ internal fun CellScreenContent( is ShowFileDeletedMessage -> showDeleteConfirmation(context, action.isFile, action.permanently) is OpenFolder -> openFolder(action.path, action.title, action.parentFolderUuid) is ShowEditErrorDialog -> editNodeError = action.nodeUuid - is ShowOfflineFileSaved -> { - val description = stringResource(R.string.offline_file_saved_message) + is ShowOfflineFileSaved -> { Toast.makeText( context, - description, + offlineFileSavedToastDescription, Toast.LENGTH_SHORT ).show() } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index fb195d484bf..3689bf80a15 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -584,7 +584,7 @@ class CellViewModel @Inject constructor( userName = userName, userHandle = null, conversationName = conversationName, - modifiedTime = modifiedTime, + modifiedTime = modifiedAt, isAvailableOffline = true, openLoadState = openLoadState, downloadProgress = downloadProgress, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt index 6c9da9d9b80..4542b2d8478 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt @@ -108,7 +108,7 @@ class OfflineFileDownloadController @Inject constructor( size = cellNode.size, downloadedAt = System.currentTimeMillis(), conversationId = cellNode.conversationId, - modifiedTime = cellNode.modifiedTime, + modifiedAt = cellNode.modifiedTime, ) ) onSuccess(existingPath) @@ -127,6 +127,7 @@ class OfflineFileDownloadController @Inject constructor( val result = download( assetId = cellNode.uuid, + conversationId = cellNode.conversationId, outFilePath = filePath, remoteFilePath = cellNode.remotePath, assetSize = cellNode.size ?: 0, @@ -154,7 +155,7 @@ class OfflineFileDownloadController @Inject constructor( size = cellNode.size, downloadedAt = System.currentTimeMillis(), conversationId = cellNode.conversationId, - modifiedTime = cellNode.modifiedTime, + modifiedAt = cellNode.modifiedTime, ) ) onSuccess(filePath.toString()) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt index bfbad9982bc..9982e665fb9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt @@ -108,6 +108,7 @@ class OpenFileDownloadController @Inject constructor( val result = download( assetId = cellNode.uuid, + conversationId = cellNode.conversationId, outFilePath = filePath, remoteFilePath = cellNode.remotePath, assetSize = cellNode.size ?: 0, From af93b1dabd2cce6afa34fd61a50cfb167bd12dc4 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 11:19:43 +0200 Subject: [PATCH 04/20] feat: cleanup --- .../feature/cells/ui/AllFilesScreen.kt | 20 +------------------ .../feature/cells/ui/CellFilesScreen.kt | 5 +++++ .../android/feature/cells/ui/CellListItem.kt | 13 ++++++------ .../feature/cells/ui/CellScreenContent.kt | 2 ++ .../cells/ui/ConversationFilesScreen.kt | 1 + 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index 18474f9e9a3..52d22d6ef02 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt @@ -78,25 +78,6 @@ fun AllFilesScreen( OfflineBanner() } } -// if (isOnline) { -// SearchTopBar( -// modifier = Modifier, -// isSearchActive = false, -// searchBarHint = stringResource(R.string.search_label), -// searchQueryTextState = rememberTextFieldState(), -// onTap = { -// navigator.navigate( -// NavigationCommand( -// SearchScreenDestination( -// screenType = DriveSearchScreenType.DRIVE, -// ) -// ) -// ) -// }, -// ) -// } else { -// OfflineBanner() -// } } }, ) { innerPadding -> @@ -108,6 +89,7 @@ fun AllFilesScreen( openFolder = { _, _, _ -> }, menuState = viewModel.menu, isAllFiles = true, + isOffline = !isOnline, isRestoreInProgress = viewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRecycleBin = viewModel.isRecycleBin(), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt index 658e515dbf2..0bc6436b4c6 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFilesScreen.kt @@ -66,6 +66,7 @@ internal fun CellFilesScreen( modifier: Modifier = Modifier, isPullToRefreshEnabled: Boolean = true, lazyListState: LazyListState = rememberLazyListState(), + showConversationName: Boolean = true, onItemMenuClick: (CellNodeUi) -> Unit ) { if (isPullToRefreshEnabled) { @@ -79,6 +80,7 @@ internal fun CellFilesScreen( lazyListState = lazyListState, onItemClick = onItemClick, onItemMenuClick = onItemMenuClick, + showConversationName = showConversationName, ) } } else { @@ -88,6 +90,7 @@ internal fun CellFilesScreen( lazyListState = lazyListState, onItemClick = onItemClick, onItemMenuClick = onItemMenuClick, + showConversationName = showConversationName, ) } } @@ -99,6 +102,7 @@ private fun ContentList( onItemClick: (CellNodeUi) -> Unit, onItemMenuClick: (CellNodeUi) -> Unit, modifier: Modifier = Modifier, + showConversationName: Boolean = true, ) { LazyColumn( modifier = modifier.fillMaxWidth(), @@ -118,6 +122,7 @@ private fun ContentList( .background(color = colorsScheme().surface) .clickable { onItemClick(item) }, cell = item, + showConversationName = showConversationName, onMenuClick = { onItemMenuClick(item) } ) WireDivider(modifier = Modifier.fillMaxWidth()) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt index c2f82dc2d3f..73385758ddf 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellListItem.kt @@ -95,6 +95,7 @@ internal fun CellListItem( cell: CellNodeUi, onMenuClick: () -> Unit, modifier: Modifier = Modifier, + showConversationName: Boolean = true, ) { val interactionSource = remember { MutableInteractionSource() } var showReadyState by remember { mutableStateOf(false) } @@ -132,7 +133,7 @@ internal fun CellListItem( ) Row(verticalAlignment = Alignment.CenterVertically) { - CellItemSubtitle(cell = cell, showReadyState = showReadyState) + CellItemSubtitle(cell = cell, showReadyState = showReadyState, showConversationName = showConversationName) } } @@ -184,7 +185,7 @@ private fun CellItemIcon(cell: CellNodeUi, showReadyState: Boolean) { } @Composable -private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { +private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean, showConversationName: Boolean) { when { cell.openLoadState is OpenLoadState.Loading -> Text( text = stringResource(R.string.tap_to_cancel_loading), @@ -241,7 +242,7 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { modifier = Modifier.padding(end = dimensions().spacing4x) ) } - cell.subtitle()?.let { + cell.subtitle(showConversationName)?.let { Text( text = it, textAlign = TextAlign.Left, @@ -446,19 +447,19 @@ private fun PublicLinkIcon( } @Composable -private fun CellNodeUi.subtitle(): String? { +private fun CellNodeUi.subtitle(showConversationName: Boolean): String? { val formattedTime = modifiedTime?.let { remember(it) { Instant.fromEpochMilliseconds(it).cellFileDateTime() } } return when { - userName != null && conversationName != null -> + showConversationName && userName != null && conversationName != null -> stringResource(R.string.file_subtitle, userName!!, conversationName!!) userName != null && formattedTime != null -> stringResource(R.string.file_subtitle_modified, formattedTime, userName!!) userName != null -> userName - conversationName != null -> conversationName + showConversationName && conversationName != null -> conversationName formattedTime != null -> formattedTime else -> null } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index dbf50a083ee..fde360bda53 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -94,6 +94,7 @@ internal fun CellScreenContent( isRecycleBin: Boolean = false, isAllFiles: Boolean = false, isSearchResult: Boolean = false, + isOffline: Boolean = false, isPullToRefreshEnabled: Boolean = true, lazyListState: LazyListState = rememberLazyListState(), retryEditNodeError: (String) -> Unit = {}, @@ -142,6 +143,7 @@ internal fun CellScreenContent( onItemMenuClick = { sendIntent(CellViewIntent.OnItemMenuClick(it)) }, isRefreshing = isRefreshing, onRefresh = onRefresh, + showConversationName = !isOffline || isAllFiles || isRecycleBin, ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index 92769c56a78..ff7bfe42360 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -300,6 +300,7 @@ internal fun ConversationFilesScreenContent( isRestoreInProgress = isRestoreInProgress, isDeleteInProgress = isDeleteInProgress, isRecycleBin = isRecycleBin, + isOffline = !isOnline, openFolder = { path, title, parentFolderUuid -> navigator.navigate( NavigationCommand( From b59a08f69dd631888121d74886d0e21cf8b5afb0 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 11:35:41 +0200 Subject: [PATCH 05/20] feat: cleanup --- .../com/wire/android/feature/cells/ui/CellFileActionsMenu.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt index a016835cf1e..a2580ce5317 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt @@ -104,8 +104,6 @@ class CellFileActionsMenu @Inject constructor( add(NodeBottomSheetAction.SHARE) } - add(NodeBottomSheetAction.PUBLIC_LINK) - add( if (cellNode.isAvailableOffline) { NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS @@ -118,8 +116,6 @@ class CellFileActionsMenu @Inject constructor( } else { add(NodeBottomSheetAction.OPEN) } - - add(NodeBottomSheetAction.PUBLIC_LINK) } private fun conversationActions( @@ -147,6 +143,7 @@ class CellFileActionsMenu @Inject constructor( addAll( listOf( NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, NodeBottomSheetAction.DELETE, From c51175a306b86b9f00c772a715556f50d1b70616 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 12:22:54 +0200 Subject: [PATCH 06/20] feat: support offline mode in search screen --- .../feature/cells/ui/search/SearchScreen.kt | 134 ++++++++++-------- 1 file changed, 76 insertions(+), 58 deletions(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt index fcdbd4af8a4..3120007e4bd 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt @@ -17,6 +17,7 @@ */ package com.wire.android.feature.cells.ui.search +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Column @@ -47,6 +48,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VersionHist import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.CellScreenContent import com.wire.android.feature.cells.ui.CellViewModel +import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet @@ -63,6 +65,8 @@ import com.wire.android.navigation.transition.SHARED_ELEMENT_SEARCH_INPUT_KEY import com.wire.android.ui.common.bottomsheet.WireSheetValue import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.search.SearchTopBar @OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -79,6 +83,7 @@ fun SearchScreen( searchScreenViewModel: SearchScreenViewModel = hiltViewModel(), ) { val uiState by searchScreenViewModel.uiState.collectAsStateWithLifecycle() + val isOnline by cellViewModel.isOnline.collectAsState() val filterTypeSheetState = rememberWireModalSheetState(WireSheetValue.Hidden) val filterTagsSheetState = rememberWireModalSheetState(WireSheetValue.Hidden) @@ -101,67 +106,80 @@ fun SearchScreen( WireScaffold( modifier = modifier, topBar = { - Column { - SearchTopBar( - modifier = Modifier.sharedElement( - sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), - animatedVisibilityScope = animatedVisibilityScope - ), - isSearchActive = uiState.isSearchActive, - shouldClearTextOnClearFocus = false, - keepBackButtonVisible = true, - searchBarHint = when (searchScreenViewModel.screenType) { - DriveSearchScreenType.SHARED_DRIVE -> stringResource(R.string.search_shared_drive_text_input_hint) - DriveSearchScreenType.DRIVE -> stringResource(R.string.search_drive_text_input_hint) - }, - searchQueryTextState = searchState, - onCloseSearchClicked = { navigator.navigateBack() }, - onActiveChanged = { - searchScreenViewModel.onSetSearchActive(it) - }, - ) - FilterChipsRow( - state = uiState.chipsState, - screenType = searchScreenViewModel.screenType, - onFilterByTagsClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterTagsSheetState.show(Unit, isImeVisible) - }, - onFilterByTypeClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterTypeSheetState.show(Unit, isImeVisible) - }, - onFilterByOwnerClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterOwnerSheetState.show(Unit, isImeVisible) - }, - onFilterBySharedByLinkClicked = { - searchScreenViewModel.onSharedByMeClicked() - }, - onFilterByConversationClicked = { - searchScreenViewModel.onSetSearchActive(false) - filterConversationSheetState.show(Unit, isImeVisible) - }, - onRemoveAllFiltersClicked = { - searchScreenViewModel.onRemoveAllFilters() - } - ) + AnimatedContent(isOnline) { online -> + if (online) { + Column { + SearchTopBar( + modifier = Modifier.sharedElement( + sharedContentState = rememberSharedContentState(key = SHARED_ELEMENT_SEARCH_INPUT_KEY), + animatedVisibilityScope = animatedVisibilityScope + ), + isSearchActive = uiState.isSearchActive, + shouldClearTextOnClearFocus = false, + keepBackButtonVisible = true, + searchBarHint = when (searchScreenViewModel.screenType) { + DriveSearchScreenType.SHARED_DRIVE -> stringResource(R.string.search_shared_drive_text_input_hint) + DriveSearchScreenType.DRIVE -> stringResource(R.string.search_drive_text_input_hint) + }, + searchQueryTextState = searchState, + onCloseSearchClicked = { navigator.navigateBack() }, + onActiveChanged = { + searchScreenViewModel.onSetSearchActive(it) + }, + ) + FilterChipsRow( + state = uiState.chipsState, + screenType = searchScreenViewModel.screenType, + onFilterByTagsClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterTagsSheetState.show(Unit, isImeVisible) + }, + onFilterByTypeClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterTypeSheetState.show(Unit, isImeVisible) + }, + onFilterByOwnerClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterOwnerSheetState.show(Unit, isImeVisible) + }, + onFilterBySharedByLinkClicked = { + searchScreenViewModel.onSharedByMeClicked() + }, + onFilterByConversationClicked = { + searchScreenViewModel.onSetSearchActive(false) + filterConversationSheetState.show(Unit, isImeVisible) + }, + onRemoveAllFiltersClicked = { + searchScreenViewModel.onRemoveAllFilters() + } + ) - with(uiState) { - SortRowWithMenu( - screenType = searchScreenViewModel.screenType, - sortingCriteria = sortingCriteria, - isSearchResult = searchState.text.isNotEmpty() || hasAnyFilter, - onSortByClicked = { - searchScreenViewModel.setSortBy(it) - }, - onOrderClicked = { - searchScreenViewModel.setSorting(it) + with(uiState) { + SortRowWithMenu( + screenType = searchScreenViewModel.screenType, + sortingCriteria = sortingCriteria, + isSearchResult = searchState.text.isNotEmpty() || hasAnyFilter, + onSortByClicked = { + searchScreenViewModel.setSortBy(it) + }, + onOrderClicked = { + searchScreenViewModel.setSorting(it) + } + ) } - ) + } + } else { + Column { + WireCenterAlignedTopAppBar( + title = "", + navigationIconType = NavigationIconType.Close(), + onNavigationPressed = { navigator.navigateBack() }, + ) + OfflineBanner() + } } } - } + }, ) { innerPadding -> val lazyListState = rememberLazyListState() @@ -173,7 +191,7 @@ fun SearchScreen( val lazyItems = if (isShowingFilteredResults) filteredItems else initialItems LaunchedEffect(uiState.sortingCriteria) { - lazyListState.animateScrollToItem(0) + lazyListState.animateScrollToItem(0) } CellScreenContent( From 389525d29c650fefd081028a4f9b3108c64f1513 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 12:45:17 +0200 Subject: [PATCH 07/20] chore: cleanup --- .../com/wire/android/feature/cells/ui/CellViewModel.kt | 10 ---------- .../feature/cells/ui/ConversationFilesScreen.kt | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 3689bf80a15..fe58bc92ff4 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -81,7 +81,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -161,14 +160,6 @@ class CellViewModel @Inject constructor( val isOnline: StateFlow = networkStateObserver.observeNetworkState() .map { it is NetworkState.ConnectedWithInternet } - .transformLatest { online -> - if (online) { - emit(true) - } else { - delay(OFFLINE_TRANSITION_DELAY_MS) - emit(false) - } - } .stateIn(viewModelScope, SharingStarted.Eagerly, true) private var isCollaboraEnabled: Boolean = false @@ -645,4 +636,3 @@ data class MenuOptions( ) private const val RESTORE_DELAY_MS = 300L -private const val OFFLINE_TRANSITION_DELAY_MS = 2_000L diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index ff7bfe42360..33dc2dc0202 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -229,7 +229,7 @@ internal fun ConversationFilesScreenContent( navigationIconType = NavigationIconType.Back(), elevation = dimensions().spacing0x, actions = { - if (!isRecycleBin) { + if (!isRecycleBin && isOnline) { MoreOptionIcon( contentDescription = R.string.content_description_conversation_files_more_button, onButtonClicked = { optionsBottomSheetState.show() } From 6489c308cc9fa53ee16601b77ade893786af290e Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 12:45:28 +0200 Subject: [PATCH 08/20] chore: kalium --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 8cb94930725..7fc7a5b5f05 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 8cb949307259386c05a9ef49a7e99dee37fd52cc +Subproject commit 7fc7a5b5f05381c30189f3c002520516969a4f57 From 785f817c73702e4219c2f11a5ce3161ae38954a4 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 13:14:52 +0200 Subject: [PATCH 09/20] chore: cleanup --- .../java/com/wire/android/feature/cells/ui/CellViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index fe58bc92ff4..c947c64809a 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -160,7 +160,11 @@ class CellViewModel @Inject constructor( val isOnline: StateFlow = networkStateObserver.observeNetworkState() .map { it is NetworkState.ConnectedWithInternet } - .stateIn(viewModelScope, SharingStarted.Eagerly, true) + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = networkStateObserver.observeNetworkState().value is NetworkState.ConnectedWithInternet, + ) private var isCollaboraEnabled: Boolean = false From 3965331ae72193a8c15b9d7c1983afc00d609ad4 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 15:08:29 +0200 Subject: [PATCH 10/20] chore: detekt --- .../main/java/com/wire/android/feature/cells/ui/CellViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index c947c64809a..1dc30c33ede 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -570,6 +570,7 @@ class CellViewModel @Inject constructor( val extension = name.substringAfterLast('.', "") return CellNodeUi.File( uuid = id, + conversationId = conversationId, name = name, mimeType = "", assetType = AttachmentFileType.fromExtension(extension), From 6125be15ddcf066e9d43ee7537fe50655465d1e3 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 15:10:44 +0200 Subject: [PATCH 11/20] chore: kalium --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 7fc7a5b5f05..3cb3dc620e2 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 7fc7a5b5f05381c30189f3c002520516969a4f57 +Subproject commit 3cb3dc620e2fd725bb793ae6bdc57058140d75e2 From 92c9caa5ef5e221545c1de986967997ce6b58404 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 16:13:24 +0200 Subject: [PATCH 12/20] chore: cleanup --- .../messagetypes/multipart/MultipartAttachmentsViewModel.kt | 1 - 1 file changed, 1 deletion(-) 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 1fbcfe44ae9..ac1aafa85c8 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 @@ -177,7 +177,6 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( conversationId = null, // TODO to replace with real conversation id in next PR outFilePath = path, assetSize = attachment.assetSize ?: 0, - conversationId = null, // TODO to replace with real conversation id in next PR ) { progress -> attachment.assetSize?.let { val value = progress.toFloat() / it From 7bedbada631f4aaf2d24baa3f8ba316dcd286191 Mon Sep 17 00:00:00 2001 From: ohassine Date: Mon, 18 May 2026 12:38:41 +0200 Subject: [PATCH 13/20] refactor: add mimeType --- .../com/wire/android/di/accountScoped/CellsModule.kt | 4 ++-- .../com/wire/android/feature/cells/ui/CellViewModel.kt | 9 +++++++-- .../feature/cells/ui/OfflineFileDownloadController.kt | 2 ++ kalium | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) 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 7360c6b1393..ce080517f9f 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 @@ -284,9 +284,9 @@ class CellsModule { @ViewModelScoped @Provides - fun provideGetConversationNamesUseCase(cellsScope: CellsScope): GetConversationNameUseCase = cellsScope.getConversationNames + fun provideGetConversationNamesUseCase(cellsScope: CellsScope): GetConversationNameUseCase = cellsScope.getConversationName @ViewModelScoped @Provides - fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNameUseCase = cellsScope.getUserNames + fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNameUseCase = cellsScope.getUserName } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 1dc30c33ede..8e917a9929f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -567,13 +567,18 @@ class CellViewModel @Inject constructor( openLoadState: OpenLoadState? = null, downloadProgress: Float? = null, ): CellNodeUi.File { + val resolvedMimeType = mimeType.orEmpty() val extension = name.substringAfterLast('.', "") return CellNodeUi.File( uuid = id, conversationId = conversationId, name = name, - mimeType = "", - assetType = AttachmentFileType.fromExtension(extension), + mimeType = resolvedMimeType, + assetType = if (resolvedMimeType.isNotBlank()) { + AttachmentFileType.fromMimeType(resolvedMimeType) + } else { + AttachmentFileType.fromExtension(extension) + }, size = size, localPath = localPath, ownerUserId = owner.ifEmpty { null }, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt index 4542b2d8478..31263a60eb9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt @@ -103,6 +103,7 @@ class OfflineFileDownloadController @Inject constructor( OfflineFileInfo( id = cellNode.uuid, name = nodeName, + mimeType = cellNode.mimeType, owner = cellNode.ownerUserId ?: "", localPath = existingPath, size = cellNode.size, @@ -150,6 +151,7 @@ class OfflineFileDownloadController @Inject constructor( OfflineFileInfo( id = cellNode.uuid, name = nodeName, + mimeType = cellNode.mimeType, owner = cellNode.ownerUserId ?: "", localPath = filePath.toString(), size = cellNode.size, diff --git a/kalium b/kalium index 3cb3dc620e2..012b5dee559 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 3cb3dc620e2fd725bb793ae6bdc57058140d75e2 +Subproject commit 012b5dee559d8f3111e948d9b83e8d84f0e1e9de From dffb61142c7c8aa2f6129a13ae29f1c793b9defd Mon Sep 17 00:00:00 2001 From: ohassine Date: Mon, 18 May 2026 17:09:19 +0200 Subject: [PATCH 14/20] feat: show offline indicator for files in conversation --- .../common/multipart/MultipartAttachmentUi.kt | 13 +++--- .../multipart/MultipartAttachmentsView.kt | 16 +++++-- .../MultipartAttachmentsViewModel.kt | 42 ++++++++++++++----- .../multipart/grid/AssetGridPreview.kt | 17 ++++++++ .../multipart/standalone/AssetPreview.kt | 18 ++++++++ .../MultipartAttachmentsViewModelTest.kt | 42 ++++++++++++++++++- 6 files changed, 127 insertions(+), 21 deletions(-) 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..0df9b2ae05e 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 @@ -30,10 +30,11 @@ 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 coil3.decode.Decoder import coil3.request.ImageRequest import coil3.request.crossfade +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.multipart.MultipartAttachmentUi @@ -59,13 +60,17 @@ fun MultipartAttachmentsView( modifier: Modifier = Modifier, viewModel: MultipartAttachmentsViewModel = when { LocalInspectionMode.current -> MultipartAttachmentsViewModelPreview - else -> hiltViewModel(key = conversationId.value) + else -> hiltViewModel(key = conversationId.value).apply { + this.conversationId = conversationId.value + } } ) { + val offlineAttachmentIds = viewModel.offlineAttachmentIds.collectAsStateWithLifecycle().value // 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() + attachment.toUiModel(isAvailableOffline = attachment.assetId() in offlineAttachmentIds).let { AssetPreview( modifier = modifier .onVisibilityChanged { visible -> @@ -86,7 +91,10 @@ fun MultipartAttachmentsView( ) } } else { - val groups = viewModel.mapAttachments(attachments) + val groups = viewModel.mapAttachments( + attachments = attachments, + offlineAttachmentIds = offlineAttachmentIds, + ) 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..654194c68de 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 @@ -32,6 +32,7 @@ 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.ObserveOfflineFilesUseCase 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 +43,50 @@ 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 mapAttachments( - attachments: List + attachments: List, + offlineAttachmentIds: Set = emptySet(), ): List { val result = mutableListOf() var group: MultipartAttachmentGroup? = null attachments.forEach { + val isAvailableOffline = it.assetId() in offlineAttachmentIds 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,6 +110,7 @@ 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) {} @@ -110,12 +126,18 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( private val kaliumFileSystem: KaliumFileSystem, private val featureFlags: KaliumConfigs, private val getWireCellsConfig: GetWireCellConfigurationUseCase, + observeOfflineFiles: ObserveOfflineFilesUseCase, ) : ViewModel(), MultipartAttachmentsViewModel { private val uploadProgress = mutableStateMapOf() + override val offlineAttachmentIds: StateFlow> = observeOfflineFiles() + .map { offlineFiles -> offlineFiles.mapTo(mutableSetOf()) { it.id } } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) private var isCollaboraEnabled: Boolean = false + internal var conversationId: String? = null + init { loadWireCellConfig() } @@ -165,7 +187,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( private fun downloadAsset(attachment: MultipartAttachmentUi) = viewModelScope.launch { - // TODO: Move kaliumFileSystem to common kalium module so that it can be used in use case + // Move kaliumFileSystem to common kalium module so that it can be used in use case val path = kaliumFileSystem.providePersistentAssetPath(attachment.fileName ?: error("No asset path")) if (kaliumFileSystem.exists(path)) { @@ -174,7 +196,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( download( assetId = attachment.uuid, - conversationId = null, // TODO to replace with real conversation id in next PR + conversationId = conversationId, outFilePath = path, assetSize = attachment.assetSize ?: 0, ) { progress -> @@ -208,7 +230,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..f66d3577f11 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 @@ -26,17 +26,21 @@ 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.ObserveOfflineFilesUseCase 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.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.runTest +import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -137,6 +141,33 @@ class MultipartAttachmentsViewModelTest { ) } + @Test + fun `with offline attachment id when mapped then attachment is marked as available offline`() = runTest { + val (_, viewModel) = Arrangement() + .arrange() + + val result = viewModel.mapAttachments( + listOf(testAssetContent.copy(id = "asset_1", mimeType = "application/pdf")), + offlineAttachmentIds = setOf("asset_1") + ) + + 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() @@ -251,15 +282,19 @@ class MultipartAttachmentsViewModelTest { @MockK lateinit var getWireCellsConfig: GetWireCellConfigurationUseCase + @MockK + lateinit var observeOfflineFiles: ObserveOfflineFilesUseCase + val kaliumFileSystem: KaliumFileSystem = FakeKaliumFileSystem() - 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 { observeOfflineFiles() } returns flowOf(emptyList()) return this to MultipartAttachmentsViewModelImpl( refreshHelper = refreshHelper, @@ -270,7 +305,10 @@ class MultipartAttachmentsViewModelTest { kaliumFileSystem = kaliumFileSystem, featureFlags = kaliumConfigs, getWireCellsConfig = getWireCellsConfig, - ) + observeOfflineFiles = observeOfflineFiles, + ).apply { + conversationId = "test-conversation-id" + } } } From aac5ec941069ffc928d161a23b493b4ee79f2090 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 20 May 2026 11:30:28 +0200 Subject: [PATCH 15/20] chore: cleanup --- .../multipart/MultipartAttachmentsView.kt | 9 ++++---- .../MultipartAttachmentsViewModel.kt | 21 +++++++++++++------ .../MultipartAttachmentsViewModelTest.kt | 7 ++++--- 3 files changed, 24 insertions(+), 13 deletions(-) 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 0df9b2ae05e..f0e99e14b42 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 @@ -30,11 +30,11 @@ 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.lifecycle.viewmodel.compose.hiltViewModel import coil3.decode.Decoder import coil3.request.ImageRequest import coil3.request.crossfade import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.multipart.MultipartAttachmentUi @@ -60,9 +60,10 @@ fun MultipartAttachmentsView( modifier: Modifier = Modifier, viewModel: MultipartAttachmentsViewModel = when { LocalInspectionMode.current -> MultipartAttachmentsViewModelPreview - else -> hiltViewModel(key = conversationId.value).apply { - this.conversationId = conversationId.value - } + else -> hiltViewModel( + key = conversationId.value, + creationCallback = { factory -> factory.create(conversationId = conversationId.value) } + ) } ) { val offlineAttachmentIds = viewModel.offlineAttachmentIds.collectAsStateWithLifecycle().value 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 0f7580431c5..7ba60e53d9b 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 @@ -41,6 +41,9 @@ 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.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.SharingStarted @@ -50,7 +53,6 @@ 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> @@ -116,8 +118,9 @@ object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel { override fun onAttachmentsHidden(attachments: List) {} } -@HiltViewModel -class MultipartAttachmentsViewModelImpl @Inject constructor( +@HiltViewModel(assistedFactory = MultipartAttachmentsViewModelImpl.Factory::class) +class MultipartAttachmentsViewModelImpl @AssistedInject constructor( + @Assisted private val conversationId: String, private val refreshHelper: CellAssetRefreshHelper, private val download: DownloadCellFileUseCase, private val getEditorUrl: GetEditorUrlUseCase, @@ -129,6 +132,11 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( observeOfflineFiles: ObserveOfflineFilesUseCase, ) : ViewModel(), MultipartAttachmentsViewModel { + @AssistedFactory + interface Factory { + fun create(conversationId: String): MultipartAttachmentsViewModelImpl + } + private val uploadProgress = mutableStateMapOf() override val offlineAttachmentIds: StateFlow> = observeOfflineFiles() .map { offlineFiles -> offlineFiles.mapTo(mutableSetOf()) { it.id } } @@ -136,13 +144,14 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( private var isCollaboraEnabled: Boolean = false - internal var conversationId: String? = null - init { 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 -> 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 f66d3577f11..4fc3b15c682 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 @@ -297,6 +297,7 @@ class MultipartAttachmentsViewModelTest { every { observeOfflineFiles() } returns flowOf(emptyList()) return this to MultipartAttachmentsViewModelImpl( + conversationId = testConversationId, refreshHelper = refreshHelper, download = download, fileManager = fileManager, @@ -306,13 +307,13 @@ class MultipartAttachmentsViewModelTest { featureFlags = kaliumConfigs, getWireCellsConfig = getWireCellsConfig, observeOfflineFiles = observeOfflineFiles, - ).apply { - conversationId = "test-conversation-id" - } + ) } } private companion object { + const val testConversationId = "test-conversation-id" + val testAssetContent = CellAssetContent( id = "assetId1", versionId = "1", From 5bba7d36b2301269a3b94697cf44f8152bd9630b Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 20 May 2026 12:22:00 +0200 Subject: [PATCH 16/20] chore: pass conversationId using savedStateHandle instead of AssistedInject --- .../messages/item/MessageContentAndStatus.kt | 1 - .../multipart/MultipartAttachmentsView.kt | 11 ++++------ .../MultipartAttachmentsViewModel.kt | 20 +++++++++---------- .../MultipartAttachmentsViewModelTest.kt | 10 +++++++++- 4 files changed, 22 insertions(+), 20 deletions(-) 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 5ecf1daebb0..d0408045d98 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 @@ -416,7 +416,6 @@ private fun MessageContent( Spacer(modifier = Modifier.height(dimensions().spacing8x)) } MultipartAttachmentsView( - conversationId = message.conversationId, attachments = messageContent.attachments, messageStyle = messageStyle, onImageAttachmentClick = onMultipartImageClick 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 f0e99e14b42..48c0ed1f2c9 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,6 +25,7 @@ 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.ui.Modifier import androidx.compose.ui.layout.onVisibilityChanged import androidx.compose.ui.platform.LocalConfiguration @@ -44,7 +45,6 @@ import com.wire.android.ui.home.conversations.model.messagetypes.multipart.grid. import com.wire.android.ui.home.conversations.model.messagetypes.multipart.standalone.AssetPreview import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.isFailed -import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.MessageAttachment /** @@ -53,20 +53,16 @@ import com.wire.kalium.logic.data.message.MessageAttachment */ @Composable fun MultipartAttachmentsView( - conversationId: ConversationId, attachments: List, messageStyle: MessageStyle, onImageAttachmentClick: (String) -> Unit, modifier: Modifier = Modifier, viewModel: MultipartAttachmentsViewModel = when { LocalInspectionMode.current -> MultipartAttachmentsViewModelPreview - else -> hiltViewModel( - key = conversationId.value, - creationCallback = { factory -> factory.create(conversationId = conversationId.value) } - ) + else -> hiltViewModel() } ) { - val offlineAttachmentIds = viewModel.offlineAttachmentIds.collectAsStateWithLifecycle().value + 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) { @@ -139,6 +135,7 @@ fun MultipartAttachmentsView( } } + @Composable private fun AttachmentsList( attachments: List, 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 7ba60e53d9b..37d292d0848 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,6 +30,7 @@ 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 @@ -41,9 +44,6 @@ 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.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.SharingStarted @@ -53,6 +53,7 @@ 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> @@ -118,9 +119,10 @@ object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel { override fun onAttachmentsHidden(attachments: List) {} } -@HiltViewModel(assistedFactory = MultipartAttachmentsViewModelImpl.Factory::class) -class MultipartAttachmentsViewModelImpl @AssistedInject constructor( - @Assisted private val conversationId: String, +@Suppress("LongParameterList") +@HiltViewModel +class MultipartAttachmentsViewModelImpl @Inject constructor( + savedStateHandle: SavedStateHandle, private val refreshHelper: CellAssetRefreshHelper, private val download: DownloadCellFileUseCase, private val getEditorUrl: GetEditorUrlUseCase, @@ -131,11 +133,7 @@ class MultipartAttachmentsViewModelImpl @AssistedInject constructor( private val getWireCellsConfig: GetWireCellConfigurationUseCase, observeOfflineFiles: ObserveOfflineFilesUseCase, ) : ViewModel(), MultipartAttachmentsViewModel { - - @AssistedFactory - interface Factory { - fun create(conversationId: String): MultipartAttachmentsViewModelImpl - } + private val conversationId = savedStateHandle.navArgs().conversationId.value private val uploadProgress = mutableStateMapOf() override val offlineAttachmentIds: StateFlow> = observeOfflineFiles() 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 4fc3b15c682..212fa97137d 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,11 +17,14 @@ */ 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 @@ -30,6 +33,7 @@ import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase 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 @@ -261,6 +265,10 @@ class MultipartAttachmentsViewModelTest { MockKAnnotations.init(this) } + private val savedStateHandle: SavedStateHandle = ConversationNavArgs( + conversationId = ConversationId(testConversationId, "test-domain"), + ).toSavedStateHandle() + @MockK lateinit var refreshHelper: CellAssetRefreshHelper @@ -297,7 +305,7 @@ class MultipartAttachmentsViewModelTest { every { observeOfflineFiles() } returns flowOf(emptyList()) return this to MultipartAttachmentsViewModelImpl( - conversationId = testConversationId, + savedStateHandle = savedStateHandle, refreshHelper = refreshHelper, download = download, fileManager = fileManager, From 3ef3638c209eddf5d0c5700c1fffc8b1817c6433 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 21 May 2026 13:53:40 +0200 Subject: [PATCH 17/20] refactor: move mapping to viewmodel --- .../multipart/MultipartAttachmentsView.kt | 16 ++++++++------ .../MultipartAttachmentsViewModel.kt | 9 ++++++-- .../MultipartAttachmentsViewModelTest.kt | 22 +++++++++++++++++-- 3 files changed, 36 insertions(+), 11 deletions(-) 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 48c0ed1f2c9..c94b7a85aa7 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 @@ -26,6 +26,7 @@ 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 @@ -39,7 +40,6 @@ 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,12 +62,16 @@ fun MultipartAttachmentsView( else -> hiltViewModel() } ) { + // 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) { val attachment = attachments.first() - attachment.toUiModel(isAvailableOffline = attachment.assetId() in offlineAttachmentIds).let { + val item = remember(attachment, offlineAttachmentIds) { + viewModel.mapAttachment(attachment) + } + item.let { AssetPreview( modifier = modifier .onVisibilityChanged { visible -> @@ -88,10 +92,9 @@ fun MultipartAttachmentsView( ) } } else { - val groups = viewModel.mapAttachments( - attachments = attachments, - offlineAttachmentIds = offlineAttachmentIds, - ) + val groups = remember(attachments, offlineAttachmentIds) { + viewModel.mapAttachments(attachments = attachments) + } Column( modifier = modifier @@ -135,7 +138,6 @@ fun MultipartAttachmentsView( } } - @Composable private fun AttachmentsList( attachments: List, 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 37d292d0848..456d9cd10f7 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 @@ -58,16 +58,21 @@ 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, - offlineAttachmentIds: Set = emptySet(), ): List { + val offlineIds = offlineAttachmentIds.value val result = mutableListOf() var group: MultipartAttachmentGroup? = null attachments.forEach { - val isAvailableOffline = it.assetId() in offlineAttachmentIds + val isAvailableOffline = it.assetId() in offlineIds if (it.isMediaAttachment()) { group = when (group) { null -> MultipartAttachmentGroup.Media(listOf(it.toUiModel(isAvailableOffline = isAvailableOffline))) 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 212fa97137d..7c94055506f 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 @@ -43,6 +43,7 @@ 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 @@ -148,11 +149,13 @@ 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")), - offlineAttachmentIds = setOf("asset_1") ) assertEquals( @@ -294,6 +297,21 @@ class MultipartAttachmentsViewModelTest { lateinit var observeOfflineFiles: ObserveOfflineFilesUseCase val kaliumFileSystem: KaliumFileSystem = FakeKaliumFileSystem() + private var offlineFiles: List = emptyList() + + fun withOfflineAttachmentId(assetId: String) = apply { + offlineFiles = listOf( + OfflineFileInfo( + id = assetId, + conversationId = testConversationId, + name = "file", + owner = "owner", + localPath = "local/path", + size = 1L, + downloadedAt = 1L, + ) + ) + } fun arrange(): Pair { @@ -302,7 +320,7 @@ class MultipartAttachmentsViewModelTest { 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 { observeOfflineFiles() } returns flowOf(emptyList()) + every { observeOfflineFiles() } returns flowOf(offlineFiles) return this to MultipartAttachmentsViewModelImpl( savedStateHandle = savedStateHandle, From e63df09ea5aa06097066ea8cb69ad64e0760b612 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 21 May 2026 14:51:51 +0200 Subject: [PATCH 18/20] refactor: address comments --- .../com/wire/android/di/accountScoped/CellsModule.kt | 6 ++++++ .../messages/item/MessageContentAndStatus.kt | 1 + .../messagetypes/multipart/MultipartAttachmentsView.kt | 4 +++- .../multipart/MultipartAttachmentsViewModel.kt | 6 +++--- .../multipart/MultipartAttachmentsViewModelTest.kt | 9 +++++---- kalium | 2 +- 6 files changed, 19 insertions(+), 9 deletions(-) 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/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index d0408045d98..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 @@ -416,6 +416,7 @@ private fun MessageContent( Spacer(modifier = Modifier.height(dimensions().spacing8x)) } MultipartAttachmentsView( + conversationId = message.conversationId, attachments = messageContent.attachments, messageStyle = messageStyle, onImageAttachmentClick = onMultipartImageClick 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 c94b7a85aa7..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 @@ -45,6 +45,7 @@ import com.wire.android.ui.home.conversations.model.messagetypes.multipart.grid. import com.wire.android.ui.home.conversations.model.messagetypes.multipart.standalone.AssetPreview import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.isFailed +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.MessageAttachment /** @@ -53,13 +54,14 @@ import com.wire.kalium.logic.data.message.MessageAttachment */ @Composable fun MultipartAttachmentsView( + conversationId: ConversationId, attachments: List, messageStyle: MessageStyle, onImageAttachmentClick: (String) -> Unit, modifier: Modifier = Modifier, viewModel: MultipartAttachmentsViewModel = when { LocalInspectionMode.current -> MultipartAttachmentsViewModelPreview - else -> hiltViewModel() + else -> hiltViewModel(key = conversationId.value) } ) { // Collect to trigger recomposition when offline availability changes. 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 456d9cd10f7..5b7e4bfdb57 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 @@ -35,7 +35,7 @@ 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.ObserveOfflineFilesUseCase +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 @@ -136,12 +136,12 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( private val kaliumFileSystem: KaliumFileSystem, private val featureFlags: KaliumConfigs, private val getWireCellsConfig: GetWireCellConfigurationUseCase, - observeOfflineFiles: ObserveOfflineFilesUseCase, + observeOfflineFilesByConversation: ObserveOfflineFilesByConversationUseCase, ) : ViewModel(), MultipartAttachmentsViewModel { private val conversationId = savedStateHandle.navArgs().conversationId.value private val uploadProgress = mutableStateMapOf() - override val offlineAttachmentIds: StateFlow> = observeOfflineFiles() + override val offlineAttachmentIds: StateFlow> = observeOfflineFilesByConversation(conversationId) .map { offlineFiles -> offlineFiles.mapTo(mutableSetOf()) { it.id } } .stateIn(viewModelScope, SharingStarted.Eagerly, emptySet()) 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 7c94055506f..a20f51741e1 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 @@ -29,7 +29,7 @@ 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.ObserveOfflineFilesUseCase +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 @@ -294,7 +294,7 @@ class MultipartAttachmentsViewModelTest { lateinit var getWireCellsConfig: GetWireCellConfigurationUseCase @MockK - lateinit var observeOfflineFiles: ObserveOfflineFilesUseCase + lateinit var observeOfflineFilesByConversation: ObserveOfflineFilesByConversationUseCase val kaliumFileSystem: KaliumFileSystem = FakeKaliumFileSystem() private var offlineFiles: List = emptyList() @@ -320,7 +320,8 @@ class MultipartAttachmentsViewModelTest { 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 { observeOfflineFiles() } returns flowOf(offlineFiles) + every { observeOfflineFilesByConversation(testConversationId) } returns flowOf(offlineFiles) + every { observeOfflineFilesByConversation("other-conversation") } returns flowOf(offlineFiles) return this to MultipartAttachmentsViewModelImpl( savedStateHandle = savedStateHandle, @@ -332,7 +333,7 @@ class MultipartAttachmentsViewModelTest { kaliumFileSystem = kaliumFileSystem, featureFlags = kaliumConfigs, getWireCellsConfig = getWireCellsConfig, - observeOfflineFiles = observeOfflineFiles, + observeOfflineFilesByConversation = observeOfflineFilesByConversation, ) } } diff --git a/kalium b/kalium index 227ebac8faa..ea191aecafc 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 227ebac8faac06c08139cfdf5062f26c38974bf9 +Subproject commit ea191aecafce6243f25bf2e87ab388be2d9b2c76 From 63a0e338f2ed5b4c41ab6573ae2a4eaed74c3b53 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 21 May 2026 14:52:47 +0200 Subject: [PATCH 19/20] feat: use case for observing offline files by conversationId --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index ea191aecafc..101e4bb4e88 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit ea191aecafce6243f25bf2e87ab388be2d9b2c76 +Subproject commit 101e4bb4e8865014bcf9e420c322b0f46cc20759 From d1ba21016226746f2105665db478163519f5f8f2 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 21 May 2026 15:57:55 +0200 Subject: [PATCH 20/20] feat: cleanup --- .../multipart/MultipartAttachmentsViewModel.kt | 4 ++-- .../multipart/MultipartAttachmentsViewModelTest.kt | 8 ++++---- kalium | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) 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 5b7e4bfdb57..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 @@ -138,7 +138,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( private val getWireCellsConfig: GetWireCellConfigurationUseCase, observeOfflineFilesByConversation: ObserveOfflineFilesByConversationUseCase, ) : ViewModel(), MultipartAttachmentsViewModel { - private val conversationId = savedStateHandle.navArgs().conversationId.value + private val conversationId = savedStateHandle.navArgs().conversationId private val uploadProgress = mutableStateMapOf() override val offlineAttachmentIds: StateFlow> = observeOfflineFilesByConversation(conversationId) @@ -208,7 +208,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( download( assetId = attachment.uuid, - conversationId = conversationId, + conversationId = conversationId.value, outFilePath = path, assetSize = attachment.assetSize ?: 0, ) { progress -> 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 a20f51741e1..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 @@ -269,7 +269,7 @@ class MultipartAttachmentsViewModelTest { } private val savedStateHandle: SavedStateHandle = ConversationNavArgs( - conversationId = ConversationId(testConversationId, "test-domain"), + conversationId = testConversationId, ).toSavedStateHandle() @MockK @@ -303,7 +303,7 @@ class MultipartAttachmentsViewModelTest { offlineFiles = listOf( OfflineFileInfo( id = assetId, - conversationId = testConversationId, + conversationId = testConversationId.value, name = "file", owner = "owner", localPath = "local/path", @@ -321,7 +321,7 @@ class MultipartAttachmentsViewModelTest { 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("other-conversation") } returns flowOf(offlineFiles) + every { observeOfflineFilesByConversation(ConversationId("other-conversation", "test-domain")) } returns flowOf(offlineFiles) return this to MultipartAttachmentsViewModelImpl( savedStateHandle = savedStateHandle, @@ -339,7 +339,7 @@ class MultipartAttachmentsViewModelTest { } private companion object { - const val testConversationId = "test-conversation-id" + val testConversationId = ConversationId("test-conversation-id", "test-domain") val testAssetContent = CellAssetContent( id = "assetId1", diff --git a/kalium b/kalium index 101e4bb4e88..192dc557579 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 101e4bb4e8865014bcf9e420c322b0f46cc20759 +Subproject commit 192dc5575795d5efd1dbd2a6297771a149adfbbc