From 3b353bd11ede7e057ab834cb14a2a436557fcef0 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 23 Apr 2026 18:56:41 +0200 Subject: [PATCH 01/44] feat: new loading behavior --- .../feature/cells/ui/AllFilesScreen.kt | 4 +- .../feature/cells/ui/CellFileActionsMenu.kt | 48 ++-- .../cells/ui/CellFileLocalPathCache.kt | 55 ++++ .../feature/cells/ui/CellFilesScreen.kt | 41 ++- .../android/feature/cells/ui/CellListItem.kt | 158 +++++++++++- .../feature/cells/ui/CellScreenContent.kt | 44 +++- .../android/feature/cells/ui/CellViewModel.kt | 241 +++++++++++++++--- .../cells/ui/ConversationFilesScreen.kt | 8 +- .../feature/cells/ui/model/CellNodeUi.kt | 12 + .../cells/ui/model/NodeBottomSheetAction.kt | 3 +- .../cells/ui/recyclebin/RecycleBinScreen.kt | 3 +- .../feature/cells/ui/search/SearchScreen.kt | 5 +- .../android/feature/cells/util/FileHelper.kt | 2 + .../cells/src/main/res/values/strings.xml | 6 + .../feature/cells/ui/CellViewModelTest.kt | 72 +++++- 15 files changed, 618 insertions(+), 84 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt 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 1935e72fea1..2648bb15af3 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 @@ -79,6 +79,7 @@ fun AllFilesScreen( isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRecycleBin = viewModel.isRecycleBin(), isSearchResult = false, + cachedLocalPaths = viewModel.cachedLocalPaths, showPublicLinkScreen = { publicLinkScreenData -> navigator.navigate( NavigationCommand( @@ -101,7 +102,8 @@ fun AllFilesScreen( ) }, isRefreshing = viewModel.isPullToRefresh.collectAsState(), - onRefresh = { viewModel.onPullToRefresh() } + onRefresh = { viewModel.onPullToRefresh() }, + fileReadyFlow = viewModel.fileReadyFlow, ) } } 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 1b914558982..bd382c43cd0 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 @@ -46,34 +46,42 @@ class CellFileActionsMenu @Inject constructor( isAllFiles || isSearching -> { buildList { - if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { - add(NodeBottomSheetAction.SHARE) + if (cellNode is CellNodeUi.File && cellNode.isOpenLoading) { + add(NodeBottomSheetAction.CANCEL_LOADING) + } else { + if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { + add(NodeBottomSheetAction.SHARE) + } + add(NodeBottomSheetAction.PUBLIC_LINK) + add(NodeBottomSheetAction.DOWNLOAD) } - add(NodeBottomSheetAction.PUBLIC_LINK) - add(NodeBottomSheetAction.DOWNLOAD) } } isConversationFiles -> { buildList { - if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { - add(NodeBottomSheetAction.SHARE) - } - add(NodeBottomSheetAction.PUBLIC_LINK) - add(NodeBottomSheetAction.DOWNLOAD) + if (cellNode is CellNodeUi.File && cellNode.isOpenLoading) { + add(NodeBottomSheetAction.CANCEL_LOADING) + } else { + if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { + add(NodeBottomSheetAction.SHARE) + } + add(NodeBottomSheetAction.PUBLIC_LINK) + add(NodeBottomSheetAction.DOWNLOAD) - if (isCollaboraEnabled && featureFlags.collaboraIntegration && cellNode.isEditSupported()) { - add(NodeBottomSheetAction.EDIT) - } + if (isCollaboraEnabled && featureFlags.collaboraIntegration && cellNode.isEditSupported()) { + add(NodeBottomSheetAction.EDIT) + } - if (featureFlags.collaboraIntegration && cellNode.isEditSupported()) { - add(NodeBottomSheetAction.VERSION_HISTORY) - } + if (featureFlags.collaboraIntegration && cellNode.isEditSupported()) { + add(NodeBottomSheetAction.VERSION_HISTORY) + } - add(NodeBottomSheetAction.ADD_REMOVE_TAGS) - add(NodeBottomSheetAction.MOVE) - add(NodeBottomSheetAction.RENAME) - add(NodeBottomSheetAction.DELETE) + add(NodeBottomSheetAction.ADD_REMOVE_TAGS) + add(NodeBottomSheetAction.MOVE) + add(NodeBottomSheetAction.RENAME) + add(NodeBottomSheetAction.DELETE) + } } } @@ -87,6 +95,7 @@ class CellFileActionsMenu @Inject constructor( internal data class Share(val node: CellNodeUi.File) : MenuActionResult internal data class Download(val node: CellNodeUi) : MenuActionResult internal data class Edit(val node: CellNodeUi) : MenuActionResult + internal data class CancelLoading(val node: CellNodeUi) : MenuActionResult internal fun onMenuItemAction( conversationId: String?, @@ -130,6 +139,7 @@ class CellFileActionsMenu @Inject constructor( NodeBottomSheetAction.DOWNLOAD -> Download(node) NodeBottomSheetAction.EDIT -> Edit(node) NodeBottomSheetAction.VERSION_HISTORY -> Action(ShowVersionHistoryScreen(node.uuid, node.name ?: "")) + NodeBottomSheetAction.CANCEL_LOADING -> CancelLoading(node) } onResult(result) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt new file mode 100644 index 00000000000..4c778d86cb6 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt @@ -0,0 +1,55 @@ +/* + * 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 + +import com.wire.android.feature.cells.ui.model.CellNodeUi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Process-scoped singleton that keeps track of locally-cached file paths (uuid → absolute path string) + * across all [CellViewModel] instances. This allows a file downloaded in one screen (e.g. Search) to + * appear as already-available in another screen (e.g. All Files) without re-downloading. + * + * Also acts as the global event bus for "file ready to open" snackbar events, so that the snackbar + * is shown on whichever screen is currently active — even if the download finished on a different screen. + */ +@Singleton +class CellFileLocalPathCache @Inject constructor() { + + private val _fileReadyChannel = Channel(Channel.BUFFERED) + val fileReadyEvents: Flow = _fileReadyChannel.receiveAsFlow() + + private val _paths = MutableStateFlow>(emptyMap()) + val paths: StateFlow> = _paths.asStateFlow() + + fun put(uuid: String, localPath: String) { + _paths.update { it + (uuid to localPath) } + } + + fun emitFileReady(file: CellNodeUi.File) { + _fileReadyChannel.trySend(file) + } +} 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 f9c17ec2faa..b5221892e5f 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,8 @@ internal fun CellFilesScreen( modifier: Modifier = Modifier, isPullToRefreshEnabled: Boolean = true, lazyListState: LazyListState = rememberLazyListState(), + externalOpenLoadStates: Map = emptyMap(), + cachedLocalPaths: Map = emptyMap(), onItemMenuClick: (CellNodeUi) -> Unit ) { if (isPullToRefreshEnabled) { @@ -78,7 +80,9 @@ internal fun CellFilesScreen( cellNodes = cellNodes, lazyListState = lazyListState, onItemClick = onItemClick, - onItemMenuClick = onItemMenuClick + onItemMenuClick = onItemMenuClick, + externalOpenLoadStates = externalOpenLoadStates, + cachedLocalPaths = cachedLocalPaths, ) } } else { @@ -87,7 +91,9 @@ internal fun CellFilesScreen( cellNodes = cellNodes, lazyListState = lazyListState, onItemClick = onItemClick, - onItemMenuClick = onItemMenuClick + onItemMenuClick = onItemMenuClick, + externalOpenLoadStates = externalOpenLoadStates, + cachedLocalPaths = cachedLocalPaths, ) } } @@ -98,7 +104,9 @@ private fun ContentList( lazyListState: LazyListState, onItemClick: (CellNodeUi) -> Unit, onItemMenuClick: (CellNodeUi) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + externalOpenLoadStates: Map = emptyMap(), + cachedLocalPaths: Map = emptyMap(), ) { LazyColumn( modifier = modifier.fillMaxWidth(), @@ -112,13 +120,34 @@ private fun ContentList( ) { index -> cellNodes[index]?.let { item -> + // Apply external open-load state overlay for items whose paging data doesn't carry it (e.g. Search) + val overlaidItem = if (item is CellNodeUi.File) { + val state = externalOpenLoadStates[item.uuid] + val cachedPath = cachedLocalPaths[item.uuid] + if (state != null || cachedPath != null) { + item.copy( + isOpenLoading = state is OpenLoadState.Loading, + isOpenReady = state is OpenLoadState.Ready, + isOpenError = state is OpenLoadState.Error, + openLoadProgress = (state as? OpenLoadState.Loading)?.progress, + // Prefer cached localPath so re-tapping after "Ready" dismissal opens from cache + localPath = (state as? OpenLoadState.Ready)?.localPath?.toString() + ?: cachedPath + ?: item.localPath, + ) + } else { + item + } + } else { + item + } CellListItem( modifier = Modifier .animateItem() .background(color = colorsScheme().surface) - .clickable { onItemClick(item) }, - cell = item, - onMenuClick = { onItemMenuClick(item) } + .clickable { onItemClick(overlaidItem) }, + cell = overlaidItem, + onMenuClick = { onItemMenuClick(overlaidItem) } ) 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 748a59578db..ce23bf3992d 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 @@ -17,6 +17,20 @@ */ package com.wire.android.feature.cells.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -33,6 +47,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple @@ -42,6 +57,7 @@ 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.graphics.StrokeCap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -73,6 +89,18 @@ internal fun CellListItem( modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } + var showReadyState by remember { mutableStateOf(false) } + val cellState = rememberUpdatedState(cell) + + LaunchedEffect(Unit) { + snapshotFlow { cellState.value.isOpenReady } + .filter { it } + .collect { + showReadyState = true + delay(3_000L) + showReadyState = false + } + } Box(modifier = modifier) { Row( @@ -82,9 +110,34 @@ internal fun CellListItem( verticalAlignment = Alignment.CenterVertically, ) { - when (cell) { - is CellNodeUi.File -> FileIconPreview(cell) - is CellNodeUi.Folder -> FolderIconPreview(cell) + val iconState = when { + cell.isOpenLoading -> CellIconState.Loading(cell.openLoadProgress) + showReadyState -> CellIconState.Ready + cell is CellNodeUi.File -> CellIconState.FileIcon(cell) + else -> CellIconState.FolderIcon(cell as CellNodeUi.Folder) + } + + AnimatedContent( + targetState = iconState, + contentKey = { state -> + when (state) { + is CellIconState.Loading -> "loading" + is CellIconState.Ready -> "ready" + is CellIconState.FileIcon -> "file" + is CellIconState.FolderIcon -> "folder" + } + }, + transitionSpec = { + (scaleIn(initialScale = 0.72f) + fadeIn()) togetherWith (scaleOut(targetScale = 0.72f) + fadeOut()) + }, + label = "cell_icon_transition", + ) { state -> + when (state) { + is CellIconState.Loading -> LoadingIconPreview(progress = state.progress) + is CellIconState.Ready -> ReadyIconPreview() + is CellIconState.FileIcon -> FileIconPreview(state.cell) + is CellIconState.FolderIcon -> FolderIconPreview(state.cell) + } } Column( @@ -104,23 +157,52 @@ internal fun CellListItem( Row( verticalAlignment = Alignment.CenterVertically, ) { - if (cell.tags.isNotEmpty()) { - WireDisplayChipWithOverFlow( - label = cell.tags.first(), - chipsCount = cell.tags.size - 1, - modifier = Modifier.padding(end = dimensions().spacing4x) - ) - } - - cell.subtitle()?.let { + if (cell.isOpenLoading) { Text( - text = it, + text = stringResource(R.string.tap_to_cancel_loading), textAlign = TextAlign.Left, overflow = TextOverflow.Ellipsis, style = typography().label04, color = colorsScheme().secondaryText, maxLines = 1, ) + } else if (cell.isOpenError) { + Text( + text = stringResource(R.string.unable_to_load_retry), + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().error, + maxLines = 1, + ) + } else if (showReadyState) { + Text( + text = stringResource(R.string.ready_to_open), + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().primary, + maxLines = 1, + ) + } else { + if (cell.tags.isNotEmpty()) { + WireDisplayChipWithOverFlow( + label = cell.tags.first(), + chipsCount = cell.tags.size - 1, + modifier = Modifier.padding(end = dimensions().spacing4x) + ) + } + + cell.subtitle()?.let { + Text( + text = it, + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().secondaryText, + maxLines = 1, + ) + } } } } @@ -154,6 +236,56 @@ internal fun CellListItem( } } +private sealed class CellIconState { + data class Loading(val progress: Float?) : CellIconState() + data object Ready : CellIconState() + data class FileIcon(val cell: CellNodeUi.File) : CellIconState() + data class FolderIcon(val cell: CellNodeUi.Folder) : CellIconState() +} + +@Composable +internal fun LoadingIconPreview(progress: Float?) { + Box( + modifier = Modifier.size(dimensions().spacing56x), + contentAlignment = Alignment.Center + ) { + if (progress != null) { + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.size(dimensions().spacing32x), + color = colorsScheme().primary, + trackColor = colorsScheme().primaryVariant, + strokeWidth = dimensions().spacing2x, + strokeCap = StrokeCap.Round, + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(dimensions().spacing32x), + color = colorsScheme().primary, + trackColor = colorsScheme().primaryVariant, + strokeWidth = dimensions().spacing2x, + strokeCap = StrokeCap.Round, + ) + } + } +} + + +@Composable +internal fun ReadyIconPreview() { + Box( + modifier = Modifier.size(dimensions().spacing56x), + contentAlignment = Alignment.Center + ) { + Icon( + modifier = Modifier.size(dimensions().spacing32x), + painter = painterResource(commonR.drawable.ic_check_circle), + contentDescription = null, + tint = colorsScheme().primary, + ) + } +} + @Composable internal fun FolderIconPreview(cell: CellNodeUi.Folder) { Box( 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 76b477cf353..769f1bf2e82 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 @@ -30,8 +30,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState @@ -66,12 +69,15 @@ import com.wire.android.feature.cells.ui.recyclebin.UnableToRestoreDialog import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.kalium.cells.domain.paging.FileListLoadError import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow @Suppress("CyclomaticComplexMethod") @Composable @@ -98,10 +104,16 @@ internal fun CellScreenContent( lazyListState: LazyListState = rememberLazyListState(), retryEditNodeError: (String) -> Unit = {}, showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> }, + externalOpenLoadStates: StateFlow> = MutableStateFlow(emptyMap()), + cachedLocalPaths: StateFlow> = MutableStateFlow(emptyMap()), + fileReadyFlow: Flow? = emptyFlow(), ) { val context = LocalContext.current val lifecycle = LocalLifecycleOwner.current + val snackbarHostState = LocalSnackbarHostState.current + val fileReadyActionLabel = stringResource(R.string.file_open_snackbar_action) + val fileReadyMessageFormat = stringResource(R.string.file_open_snackbar_message) var deleteConfirmation by remember { mutableStateOf?>((null)) } var restoreConfirmation by remember { mutableStateOf(null) } @@ -111,6 +123,18 @@ internal fun CellScreenContent( var menu by remember { mutableStateOf(null) } val downloadFile by downloadFileState.collectAsState() + val externalLoadStates by externalOpenLoadStates.collectAsState() + val cachedPaths by cachedLocalPaths.collectAsState() + + DisposableEffect(lifecycle) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + sendIntent(CellViewIntent.OnScreenLeave) + } + } + lifecycle.lifecycle.addObserver(observer) + onDispose { lifecycle.lifecycle.removeObserver(observer) } + } when { pagingListItems.isLoading() -> LoadingScreen(modifier = modifier) @@ -139,7 +163,9 @@ internal fun CellScreenContent( onItemClick = { sendIntent(CellViewIntent.OnItemClick(it)) }, onItemMenuClick = { sendIntent(CellViewIntent.OnItemMenuClick(it)) }, isRefreshing = isRefreshing, - onRefresh = onRefresh + onRefresh = onRefresh, + externalOpenLoadStates = externalLoadStates, + cachedLocalPaths = cachedPaths, ) } @@ -254,6 +280,22 @@ internal fun CellScreenContent( } } + // Lifecycle-independent: collects even when this screen is in the back stack, so the snackbar + // appears on whichever Cell screen is currently active (global snackbar behaviour). + LaunchedEffect(Unit) { + fileReadyFlow?.collect { file -> + val message = fileReadyMessageFormat.format(file.name ?: file.uuid) + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = fileReadyActionLabel, + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) { + sendIntent(CellViewIntent.OnItemClick(file)) + } + } + } + LaunchedEffect(Unit) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { menuState.collect { showMenu -> 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 ae927ad5c92..dafe65e3ac9 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 @@ -55,6 +55,7 @@ import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.featureConfig.CollaboraEdition import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -91,6 +92,7 @@ class CellViewModel @Inject constructor( private val onlineEditor: OnlineEditor, private val cellFileActionsMenu: CellFileActionsMenu, private val getWireCellsConfig: GetWireCellConfigurationUseCase, + private val sharedPathCache: CellFileLocalPathCache, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -118,6 +120,33 @@ class CellViewModel @Inject constructor( // Download progress value for each file being downloaded. private val downloadDataFlow = MutableStateFlow>(emptyMap()) + // Active storage-download jobs keyed by node UUID. + private val downloadJobs = mutableMapOf() + + // Active open-download jobs keyed by node UUID, used to support cancellation of loading. + private val openDownloadJobs = mutableMapOf() + + // Monotonically-increasing generation per UUID — incremented on every new startOpenDownload call. + // Stale progress callbacks from a previous (cancelled) download carry an old generation and are ignored. + private val openDownloadGeneration = mutableMapOf() + + // Open-loading state: tracks files being silently downloaded for immediate open. + private val openLoadStateFlow = MutableStateFlow>(emptyMap()) + + /** Public map of uuid → (isOpenLoading, isOpenReady) for screens that build their own paging flow (e.g. Search). */ + internal val openLoadStates: StateFlow> = openLoadStateFlow.asStateFlow() + + /** + * File-ready events for the "ready to open" snackbar. Backed by the singleton channel so the + * event reaches whichever screen is currently active, even if the download finished on a different + * screen (e.g. completed in Search while the user navigated back to All Files). + */ + internal val fileReadyFlow = sharedPathCache.fileReadyEvents + + /** Cached local file paths from completed open-downloads, keyed by uuid. Used by Search screen overlay. */ + private val _cachedLocalPaths = MutableStateFlow>(emptyMap()) + internal val cachedLocalPaths: StateFlow> = _cachedLocalPaths.asStateFlow() + private val removedItemsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) // Used to navigate to the root of the recycle bin after restoring a parent folder. @@ -139,6 +168,13 @@ class CellViewModel @Inject constructor( init { loadWireCellConfig() checkCellAvailabilityAndRefresh() + viewModelScope.launch { + try { + sharedPathCache.paths.collect { _cachedLocalPaths.value = it } + } catch (_: Throwable) { + // sharedPathCache.paths unavailable — cachedLocalPaths stays empty + } + } } private fun checkCellAvailabilityAndRefresh() = viewModelScope.launch { @@ -166,8 +202,10 @@ class CellViewModel @Inject constructor( ), ).cachedIn(viewModelScope), removedItemsFlow, - downloadDataFlow - ) { pagingData, removedItems, downloadData -> + downloadDataFlow, + openLoadStateFlow, + _cachedLocalPaths, + ) { pagingData, removedItems, downloadData, openLoadStates, cachedPaths -> var emittedRefreshDone = false pagingData @@ -183,6 +221,7 @@ class CellViewModel @Inject constructor( _pagingRefreshDone.tryEmit(Unit) } + val openLoadState = openLoadStates[node.uuid] when (node) { is Node.Folder -> node.toUiModel().copy( downloadProgress = downloadData[node.uuid]?.progress @@ -191,6 +230,13 @@ class CellViewModel @Inject constructor( is Node.File -> node.toUiModel().copy( downloadProgress = downloadData[node.uuid]?.progress, localPath = downloadData[node.uuid]?.localPath?.toString() + ?: (openLoadState as? OpenLoadState.Ready)?.localPath?.toString() + ?: cachedPaths[node.uuid] + ?: node.localPath, + isOpenLoading = openLoadState is OpenLoadState.Loading, + isOpenReady = openLoadState is OpenLoadState.Ready, + isOpenError = openLoadState is OpenLoadState.Error, + openLoadProgress = (openLoadState as? OpenLoadState.Loading)?.progress, ) } } @@ -231,6 +277,8 @@ class CellViewModel @Inject constructor( is CellViewIntent.OnNodeRestoreConfirmed -> restoreNodeFromRecycleBin(intent.node) is CellViewIntent.OnDownloadMenuClosed -> onDownloadMenuClosed() is CellViewIntent.OnParentFolderRestoreConfirmed -> restoreNodeFromRecycleBin(intent.node) + is CellViewIntent.OnCancelDownload -> cancelDownload(intent.uuid) + is CellViewIntent.OnScreenLeave -> clearAllErrorStates() } } @@ -244,12 +292,96 @@ class CellViewModel @Inject constructor( private fun onFileClick(cellNode: CellNodeUi.File) { when { + cellNode.isOpenReady -> openLocalFile(cellNode) + cellNode.isOpenLoading -> cancelOpenDownload(cellNode.uuid) + cellNode.isOpenError -> startOpenDownload(cellNode) cellNode.localFileAvailable() -> openLocalFile(cellNode) cellNode.canOpenWithUrl() -> openFileContentUrl(cellNode) - else -> viewModelScope.launch { _downloadFileSheet.emit(cellNode) } + else -> startOpenDownload(cellNode) } } + private fun startOpenDownload(cellNode: CellNodeUi.File) { + // Stamp a new generation for this download session. + val myGeneration = (openDownloadGeneration[cellNode.uuid] ?: 0L) + 1L + openDownloadGeneration[cellNode.uuid] = myGeneration + + val job = viewModelScope.launch { + val nodeName = cellNode.name ?: run { + sendAction(ShowError(CellError.OTHER_ERROR)) + return@launch + } + + val cacheDir = fileHelper.getCacheDir() + val filePath = fileNameResolver.getUniqueFile(cacheDir, nodeName).toPath().toOkioPath() + + // Track whether the 300ms threshold was crossed before download completed + var spinnerShown = false + + val showSpinnerJob = launch { + delay(OPEN_SPINNER_DELAY_MS) + spinnerShown = true + updateOpenLoadState(cellNode.uuid) { OpenLoadState.Loading() } + } + + download( + assetId = cellNode.uuid, + outFilePath = filePath, + remoteFilePath = cellNode.remotePath, + assetSize = cellNode.size ?: 0, + ) { progress -> + // Dispatch to main thread. Guard with generation check so stale callbacks + // from a cancelled download never overwrite state belonging to a newer session. + viewModelScope.launch { + if (openDownloadGeneration[cellNode.uuid] == myGeneration) { + val assetSize = cellNode.size ?: 0 + if (assetSize > 0) { + val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) + updateOpenLoadState(cellNode.uuid) { OpenLoadState.Loading(progressValue) } + } + } + } + } + .onSuccess { + showSpinnerJob.cancel() + openDownloadJobs.remove(cellNode.uuid) + openDownloadGeneration[cellNode.uuid] = (openDownloadGeneration[cellNode.uuid] ?: 0L) + 1L + // Cache the local path so future taps open directly + updateDownloadData(cellNode.uuid) { DownloadData(localPath = filePath) } + if (!spinnerShown) { + // Fast path: download completed before 300ms threshold — open instantly + clearOpenLoadState(cellNode.uuid) + openLocalFile(cellNode.copy(localPath = filePath.toString())) + } else { + // Slow path: spinner was already shown — show ready state + snackbar + updateOpenLoadState(cellNode.uuid) { OpenLoadState.Ready(filePath) } + sharedPathCache.emitFileReady(cellNode.copy(localPath = filePath.toString())) + // Auto-dismiss the "Ready" state after 3 seconds + launch { + delay(OPEN_READY_DISMISS_MS) + clearOpenLoadState(cellNode.uuid) + } + } + } + .onFailure { + showSpinnerJob.cancel() + openDownloadJobs.remove(cellNode.uuid) + openDownloadGeneration[cellNode.uuid] = (openDownloadGeneration[cellNode.uuid] ?: 0L) + 1L + updateOpenLoadState(cellNode.uuid) { OpenLoadState.Error } + } + } + openDownloadJobs[cellNode.uuid] = job + } + + internal fun cancelOpenDownload(uuid: String) { + openDownloadJobs.remove(uuid)?.cancel() + // Increment instead of removing so that any already-dispatched viewModelScope.launch + // callbacks from the cancelled download (which escape job cancellation) never match + // the generation of the next download session. + openDownloadGeneration[uuid] = (openDownloadGeneration[uuid] ?: 0L) + 1L + clearOpenLoadState(uuid) + } + private fun onFolderClick(cellNode: CellNodeUi.Folder) { val path = when { isRecycleBin() -> if (currentNodeUuid()?.contains("recycle_bin") == true) { @@ -281,40 +413,48 @@ class CellViewModel @Inject constructor( ) } - private fun downloadNode(node: CellNodeUi) = viewModelScope.launch { - - val (nodeName, nodeRemotePath) = when (node) { - is CellNodeUi.File -> Pair(node.name, node.remotePath) - is CellNodeUi.Folder -> Pair(node.name + ZIP_EXTENSION, node.remotePath + ZIP_EXTENSION) - } + private fun downloadNode(node: CellNodeUi) { + val job = viewModelScope.launch { - if (nodeName.isNullOrBlank()) { - sendAction(ShowError(CellError.OTHER_ERROR)) - return@launch - } + val (nodeName, nodeRemotePath) = when (node) { + is CellNodeUi.File -> Pair(node.name, node.remotePath) + is CellNodeUi.Folder -> Pair(node.name + ZIP_EXTENSION, node.remotePath + ZIP_EXTENSION) + } - val publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val filePath = fileNameResolver.getUniqueFile(publicDir, nodeName).toPath().toOkioPath() - - download( - assetId = node.uuid, - outFilePath = filePath, - remoteFilePath = nodeRemotePath, - assetSize = node.size ?: 0, - ) { progress -> - node.size?.let { - updateDownloadProgress(progress, it, node, filePath) + if (nodeName.isNullOrBlank()) { + sendAction(ShowError(CellError.OTHER_ERROR)) + return@launch } - }.onSuccess { - updateDownloadData(node.uuid) { - DownloadData( - localPath = filePath - ) + + val publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val filePath = fileNameResolver.getUniqueFile(publicDir, nodeName).toPath().toOkioPath() + + download( + assetId = node.uuid, + outFilePath = filePath, + remoteFilePath = nodeRemotePath, + assetSize = node.size ?: 0, + ) { progress -> + node.size?.let { + updateDownloadProgress(progress, it, node, filePath) + } + }.onSuccess { + updateDownloadData(node.uuid) { + DownloadData( + localPath = filePath + ) + } + }.onFailure { + downloadJobs.remove(node.uuid) + _downloadFileSheet.update { null } + sendAction(ShowError(CellError.DOWNLOAD_FAILED)) } - }.onFailure { - _downloadFileSheet.update { null } - sendAction(ShowError(CellError.DOWNLOAD_FAILED)) } + downloadJobs[node.uuid] = job + } + + internal fun cancelDownload(uuid: String) { + cancelOpenDownload(uuid) } private fun updateDownloadProgress(progress: Long, it: Long, node: CellNodeUi, path: Path) = viewModelScope.launch { @@ -395,6 +535,7 @@ class CellViewModel @Inject constructor( is CellFileActionsMenu.Download -> downloadNode(result.node) is CellFileActionsMenu.Edit -> editNode(result.node.uuid) is CellFileActionsMenu.Share -> shareFile(result.node) + is CellFileActionsMenu.CancelLoading -> cancelDownload(result.node.uuid) } } } @@ -495,13 +636,37 @@ class CellViewModel @Inject constructor( } private fun updateDownloadData(uuid: String, block: () -> DownloadData) { + val data = block() + // Persist to the process-scoped cache so other CellViewModel instances (e.g. AllFiles ↔ Search) + // can see locally-available files without re-downloading. + data.localPath?.toString()?.let { sharedPathCache.put(uuid, it) } downloadDataFlow.update { map -> val progressMap = map.toMutableMap() - progressMap[uuid] = block() + progressMap[uuid] = data progressMap.toImmutableMap() } } + private fun updateOpenLoadState(uuid: String, block: () -> OpenLoadState) { + openLoadStateFlow.update { map -> + map.toMutableMap().apply { put(uuid, block()) }.toImmutableMap() + } + } + + private fun clearOpenLoadState(uuid: String) { + openLoadStateFlow.update { map -> + map.toMutableMap().apply { remove(uuid) }.toImmutableMap() + } + } + + internal fun clearAllErrorStates() { + openLoadStateFlow.update { map -> + map.toMutableMap().apply { + entries.removeAll { it.value is OpenLoadState.Error } + }.toImmutableMap() + } + } + private fun loadWireCellConfig() = viewModelScope.launch { val config = getWireCellsConfig() isCollaboraEnabled = config?.collabora != CollaboraEdition.NO @@ -529,6 +694,8 @@ sealed interface CellViewIntent { data class OnNodeRestoreConfirmed(val node: CellNodeUi) : CellViewIntent data class OnParentFolderRestoreConfirmed(val node: CellNodeUi) : CellViewIntent data object OnDownloadMenuClosed : CellViewIntent + data class OnCancelDownload(val uuid: String) : CellViewIntent + data object OnScreenLeave : CellViewIntent } sealed interface CellViewAction @@ -566,4 +733,12 @@ private data class DownloadData( val localPath: Path? = null, ) +internal sealed interface OpenLoadState { + data class Loading(val progress: Float = 0f) : OpenLoadState + data class Ready(val localPath: Path) : OpenLoadState + data object Error : OpenLoadState +} + private const val RESTORE_DELAY_MS = 300L +private const val OPEN_SPINNER_DELAY_MS = 300L +private const val OPEN_READY_DISMISS_MS = 3_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 3bee91ca280..d66456e10ee 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 @@ -86,6 +86,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf /** @@ -119,7 +120,8 @@ fun ConversationFilesScreen( breadcrumbs = viewModel.breadcrumbs(), sendIntent = viewModel::sendIntent, onRefresh = viewModel::onPullToRefresh, - retryEditNodeError = viewModel::editNode + retryEditNodeError = viewModel::editNode, + fileReadyFlow = viewModel.fileReadyFlow, ) LaunchedEffect(Unit) { @@ -148,6 +150,7 @@ fun ConversationFilesScreenContent( isRecycleBin: Boolean = false, isRestoreInProgress: Boolean = false, breadcrumbs: Array? = emptyArray(), + fileReadyFlow: Flow = emptyFlow(), ) { val sharedScope = LocalSharedTransitionScope.current @@ -353,7 +356,8 @@ fun ConversationFilesScreenContent( }, retryEditNodeError = { retryEditNodeError(it) }, isRefreshing = isRefreshing, - onRefresh = onRefresh + onRefresh = onRefresh, + fileReadyFlow = fileReadyFlow, ) } } 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 e661653b16d..5dca41b533d 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 @@ -38,6 +38,10 @@ sealed class CellNodeUi { abstract val size: Long? abstract val downloadProgress: Float? abstract val tags: List + abstract val isOpenLoading: Boolean + abstract val isOpenReady: Boolean + abstract val isOpenError: Boolean + abstract val openLoadProgress: Float? data class Folder( override val name: String?, @@ -52,6 +56,10 @@ sealed class CellNodeUi { override val size: Long?, override val downloadProgress: Float? = null, override val tags: List = emptyList(), + override val isOpenLoading: Boolean = false, + override val isOpenReady: Boolean = false, + override val isOpenError: Boolean = false, + override val openLoadProgress: Float? = null, ) : CellNodeUi() data class File( @@ -74,6 +82,10 @@ sealed class CellNodeUi { val previewUrl: String? = null, override val tags: List = emptyList(), val isEditSupported: Boolean = false, + override val isOpenLoading: Boolean = false, + override val isOpenReady: Boolean = false, + override val isOpenError: Boolean = false, + override val openLoadProgress: Float? = null, ) : CellNodeUi() } 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 9db85bcdf9b..c9ed9ab42d7 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 @@ -34,5 +34,6 @@ enum class NodeBottomSheetAction( RESTORE(R.string.restore_label, R.drawable.ic_restore), DELETE(R.string.delete_label, com.wire.android.ui.common.R.drawable.ic_delete, true), DELETE_PERMANENTLY(R.string.delete_permanently, com.wire.android.ui.common.R.drawable.ic_delete, true), - VERSION_HISTORY(R.string.see_version_history_bottom_sheet, R.drawable.ic_version_history) + VERSION_HISTORY(R.string.see_version_history_bottom_sheet, R.drawable.ic_version_history), + CANCEL_LOADING(R.string.cancel_loading_label, com.wire.android.ui.common.R.drawable.ic_close, true) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index d7ab97de41c..7d9417fbc3f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -141,7 +141,8 @@ fun RecycleBinScreen( showRenameScreen = { }, showAddRemoveTagsScreen = {}, isRefreshing = cellViewModel.isPullToRefresh.collectAsState(), - onRefresh = { cellViewModel.onPullToRefresh() } + onRefresh = { cellViewModel.onPullToRefresh() }, + fileReadyFlow = cellViewModel.fileReadyFlow, ) } } 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 4ac5a3ac56f..81b37f789fd 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 @@ -236,7 +236,10 @@ fun SearchScreen( }, retryEditNodeError = { cellViewModel.editNode(it) }, isRefreshing = remember { mutableStateOf(false) }, - onRefresh = { } + onRefresh = { }, + externalOpenLoadStates = cellViewModel.openLoadStates, + cachedLocalPaths = cellViewModel.cachedLocalPaths, + fileReadyFlow = cellViewModel.fileReadyFlow, ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt index b576a3836df..8d6dfa14980 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt @@ -144,6 +144,8 @@ class FileHelper @Inject constructor( } } + fun getCacheDir(): File = context.cacheDir + private fun Context.getProviderAuthority() = "$packageName.provider" private fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index c031ab84973..182b07dad0d 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -76,6 +76,12 @@ Create Folder Folder Name Loading files… + Tap to cancel loading + Unable to load, retry + Ready to open + Cancel loading… + \"%1$s\" ready to open + Open Unable to create folder. Please try again Move to folder Move Here 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 419fd99b1b0..287610a6474 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 @@ -50,8 +50,10 @@ import io.mockk.impl.annotations.MockK import io.mockk.mockkObject import io.mockk.mockkStatic import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -148,9 +150,32 @@ class CellViewModelTest { } @Test - fun `given view model when file clicked and local file is not present and url is not openable then download dialog shown`() = runTest { - val (_, viewModel) = Arrangement() + fun `given view model when file clicked and local file is not present and url is not openable then download starts immediately`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withLoadSuccess() + .withDownloadSuccess() + .arrange() + + val testFile = testFiles[0].copy( + localPath = null, + contentUrl = null + ).toUiModel() + + viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) + // Advance time so download coroutine can complete + advanceUntilIdle() + + // No download dialog shown — download starts silently + assertEquals(null, viewModel.downloadFileSheet.value) + // Download use case was called + coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any()) } + } + + @Test + fun `given view model when file tap triggers slow download then file ready event is emitted to shared cache`() = runTest { + val (arrangement, viewModel) = Arrangement() .withLoadSuccess() + .withSlowDownloadSuccess() .arrange() val testFile = testFiles[0].copy( @@ -158,15 +183,33 @@ class CellViewModelTest { contentUrl = null ).toUiModel() - viewModel.downloadFileSheet.test { + arrangement.sharedPathCache.fileReadyEvents.test { viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) + advanceUntilIdle() - with(expectMostRecentItem()) { - assertEquals(testFile, this) - } + val file = awaitItem() + assertEquals(testFile.uuid, file.uuid) } } + @Test + fun `given cached local path in shared cache when file clicked then file is opened without re-downloading`() = runTest { + val cachedPath = "/cache/fileName" + val (arrangement, viewModel) = Arrangement() + .withLoadSuccess() + .withCachedPath(testFiles[0].uuid, cachedPath) + .arrange() + + val testFile = testFiles[0].copy(localPath = null, contentUrl = null).toUiModel() + + viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) + advanceUntilIdle() + + // Should open the cached file, not trigger a new download + coVerify(exactly = 0) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } + } + @Test fun `given view model when download confirmed then file is downloaded`() = runTest { val (arrangement, viewModel) = Arrangement() @@ -272,6 +315,8 @@ class CellViewModelTest { @MockK lateinit var fileNameResolver: FileNameResolver + val sharedPathCache = CellFileLocalPathCache() + @MockK lateinit var getEditorUrlUseCase: GetEditorUrlUseCase @@ -329,10 +374,23 @@ class CellViewModelTest { ) } + fun withCachedPath(uuid: String, path: String) = apply { + sharedPathCache.put(uuid, path) + mockkStatic(java.io.File::class) + every { java.io.File(path).exists() } returns true + } + fun withDownloadSuccess() = apply { coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any()) } returns Unit.right() } + fun withSlowDownloadSuccess() = apply { + coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any()) } coAnswers { + delay(500) // Simulate download taking 500ms (longer than the 300ms threshold) + Unit.right() + } + } + fun withDownloadFailure() = apply { coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any()) } returns CoreFailure.Unknown(IllegalStateException("Test")).left() @@ -350,6 +408,7 @@ class CellViewModelTest { mockkStatic(Environment::class) + every { fileHelper.getCacheDir() } returns File("") every { fileNameResolver.getUniqueFile(any(), any()) } returns File("") coEvery { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) } returns File("") @@ -368,6 +427,7 @@ class CellViewModelTest { getEditorUrl = getEditorUrlUseCase, cellFileActionsMenu = cellFileActionsMenu, getWireCellsConfig = getWireCellsConfig, + sharedPathCache = sharedPathCache, ) } } From 43c0418b37538bdc0f9bd9902c7223fed8a86e5d Mon Sep 17 00:00:00 2001 From: ohassine Date: Mon, 27 Apr 2026 18:24:06 +0200 Subject: [PATCH 02/44] feat: remove download to external storage option --- .../feature/cells/ui/AllFilesScreen.kt | 1 - .../feature/cells/ui/CellFileActionsMenu.kt | 4 - .../feature/cells/ui/CellScreenContent.kt | 10 --- .../android/feature/cells/ui/CellViewModel.kt | 82 ------------------- .../cells/ui/ConversationFilesScreen.kt | 5 -- ...rsationFilesWithSlideInTransitionScreen.kt | 1 - .../cells/ui/model/NodeBottomSheetAction.kt | 1 - .../cells/ui/recyclebin/RecycleBinScreen.kt | 1 - .../feature/cells/ui/search/SearchScreen.kt | 1 - .../cells/src/main/res/values-de/strings.xml | 1 - .../cells/src/main/res/values-ru/strings.xml | 1 - .../cells/src/main/res/values-tr/strings.xml | 1 - .../cells/src/main/res/values/strings.xml | 1 - .../cells/ui/CellFileActionsMenuTest.kt | 30 +------ .../feature/cells/ui/CellViewModelTest.kt | 43 ---------- 15 files changed, 2 insertions(+), 181 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 2648bb15af3..7c5a349e4d0 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 @@ -72,7 +72,6 @@ fun AllFilesScreen( pagingListItems = pagingListItems, sendIntent = { viewModel.sendIntent(it) }, openFolder = { _, _, _ -> }, - downloadFileState = viewModel.downloadFileSheet, menuState = viewModel.menu, isAllFiles = true, isRestoreInProgress = viewModel.isRestoreInProgress.collectAsState().value, 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 bd382c43cd0..72b8cc13ea2 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 @@ -53,7 +53,6 @@ class CellFileActionsMenu @Inject constructor( add(NodeBottomSheetAction.SHARE) } add(NodeBottomSheetAction.PUBLIC_LINK) - add(NodeBottomSheetAction.DOWNLOAD) } } } @@ -67,7 +66,6 @@ class CellFileActionsMenu @Inject constructor( add(NodeBottomSheetAction.SHARE) } add(NodeBottomSheetAction.PUBLIC_LINK) - add(NodeBottomSheetAction.DOWNLOAD) if (isCollaboraEnabled && featureFlags.collaboraIntegration && cellNode.isEditSupported()) { add(NodeBottomSheetAction.EDIT) @@ -93,7 +91,6 @@ class CellFileActionsMenu @Inject constructor( internal sealed interface MenuActionResult internal data class Action(val action: CellViewAction) : MenuActionResult internal data class Share(val node: CellNodeUi.File) : MenuActionResult - internal data class Download(val node: CellNodeUi) : MenuActionResult internal data class Edit(val node: CellNodeUi) : MenuActionResult internal data class CancelLoading(val node: CellNodeUi) : MenuActionResult @@ -136,7 +133,6 @@ class CellFileActionsMenu @Inject constructor( NodeBottomSheetAction.PUBLIC_LINK -> Action(ShowPublicLinkScreen(node)) NodeBottomSheetAction.RENAME -> Action(ShowRenameScreen(node)) NodeBottomSheetAction.DELETE -> Action(ShowDeleteConfirmation(node = node, isPermanentDelete = false)) - NodeBottomSheetAction.DOWNLOAD -> Download(node) NodeBottomSheetAction.EDIT -> Edit(node) NodeBottomSheetAction.VERSION_HISTORY -> Action(ShowVersionHistoryScreen(node.uuid, node.name ?: "")) NodeBottomSheetAction.CANCEL_LOADING -> CancelLoading(node) 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 769f1bf2e82..86ca5074ada 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 @@ -59,7 +59,6 @@ import com.wire.android.feature.cells.ui.common.LoadingScreen import com.wire.android.feature.cells.ui.common.WireCellErrorDialog import com.wire.android.feature.cells.ui.dialog.DeleteConfirmationDialog import com.wire.android.feature.cells.ui.dialog.NodeActionsBottomSheet -import com.wire.android.feature.cells.ui.download.DownloadFileBottomSheet import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData @@ -86,7 +85,6 @@ internal fun CellScreenContent( pagingListItems: LazyPagingItems, sendIntent: (CellViewIntent) -> Unit, openFolder: (String, String, String?) -> Unit, - downloadFileState: StateFlow, menuState: Flow, showPublicLinkScreen: (PublicLinkScreenData) -> Unit, showRenameScreen: (CellNodeUi) -> Unit, @@ -122,7 +120,6 @@ internal fun CellScreenContent( var editNodeError by remember { mutableStateOf(null) } var menu by remember { mutableStateOf(null) } - val downloadFile by downloadFileState.collectAsState() val externalLoadStates by externalOpenLoadStates.collectAsState() val cachedPaths by cachedLocalPaths.collectAsState() @@ -180,13 +177,6 @@ internal fun CellScreenContent( ) } - downloadFile?.let { file -> - DownloadFileBottomSheet( - file = file, - onDismiss = { sendIntent(CellViewIntent.OnDownloadMenuClosed) }, - onDownload = { sendIntent(CellViewIntent.OnFileDownloadConfirmed(file)) }, - ) - } deleteConfirmation?.let { (node, isPermanentDelete) -> DeleteConfirmationDialog( 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 dafe65e3ac9..f4320d9cea8 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 @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui -import android.os.Environment import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.LoadState @@ -107,9 +106,6 @@ class CellViewModel @Inject constructor( private val _menu: MutableSharedFlow = MutableSharedFlow() internal val menu = _menu.asSharedFlow() - // Show bottom sheet with download progress. - private val _downloadFileSheet: MutableStateFlow = MutableStateFlow(null) - internal val downloadFileSheet = _downloadFileSheet.asStateFlow() private val _isRestoreInProgress = MutableStateFlow(false) val isRestoreInProgress = _isRestoreInProgress.asStateFlow() @@ -120,8 +116,6 @@ class CellViewModel @Inject constructor( // Download progress value for each file being downloaded. private val downloadDataFlow = MutableStateFlow>(emptyMap()) - // Active storage-download jobs keyed by node UUID. - private val downloadJobs = mutableMapOf() // Active open-download jobs keyed by node UUID, used to support cancellation of loading. private val openDownloadJobs = mutableMapOf() @@ -272,10 +266,8 @@ class CellViewModel @Inject constructor( is CellViewIntent.OnItemMenuClick -> onItemMenuClick(intent.cellNode) is CellViewIntent.OnMenuItemActionSelected -> onMenuItemAction(intent.node, intent.action) - is CellViewIntent.OnFileDownloadConfirmed -> downloadNode(intent.file) is CellViewIntent.OnNodeDeleteConfirmed -> deleteFile(intent.node) is CellViewIntent.OnNodeRestoreConfirmed -> restoreNodeFromRecycleBin(intent.node) - is CellViewIntent.OnDownloadMenuClosed -> onDownloadMenuClosed() is CellViewIntent.OnParentFolderRestoreConfirmed -> restoreNodeFromRecycleBin(intent.node) is CellViewIntent.OnCancelDownload -> cancelDownload(intent.uuid) is CellViewIntent.OnScreenLeave -> clearAllErrorStates() @@ -413,75 +405,10 @@ class CellViewModel @Inject constructor( ) } - private fun downloadNode(node: CellNodeUi) { - val job = viewModelScope.launch { - - val (nodeName, nodeRemotePath) = when (node) { - is CellNodeUi.File -> Pair(node.name, node.remotePath) - is CellNodeUi.Folder -> Pair(node.name + ZIP_EXTENSION, node.remotePath + ZIP_EXTENSION) - } - - if (nodeName.isNullOrBlank()) { - sendAction(ShowError(CellError.OTHER_ERROR)) - return@launch - } - - val publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val filePath = fileNameResolver.getUniqueFile(publicDir, nodeName).toPath().toOkioPath() - - download( - assetId = node.uuid, - outFilePath = filePath, - remoteFilePath = nodeRemotePath, - assetSize = node.size ?: 0, - ) { progress -> - node.size?.let { - updateDownloadProgress(progress, it, node, filePath) - } - }.onSuccess { - updateDownloadData(node.uuid) { - DownloadData( - localPath = filePath - ) - } - }.onFailure { - downloadJobs.remove(node.uuid) - _downloadFileSheet.update { null } - sendAction(ShowError(CellError.DOWNLOAD_FAILED)) - } - } - downloadJobs[node.uuid] = job - } - internal fun cancelDownload(uuid: String) { cancelOpenDownload(uuid) } - private fun updateDownloadProgress(progress: Long, it: Long, node: CellNodeUi, path: Path) = viewModelScope.launch { - - val value = progress.toFloat() / it - - if (value < 1) { - updateDownloadData(node.uuid) { - DownloadData( - progress = value - ) - } - } else { - updateDownloadData(node.uuid) { - DownloadData( - localPath = path - ) - } - - if (_downloadFileSheet.value?.uuid == node.uuid) { - _downloadFileSheet.update { null } - if (node is CellNodeUi.File) { - openLocalFile(node.copy(localPath = path.toString())) - } - } - } - } private fun openFileContentUrl(file: CellNodeUi.File) { file.contentUrl?.let { url -> @@ -532,7 +459,6 @@ class CellViewModel @Inject constructor( ) { result -> when (result) { is CellFileActionsMenu.Action -> sendAction(result.action) - is CellFileActionsMenu.Download -> downloadNode(result.node) is CellFileActionsMenu.Edit -> editNode(result.node.uuid) is CellFileActionsMenu.Share -> shareFile(result.node) is CellFileActionsMenu.CancelLoading -> cancelDownload(result.node.uuid) @@ -631,9 +557,6 @@ class CellViewModel @Inject constructor( private fun removeFromListUi(node: CellNodeUi) = removedItemsFlow.update { it + node.uuid } private fun addToListUi(node: CellNodeUi) = removedItemsFlow.update { it - node.uuid } fun clearRemovedItems() = removedItemsFlow.update { emptyList() } - private fun onDownloadMenuClosed() { - _downloadFileSheet.update { null } - } private fun updateDownloadData(uuid: String, block: () -> DownloadData) { val data = block() @@ -673,8 +596,6 @@ class CellViewModel @Inject constructor( } companion object { - const val ZIP_EXTENSION = ".zip" - private val emptyData: PagingData = PagingData.empty( LoadStates( refresh = LoadState.NotLoading(true), @@ -689,11 +610,9 @@ sealed interface CellViewIntent { data class OnItemClick(val file: CellNodeUi) : CellViewIntent data class OnItemMenuClick(val cellNode: CellNodeUi) : CellViewIntent data class OnMenuItemActionSelected(val node: CellNodeUi, val action: NodeBottomSheetAction) : CellViewIntent - data class OnFileDownloadConfirmed(val file: CellNodeUi.File) : CellViewIntent data class OnNodeDeleteConfirmed(val node: CellNodeUi) : CellViewIntent data class OnNodeRestoreConfirmed(val node: CellNodeUi) : CellViewIntent data class OnParentFolderRestoreConfirmed(val node: CellNodeUi) : CellViewIntent - data object OnDownloadMenuClosed : CellViewIntent data class OnCancelDownload(val uuid: String) : CellViewIntent data object OnScreenLeave : CellViewIntent } @@ -718,7 +637,6 @@ internal data class OpenFolder(val path: String, val title: String, val parentFo internal data class ShowEditErrorDialog(val nodeUuid: String) : CellViewAction internal enum class CellError(val message: Int) { - DOWNLOAD_FAILED(R.string.cell_files_download_failure_message), NO_APP_FOUND(R.string.no_app_found), OTHER_ERROR(R.string.action_failed) } 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 d66456e10ee..72792f29f95 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 @@ -85,7 +85,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -111,7 +110,6 @@ fun ConversationFilesScreen( isRecycleBin = viewModel.isRecycleBin(), actions = viewModel.actions, pagingListItems = viewModel.nodesFlow.collectAsLazyPagingItems(), - downloadFileSheet = viewModel.downloadFileSheet, menu = viewModel.menu, isSearchResult = false, isRestoreInProgress = viewModel.isRestoreInProgress.collectAsState().value, @@ -138,7 +136,6 @@ fun ConversationFilesScreenContent( isSearchResult: Boolean, actions: Flow, pagingListItems: LazyPagingItems, - downloadFileSheet: StateFlow, menu: SharedFlow, sendIntent: (CellViewIntent) -> Unit, isRefreshing: State, @@ -288,7 +285,6 @@ fun ConversationFilesScreenContent( actionsFlow = actions, pagingListItems = pagingListItems, sendIntent = sendIntent, - downloadFileState = downloadFileSheet, menuState = menu, isSearchResult = isSearchResult, isRestoreInProgress = isRestoreInProgress, @@ -412,7 +408,6 @@ fun PreviewConversationFilesScreen() { ) ) ).collectAsLazyPagingItems(), - downloadFileSheet = MutableStateFlow(null), menu = MutableSharedFlow(replay = 0), sendIntent = {}, screenTitle = "Android", diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt index c800ba5b42d..5e793400523 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt @@ -66,7 +66,6 @@ fun ConversationFilesWithSlideInTransitionScreen( isRecycleBin = viewModel.isRecycleBin(), actions = viewModel.actions, pagingListItems = viewModel.nodesFlow.collectAsLazyPagingItems(), - downloadFileSheet = viewModel.downloadFileSheet, menu = viewModel.menu, isRestoreInProgress = viewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, 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 c9ed9ab42d7..d1ff2e6cfc0 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 @@ -29,7 +29,6 @@ enum class NodeBottomSheetAction( ADD_REMOVE_TAGS(R.string.add_remove_tags_label, R.drawable.ic_tags), MOVE(R.string.move_label, R.drawable.ic_folder), RENAME(R.string.rename_label, R.drawable.ic_rename), - DOWNLOAD(R.string.download_label, R.drawable.ic_save), EDIT(R.string.edit_label, com.wire.android.ui.common.R.drawable.ic_edit), RESTORE(R.string.restore_label, R.drawable.ic_restore), DELETE(R.string.delete_label, com.wire.android.ui.common.R.drawable.ic_delete, true), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index 7d9417fbc3f..132c9ecd615 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -95,7 +95,6 @@ fun RecycleBinScreen( actionsFlow = cellViewModel.actions, pagingListItems = cellViewModel.nodesFlow.collectAsLazyPagingItems(), sendIntent = { cellViewModel.sendIntent(it) }, - downloadFileState = cellViewModel.downloadFileSheet, menuState = cellViewModel.menu, isRecycleBin = true, isRestoreInProgress = cellViewModel.isRestoreInProgress.collectAsState().value, 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 81b37f789fd..56125d1d756 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 @@ -183,7 +183,6 @@ fun SearchScreen( actionsFlow = cellViewModel.actions, pagingListItems = lazyItems, sendIntent = { cellViewModel.sendIntent(it) }, - downloadFileState = cellViewModel.downloadFileSheet, menuState = cellViewModel.menu, isSearchResult = true, isRestoreInProgress = cellViewModel.isRestoreInProgress.collectAsState().value, diff --git a/features/cells/src/main/res/values-de/strings.xml b/features/cells/src/main/res/values-de/strings.xml index 1b762c6cff9..c8d7991432b 100644 --- a/features/cells/src/main/res/values-de/strings.xml +++ b/features/cells/src/main/res/values-de/strings.xml @@ -33,7 +33,6 @@ Wiederherstellen Tags hinzufügen oder entfernen In Ordner verschieben - Herunterladen Datei bearbeiten Link kopieren Link teilen diff --git a/features/cells/src/main/res/values-ru/strings.xml b/features/cells/src/main/res/values-ru/strings.xml index 616294acbab..2582bc99384 100644 --- a/features/cells/src/main/res/values-ru/strings.xml +++ b/features/cells/src/main/res/values-ru/strings.xml @@ -35,7 +35,6 @@ Восстановить Добавить или удалить теги Переместить в папку - Скачать Изменить файл Скопировать ссылку Поделиться ссылкой diff --git a/features/cells/src/main/res/values-tr/strings.xml b/features/cells/src/main/res/values-tr/strings.xml index 4218ff7c33d..deedf0f6077 100644 --- a/features/cells/src/main/res/values-tr/strings.xml +++ b/features/cells/src/main/res/values-tr/strings.xml @@ -23,7 +23,6 @@ Sil Kalıcı olarak sil Geri yükle - İndir Bağlantıyı kopyala Bağlantıyı paylaş Bağlantı panoya kopyalandı diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 2f707a8c043..29c5cce3a2f 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -37,7 +37,6 @@ Restore Add or Remove Tags Move to folder - Download Edit file Copy Link Share Link diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index 68fe3c39097..f3002f1b406 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -28,7 +28,7 @@ import org.junit.jupiter.api.Test class CellFileActionsMenuTest { @Test - fun `GIVEN Search context AND File node with local file available WHEN onItemMenuClick called THEN emits SHARE PUBLIC_LINK DOWNLOAD actions`() = + fun `GIVEN Search context AND File node with local file available WHEN onItemMenuClick called THEN emits SHARE PUBLIC_LINK actions`() = runTest { // WHEN @@ -42,7 +42,6 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, - NodeBottomSheetAction.DOWNLOAD ), items ) @@ -84,7 +83,6 @@ class CellFileActionsMenuTest { assertEquals( listOf( NodeBottomSheetAction.PUBLIC_LINK, - NodeBottomSheetAction.DOWNLOAD, NodeBottomSheetAction.ADD_REMOVE_TAGS, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, @@ -95,7 +93,7 @@ class CellFileActionsMenuTest { } @Test - fun `GIVEN AllFiles context AND File node with local file available WHEN onItemMenuClick called THEN emits SHARE PUBLIC_LINK DOWNLOAD actions`() = + fun `GIVEN AllFiles context AND File node with local file available WHEN onItemMenuClick called THEN emits SHARE PUBLIC_LINK actions`() = runTest { // WHEN @@ -108,7 +106,6 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, - NodeBottomSheetAction.DOWNLOAD ), items ) @@ -131,7 +128,6 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, - NodeBottomSheetAction.DOWNLOAD, NodeBottomSheetAction.EDIT, NodeBottomSheetAction.VERSION_HISTORY, NodeBottomSheetAction.ADD_REMOVE_TAGS, @@ -159,7 +155,6 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, - NodeBottomSheetAction.DOWNLOAD, NodeBottomSheetAction.ADD_REMOVE_TAGS, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, @@ -397,27 +392,6 @@ class CellFileActionsMenuTest { ) } - @Test - fun `GIVEN file menu WHEN download option selected called THEN correct action emitted`() = - runTest { - // GIVEN - val menu = actionsMenu() - - // WHEN - menu.onMenuItemAction( - conversationId = null, - parentFolderUuid = null, - node = fileNode, - action = NodeBottomSheetAction.DOWNLOAD, - onResult = { result -> - // THEN - assertEquals( - CellFileActionsMenu.Download(fileNode), - result - ) - } - ) - } @Test fun `GIVEN file menu WHEN edit option selected called THEN correct action emitted`() = 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 287610a6474..7ce312ec83d 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 @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui -import android.os.Environment import androidx.lifecycle.SavedStateHandle import androidx.paging.LoadState import androidx.paging.LoadStates @@ -38,8 +37,6 @@ 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.download.DownloadCellFileUseCase -import com.wire.kalium.common.error.CoreFailure -import com.wire.kalium.common.functional.left import com.wire.kalium.common.functional.right import com.wire.kalium.logic.data.asset.KaliumFileSystem import io.mockk.MockKAnnotations @@ -61,7 +58,6 @@ import okio.Path.Companion.toPath import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -165,8 +161,6 @@ class CellViewModelTest { // Advance time so download coroutine can complete advanceUntilIdle() - // No download dialog shown — download starts silently - assertEquals(null, viewModel.downloadFileSheet.value) // Download use case was called coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any()) } } @@ -210,36 +204,6 @@ class CellViewModelTest { coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } } - @Test - fun `given view model when download confirmed then file is downloaded`() = runTest { - val (arrangement, viewModel) = Arrangement() - .withLoadSuccess() - .withDownloadSuccess() - .arrange() - - viewModel.sendIntent(CellViewIntent.OnFileDownloadConfirmed(testFiles[0].toUiModel())) - - coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any()) } - } - - @Test - fun `given view model when download confirmed and download fails then error is emitted`() = runTest { - val (arrangement, viewModel) = Arrangement() - .withLoadSuccess() - .withDownloadFailure() - .arrange() - - viewModel.actions.test { - - viewModel.sendIntent(CellViewIntent.OnFileDownloadConfirmed(testFiles[0].toUiModel())) - - with(expectMostRecentItem()) { - assertTrue(this is ShowError) - } - } - - coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any()) } - } @Test fun `given view model when delete is confirmed then file is removed from the list`() = runTest { @@ -391,10 +355,6 @@ class CellViewModelTest { } } - fun withDownloadFailure() = apply { - coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any()) } returns - CoreFailure.Unknown(IllegalStateException("Test")).left() - } fun withDeleteSuccess() = apply { coEvery { deleteCellAssetUseCase(any(), any()) } returns Unit.right() @@ -406,11 +366,8 @@ class CellViewModelTest { fun arrange(): Pair { - mockkStatic(Environment::class) - every { fileHelper.getCacheDir() } returns File("") every { fileNameResolver.getUniqueFile(any(), any()) } returns File("") - coEvery { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) } returns File("") coEvery { getWireCellsConfig() } returns null From 9ea06e764142e19d71e59914efaf596bade5b5c7 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 28 Apr 2026 08:34:57 +0100 Subject: [PATCH 03/44] feat: cleanup --- .../feature/cells/ui/CellFilesScreen.kt | 1 + .../feature/cells/ui/CellScreenContent.kt | 1 + .../android/feature/cells/ui/CellViewModel.kt | 45 +++---------------- .../feature/cells/ui/model/OpenLoadState.kt | 26 +++++++++++ 4 files changed, 33 insertions(+), 40 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/model/OpenLoadState.kt 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 b5221892e5f..2b9c5b546e4 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 @@ -46,6 +46,7 @@ import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme 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 86ca5074ada..5c3d9518a10 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 @@ -61,6 +61,7 @@ import com.wire.android.feature.cells.ui.dialog.DeleteConfirmationDialog import com.wire.android.feature.cells.ui.dialog.NodeActionsBottomSheet import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData import com.wire.android.feature.cells.ui.recyclebin.RestoreConfirmationDialog import com.wire.android.feature.cells.ui.recyclebin.RestoreParentFolderConfirmationDialog 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 f4320d9cea8..5dca7d4e4cb 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 @@ -31,6 +31,7 @@ import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.CellNodeUi 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.ui.model.toUiModel @@ -106,17 +107,12 @@ class CellViewModel @Inject constructor( private val _menu: MutableSharedFlow = MutableSharedFlow() internal val menu = _menu.asSharedFlow() - private val _isRestoreInProgress = MutableStateFlow(false) val isRestoreInProgress = _isRestoreInProgress.asStateFlow() private val _isDeleteInProgress = MutableStateFlow(false) val isDeleteInProgress = _isDeleteInProgress.asStateFlow() - // Download progress value for each file being downloaded. - private val downloadDataFlow = MutableStateFlow>(emptyMap()) - - // Active open-download jobs keyed by node UUID, used to support cancellation of loading. private val openDownloadJobs = mutableMapOf() @@ -196,10 +192,9 @@ class CellViewModel @Inject constructor( ), ).cachedIn(viewModelScope), removedItemsFlow, - downloadDataFlow, openLoadStateFlow, _cachedLocalPaths, - ) { pagingData, removedItems, downloadData, openLoadStates, cachedPaths -> + ) { pagingData, removedItems, openLoadStates, cachedPaths -> var emittedRefreshDone = false pagingData @@ -217,14 +212,10 @@ class CellViewModel @Inject constructor( val openLoadState = openLoadStates[node.uuid] when (node) { - is Node.Folder -> node.toUiModel().copy( - downloadProgress = downloadData[node.uuid]?.progress - ) + is Node.Folder -> node.toUiModel() is Node.File -> node.toUiModel().copy( - downloadProgress = downloadData[node.uuid]?.progress, - localPath = downloadData[node.uuid]?.localPath?.toString() - ?: (openLoadState as? OpenLoadState.Ready)?.localPath?.toString() + localPath = openLoadState?.let { (it as? OpenLoadState.Ready)?.localPath?.toString() } ?: cachedPaths[node.uuid] ?: node.localPath, isOpenLoading = openLoadState is OpenLoadState.Loading, @@ -339,7 +330,7 @@ class CellViewModel @Inject constructor( openDownloadJobs.remove(cellNode.uuid) openDownloadGeneration[cellNode.uuid] = (openDownloadGeneration[cellNode.uuid] ?: 0L) + 1L // Cache the local path so future taps open directly - updateDownloadData(cellNode.uuid) { DownloadData(localPath = filePath) } + sharedPathCache.put(cellNode.uuid, filePath.toString()) if (!spinnerShown) { // Fast path: download completed before 300ms threshold — open instantly clearOpenLoadState(cellNode.uuid) @@ -367,9 +358,6 @@ class CellViewModel @Inject constructor( internal fun cancelOpenDownload(uuid: String) { openDownloadJobs.remove(uuid)?.cancel() - // Increment instead of removing so that any already-dispatched viewModelScope.launch - // callbacks from the cancelled download (which escape job cancellation) never match - // the generation of the next download session. openDownloadGeneration[uuid] = (openDownloadGeneration[uuid] ?: 0L) + 1L clearOpenLoadState(uuid) } @@ -558,18 +546,6 @@ class CellViewModel @Inject constructor( private fun addToListUi(node: CellNodeUi) = removedItemsFlow.update { it - node.uuid } fun clearRemovedItems() = removedItemsFlow.update { emptyList() } - private fun updateDownloadData(uuid: String, block: () -> DownloadData) { - val data = block() - // Persist to the process-scoped cache so other CellViewModel instances (e.g. AllFiles ↔ Search) - // can see locally-available files without re-downloading. - data.localPath?.toString()?.let { sharedPathCache.put(uuid, it) } - downloadDataFlow.update { map -> - val progressMap = map.toMutableMap() - progressMap[uuid] = data - progressMap.toImmutableMap() - } - } - private fun updateOpenLoadState(uuid: String, block: () -> OpenLoadState) { openLoadStateFlow.update { map -> map.toMutableMap().apply { put(uuid, block()) }.toImmutableMap() @@ -646,17 +622,6 @@ data class MenuOptions( val actions: List ) -private data class DownloadData( - val progress: Float? = null, - val localPath: Path? = null, -) - -internal sealed interface OpenLoadState { - data class Loading(val progress: Float = 0f) : OpenLoadState - data class Ready(val localPath: Path) : OpenLoadState - data object Error : OpenLoadState -} - private const val RESTORE_DELAY_MS = 300L private const val OPEN_SPINNER_DELAY_MS = 300L private const val OPEN_READY_DISMISS_MS = 3_000L diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/OpenLoadState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/OpenLoadState.kt new file mode 100644 index 00000000000..4bd58e31daf --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/OpenLoadState.kt @@ -0,0 +1,26 @@ +/* + * 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.model + +import okio.Path + +internal sealed interface OpenLoadState { + data class Loading(val progress: Float = 0f) : OpenLoadState + data class Ready(val localPath: Path) : OpenLoadState + data object Error : OpenLoadState +} From fa81683c19c5229f7cc1cb724d7c928063d0e53f Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 28 Apr 2026 10:37:22 +0100 Subject: [PATCH 04/44] feat: detekt --- .../java/com/wire/android/feature/cells/ui/CellListItem.kt | 2 +- .../com/wire/android/feature/cells/ui/CellScreenContent.kt | 1 - .../java/com/wire/android/feature/cells/ui/CellViewModel.kt | 2 -- .../wire/android/feature/cells/ui/CellFileActionsMenuTest.kt | 3 +-- .../com/wire/android/feature/cells/ui/CellViewModelTest.kt | 2 -- 5 files changed, 2 insertions(+), 8 deletions(-) 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 ce23bf3992d..bd4c45b03ec 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 @@ -82,6 +82,7 @@ import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.common.R as commonR +@Suppress("CyclomaticComplexMethod") @Composable internal fun CellListItem( cell: CellNodeUi, @@ -270,7 +271,6 @@ internal fun LoadingIconPreview(progress: Float?) { } } - @Composable internal fun ReadyIconPreview() { Box( 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 5c3d9518a10..85c7ad039f0 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 @@ -178,7 +178,6 @@ internal fun CellScreenContent( ) } - deleteConfirmation?.let { (node, isPermanentDelete) -> DeleteConfirmationDialog( itemName = node.name ?: "", 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 5dca7d4e4cb..aa7cb3d68b9 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 @@ -72,7 +72,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import okio.Path import okio.Path.Companion.toOkioPath import okio.Path.Companion.toPath import javax.inject.Inject @@ -397,7 +396,6 @@ class CellViewModel @Inject constructor( cancelOpenDownload(uuid) } - private fun openFileContentUrl(file: CellNodeUi.File) { file.contentUrl?.let { url -> fileHelper.openAssetUrlWithExternalApp( diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index f3002f1b406..8d36f065250 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -391,8 +391,7 @@ class CellFileActionsMenuTest { } ) } - - + @Test fun `GIVEN file menu WHEN edit option selected called THEN correct action emitted`() = runTest { 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 7ce312ec83d..362c231252e 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 @@ -204,7 +204,6 @@ class CellViewModelTest { coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } } - @Test fun `given view model when delete is confirmed then file is removed from the list`() = runTest { val (_, viewModel) = Arrangement() @@ -355,7 +354,6 @@ class CellViewModelTest { } } - fun withDeleteSuccess() = apply { coEvery { deleteCellAssetUseCase(any(), any()) } returns Unit.right() } From ff7f3b5ab2884472aa63bfb781993a34589eb74a Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 28 Apr 2026 14:53:51 +0100 Subject: [PATCH 05/44] feat: cleanup --- .../android/feature/cells/ui/CellViewModel.kt | 86 ++++++++++--------- .../cells/ui/CellFileActionsMenuTest.kt | 2 +- 2 files changed, 47 insertions(+), 41 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 b6f978f76dd..03f48abc9b7 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 @@ -37,11 +37,11 @@ import com.wire.android.feature.cells.ui.model.localFileAvailable 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 +import com.wire.android.feature.cells.ui.search.sort.SortingCriteria +import com.wire.android.feature.cells.ui.search.sort.toKaliumCriteria import com.wire.android.feature.cells.util.FileHelper import com.wire.android.feature.cells.util.FileNameResolver import com.wire.android.ui.common.ActionsViewModel -import com.wire.android.feature.cells.ui.search.sort.SortingCriteria -import com.wire.android.feature.cells.ui.search.sort.toKaliumCriteria import com.wire.kalium.cells.data.FileFilters import com.wire.kalium.cells.data.SortingSpec import com.wire.kalium.cells.domain.model.Node @@ -196,47 +196,53 @@ class CellViewModel @Inject constructor( } refreshTrigger.flatMapLatest { - combine( - getCellFilesPaged( - conversationId = navArgs.conversationId, - fileFilters = FileFilters( - onlyDeleted = navArgs.isRecycleBin ?: false, - ), - ).cachedIn(viewModelScope), - removedItemsFlow, - openLoadStateFlow, - _cachedLocalPaths, - ) { pagingData, removedItems, openLoadStates, cachedPaths -> - var emittedRefreshDone = false - - pagingData - .filter { node: Node -> node.uuid !in removedItems } - .map { node -> - if (!emittedRefreshDone) { - emittedRefreshDone = true - - if (_isPullToRefresh.value) { - _isPullToRefresh.value = false + _defaultSortingCriteria.flatMapLatest { sortingCriteria -> + combine( + getCellFilesPaged( + conversationId = navArgs.conversationId, + fileFilters = FileFilters( + onlyDeleted = navArgs.isRecycleBin ?: false, + ), + sortingSpec = SortingSpec( + criteria = sortingCriteria.toKaliumCriteria(), + descending = sortingCriteria.isDescending, + ), + ).cachedIn(viewModelScope), + removedItemsFlow, + openLoadStateFlow, + _cachedLocalPaths, + ) { pagingData, removedItems, openLoadStates, cachedPaths -> + var emittedRefreshDone = false + + pagingData + .filter { node: Node -> node.uuid !in removedItems } + .map { node -> + if (!emittedRefreshDone) { + emittedRefreshDone = true + + if (_isPullToRefresh.value) { + _isPullToRefresh.value = false + } + + _pagingRefreshDone.tryEmit(Unit) } - _pagingRefreshDone.tryEmit(Unit) - } - - val openLoadState = openLoadStates[node.uuid] - when (node) { - is Node.Folder -> node.toUiModel() - - is Node.File -> node.toUiModel().copy( - localPath = openLoadState?.let { (it as? OpenLoadState.Ready)?.localPath?.toString() } - ?: cachedPaths[node.uuid] - ?: node.localPath, - isOpenLoading = openLoadState is OpenLoadState.Loading, - isOpenReady = openLoadState is OpenLoadState.Ready, - isOpenError = openLoadState is OpenLoadState.Error, - openLoadProgress = (openLoadState as? OpenLoadState.Loading)?.progress, - ) + val openLoadState = openLoadStates[node.uuid] + when (node) { + is Node.Folder -> node.toUiModel() + + is Node.File -> node.toUiModel().copy( + localPath = openLoadState?.let { (it as? OpenLoadState.Ready)?.localPath?.toString() } + ?: cachedPaths[node.uuid] + ?: node.localPath, + isOpenLoading = openLoadState is OpenLoadState.Loading, + isOpenReady = openLoadState is OpenLoadState.Ready, + isOpenError = openLoadState is OpenLoadState.Error, + openLoadProgress = (openLoadState as? OpenLoadState.Loading)?.progress, + ) + } } - } + } } } }.shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index 8d36f065250..0f6ed6efb5b 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -391,7 +391,7 @@ class CellFileActionsMenuTest { } ) } - + @Test fun `GIVEN file menu WHEN edit option selected called THEN correct action emitted`() = runTest { From 8d57c008bbf26d08c2cd05092c9e9147403013e6 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 28 Apr 2026 16:51:04 +0100 Subject: [PATCH 06/44] feat: cleanup --- .../com/wire/android/feature/cells/ui/CellViewModel.kt | 9 ++++++++- .../wire/android/feature/cells/ui/CellViewModelTest.kt | 3 --- 2 files changed, 8 insertions(+), 4 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 03f48abc9b7..1fa060f4551 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 @@ -298,7 +298,14 @@ class CellViewModel @Inject constructor( cellNode.isOpenError -> startOpenDownload(cellNode) cellNode.localFileAvailable() -> openLocalFile(cellNode) cellNode.canOpenWithUrl() -> openFileContentUrl(cellNode) - else -> startOpenDownload(cellNode) + else -> { + val cachedPath = sharedPathCache.paths.value[cellNode.uuid] + if (cachedPath != null) { + openLocalFile(cellNode.copy(localPath = cachedPath)) + } else { + startOpenDownload(cellNode) + } + } } } 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 5eadc7cc727..a083c6e920a 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 @@ -45,7 +45,6 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockkObject -import io.mockk.mockkStatic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOf @@ -339,8 +338,6 @@ class CellViewModelTest { fun withCachedPath(uuid: String, path: String) = apply { sharedPathCache.put(uuid, path) - mockkStatic(java.io.File::class) - every { java.io.File(path).exists() } returns true } fun withDownloadSuccess() = apply { From 5296036d38393826b4c99997f3a5d3cf02e86212 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 28 Apr 2026 17:39:58 +0100 Subject: [PATCH 07/44] feat: cleanup --- .../feature/cells/ui/AllFilesScreen.kt | 1 + .../android/feature/cells/ui/CellListItem.kt | 275 +++++++++--------- .../android/feature/cells/ui/CellViewModel.kt | 7 - .../cells/ui/ConversationFilesScreen.kt | 8 +- ...rsationFilesWithSlideInTransitionScreen.kt | 4 +- .../ui/download/DownloadFileBottomSheet.kt | 220 -------------- .../feature/cells/ui/model/CellNodeUi.kt | 3 - 7 files changed, 141 insertions(+), 377 deletions(-) delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt 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 158bb25a935..09f9a523aea 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 @@ -83,6 +83,7 @@ fun AllFilesScreen( isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRecycleBin = viewModel.isRecycleBin(), isSearchResult = false, + externalOpenLoadStates = viewModel.openLoadStates, cachedLocalPaths = viewModel.cachedLocalPaths, showPublicLinkScreen = { publicLinkScreenData -> navigator.navigate( 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 bd4c45b03ec..8c26120318f 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 @@ -18,14 +18,6 @@ package com.wire.android.feature.cells.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filter import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -52,7 +44,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -77,12 +75,12 @@ import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes import com.wire.android.ui.common.chip.WireDisplayChipWithOverFlow import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.progress.WireLinearProgressIndicator import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter import com.wire.android.ui.common.R as commonR -@Suppress("CyclomaticComplexMethod") @Composable internal fun CellListItem( cell: CellNodeUi, @@ -103,137 +101,125 @@ internal fun CellListItem( } } - Box(modifier = modifier) { - Row( - modifier = Modifier - .height(dimensions().spacing64x) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + Row( + modifier = modifier + .height(dimensions().spacing64x) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { - val iconState = when { - cell.isOpenLoading -> CellIconState.Loading(cell.openLoadProgress) - showReadyState -> CellIconState.Ready - cell is CellNodeUi.File -> CellIconState.FileIcon(cell) - else -> CellIconState.FolderIcon(cell as CellNodeUi.Folder) - } + val iconState = when { + cell.isOpenLoading -> CellIconState.Loading(cell.openLoadProgress) + showReadyState -> CellIconState.Ready + cell is CellNodeUi.File -> CellIconState.FileIcon(cell) + else -> CellIconState.FolderIcon(cell as CellNodeUi.Folder) + } - AnimatedContent( - targetState = iconState, - contentKey = { state -> - when (state) { - is CellIconState.Loading -> "loading" - is CellIconState.Ready -> "ready" - is CellIconState.FileIcon -> "file" - is CellIconState.FolderIcon -> "folder" - } - }, - transitionSpec = { - (scaleIn(initialScale = 0.72f) + fadeIn()) togetherWith (scaleOut(targetScale = 0.72f) + fadeOut()) - }, - label = "cell_icon_transition", - ) { state -> + AnimatedContent( + targetState = iconState, + contentKey = { state -> when (state) { - is CellIconState.Loading -> LoadingIconPreview(progress = state.progress) - is CellIconState.Ready -> ReadyIconPreview() - is CellIconState.FileIcon -> FileIconPreview(state.cell) - is CellIconState.FolderIcon -> FolderIconPreview(state.cell) + is CellIconState.Loading -> "loading" + is CellIconState.Ready -> "ready" + is CellIconState.FileIcon -> "file" + is CellIconState.FolderIcon -> "folder" } + }, + transitionSpec = { + (scaleIn(initialScale = 0.72f) + fadeIn()) togetherWith (scaleOut(targetScale = 0.72f) + fadeOut()) + }, + label = "cell_icon_transition", + ) { state -> + when (state) { + is CellIconState.Loading -> LoadingIconPreview(progress = state.progress) + is CellIconState.Ready -> ReadyIconPreview() + is CellIconState.FileIcon -> FileIconPreview(state.cell) + is CellIconState.FolderIcon -> FolderIconPreview(state.cell) } + } - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(dimensions().spacing2x) - ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(dimensions().spacing2x) + ) { - Text( - text = cell.name ?: "", - style = typography().title02, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Text( + text = cell.name ?: "", + style = typography().title02, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (cell.isOpenLoading) { - Text( - text = stringResource(R.string.tap_to_cancel_loading), - textAlign = TextAlign.Left, - overflow = TextOverflow.Ellipsis, - style = typography().label04, - color = colorsScheme().secondaryText, - maxLines = 1, - ) - } else if (cell.isOpenError) { - Text( - text = stringResource(R.string.unable_to_load_retry), - textAlign = TextAlign.Left, - overflow = TextOverflow.Ellipsis, - style = typography().label04, - color = colorsScheme().error, - maxLines = 1, + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (cell.isOpenLoading) { + Text( + text = stringResource(R.string.tap_to_cancel_loading), + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().secondaryText, + maxLines = 1, + ) + } else if (cell.isOpenError) { + Text( + text = stringResource(R.string.unable_to_load_retry), + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().error, + maxLines = 1, + ) + } else if (showReadyState) { + Text( + text = stringResource(R.string.ready_to_open), + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().primary, + maxLines = 1, + ) + } else { + if (cell.tags.isNotEmpty()) { + WireDisplayChipWithOverFlow( + label = cell.tags.first(), + chipsCount = cell.tags.size - 1, + modifier = Modifier.padding(end = dimensions().spacing4x) ) - } else if (showReadyState) { + } + + cell.subtitle()?.let { Text( - text = stringResource(R.string.ready_to_open), + text = it, textAlign = TextAlign.Left, overflow = TextOverflow.Ellipsis, style = typography().label04, - color = colorsScheme().primary, + color = colorsScheme().secondaryText, maxLines = 1, ) - } else { - if (cell.tags.isNotEmpty()) { - WireDisplayChipWithOverFlow( - label = cell.tags.first(), - chipsCount = cell.tags.size - 1, - modifier = Modifier.padding(end = dimensions().spacing4x) - ) - } - - cell.subtitle()?.let { - Text( - text = it, - textAlign = TextAlign.Left, - overflow = TextOverflow.Ellipsis, - style = typography().label04, - color = colorsScheme().secondaryText, - maxLines = 1, - ) - } } } } - Icon( - painter = painterResource(commonR.drawable.ic_more_vert), - contentDescription = null, - modifier = Modifier - .padding(end = dimensions().spacing16x) - .clickable( - onClick = { onMenuClick() }, - interactionSource = interactionSource, - indication = ripple( - bounded = false, - radius = dimensions().spacing24x, - color = Color.Transparent - ) - ) - .then(Modifier.size(dimensions().spacing24x)) - ) - } - cell.downloadProgress?.let { - WireLinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomStart), - progress = { it }, - color = colorsScheme().primary, - trackColor = Color.Transparent, - ) } + Icon( + painter = painterResource(commonR.drawable.ic_more_vert), + contentDescription = null, + modifier = Modifier + .padding(end = dimensions().spacing16x) + .clickable( + onClick = { onMenuClick() }, + interactionSource = interactionSource, + indication = ripple( + bounded = false, + radius = dimensions().spacing24x, + color = Color.Transparent + ) + ) + .then(Modifier.size(dimensions().spacing24x)) + ) } } @@ -246,6 +232,10 @@ private sealed class CellIconState { @Composable internal fun LoadingIconPreview(progress: Float?) { + val modifier = Modifier.size(dimensions().spacing32x) + val color = colorsScheme().primary + val trackColor = colorsScheme().primaryVariant + val strokeWidth = dimensions().spacing2x Box( modifier = Modifier.size(dimensions().spacing56x), contentAlignment = Alignment.Center @@ -253,18 +243,18 @@ internal fun LoadingIconPreview(progress: Float?) { if (progress != null) { CircularProgressIndicator( progress = { progress }, - modifier = Modifier.size(dimensions().spacing32x), - color = colorsScheme().primary, - trackColor = colorsScheme().primaryVariant, - strokeWidth = dimensions().spacing2x, + modifier = modifier, + color = color, + trackColor = trackColor, + strokeWidth = strokeWidth, strokeCap = StrokeCap.Round, ) } else { CircularProgressIndicator( - modifier = Modifier.size(dimensions().spacing32x), - color = colorsScheme().primary, - trackColor = colorsScheme().primaryVariant, - strokeWidth = dimensions().spacing2x, + modifier = modifier, + color = color, + trackColor = trackColor, + strokeWidth = strokeWidth, strokeCap = StrokeCap.Round, ) } @@ -386,21 +376,19 @@ private fun PublicLinkIcon( } @Composable -private fun CellNodeUi.subtitle() = - when { - userName != null && conversationName != null -> { - stringResource(R.string.file_subtitle, userName!!, conversationName!!) - } - - userName != null && modifiedTime != null -> { - stringResource(R.string.file_subtitle_modified, modifiedTime!!, userName!!) - } - - userName != null -> userName - conversationName != null -> conversationName - modifiedTime != null -> modifiedTime +private fun CellNodeUi.subtitle(): String? { + val user = userName + val conv = conversationName + val time = modifiedTime + return when { + user != null && conv != null -> stringResource(R.string.file_subtitle, user, conv) + user != null && time != null -> stringResource(R.string.file_subtitle_modified, time, user) + user != null -> user + conv != null -> conv + time != null -> time else -> null } +} @PreviewMultipleThemes @Composable @@ -410,7 +398,6 @@ private fun PreviewCellListItem() { cell = CellNodeUi.File( uuid = "", name = "file name", - downloadProgress = 0.75f, assetType = AttachmentFileType.IMAGE, size = 123214, localPath = null, 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 1fa060f4551..145ecf21d99 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 @@ -118,8 +118,6 @@ class CellViewModel @Inject constructor( // Active open-download jobs keyed by node UUID, used to support cancellation of loading. private val openDownloadJobs = mutableMapOf() - // Monotonically-increasing generation per UUID — incremented on every new startOpenDownload call. - // Stale progress callbacks from a previous (cancelled) download carry an old generation and are ignored. private val openDownloadGeneration = mutableMapOf() // Open-loading state: tracks files being silently downloaded for immediate open. @@ -128,11 +126,6 @@ class CellViewModel @Inject constructor( /** Public map of uuid → (isOpenLoading, isOpenReady) for screens that build their own paging flow (e.g. Search). */ internal val openLoadStates: StateFlow> = openLoadStateFlow.asStateFlow() - /** - * File-ready events for the "ready to open" snackbar. Backed by the singleton channel so the - * event reaches whichever screen is currently active, even if the download finished on a different - * screen (e.g. completed in Search while the user navigated back to All Files). - */ internal val fileReadyFlow = sharedPathCache.fileReadyEvents /** Cached local file paths from completed open-downloads, keyed by uuid. Used by Search screen overlay. */ 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 72792f29f95..1242f602881 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 @@ -61,6 +61,7 @@ import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.PreviewNavigator @@ -85,6 +86,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -120,6 +122,7 @@ fun ConversationFilesScreen( onRefresh = viewModel::onPullToRefresh, retryEditNodeError = viewModel::editNode, fileReadyFlow = viewModel.fileReadyFlow, + externalOpenLoadStates = viewModel.openLoadStates, ) LaunchedEffect(Unit) { @@ -129,7 +132,7 @@ fun ConversationFilesScreen( @OptIn(ExperimentalSharedTransitionApi::class) @Composable -fun ConversationFilesScreenContent( +internal fun ConversationFilesScreenContent( animatedVisibilityScope: AnimatedVisibilityScope, navigator: WireNavigator, currentNodeUuid: String?, @@ -148,6 +151,7 @@ fun ConversationFilesScreenContent( isRestoreInProgress: Boolean = false, breadcrumbs: Array? = emptyArray(), fileReadyFlow: Flow = emptyFlow(), + externalOpenLoadStates: StateFlow> = MutableStateFlow(emptyMap()), ) { val sharedScope = LocalSharedTransitionScope.current @@ -354,6 +358,7 @@ fun ConversationFilesScreenContent( isRefreshing = isRefreshing, onRefresh = onRefresh, fileReadyFlow = fileReadyFlow, + externalOpenLoadStates = externalOpenLoadStates, ) } } @@ -378,7 +383,6 @@ fun PreviewConversationFilesScreen() { CellNodeUi.File( uuid = "file1", name = "File 1", - downloadProgress = 0.5f, assetType = AttachmentFileType.IMAGE, size = 123456, localPath = null, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt index 5e793400523..eda47b5828f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesWithSlideInTransitionScreen.kt @@ -73,6 +73,8 @@ fun ConversationFilesWithSlideInTransitionScreen( breadcrumbs = cellFilesNavArgs.breadcrumbs, sendIntent = viewModel::sendIntent, onRefresh = viewModel::onPullToRefresh, - retryEditNodeError = viewModel::editNode + retryEditNodeError = viewModel::editNode, + fileReadyFlow = viewModel.fileReadyFlow, + externalOpenLoadStates = viewModel.openLoadStates, ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt deleted file mode 100644 index a0a13fec6f5..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/download/DownloadFileBottomSheet.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.cells.ui.download - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import com.wire.android.feature.cells.R -import com.wire.android.feature.cells.domain.model.AttachmentFileType -import com.wire.android.feature.cells.ui.FileIconPreview -import com.wire.android.feature.cells.ui.model.CellNodeUi -import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes -import com.wire.android.ui.common.button.WireButton -import com.wire.android.ui.common.button.WireSecondaryButton -import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.progress.WireLinearProgressIndicator -import com.wire.android.ui.common.typography -import com.wire.android.ui.theme.WireTheme -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun DownloadFileBottomSheet( - file: CellNodeUi.File, - onDismiss: () -> Unit, - onDownload: () -> Unit, -) { - - val sheetState = rememberModalBottomSheetState() - val scope = rememberCoroutineScope() - - ModalBottomSheet( - onDismissRequest = { - onDismiss() - }, - sheetState = sheetState - ) { - ContentView( - file = file, - onCancel = { - scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) { - onDismiss() - } - } - }, - onDownload = onDownload, - ) - } -} - -@Composable -private fun ContentView( - file: CellNodeUi.File, - onCancel: () -> Unit, - onDownload: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensions().spacing24x) - ) { - Row( - modifier = Modifier - .height(dimensions().spacing64x) - .padding(horizontal = dimensions().spacing8x) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - - FileIconPreview(file) - - Text( - text = file.name ?: "", - style = typography().title02, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Column( - modifier = Modifier - .height(dimensions().spacing120x) - .fillMaxWidth(), - verticalArrangement = Arrangement.Center - ) { - - if (file.downloadProgress == null) { - - Text( - modifier = Modifier.padding(dimensions().spacing16x), - text = stringResource(R.string.download_file_message), - ) - - Row( - modifier = Modifier - .padding(horizontal = dimensions().spacing8x), - horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) - ) { - - WireSecondaryButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.cancel), - onClick = onCancel - ) - - WireButton( - modifier = Modifier.weight(1f), - text = stringResource(R.string.download), - onClick = onDownload - ) - } - } else { - - Column { - - Text( - modifier = Modifier.padding(dimensions().spacing16x), - text = stringResource(R.string.downloading_file_message), - ) - - WireLinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensions().spacing8x), - progress = { file.downloadProgress }, - ) - } - } - } - } -} - -@PreviewMultipleThemes -@Composable -private fun DownloadFileBottomSheetPreview() { - WireTheme { - ContentView( - file = CellNodeUi.File( - name = "file.txt", - conversationName = "Conversation", - downloadProgress = null, - uuid = "234324", - mimeType = "video/mp4", - assetType = AttachmentFileType.VIDEO, - size = 23432532532, - localPath = null, - userName = null, - ownerUserId = "userId", - userHandle = "userHandle", - modifiedTime = null, - remotePath = null, - contentHash = null, - contentUrl = null, - previewUrl = null, - publicLinkId = null, - ), - onCancel = {}, - onDownload = {} - ) - } -} - -@PreviewMultipleThemes -@Composable -private fun DownloadFileBottomSheetDownloadingPreview() { - WireTheme { - ContentView( - file = CellNodeUi.File( - name = "file.txt", - conversationName = "Conversation", - downloadProgress = 0.75f, - uuid = "234324", - mimeType = "video/mp4", - assetType = AttachmentFileType.VIDEO, - size = 23432532532, - localPath = null, - userName = null, - ownerUserId = "userId", - userHandle = "userHandle", - modifiedTime = null, - remotePath = null, - contentHash = null, - contentUrl = null, - previewUrl = null, - publicLinkId = null, - ), - onCancel = {}, - onDownload = {} - ) - } -} 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 5dca41b533d..6fbc109a830 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 @@ -36,7 +36,6 @@ sealed class CellNodeUi { abstract val publicLinkId: String? abstract val remotePath: String? abstract val size: Long? - abstract val downloadProgress: Float? abstract val tags: List abstract val isOpenLoading: Boolean abstract val isOpenReady: Boolean @@ -54,7 +53,6 @@ sealed class CellNodeUi { override val publicLinkId: String? = null, override val remotePath: String? = null, override val size: Long?, - override val downloadProgress: Float? = null, override val tags: List = emptyList(), override val isOpenLoading: Boolean = false, override val isOpenReady: Boolean = false, @@ -73,7 +71,6 @@ sealed class CellNodeUi { override val publicLinkId: String? = null, override val remotePath: String? = null, override val size: Long?, - override val downloadProgress: Float? = null, val mimeType: String, val assetType: AttachmentFileType, val localPath: String?, From add26eed5f7183a62f70bf6114b3420e4395a1c1 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 29 Apr 2026 17:22:17 +0100 Subject: [PATCH 08/44] fix: cleanup --- .../android/feature/cells/ui/CellFileLocalPathCache.kt | 8 ++------ .../com/wire/android/feature/cells/ui/CellFilesScreen.kt | 2 -- .../com/wire/android/feature/cells/ui/CellListItem.kt | 9 +-------- .../wire/android/feature/cells/ui/CellScreenContent.kt | 2 -- .../feature/cells/ui/recyclebin/RecycleBinScreen.kt | 3 +-- 5 files changed, 4 insertions(+), 20 deletions(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt index 4c778d86cb6..26b91436db7 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt @@ -29,12 +29,8 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Process-scoped singleton that keeps track of locally-cached file paths (uuid → absolute path string) - * across all [CellViewModel] instances. This allows a file downloaded in one screen (e.g. Search) to - * appear as already-available in another screen (e.g. All Files) without re-downloading. - * - * Also acts as the global event bus for "file ready to open" snackbar events, so that the snackbar - * is shown on whichever screen is currently active — even if the download finished on a different screen. + * A simple in-memory cache to store local file paths for cell nodes. + * It also provides a channel to emit events when a file is ready to be opened. */ @Singleton class CellFileLocalPathCache @Inject constructor() { 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 2b9c5b546e4..c990ec6b119 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 @@ -121,7 +121,6 @@ private fun ContentList( ) { index -> cellNodes[index]?.let { item -> - // Apply external open-load state overlay for items whose paging data doesn't carry it (e.g. Search) val overlaidItem = if (item is CellNodeUi.File) { val state = externalOpenLoadStates[item.uuid] val cachedPath = cachedLocalPaths[item.uuid] @@ -131,7 +130,6 @@ private fun ContentList( isOpenReady = state is OpenLoadState.Ready, isOpenError = state is OpenLoadState.Error, openLoadProgress = (state as? OpenLoadState.Loading)?.progress, - // Prefer cached localPath so re-tapping after "Ready" dismissal opens from cache localPath = (state as? OpenLoadState.Ready)?.localPath?.toString() ?: cachedPath ?: item.localPath, 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 8c26120318f..6e71cb5e3ba 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 @@ -117,14 +117,7 @@ internal fun CellListItem( AnimatedContent( targetState = iconState, - contentKey = { state -> - when (state) { - is CellIconState.Loading -> "loading" - is CellIconState.Ready -> "ready" - is CellIconState.FileIcon -> "file" - is CellIconState.FolderIcon -> "folder" - } - }, + contentKey = { it::class.simpleName }, transitionSpec = { (scaleIn(initialScale = 0.72f) + fadeIn()) togetherWith (scaleOut(targetScale = 0.72f) + fadeOut()) }, 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 85c7ad039f0..340ccc79daa 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 @@ -270,8 +270,6 @@ internal fun CellScreenContent( } } - // Lifecycle-independent: collects even when this screen is in the back stack, so the snackbar - // appears on whichever Cell screen is currently active (global snackbar behaviour). LaunchedEffect(Unit) { fileReadyFlow?.collect { file -> val message = fileReadyMessageFormat.format(file.name ?: file.uuid) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index 132c9ecd615..0f0d45e3ed8 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -140,8 +140,7 @@ fun RecycleBinScreen( showRenameScreen = { }, showAddRemoveTagsScreen = {}, isRefreshing = cellViewModel.isPullToRefresh.collectAsState(), - onRefresh = { cellViewModel.onPullToRefresh() }, - fileReadyFlow = cellViewModel.fileReadyFlow, + onRefresh = { cellViewModel.onPullToRefresh() } ) } } From 942a6e2b60b404566322f1fa525040b640ceb1da Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 29 Apr 2026 19:36:18 +0100 Subject: [PATCH 09/44] fix: cleanup --- .../android/feature/cells/ui/CellViewModel.kt | 68 ++++++------------- 1 file changed, 21 insertions(+), 47 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 145ecf21d99..f5c09911e7d 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 @@ -60,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -115,10 +116,8 @@ class CellViewModel @Inject constructor( private val _isDeleteInProgress = MutableStateFlow(false) val isDeleteInProgress = _isDeleteInProgress.asStateFlow() - // Active open-download jobs keyed by node UUID, used to support cancellation of loading. - private val openDownloadJobs = mutableMapOf() - - private val openDownloadGeneration = mutableMapOf() + private data class DownloadSession(val job: Job, val token: Long) + private val openDownloads = mutableMapOf() // Open-loading state: tracks files being silently downloaded for immediate open. private val openLoadStateFlow = MutableStateFlow>(emptyMap()) @@ -126,11 +125,10 @@ class CellViewModel @Inject constructor( /** Public map of uuid → (isOpenLoading, isOpenReady) for screens that build their own paging flow (e.g. Search). */ internal val openLoadStates: StateFlow> = openLoadStateFlow.asStateFlow() - internal val fileReadyFlow = sharedPathCache.fileReadyEvents + internal val fileReadyFlow: Flow = sharedPathCache.fileReadyEvents /** Cached local file paths from completed open-downloads, keyed by uuid. Used by Search screen overlay. */ - private val _cachedLocalPaths = MutableStateFlow>(emptyMap()) - internal val cachedLocalPaths: StateFlow> = _cachedLocalPaths.asStateFlow() + internal val cachedLocalPaths: StateFlow> = sharedPathCache.paths private val removedItemsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -163,13 +161,6 @@ class CellViewModel @Inject constructor( init { loadWireCellConfig() checkCellAvailabilityAndRefresh() - viewModelScope.launch { - try { - sharedPathCache.paths.collect { _cachedLocalPaths.value = it } - } catch (_: Throwable) { - // sharedPathCache.paths unavailable — cachedLocalPaths stays empty - } - } } private fun checkCellAvailabilityAndRefresh() = viewModelScope.launch { @@ -203,7 +194,7 @@ class CellViewModel @Inject constructor( ).cachedIn(viewModelScope), removedItemsFlow, openLoadStateFlow, - _cachedLocalPaths, + sharedPathCache.paths, ) { pagingData, removedItems, openLoadStates, cachedPaths -> var emittedRefreshDone = false @@ -303,9 +294,7 @@ class CellViewModel @Inject constructor( } private fun startOpenDownload(cellNode: CellNodeUi.File) { - // Stamp a new generation for this download session. - val myGeneration = (openDownloadGeneration[cellNode.uuid] ?: 0L) + 1L - openDownloadGeneration[cellNode.uuid] = myGeneration + val myToken = (openDownloads[cellNode.uuid]?.token ?: 0L) + 1L val job = viewModelScope.launch { val nodeName = cellNode.name ?: run { @@ -316,7 +305,6 @@ class CellViewModel @Inject constructor( val cacheDir = fileHelper.getCacheDir() val filePath = fileNameResolver.getUniqueFile(cacheDir, nodeName).toPath().toOkioPath() - // Track whether the 300ms threshold was crossed before download completed var spinnerShown = false val showSpinnerJob = launch { @@ -331,10 +319,8 @@ class CellViewModel @Inject constructor( remoteFilePath = cellNode.remotePath, assetSize = cellNode.size ?: 0, ) { progress -> - // Dispatch to main thread. Guard with generation check so stale callbacks - // from a cancelled download never overwrite state belonging to a newer session. - viewModelScope.launch { - if (openDownloadGeneration[cellNode.uuid] == myGeneration) { + viewModelScope.launch { + if (openDownloads[cellNode.uuid]?.token == myToken) { val assetSize = cellNode.size ?: 0 if (assetSize > 0) { val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) @@ -345,9 +331,7 @@ class CellViewModel @Inject constructor( } .onSuccess { showSpinnerJob.cancel() - openDownloadJobs.remove(cellNode.uuid) - openDownloadGeneration[cellNode.uuid] = (openDownloadGeneration[cellNode.uuid] ?: 0L) + 1L - // Cache the local path so future taps open directly + openDownloads.remove(cellNode.uuid) sharedPathCache.put(cellNode.uuid, filePath.toString()) if (!spinnerShown) { // Fast path: download completed before 300ms threshold — open instantly @@ -366,17 +350,15 @@ class CellViewModel @Inject constructor( } .onFailure { showSpinnerJob.cancel() - openDownloadJobs.remove(cellNode.uuid) - openDownloadGeneration[cellNode.uuid] = (openDownloadGeneration[cellNode.uuid] ?: 0L) + 1L + openDownloads.remove(cellNode.uuid) updateOpenLoadState(cellNode.uuid) { OpenLoadState.Error } } } - openDownloadJobs[cellNode.uuid] = job + openDownloads[cellNode.uuid] = DownloadSession(job, myToken) } internal fun cancelOpenDownload(uuid: String) { - openDownloadJobs.remove(uuid)?.cancel() - openDownloadGeneration[uuid] = (openDownloadGeneration[uuid] ?: 0L) + 1L + openDownloads.remove(uuid)?.job?.cancel() clearOpenLoadState(uuid) } @@ -563,25 +545,17 @@ class CellViewModel @Inject constructor( private fun addToListUi(node: CellNodeUi) = removedItemsFlow.update { it - node.uuid } fun clearRemovedItems() = removedItemsFlow.update { emptyList() } - private fun updateOpenLoadState(uuid: String, block: () -> OpenLoadState) { - openLoadStateFlow.update { map -> - map.toMutableMap().apply { put(uuid, block()) }.toImmutableMap() - } + private fun modifyOpenLoadStates(block: MutableMap.() -> Unit) { + openLoadStateFlow.update { it.toMutableMap().apply(block).toImmutableMap() } } - private fun clearOpenLoadState(uuid: String) { - openLoadStateFlow.update { map -> - map.toMutableMap().apply { remove(uuid) }.toImmutableMap() - } - } + private fun updateOpenLoadState(uuid: String, block: () -> OpenLoadState) = + modifyOpenLoadStates { put(uuid, block()) } - internal fun clearAllErrorStates() { - openLoadStateFlow.update { map -> - map.toMutableMap().apply { - entries.removeAll { it.value is OpenLoadState.Error } - }.toImmutableMap() - } - } + private fun clearOpenLoadState(uuid: String) = modifyOpenLoadStates { remove(uuid) } + + internal fun clearAllErrorStates() = + modifyOpenLoadStates { entries.removeAll { it.value is OpenLoadState.Error } } private fun loadWireCellConfig() = viewModelScope.launch { val config = getWireCellsConfig() From a9e6d8c272171f4022565897e9dc0359ba8905af Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 29 Apr 2026 21:35:44 +0100 Subject: [PATCH 10/44] feat: refactor --- .../feature/cells/ui/CellFilesScreen.kt | 20 +-- .../android/feature/cells/ui/CellListItem.kt | 26 +-- .../android/feature/cells/ui/CellViewModel.kt | 104 ++---------- .../cells/ui/OpenFileDownloadController.kt | 150 ++++++++++++++++++ .../feature/cells/ui/model/CellNodeUi.kt | 17 ++ 5 files changed, 198 insertions(+), 119 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt 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 c990ec6b119..46005aa32d3 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 @@ -47,6 +47,7 @@ import androidx.paging.compose.itemKey import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.OpenLoadState +import com.wire.android.feature.cells.ui.model.withOpenLoadState import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme @@ -122,21 +123,10 @@ private fun ContentList( cellNodes[index]?.let { item -> val overlaidItem = if (item is CellNodeUi.File) { - val state = externalOpenLoadStates[item.uuid] - val cachedPath = cachedLocalPaths[item.uuid] - if (state != null || cachedPath != null) { - item.copy( - isOpenLoading = state is OpenLoadState.Loading, - isOpenReady = state is OpenLoadState.Ready, - isOpenError = state is OpenLoadState.Error, - openLoadProgress = (state as? OpenLoadState.Loading)?.progress, - localPath = (state as? OpenLoadState.Ready)?.localPath?.toString() - ?: cachedPath - ?: item.localPath, - ) - } else { - item - } + item.withOpenLoadState( + state = externalOpenLoadStates[item.uuid], + cachedPath = cachedLocalPaths[item.uuid], + ) } else { item } 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 6e71cb5e3ba..d57bba7b45c 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 @@ -91,7 +91,7 @@ internal fun CellListItem( var showReadyState by remember { mutableStateOf(false) } val cellState = rememberUpdatedState(cell) - LaunchedEffect(Unit) { + LaunchedEffect(cell.uuid) { snapshotFlow { cellState.value.isOpenReady } .filter { it } .collect { @@ -369,19 +369,21 @@ private fun PublicLinkIcon( } @Composable -private fun CellNodeUi.subtitle(): String? { - val user = userName - val conv = conversationName - val time = modifiedTime - return when { - user != null && conv != null -> stringResource(R.string.file_subtitle, user, conv) - user != null && time != null -> stringResource(R.string.file_subtitle_modified, time, user) - user != null -> user - conv != null -> conv - time != null -> time +private fun CellNodeUi.subtitle() = + when { + userName != null && conversationName != null -> { + stringResource(R.string.file_subtitle, userName!!, conversationName!!) + } + + userName != null && modifiedTime != null -> { + stringResource(R.string.file_subtitle_modified, modifiedTime!!, userName!!) + } + + userName != null -> userName + conversationName != null -> conversationName + modifiedTime != null -> modifiedTime else -> null } -} @PreviewMultipleThemes @Composable 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 f5c09911e7d..4e76ea1c1e7 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 @@ -40,7 +40,6 @@ import com.wire.android.feature.cells.ui.search.SearchNavArgs import com.wire.android.feature.cells.ui.search.sort.SortingCriteria import com.wire.android.feature.cells.ui.search.sort.toKaliumCriteria import com.wire.android.feature.cells.util.FileHelper -import com.wire.android.feature.cells.util.FileNameResolver import com.wire.android.ui.common.ActionsViewModel import com.wire.kalium.cells.data.FileFilters import com.wire.kalium.cells.data.SortingSpec @@ -51,14 +50,11 @@ import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase 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.download.DownloadCellFileUseCase 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 dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -76,7 +72,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import okio.Path.Companion.toOkioPath import okio.Path.Companion.toPath import javax.inject.Inject @@ -87,15 +82,14 @@ class CellViewModel @Inject constructor( private val getCellFilesPaged: GetPaginatedFilesFlowUseCase, private val deleteCellAsset: DeleteCellAssetUseCase, private val restoreNodeFromRecycleBinUseCase: RestoreNodeFromRecycleBinUseCase, - private val download: DownloadCellFileUseCase, private val isCellAvailable: IsAtLeastOneCellAvailableUseCase, private val fileHelper: FileHelper, - private val fileNameResolver: FileNameResolver, private val getEditorUrl: GetEditorUrlUseCase, private val onlineEditor: OnlineEditor, private val cellFileActionsMenu: CellFileActionsMenu, private val getWireCellsConfig: GetWireCellConfigurationUseCase, private val sharedPathCache: CellFileLocalPathCache, + private val openFileDownloadController: OpenFileDownloadController, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -116,14 +110,8 @@ class CellViewModel @Inject constructor( private val _isDeleteInProgress = MutableStateFlow(false) val isDeleteInProgress = _isDeleteInProgress.asStateFlow() - private data class DownloadSession(val job: Job, val token: Long) - private val openDownloads = mutableMapOf() - - // Open-loading state: tracks files being silently downloaded for immediate open. - private val openLoadStateFlow = MutableStateFlow>(emptyMap()) - - /** Public map of uuid → (isOpenLoading, isOpenReady) for screens that build their own paging flow (e.g. Search). */ - internal val openLoadStates: StateFlow> = openLoadStateFlow.asStateFlow() + /** Public map of uuid → open-load state for screens that build their own paging flow (e.g. Search). */ + internal val openLoadStates: StateFlow> = openFileDownloadController.openLoadStates internal val fileReadyFlow: Flow = sharedPathCache.fileReadyEvents @@ -193,7 +181,7 @@ class CellViewModel @Inject constructor( ), ).cachedIn(viewModelScope), removedItemsFlow, - openLoadStateFlow, + openFileDownloadController.openLoadStates, sharedPathCache.paths, ) { pagingData, removedItems, openLoadStates, cachedPaths -> var emittedRefreshDone = false @@ -294,72 +282,16 @@ class CellViewModel @Inject constructor( } private fun startOpenDownload(cellNode: CellNodeUi.File) { - val myToken = (openDownloads[cellNode.uuid]?.token ?: 0L) + 1L - - val job = viewModelScope.launch { - val nodeName = cellNode.name ?: run { - sendAction(ShowError(CellError.OTHER_ERROR)) - return@launch - } - - val cacheDir = fileHelper.getCacheDir() - val filePath = fileNameResolver.getUniqueFile(cacheDir, nodeName).toPath().toOkioPath() - - var spinnerShown = false - - val showSpinnerJob = launch { - delay(OPEN_SPINNER_DELAY_MS) - spinnerShown = true - updateOpenLoadState(cellNode.uuid) { OpenLoadState.Loading() } - } - - download( - assetId = cellNode.uuid, - outFilePath = filePath, - remoteFilePath = cellNode.remotePath, - assetSize = cellNode.size ?: 0, - ) { progress -> - viewModelScope.launch { - if (openDownloads[cellNode.uuid]?.token == myToken) { - val assetSize = cellNode.size ?: 0 - if (assetSize > 0) { - val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) - updateOpenLoadState(cellNode.uuid) { OpenLoadState.Loading(progressValue) } - } - } - } - } - .onSuccess { - showSpinnerJob.cancel() - openDownloads.remove(cellNode.uuid) - sharedPathCache.put(cellNode.uuid, filePath.toString()) - if (!spinnerShown) { - // Fast path: download completed before 300ms threshold — open instantly - clearOpenLoadState(cellNode.uuid) - openLocalFile(cellNode.copy(localPath = filePath.toString())) - } else { - // Slow path: spinner was already shown — show ready state + snackbar - updateOpenLoadState(cellNode.uuid) { OpenLoadState.Ready(filePath) } - sharedPathCache.emitFileReady(cellNode.copy(localPath = filePath.toString())) - // Auto-dismiss the "Ready" state after 3 seconds - launch { - delay(OPEN_READY_DISMISS_MS) - clearOpenLoadState(cellNode.uuid) - } - } - } - .onFailure { - showSpinnerJob.cancel() - openDownloads.remove(cellNode.uuid) - updateOpenLoadState(cellNode.uuid) { OpenLoadState.Error } - } - } - openDownloads[cellNode.uuid] = DownloadSession(job, myToken) + openFileDownloadController.start( + scope = viewModelScope, + cellNode = cellNode, + onOpenFile = ::openLocalFile, + onError = { sendAction(ShowError(it)) }, + ) } internal fun cancelOpenDownload(uuid: String) { - openDownloads.remove(uuid)?.job?.cancel() - clearOpenLoadState(uuid) + openFileDownloadController.cancel(uuid) } private fun onFolderClick(cellNode: CellNodeUi.Folder) { @@ -545,17 +477,7 @@ class CellViewModel @Inject constructor( private fun addToListUi(node: CellNodeUi) = removedItemsFlow.update { it - node.uuid } fun clearRemovedItems() = removedItemsFlow.update { emptyList() } - private fun modifyOpenLoadStates(block: MutableMap.() -> Unit) { - openLoadStateFlow.update { it.toMutableMap().apply(block).toImmutableMap() } - } - - private fun updateOpenLoadState(uuid: String, block: () -> OpenLoadState) = - modifyOpenLoadStates { put(uuid, block()) } - - private fun clearOpenLoadState(uuid: String) = modifyOpenLoadStates { remove(uuid) } - - internal fun clearAllErrorStates() = - modifyOpenLoadStates { entries.removeAll { it.value is OpenLoadState.Error } } + internal fun clearAllErrorStates() = openFileDownloadController.clearAllErrorStates() private fun loadWireCellConfig() = viewModelScope.launch { val config = getWireCellsConfig() @@ -614,5 +536,3 @@ data class MenuOptions( ) private const val RESTORE_DELAY_MS = 300L -private const val OPEN_SPINNER_DELAY_MS = 300L -private const val OPEN_READY_DISMISS_MS = 3_000L 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 new file mode 100644 index 00000000000..f7f7cd71a78 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt @@ -0,0 +1,150 @@ +/* + * 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 + +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.model.OpenLoadState +import com.wire.android.feature.cells.util.FileHelper +import com.wire.android.feature.cells.util.FileNameResolver +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase +import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.onSuccess +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okio.Path.Companion.toOkioPath +import javax.inject.Inject + +/** + * Controller for managing the state of file downloads triggered by "Open" actions in the UI. + */ +class OpenFileDownloadController @Inject constructor( + private val download: DownloadCellFileUseCase, + private val fileHelper: FileHelper, + private val fileNameResolver: FileNameResolver, + private val sharedPathCache: CellFileLocalPathCache, +) { + + private val _openDownloads = MutableStateFlow>(emptyMap()) + + private val _openLoadStates = MutableStateFlow>(emptyMap()) + + internal val openLoadStates: StateFlow> = _openLoadStates.asStateFlow() + + internal fun start( + scope: CoroutineScope, + cellNode: CellNodeUi.File, + onOpenFile: (CellNodeUi.File) -> Unit, + onError: (CellError) -> Unit, + ) { + // Cancel any previous download for this file (e.g. rapid retries after error). + _openDownloads.value[cellNode.uuid]?.cancel() + + val job = scope.launch { + val nodeName = cellNode.name ?: run { + onError(CellError.OTHER_ERROR) + return@launch + } + + val cacheDir = fileHelper.getCacheDir() + val filePath = fileNameResolver.getUniqueFile(cacheDir, nodeName).toPath().toOkioPath() + + var spinnerShown = false + + val showSpinnerJob = launch { + delay(OPEN_SPINNER_DELAY_MS) + spinnerShown = true + setLoadState(cellNode.uuid, OpenLoadState.Loading()) + } + + download( + assetId = cellNode.uuid, + outFilePath = filePath, + remoteFilePath = cellNode.remotePath, + assetSize = cellNode.size ?: 0, + ) { progress -> + // Child coroutine — cancelled automatically when the parent job is cancelled, + launch { + val assetSize = cellNode.size ?: 0 + if (assetSize > 0) { + val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) + setLoadState(cellNode.uuid, OpenLoadState.Loading(progressValue)) + } + } + } + .onSuccess { + showSpinnerJob.cancel() + _openDownloads.update { it - cellNode.uuid } + sharedPathCache.put(cellNode.uuid, filePath.toString()) + + if (!spinnerShown) { + // Fast path: completed before threshold — open immediately. + clearLoadState(cellNode.uuid) + onOpenFile(cellNode.copy(localPath = filePath.toString())) + } else { + // Slow path: spinner was visible — show "Ready" badge + snackbar. + setLoadState(cellNode.uuid, OpenLoadState.Ready(filePath)) + sharedPathCache.emitFileReady(cellNode.copy(localPath = filePath.toString())) + launch { + delay(OPEN_READY_DISMISS_MS) + clearLoadState(cellNode.uuid) + } + } + } + .onFailure { + showSpinnerJob.cancel() + _openDownloads.update { it - cellNode.uuid } + setLoadState(cellNode.uuid, OpenLoadState.Error) + } + } + + _openDownloads.update { it + (cellNode.uuid to job) } + } + + internal fun cancel(uuid: String) { + var job: Job? = null + _openDownloads.update { map -> + job = map[uuid] + map - uuid + } + job?.cancel() + clearLoadState(uuid) + } + + internal fun clearAllErrorStates() = + _openLoadStates.update { states -> + states.filterValues { it !is OpenLoadState.Error }.toImmutableMap() + } + + private fun setLoadState(uuid: String, state: OpenLoadState) = + _openLoadStates.update { it.toMutableMap().apply { put(uuid, state) }.toImmutableMap() } + + private fun clearLoadState(uuid: String) = + _openLoadStates.update { it.toMutableMap().apply { remove(uuid) }.toImmutableMap() } + + companion object { + private const val OPEN_SPINNER_DELAY_MS = 300L + private const val OPEN_READY_DISMISS_MS = 3_000L + } +} 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 6fbc109a830..b8283506ecb 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 @@ -129,6 +129,23 @@ private fun Node.Folder.formattedModifiedTime() = modifiedTime?.let { Instant.fromEpochMilliseconds(it).cellFileDateTime() } +internal fun CellNodeUi.File.withOpenLoadState( + state: OpenLoadState?, + cachedPath: String?, +): CellNodeUi.File = if (state == null && cachedPath == null) { + this +} else { + copy( + isOpenLoading = state is OpenLoadState.Loading, + isOpenReady = state is OpenLoadState.Ready, + isOpenError = state is OpenLoadState.Error, + openLoadProgress = (state as? OpenLoadState.Loading)?.progress, + localPath = (state as? OpenLoadState.Ready)?.localPath?.toString() + ?: cachedPath + ?: localPath, + ) +} + internal fun CellNodeUi.File.localFileAvailable() = localPath != null internal fun CellNodeUi.File.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO, PDF) internal fun CellNodeUi.isEditSupported() = (this as? CellNodeUi.File)?.isEditSupported == true From 2bb96f27ad46de70449bfd3e13da1006b1449074 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 29 Apr 2026 21:54:42 +0100 Subject: [PATCH 11/44] feat: unit test --- .../cells/ui/CellFileActionsMenuTest.kt | 1 - .../feature/cells/ui/CellViewModelTest.kt | 16 +- .../ui/OpenFileDownloadControllerTest.kt | 377 ++++++++++++++++++ 3 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index 0f6ed6efb5b..a8b7529e85b 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -442,7 +442,6 @@ class CellFileActionsMenuTest { val fileNode = CellNodeUi.File( name = "file.txt", conversationName = "Conversation", - downloadProgress = null, uuid = "fileUuid", mimeType = "video/mp4", assetType = AttachmentFileType.VIDEO, 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 a083c6e920a..8f952d6e4b1 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 @@ -38,7 +38,6 @@ 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 import com.wire.kalium.common.functional.right -import com.wire.kalium.logic.data.asset.KaliumFileSystem import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify @@ -271,8 +270,6 @@ class CellViewModelTest { @MockK lateinit var fileHelper: FileHelper - @MockK - lateinit var kaliumFileSystem: KaliumFileSystem @MockK lateinit var fileNameResolver: FileNameResolver @@ -303,9 +300,6 @@ class CellViewModelTest { every { savedStateHandle.get(any()) } returns conversationId every { savedStateHandle.get("conversationId") } returns conversationId - every { kaliumFileSystem.providePersistentAssetPath(any()) } returns localFilePath - - every { kaliumFileSystem.exists(any()) } returns false coEvery { isCellAvailableUseCase.invoke() } returns true.right() @@ -366,20 +360,26 @@ class CellViewModelTest { coEvery { getWireCellsConfig() } returns null + val openFileDownloadController = OpenFileDownloadController( + download = downloadCellFileUseCase, + fileHelper = fileHelper, + fileNameResolver = fileNameResolver, + sharedPathCache = sharedPathCache, + ) + return this to CellViewModel( savedStateHandle = savedStateHandle, getCellFilesPaged = getCellFilesPagedUseCase, deleteCellAsset = deleteCellAssetUseCase, restoreNodeFromRecycleBinUseCase = restoreNodeFromRecycleBinUseCase, - download = downloadCellFileUseCase, isCellAvailable = isCellAvailableUseCase, fileHelper = fileHelper, - fileNameResolver = fileNameResolver, onlineEditor = onlineEditor, getEditorUrl = getEditorUrlUseCase, cellFileActionsMenu = cellFileActionsMenu, getWireCellsConfig = getWireCellsConfig, sharedPathCache = sharedPathCache, + openFileDownloadController = openFileDownloadController, ) } } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt new file mode 100644 index 00000000000..a644254ab97 --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -0,0 +1,377 @@ +/* + * 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 + +import app.cash.turbine.test +import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.model.OpenLoadState +import com.wire.android.feature.cells.util.FileHelper +import com.wire.android.feature.cells.util.FileNameResolver +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.left +import com.wire.kalium.common.functional.right +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File + +class OpenFileDownloadControllerTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach + fun beforeEach() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun afterEach() { + Dispatchers.resetMain() + } + + // region — error / guard cases + + @Test + fun givenFileWithNoName_whenStartCalled_thenOnErrorCallbackInvoked() = runTest { + val (_, controller) = Arrangement().arrange() + var errorReceived: CellError? = null + + controller.start( + scope = this, + cellNode = testFile.copy(name = null), + onOpenFile = {}, + onError = { errorReceived = it }, + ) + advanceUntilIdle() + + assertEquals(CellError.OTHER_ERROR, errorReceived) + } + + @Test + fun givenFastDownloadSuccess_whenStartCalled_thenFileOpenedImmediatelyWithNoLoadingState() = runTest { + val (_, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + var openedFile: CellNodeUi.File? = null + + controller.start( + scope = this, + cellNode = testFile, + onOpenFile = { openedFile = it }, + onError = {}, + ) + advanceUntilIdle() + + assertEquals(testFile.uuid, openedFile?.uuid) + assertTrue(controller.openLoadStates.value.isEmpty(), "No load state should be set on fast path") + } + + @Test + fun givenFastDownloadSuccess_whenStartCalled_thenLocalPathStoredInSharedCache() = runTest { + val (arrangement, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + + controller.start( + scope = this, + cellNode = testFile, + onOpenFile = {}, + onError = {}, + ) + advanceUntilIdle() + + assertNotNull(arrangement.sharedPathCache.paths.value[testFile.uuid]) + } + + @Test + fun givenSlowDownloadSuccess_whenSpinnerThresholdPassed_thenLoadingStateAppears() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + + controller.start( + scope = this, + cellNode = testFile, + onOpenFile = {}, + onError = {}, + ) + advanceTimeBy(SPINNER_THRESHOLD_MS + 1) + + assertEquals(OpenLoadState.Loading(), controller.openLoadStates.value[testFile.uuid]) + } + + @Test + fun givenSlowDownloadSuccess_whenDownloadCompletes_thenStateBecomesReady() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + + controller.start( + scope = this, + cellNode = testFile, + onOpenFile = {}, + onError = {}, + ) + // Advance past the spinner (300 ms) and the download (500 ms) but NOT past the + // auto-dismiss delay (3 000 ms) — otherwise advanceUntilIdle would clear the state too. + advanceTimeBy(501) + + assertTrue( + controller.openLoadStates.value[testFile.uuid] is OpenLoadState.Ready, + "Expected Ready state after slow download" + ) + } + + @Test + fun givenSlowDownloadSuccess_whenDownloadCompletes_thenFileReadyEventEmittedToSharedCache() = runTest { + val (arrangement, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + + arrangement.sharedPathCache.fileReadyEvents.test { + controller.start( + scope = this@runTest, + cellNode = testFile, + onOpenFile = {}, + onError = {}, + ) + advanceUntilIdle() + + assertEquals(testFile.uuid, awaitItem().uuid) + } + } + + @Test + fun givenReadyState_afterAutoDismissDelay_thenLoadStateIsCleared() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + + controller.start( + scope = this, + cellNode = testFile, + onOpenFile = {}, + onError = {}, + ) + advanceUntilIdle() // download completes → Ready + + advanceTimeBy(AUTO_DISMISS_MS + 1) // auto-dismiss fires + + assertNull(controller.openLoadStates.value[testFile.uuid], "Load state should be cleared after auto-dismiss") + } + + @Test + fun givenDownloadFailure_whenStartCalled_thenErrorStateSet() = runTest { + val (_, controller) = Arrangement() + .withDownloadFailure() + .arrange() + + controller.start( + scope = this, + cellNode = testFile, + onOpenFile = {}, + onError = {}, + ) + advanceUntilIdle() + + assertEquals(OpenLoadState.Error, controller.openLoadStates.value[testFile.uuid]) + } + + @Test + fun givenActiveDownload_whenCancelCalled_thenLoadStateClearedImmediately() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + + controller.start(scope = this, cellNode = testFile, onOpenFile = {}, onError = {}) + advanceTimeBy(SPINNER_THRESHOLD_MS + 1) // spinner shown → Loading state + + controller.cancel(testFile.uuid) + + assertNull(controller.openLoadStates.value[testFile.uuid], "Cancel should clear load state") + } + + @Test + fun givenActiveDownload_whenCancelCalled_thenFileIsNotOpened() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + val openedFiles = mutableListOf() + + controller.start(scope = this, cellNode = testFile, onOpenFile = { openedFiles += it }, onError = {}) + advanceTimeBy(100) + + controller.cancel(testFile.uuid) + advanceUntilIdle() + + assertTrue(openedFiles.isEmpty(), "File must not be opened after cancel") + } + + @Test + fun givenRapidRetryForSameFile_whenStartCalledTwice_thenOnlySecondDownloadCompletes() = runTest { + val (arrangement, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + + // Both starts are synchronous: when start() #2 runs, job #1 is still suspended at + // its download delay, so it gets cancelled immediately before completing. + controller.start(scope = this, cellNode = testFile, onOpenFile = {}, onError = {}) + controller.start(scope = this, cellNode = testFile, onOpenFile = {}, onError = {}) + + // Advance past spinner (300 ms) + download (500 ms), but NOT past auto-dismiss (3 000 ms). + advanceTimeBy(501) + + // The shared path cache is populated by onSuccess regardless of fast/slow path. + // Exactly one entry means exactly one download completed. + assertEquals(1, arrangement.sharedPathCache.paths.value.size, + "Only the second download should have stored a path") + assertTrue(controller.openLoadStates.value[testFile.uuid] is OpenLoadState.Ready, + "State should be Ready after the second download completes") + } + + @Test + fun givenProgressUpdate_whenDownloadProgresses_thenLoadingProgressReflected() = runTest { + val (_, controller) = Arrangement() + .withProgressThenSuccess(progress = 512L) + .arrange() + + controller.start(scope = this, cellNode = testFile.copy(size = 1024L), onOpenFile = {}, onError = {}) + // Advance to 401 ms: + // 300 ms → spinner fires → Loading() shown + // 400 ms → onProgressUpdate fires → child launch sets Loading(0.5f) + // 500 ms → download completes (NOT yet reached) + advanceTimeBy(401) + + val state = controller.openLoadStates.value[testFile.uuid] + assertEquals(0.5f, (state as? OpenLoadState.Loading)?.progress) + } + + @Test + fun givenErrorAndLoadingStates_whenClearAllErrorStatesCalled_thenOnlyErrorsRemoved() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess(uuid = testFile.uuid) + .withDownloadFailure(uuid = anotherFile.uuid) + .arrange() + + // testFile → Loading (slow, timer fires), anotherFile → Error (immediate failure) + controller.start(scope = this, cellNode = testFile, onOpenFile = {}, onError = {}) + controller.start(scope = this, cellNode = anotherFile, onOpenFile = {}, onError = {}) + advanceTimeBy(SPINNER_THRESHOLD_MS + 1) // spinner for testFile shows Loading + // Advance only enough for anotherFile's (instant) download to fail — but NOT past the + // 500 ms slow download so testFile stays in Loading. + advanceTimeBy(1) // anotherFile failure is already settled; testFile still downloading + + controller.clearAllErrorStates() + + assertNull(controller.openLoadStates.value[anotherFile.uuid], "Error state should be removed") + assertNotNull(controller.openLoadStates.value[testFile.uuid], "Loading state should be preserved") + } + + private companion object { + const val SPINNER_THRESHOLD_MS = 300L + const val AUTO_DISMISS_MS = 3_000L + + val testFile = CellNodeUi.File( + uuid = "test-uuid", + name = "report.pdf", + mimeType = "application/pdf", + assetType = AttachmentFileType.OTHER, + localPath = null, + size = 1024L, + remotePath = "remote/report.pdf", + userName = null, + userHandle = null, + ownerUserId = null, + conversationName = null, + modifiedTime = null, + ) + + val anotherFile = testFile.copy(uuid = "another-uuid", name = "photo.jpg") + } + + private inner class Arrangement { + + @MockK + lateinit var downloadUseCase: DownloadCellFileUseCase + + @MockK + lateinit var fileHelper: FileHelper + + @MockK + lateinit var fileNameResolver: FileNameResolver + + val sharedPathCache = CellFileLocalPathCache() + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { fileHelper.getCacheDir() } returns File("") + every { fileNameResolver.getUniqueFile(any(), any()) } returns File("report.pdf") + } + + fun withDownloadSuccess(uuid: String = testFile.uuid) = apply { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } returns Unit.right() + } + + fun withSlowDownloadSuccess(uuid: String = testFile.uuid) = apply { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } coAnswers { + delay(500) // Exceeds 300 ms spinner threshold + Unit.right() + } + } + + fun withDownloadFailure(uuid: String = testFile.uuid) = apply { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } returns + StorageFailure.DataNotFound.left() + } + + fun withProgressThenSuccess(progress: Long, uuid: String = testFile.uuid) = apply { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } coAnswers { + val onProgressUpdate = arg<(Long) -> Unit>(4) + delay(400) // after spinner threshold (300 ms) — progress replaces Loading() + onProgressUpdate(progress) + delay(100) // download finishes at 500 ms total + Unit.right() + } + } + + fun arrange() = this to OpenFileDownloadController( + download = downloadUseCase, + fileHelper = fileHelper, + fileNameResolver = fileNameResolver, + sharedPathCache = sharedPathCache, + ) + } +} From 771b73d555069b98cb97fdc05c2547788c809093 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 29 Apr 2026 23:52:14 +0100 Subject: [PATCH 12/44] feat: detekt --- .../android/feature/cells/ui/CellListItem.kt | 1 + .../feature/cells/ui/CellViewModelTest.kt | 2 -- .../cells/ui/OpenFileDownloadControllerTest.kt | 16 +++++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) 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 d57bba7b45c..ecd038f69bb 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 @@ -81,6 +81,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import com.wire.android.ui.common.R as commonR +@Suppress("CyclomaticComplexMethod") @Composable internal fun CellListItem( cell: CellNodeUi, 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 8f952d6e4b1..6c4e9fdcaaa 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 @@ -270,7 +270,6 @@ class CellViewModelTest { @MockK lateinit var fileHelper: FileHelper - @MockK lateinit var fileNameResolver: FileNameResolver @@ -300,7 +299,6 @@ class CellViewModelTest { every { savedStateHandle.get(any()) } returns conversationId every { savedStateHandle.get("conversationId") } returns conversationId - coEvery { isCellAvailableUseCase.invoke() } returns true.right() coEvery { getCellFilesPagedUseCase.invoke(any(), any(), any(), any()) } returns flowOf( diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index a644254ab97..dae98c7a8bc 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -62,8 +62,6 @@ class OpenFileDownloadControllerTest { Dispatchers.resetMain() } - // region — error / guard cases - @Test fun givenFileWithNoName_whenStartCalled_thenOnErrorCallbackInvoked() = runTest { val (_, controller) = Arrangement().arrange() @@ -256,10 +254,14 @@ class OpenFileDownloadControllerTest { // The shared path cache is populated by onSuccess regardless of fast/slow path. // Exactly one entry means exactly one download completed. - assertEquals(1, arrangement.sharedPathCache.paths.value.size, - "Only the second download should have stored a path") - assertTrue(controller.openLoadStates.value[testFile.uuid] is OpenLoadState.Ready, - "State should be Ready after the second download completes") + assertEquals( + 1, arrangement.sharedPathCache.paths.value.size, + "Only the second download should have stored a path" + ) + assertTrue( + controller.openLoadStates.value[testFile.uuid] is OpenLoadState.Ready, + "State should be Ready after the second download completes" + ) } @Test @@ -354,7 +356,7 @@ class OpenFileDownloadControllerTest { fun withDownloadFailure(uuid: String = testFile.uuid) = apply { coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } returns - StorageFailure.DataNotFound.left() + StorageFailure.DataNotFound.left() } fun withProgressThenSuccess(progress: Long, uuid: String = testFile.uuid) = apply { From 784c3d39ca7b13bce561d65d4d759bb3e3cc8358 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 30 Apr 2026 08:15:17 +0100 Subject: [PATCH 13/44] feat: detekt --- .../android/feature/cells/ui/OpenFileDownloadControllerTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index dae98c7a8bc..87d753eb73f 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -255,7 +255,8 @@ class OpenFileDownloadControllerTest { // The shared path cache is populated by onSuccess regardless of fast/slow path. // Exactly one entry means exactly one download completed. assertEquals( - 1, arrangement.sharedPathCache.paths.value.size, + 1, + arrangement.sharedPathCache.paths.value.size, "Only the second download should have stored a path" ) assertTrue( From d76da0583b05eb4aa365c1a7a922ede179ae43af Mon Sep 17 00:00:00 2001 From: ohassine Date: Mon, 4 May 2026 11:17:46 +0200 Subject: [PATCH 14/44] chore: test --- .../kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt index 2cd630be72a..be76cdd94eb 100644 --- a/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt +++ b/app/src/androidTest/kotlin/com/wire/android/ui/debug/DebugScreenComposeTest.kt @@ -42,6 +42,7 @@ class DebugScreenComposeTest { onDatabaseLoggerEnabledChanged = {}, onShowFeatureFlags = {}, onFlushLogs = { CompletableDeferred(Unit) }, + onShowCryptoStats = {} ) } } From defa8086e467211eb6992674c995bc2b0c1047e5 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 6 May 2026 10:59:18 +0200 Subject: [PATCH 15/44] feat(drive): Make files available offline --- .../android/di/accountScoped/CellsModule.kt | 15 ++ .../feature/cells/ui/CellFileActionsMenu.kt | 33 +++++ .../android/feature/cells/ui/CellListItem.kt | 27 +++- .../feature/cells/ui/CellScreenContent.kt | 5 + .../android/feature/cells/ui/CellViewModel.kt | 51 ++++++- .../cells/ui/OfflineFileDownloadController.kt | 128 ++++++++++++++++++ .../cells/ui/OpenFileDownloadController.kt | 2 + .../feature/cells/ui/model/CellNodeUi.kt | 8 ++ .../cells/ui/model/NodeBottomSheetAction.kt | 5 +- .../android/feature/cells/util/FileHelper.kt | 6 + .../src/main/res/drawable/ic_downloaded.xml | 24 ++++ .../cells/src/main/res/values/strings.xml | 4 + .../cells/ui/CellFileActionsMenuTest.kt | 119 ++++++++++++++++ .../feature/cells/ui/CellViewModelTest.kt | 24 ++++ kalium | 2 +- 15 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt create mode 100644 features/cells/src/main/res/drawable/ic_downloaded.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 4c8e5772fcc..acf5b0182c9 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,9 @@ 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.offline.DeleteOfflineFileUseCase +import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase +import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase import com.wire.kalium.cells.paginatedConversationsFlowUseCase import com.wire.kalium.cells.paginatedFilesFlowUseCase import com.wire.kalium.logic.CoreLogic @@ -259,4 +262,16 @@ class CellsModule { @Provides fun provideGetPaginatedConversationsFlowUseCase(cellsScope: CellsScope): GetPaginatedCellConversationsFlowUseCase = cellsScope.paginatedConversationsFlowUseCase + + @ViewModelScoped + @Provides + fun provideSaveOfflineFileUseCase(cellsScope: CellsScope): SaveOfflineFileUseCase = cellsScope.saveOfflineFile + + @ViewModelScoped + @Provides + fun provideDeleteOfflineFileUseCase(cellsScope: CellsScope): DeleteOfflineFileUseCase = cellsScope.deleteOfflineFile + + @ViewModelScoped + @Provides + fun provideObserveOfflineFilesUseCase(cellsScope: CellsScope): ObserveOfflineFilesUseCase = cellsScope.observeOfflineFiles } 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 72b8cc13ea2..86e02b97605 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 @@ -48,11 +48,20 @@ class CellFileActionsMenu @Inject constructor( buildList { if (cellNode is CellNodeUi.File && cellNode.isOpenLoading) { add(NodeBottomSheetAction.CANCEL_LOADING) + } else if (cellNode is CellNodeUi.File && cellNode.downloadProgress != null) { + add(NodeBottomSheetAction.CANCEL_DOWNLOAD) } else { if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { add(NodeBottomSheetAction.SHARE) } add(NodeBottomSheetAction.PUBLIC_LINK) + if (cellNode is CellNodeUi.File) { + if (cellNode.isAvailableOffline) { + add(NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS) + } else { + add(NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE) + } + } } } } @@ -61,12 +70,22 @@ class CellFileActionsMenu @Inject constructor( buildList { if (cellNode is CellNodeUi.File && cellNode.isOpenLoading) { add(NodeBottomSheetAction.CANCEL_LOADING) + } else if (cellNode is CellNodeUi.File && cellNode.downloadProgress != null) { + add(NodeBottomSheetAction.CANCEL_DOWNLOAD) } else { if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { add(NodeBottomSheetAction.SHARE) } add(NodeBottomSheetAction.PUBLIC_LINK) + if (cellNode is CellNodeUi.File) { + if (cellNode.isAvailableOffline) { + add(NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS) + } else { + add(NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE) + } + } + if (isCollaboraEnabled && featureFlags.collaboraIntegration && cellNode.isEditSupported()) { add(NodeBottomSheetAction.EDIT) } @@ -93,6 +112,10 @@ class CellFileActionsMenu @Inject constructor( 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 + internal data class CancelDownload(val node: CellNodeUi) : MenuActionResult + internal data class Download(val node: CellNodeUi) : MenuActionResult + internal data class MakeAvailableOffline(val node: CellNodeUi.File) : MenuActionResult + internal data class RemoveOfflineAccess(val node: CellNodeUi.File) : MenuActionResult internal fun onMenuItemAction( conversationId: String?, @@ -136,6 +159,16 @@ class CellFileActionsMenu @Inject constructor( NodeBottomSheetAction.EDIT -> Edit(node) NodeBottomSheetAction.VERSION_HISTORY -> Action(ShowVersionHistoryScreen(node.uuid, node.name ?: "")) NodeBottomSheetAction.CANCEL_LOADING -> CancelLoading(node) + NodeBottomSheetAction.CANCEL_DOWNLOAD -> CancelDownload(node) + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE -> { + if (node is CellNodeUi.File) MakeAvailableOffline(node) + else Action(ShowPublicLinkScreen(node)) + } + + NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS -> { + if (node is CellNodeUi.File) RemoveOfflineAccess(node) + else Action(ShowPublicLinkScreen(node)) + } } onResult(result) 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 ecd038f69bb..46d1bb72ea7 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 @@ -111,6 +111,7 @@ internal fun CellListItem( val iconState = when { cell.isOpenLoading -> CellIconState.Loading(cell.openLoadProgress) + cell.downloadProgress != null -> CellIconState.Downloading(cell.downloadProgress) showReadyState -> CellIconState.Ready cell is CellNodeUi.File -> CellIconState.FileIcon(cell) else -> CellIconState.FolderIcon(cell as CellNodeUi.Folder) @@ -126,6 +127,7 @@ internal fun CellListItem( ) { state -> when (state) { is CellIconState.Loading -> LoadingIconPreview(progress = state.progress) + is CellIconState.Downloading -> LoadingIconPreview(progress = state.progress) is CellIconState.Ready -> ReadyIconPreview() is CellIconState.FileIcon -> FileIconPreview(state.cell) is CellIconState.FolderIcon -> FolderIconPreview(state.cell) @@ -138,14 +140,13 @@ internal fun CellListItem( .weight(1f), verticalArrangement = Arrangement.spacedBy(dimensions().spacing2x) ) { - Text( text = cell.name ?: "", style = typography().title02, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) - Row( verticalAlignment = Alignment.CenterVertically, ) { @@ -158,6 +159,15 @@ internal fun CellListItem( color = colorsScheme().secondaryText, maxLines = 1, ) + } else if (cell.downloadProgress != null) { + Text( + text = stringResource(R.string.downloading_file_message), + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().secondaryText, + maxLines = 1, + ) } else if (cell.isOpenError) { Text( text = stringResource(R.string.unable_to_load_retry), @@ -177,6 +187,16 @@ internal fun CellListItem( maxLines = 1, ) } else { + if (cell.isAvailableOffline) { + Icon( + modifier = Modifier + .padding(end = dimensions().spacing6x), + painter = painterResource(R.drawable.ic_downloaded), + contentDescription = null, + tint = colorsScheme().secondaryText, + ) + } + if (cell.tags.isNotEmpty()) { WireDisplayChipWithOverFlow( label = cell.tags.first(), @@ -219,6 +239,7 @@ internal fun CellListItem( private sealed class CellIconState { data class Loading(val progress: Float?) : CellIconState() + data class Downloading(val progress: Float?) : CellIconState() data object Ready : CellIconState() data class FileIcon(val cell: CellNodeUi.File) : CellIconState() data class FolderIcon(val cell: CellNodeUi.Folder) : CellIconState() 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 340ccc79daa..4a854ee1368 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 @@ -267,6 +267,11 @@ 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 -> Toast.makeText( + context, + context.getString(com.wire.android.feature.cells.R.string.offline_file_saved_message), + 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 4e76ea1c1e7..e10c68e7e82 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 @@ -50,6 +50,8 @@ import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase 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.ObserveOfflineFilesUseCase +import com.wire.kalium.cells.domain.usecase.offline.DeleteOfflineFileUseCase import com.wire.kalium.common.functional.fold import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess @@ -90,6 +92,9 @@ class CellViewModel @Inject constructor( private val getWireCellsConfig: GetWireCellConfigurationUseCase, private val sharedPathCache: CellFileLocalPathCache, private val openFileDownloadController: OpenFileDownloadController, + private val offlineFileDownloadController: OfflineFileDownloadController, + private val observeOfflineFiles: ObserveOfflineFilesUseCase, + private val deleteOfflineFile: DeleteOfflineFileUseCase, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -151,6 +156,21 @@ class CellViewModel @Inject constructor( checkCellAvailabilityAndRefresh() } + /** Offline paths from db: uuid → localPath. Shared across all CellViewModel instances via the Flow. */ + private val offlinePathsFlow = observeOfflineFiles() + .shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) + + /** + * Combined offline data: pair of (uuid → localPath from DB) and (uuid → download progress). + * Merges two related sources into one so we don't exceed the 5-flow combine limit. + */ + private val offlineDataFlow = combine( + offlinePathsFlow, + offlineFileDownloadController.downloadProgresses, + ) { offlineFiles, downloadProgresses -> + Pair(offlineFiles, downloadProgresses) + } + private fun checkCellAvailabilityAndRefresh() = viewModelScope.launch { val cellAvailable = isCellAvailable().fold({ false }, { it }) cellAvailableFlow.value = cellAvailable @@ -183,7 +203,9 @@ class CellViewModel @Inject constructor( removedItemsFlow, openFileDownloadController.openLoadStates, sharedPathCache.paths, - ) { pagingData, removedItems, openLoadStates, cachedPaths -> + offlineDataFlow, + ) { pagingData, removedItems, openLoadStates, cachedPaths, (offlineFiles, downloadProgresses) -> + val offlinePathsMap = offlineFiles.associate { it.id to it.localPath } var emittedRefreshDone = false pagingData @@ -201,16 +223,22 @@ class CellViewModel @Inject constructor( val openLoadState = openLoadStates[node.uuid] when (node) { - is Node.Folder -> node.toUiModel() + is Node.Folder -> node.toUiModel().copy( + downloadProgress = downloadProgresses[node.uuid], + isAvailableOffline = offlinePathsMap.containsKey(node.uuid), + ) is Node.File -> node.toUiModel().copy( localPath = openLoadState?.let { (it as? OpenLoadState.Ready)?.localPath?.toString() } ?: cachedPaths[node.uuid] + ?: offlinePathsMap[node.uuid] ?: node.localPath, isOpenLoading = openLoadState is OpenLoadState.Loading, isOpenReady = openLoadState is OpenLoadState.Ready, isOpenError = openLoadState is OpenLoadState.Error, openLoadProgress = (openLoadState as? OpenLoadState.Loading)?.progress, + downloadProgress = downloadProgresses[node.uuid], + isAvailableOffline = offlinePathsMap.containsKey(node.uuid), ) } } @@ -381,10 +409,27 @@ class CellViewModel @Inject constructor( is CellFileActionsMenu.Edit -> editNode(result.node.uuid) is CellFileActionsMenu.Share -> shareFile(result.node) is CellFileActionsMenu.CancelLoading -> cancelDownload(result.node.uuid) + is CellFileActionsMenu.CancelDownload -> offlineFileDownloadController.cancel(result.node.uuid) + is CellFileActionsMenu.MakeAvailableOffline -> makeAvailableOffline(result.node) + is CellFileActionsMenu.RemoveOfflineAccess -> removeOfflineAccess(result.node) + is CellFileActionsMenu.Download -> Unit // unused in this context } } } + private fun makeAvailableOffline(node: CellNodeUi.File) { + offlineFileDownloadController.start( + scope = viewModelScope, + cellNode = node, + onSuccess = { _ -> sendAction(ShowOfflineFileSaved) }, + onError = { sendAction(ShowError(it)) }, + ) + } + + private fun removeOfflineAccess(node: CellNodeUi.File) = viewModelScope.launch { + deleteOfflineFile(node.uuid) + } + internal fun editNode(nodeUuid: String) = viewModelScope.launch { getEditorUrl(nodeUuid) .onSuccess { url -> @@ -524,8 +569,10 @@ internal data class ShowFileDeletedMessage(val isFile: Boolean, val permanently: internal data object RefreshData : CellViewAction internal data class OpenFolder(val path: String, val title: String, val parentFolderUuid: String?) : CellViewAction internal data class ShowEditErrorDialog(val nodeUuid: String) : CellViewAction +internal data object ShowOfflineFileSaved : CellViewAction internal enum class CellError(val message: Int) { + DOWNLOAD_FAILED(R.string.cell_files_download_failure_message), NO_APP_FOUND(R.string.no_app_found), OTHER_ERROR(R.string.action_failed) } 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 new file mode 100644 index 00000000000..34bca88ad07 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt @@ -0,0 +1,128 @@ +/* + * 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 + +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.util.FileHelper +import com.wire.android.feature.cells.util.FileNameResolver +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.offline.OfflineFileInfo +import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase +import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.onSuccess +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okio.Path.Companion.toOkioPath +import javax.inject.Inject + +/** + * Controller that handles downloading a cell file to app-specific external storage + * and persisting its metadata in the database for offline access. + */ +class OfflineFileDownloadController @Inject constructor( + private val download: DownloadCellFileUseCase, + private val fileHelper: FileHelper, + private val fileNameResolver: FileNameResolver, + private val saveOfflineFile: SaveOfflineFileUseCase, +) { + private val _downloadProgresses = MutableStateFlow>(emptyMap()) + private val _activeJobs = MutableStateFlow>(emptyMap()) + + /** Maps uuid → download progress (0f–1f) for all active offline downloads. */ + internal val downloadProgresses: StateFlow> = _downloadProgresses.asStateFlow() + + internal fun start( + scope: CoroutineScope, + cellNode: CellNodeUi.File, + onSuccess: (localPath: String) -> Unit, + onError: (CellError) -> Unit, + ) { + // Cancel any previous download for this node + _activeJobs.value[cellNode.uuid]?.cancel() + + val job = scope.launch { + val nodeName = cellNode.name ?: run { + onError(CellError.OTHER_ERROR) + return@launch + } + + val externalDir = fileHelper.getExternalFilesDir() + val filePath = fileNameResolver.getUniqueFile(externalDir, nodeName).toPath().toOkioPath() + + _downloadProgresses.update { it.toMutableMap().apply { put(cellNode.uuid, null) }.toImmutableMap() } + + download( + assetId = cellNode.uuid, + outFilePath = filePath, + remoteFilePath = cellNode.remotePath, + assetSize = cellNode.size ?: 0, + name = cellNode.name, + ownerId = cellNode.ownerUserId, + ) { progress -> + launch { + val assetSize = cellNode.size ?: 0 + if (assetSize > 0) { + val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) + _downloadProgresses.update { + it.toMutableMap().apply { put(cellNode.uuid, progressValue) }.toImmutableMap() + } + } + } + } + .onSuccess { + _downloadProgresses.update { it.toMutableMap().apply { remove(cellNode.uuid) }.toImmutableMap() } + saveOfflineFile( + OfflineFileInfo( + id = cellNode.uuid, + name = nodeName, + owner = cellNode.ownerUserId ?: "", + localPath = filePath.toString(), + size = cellNode.size, + downloadedAt = System.currentTimeMillis(), + ) + ) + onSuccess(filePath.toString()) + } + .onFailure { + _downloadProgresses.update { it.toMutableMap().apply { remove(cellNode.uuid) }.toImmutableMap() } + onError(CellError.DOWNLOAD_FAILED) + } + } + + _activeJobs.update { it.toMutableMap().apply { put(cellNode.uuid, job) }.toImmutableMap() } + job.invokeOnCompletion { + _activeJobs.update { it.toMutableMap().apply { remove(cellNode.uuid) }.toImmutableMap() } + } + } + + internal fun cancel(uuid: String) { + var job: Job? = null + _activeJobs.update { current -> + job = current[uuid] + current.toMutableMap().apply { remove(uuid) }.toImmutableMap() + } + job?.cancel() + _downloadProgresses.update { it.toMutableMap().apply { remove(uuid) }.toImmutableMap() } + } +} 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 f7f7cd71a78..f11888464c3 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 @@ -83,6 +83,8 @@ class OpenFileDownloadController @Inject constructor( outFilePath = filePath, remoteFilePath = cellNode.remotePath, assetSize = cellNode.size ?: 0, + name = cellNode.name, + ownerId = cellNode.ownerUserId, ) { progress -> // Child coroutine — cancelled automatically when the parent job is cancelled, launch { 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 b8283506ecb..6223e9bc109 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 @@ -41,6 +41,10 @@ sealed class CellNodeUi { abstract val isOpenReady: Boolean abstract val isOpenError: Boolean abstract val openLoadProgress: Float? + /** Non-null while a background download (e.g. "Make Available Offline") is in progress. */ + abstract val downloadProgress: Float? + /** True when this file has been saved for offline use (persisted in the offline files DB). */ + abstract val isAvailableOffline: Boolean data class Folder( override val name: String?, @@ -58,6 +62,8 @@ sealed class CellNodeUi { override val isOpenReady: Boolean = false, override val isOpenError: Boolean = false, override val openLoadProgress: Float? = null, + override val downloadProgress: Float? = null, + override val isAvailableOffline: Boolean = false, ) : CellNodeUi() data class File( @@ -83,6 +89,8 @@ sealed class CellNodeUi { override val isOpenReady: Boolean = false, override val isOpenError: Boolean = false, override val openLoadProgress: Float? = null, + override val downloadProgress: Float? = null, + override val isAvailableOffline: Boolean = false, ) : CellNodeUi() } 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 d1ff2e6cfc0..10f63ff52c4 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 @@ -34,5 +34,8 @@ enum class NodeBottomSheetAction( DELETE(R.string.delete_label, com.wire.android.ui.common.R.drawable.ic_delete, true), DELETE_PERMANENTLY(R.string.delete_permanently, com.wire.android.ui.common.R.drawable.ic_delete, true), VERSION_HISTORY(R.string.see_version_history_bottom_sheet, R.drawable.ic_version_history), - CANCEL_LOADING(R.string.cancel_loading_label, com.wire.android.ui.common.R.drawable.ic_close, true) + 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), } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt index 8d6dfa14980..5aaa5ed740f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt @@ -146,6 +146,12 @@ class FileHelper @Inject constructor( fun getCacheDir(): File = context.cacheDir + /** + * Returns the app-specific external storage directory. + * No permissions needed, deleted when the app is uninstalled. + */ + fun getExternalFilesDir(): File = context.getExternalFilesDir(null) ?: context.filesDir + private fun Context.getProviderAuthority() = "$packageName.provider" private fun Context.pathToUri(assetDataPath: Path, assetName: String?): Uri = diff --git a/features/cells/src/main/res/drawable/ic_downloaded.xml b/features/cells/src/main/res/drawable/ic_downloaded.xml new file mode 100644 index 00000000000..7f5481ebf56 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_downloaded.xml @@ -0,0 +1,24 @@ + + + + diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 29c5cce3a2f..125038029a1 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -79,6 +79,10 @@ Unable to load, retry Ready to open Cancel loading… + Cancel download + Make available offline + Remove offline access + File saved for offline use \"%1$s\" ready to open Open Unable to create folder. Please try again diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index a8b7529e85b..7025c8b5a40 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -42,6 +42,7 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, ), items ) @@ -83,6 +84,7 @@ class CellFileActionsMenuTest { assertEquals( listOf( NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, NodeBottomSheetAction.ADD_REMOVE_TAGS, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, @@ -106,6 +108,7 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, ), items ) @@ -128,6 +131,7 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, NodeBottomSheetAction.EDIT, NodeBottomSheetAction.VERSION_HISTORY, NodeBottomSheetAction.ADD_REMOVE_TAGS, @@ -155,6 +159,7 @@ class CellFileActionsMenuTest { listOf( NodeBottomSheetAction.SHARE, NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, NodeBottomSheetAction.ADD_REMOVE_TAGS, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, @@ -392,6 +397,120 @@ class CellFileActionsMenuTest { ) } + @Test + fun `GIVEN file is downloading offline WHEN building allFiles menu THEN emits only CANCEL_DOWNLOAD`() = + runTest { + // WHEN + val items = buildMenu( + fileNode = fileNode.copy(downloadProgress = 0.5f), + isAllFiles = true, + ) + + // THEN + assertEquals( + listOf(NodeBottomSheetAction.CANCEL_DOWNLOAD), + items + ) + } + + @Test + fun `GIVEN file is downloading offline WHEN building conversationFiles menu THEN emits only CANCEL_DOWNLOAD`() = + runTest { + // WHEN + val items = buildMenu( + fileNode = fileNode.copy(downloadProgress = 0.5f), + isConversationFiles = true, + ) + + // THEN + assertEquals( + listOf(NodeBottomSheetAction.CANCEL_DOWNLOAD), + items + ) + } + + @Test + fun `GIVEN file menu WHEN cancel download option selected THEN correct action emitted`() = + runTest { + // GIVEN + val menu = actionsMenu() + + // WHEN + menu.onMenuItemAction( + conversationId = null, + parentFolderUuid = null, + node = fileNode, + action = NodeBottomSheetAction.CANCEL_DOWNLOAD, + onResult = { result -> + // THEN + assertEquals(CellFileActionsMenu.CancelDownload(fileNode), result) + } + ) + } + + @Test + fun `GIVEN file is available offline WHEN building allFiles menu THEN emits REMOVE_OFFLINE_ACCESS instead of MAKE_AVAILABLE_OFFLINE`() = + runTest { + // WHEN + val items = buildMenu( + fileNode = fileNode.copy(isAvailableOffline = true), + isAllFiles = true, + ) + + // THEN + assertEquals( + listOf( + NodeBottomSheetAction.SHARE, + NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS, + ), + items + ) + } + + @Test + fun `GIVEN file is available offline WHEN building conversationFiles menu THEN emits REMOVE_OFFLINE_ACCESS instead of MAKE_AVAILABLE_OFFLINE`() = + runTest { + // WHEN + val items = buildMenu( + fileNode = fileNode.copy(isAvailableOffline = true), + isConversationFiles = true, + ) + + // THEN + assertEquals( + listOf( + NodeBottomSheetAction.SHARE, + NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS, + NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.MOVE, + NodeBottomSheetAction.RENAME, + NodeBottomSheetAction.DELETE, + ), + items + ) + } + + @Test + fun `GIVEN file menu WHEN remove offline access option selected THEN correct action emitted`() = + runTest { + // GIVEN + val menu = actionsMenu() + + // WHEN + menu.onMenuItemAction( + conversationId = null, + parentFolderUuid = null, + node = fileNode, + action = NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS, + onResult = { result -> + // THEN + assertEquals(CellFileActionsMenu.RemoveOfflineAccess(fileNode), result) + } + ) + } + @Test fun `GIVEN file menu WHEN edit option selected called THEN correct action emitted`() = runTest { 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 6c4e9fdcaaa..f69bd506537 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 @@ -37,6 +37,9 @@ 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.download.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase +import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase +import com.wire.kalium.cells.domain.usecase.offline.DeleteOfflineFileUseCase import com.wire.kalium.common.functional.right import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -287,6 +290,15 @@ class CellViewModelTest { @MockK lateinit var getWireCellsConfig: GetWireCellConfigurationUseCase + @MockK + lateinit var observeOfflineFilesUseCase: ObserveOfflineFilesUseCase + + @MockK + lateinit var saveOfflineFileUseCase: SaveOfflineFileUseCase + + @MockK + lateinit var deleteOfflineFileUseCase: DeleteOfflineFileUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) @@ -301,6 +313,8 @@ class CellViewModelTest { coEvery { isCellAvailableUseCase.invoke() } returns true.right() + every { observeOfflineFilesUseCase.invoke() } returns flowOf(emptyList()) + coEvery { getCellFilesPagedUseCase.invoke(any(), any(), any(), any()) } returns flowOf( PagingData.from( data = listOf( @@ -365,6 +379,13 @@ class CellViewModelTest { sharedPathCache = sharedPathCache, ) + val offlineFileDownloadController = OfflineFileDownloadController( + download = downloadCellFileUseCase, + fileHelper = fileHelper, + fileNameResolver = fileNameResolver, + saveOfflineFile = saveOfflineFileUseCase, + ) + return this to CellViewModel( savedStateHandle = savedStateHandle, getCellFilesPaged = getCellFilesPagedUseCase, @@ -378,6 +399,9 @@ class CellViewModelTest { getWireCellsConfig = getWireCellsConfig, sharedPathCache = sharedPathCache, openFileDownloadController = openFileDownloadController, + offlineFileDownloadController = offlineFileDownloadController, + observeOfflineFiles = observeOfflineFilesUseCase, + deleteOfflineFile = deleteOfflineFileUseCase, ) } } diff --git a/kalium b/kalium index a3b59d3d899..e854de386df 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit a3b59d3d8998387672ee4316a813b481a4c9d34b +Subproject commit e854de386df9e1d3b3d26e5a01132fdd86d3842d From ed03ad96cc9d5c177b9a36c8edbb83f9ed10b93f Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 7 May 2026 16:02:12 +0200 Subject: [PATCH 16/44] feat(drive): Make files available offline --- .../android/di/accountScoped/CellsModule.kt | 5 + .../feature/cells/ui/AllFilesScreen.kt | 2 - .../cells/ui/CellFileLocalPathCache.kt | 14 ++ .../feature/cells/ui/CellFilesScreen.kt | 4 +- .../android/feature/cells/ui/CellListItem.kt | 2 +- .../android/feature/cells/ui/CellViewModel.kt | 71 ++++++---- .../feature/cells/ui/DownloadFailureUtils.kt | 63 +++++++++ .../cells/ui/OfflineFileDownloadController.kt | 129 +++++++++++------- .../cells/ui/OpenFileDownloadController.kt | 123 ++++++++--------- .../feature/cells/ui/model/CellNodeUi.kt | 35 +++-- .../cells/ui/search/SearchScreenViewModel.kt | 15 +- .../cells/src/main/res/values/strings.xml | 2 + .../feature/cells/ui/CellViewModelTest.kt | 47 ++++++- .../ui/OpenFileDownloadControllerTest.kt | 79 ++++++++--- 14 files changed, 410 insertions(+), 181 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt 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 acf5b0182c9..f710aae5bf9 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 @@ -63,6 +63,7 @@ import com.wire.kalium.cells.domain.usecase.publiclink.UpdatePublicLinkPasswordU 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.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.SaveOfflineFileUseCase import com.wire.kalium.cells.paginatedConversationsFlowUseCase @@ -274,4 +275,8 @@ class CellsModule { @ViewModelScoped @Provides fun provideObserveOfflineFilesUseCase(cellsScope: CellsScope): ObserveOfflineFilesUseCase = cellsScope.observeOfflineFiles + + @ViewModelScoped + @Provides + fun provideGetOfflineFileUseCase(cellsScope: CellsScope): GetOfflineFileUseCase = cellsScope.getOfflineFile } 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 09f9a523aea..3dc9c3ec67f 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 @@ -83,8 +83,6 @@ fun AllFilesScreen( isDeleteInProgress = viewModel.isDeleteInProgress.collectAsState().value, isRecycleBin = viewModel.isRecycleBin(), isSearchResult = false, - externalOpenLoadStates = viewModel.openLoadStates, - cachedLocalPaths = viewModel.cachedLocalPaths, showPublicLinkScreen = { publicLinkScreenData -> navigator.navigate( NavigationCommand( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt index 47bea39da4b..c9bca5e8912 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileLocalPathCache.kt @@ -37,6 +37,7 @@ import javax.inject.Singleton * * - [fileReadyEvents]: emitted when a slow download finishes so the UI can show a snackbar. * - [openLoadStates]: per-uuid Loading / Ready / Error state consumed by paging combines. + * - [downloadProgresses]: per-uuid offline-download progress */ @Singleton class CellFileLocalPathCache @Inject constructor() { @@ -47,6 +48,9 @@ class CellFileLocalPathCache @Inject constructor() { private val _openLoadStates = MutableStateFlow>(emptyMap()) internal val openLoadStates: StateFlow> = _openLoadStates.asStateFlow() + private val _downloadProgresses = MutableStateFlow>(emptyMap()) + internal val downloadProgresses: StateFlow> = _downloadProgresses.asStateFlow() + // Session-level guard: records the local path once a download completes so that a // subsequent tap opens the file immediately, even if the paging source hasn't refreshed // yet with the new localPath from the DB. @@ -57,6 +61,10 @@ class CellFileLocalPathCache @Inject constructor() { internal fun getCompletedPath(uuid: String): String? = completedPaths[uuid] + internal fun clearCompletedPath(uuid: String) { + completedPaths.remove(uuid) + } + fun emitFileReady(file: CellNodeUi.File) { _fileReadyChannel.trySend(file) } @@ -65,4 +73,10 @@ class CellFileLocalPathCache @Inject constructor() { _openLoadStates.update { it + (uuid to state) } internal fun clearOpenLoadState(uuid: String) = _openLoadStates.update { it - uuid } + + internal fun setDownloadProgress(uuid: String, progress: Float?) = + _downloadProgresses.update { it + (uuid to progress) } + + internal fun clearDownloadProgress(uuid: String) = + _downloadProgresses.update { it - uuid } } 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 1afd74baa7d..658e515dbf2 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 @@ -31,8 +31,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -46,8 +46,6 @@ import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.model.CellNodeUi -import com.wire.android.feature.cells.ui.model.OpenLoadState -import com.wire.android.feature.cells.ui.model.withOpenLoadState import com.wire.android.feature.cells.ui.util.PreviewMultipleThemes import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.colorsScheme 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 435d588ad5d..93777d28f08 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 @@ -198,7 +198,7 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { cell.downloadProgress != null -> Text( - text = stringResource(R.string.downloading_file_message), + text = stringResource(R.string.tap_to_cancel_download), textAlign = TextAlign.Left, overflow = TextOverflow.Ellipsis, style = typography().label04, 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 9b8e840a070..3f64babccee 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 @@ -35,8 +35,6 @@ 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.ui.model.toUiModel -import com.wire.android.feature.cells.ui.model.withOpenLoadState -import com.wire.android.feature.cells.ui.model.withOpenLoadState import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.feature.cells.ui.search.SearchNavArgs import com.wire.android.feature.cells.ui.search.sort.SortingCriteria @@ -52,8 +50,9 @@ import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase 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.ObserveOfflineFilesUseCase 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.fold import com.wire.kalium.common.functional.onFailure import com.wire.kalium.common.functional.onSuccess @@ -76,7 +75,9 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okio.Path.Companion.toPath +import java.io.File import javax.inject.Inject @Suppress("TooManyFunctions", "LongParameterList") @@ -97,6 +98,7 @@ class CellViewModel @Inject constructor( private val offlineFileDownloadController: OfflineFileDownloadController, private val observeOfflineFiles: ObserveOfflineFilesUseCase, private val deleteOfflineFile: DeleteOfflineFileUseCase, + private val getOfflineFile: GetOfflineFileUseCase, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -117,14 +119,8 @@ class CellViewModel @Inject constructor( private val _isDeleteInProgress = MutableStateFlow(false) val isDeleteInProgress = _isDeleteInProgress.asStateFlow() - /** Public map of uuid → open-load state for screens that build their own paging flow (e.g. Search). */ - internal val openLoadStates: StateFlow> = openFileDownloadController.openLoadStates - internal val fileReadyFlow: Flow = sharedPathCache.fileReadyEvents - /** Cached local file paths from completed open-downloads, keyed by uuid. Used by Search screen overlay. */ - internal val cachedLocalPaths: StateFlow> = sharedPathCache.paths - private val removedItemsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) // Used to navigate to the root of the recycle bin after restoring a parent folder. @@ -203,30 +199,33 @@ class CellViewModel @Inject constructor( ), ).cachedIn(viewModelScope), removedItemsFlow, - openFileDownloadController.openLoadStates, - sharedPathCache.paths, + sharedPathCache.openLoadStates, offlineDataFlow, - ) { pagingData, removedItems, openLoadStates, cachedPaths, (offlineFiles, downloadProgresses) -> - val offlinePathsMap = offlineFiles.associate { it.id to it.localPath } + ) { pagingData, removedItems, openLoadStates, (offlineFiles, downloadProgresses) -> + val offlineUuids = offlineFiles.map { it.id }.toSet() var emittedRefreshDone = false - pagingData - .filter { node: Node -> node.uuid !in removedItems } - .map { node -> - if (!emittedRefreshDone) { - emittedRefreshDone = true - - if (_isPullToRefresh.value) { - _isPullToRefresh.value = false - } + pagingData + .filter { node: Node -> node.uuid !in removedItems } + .map { node -> + if (!emittedRefreshDone) { + emittedRefreshDone = true - _pagingRefreshDone.tryEmit(Unit) + if (_isPullToRefresh.value) { + _isPullToRefresh.value = false } + _pagingRefreshDone.tryEmit(Unit) + } + val openLoadState = openLoadStates[node.uuid] when (node) { is Node.Folder -> node.toUiModel() - is Node.File -> node.toUiModel().withOpenLoadState(openLoadState) + is Node.File -> node.toUiModel( + openLoadState = openLoadState, + downloadProgress = downloadProgresses[node.uuid], + isAvailableOffline = node.uuid in offlineUuids, + ) } } } @@ -281,6 +280,7 @@ class CellViewModel @Inject constructor( when { cellNode.openLoadState is OpenLoadState.Ready -> openLocalFile(cellNode) cellNode.openLoadState is OpenLoadState.Loading -> cancelOpenDownload(cellNode.uuid) + cellNode.downloadProgress != null -> offlineFileDownloadController.cancel(cellNode.uuid, viewModelScope) cellNode.localFileAvailable() -> openLocalFile(cellNode) cellNode.openLoadState is OpenLoadState.Error -> startOpenDownload(cellNode) cellNode.canOpenWithUrl() -> openFileContentUrl(cellNode) @@ -298,7 +298,7 @@ class CellViewModel @Inject constructor( } internal fun cancelOpenDownload(uuid: String) { - openFileDownloadController.cancel(uuid) + openFileDownloadController.cancel(uuid, viewModelScope) } private fun onFolderClick(cellNode: CellNodeUi.Folder) { @@ -388,7 +388,7 @@ class CellViewModel @Inject constructor( is CellFileActionsMenu.Edit -> editNode(result.node.uuid) is CellFileActionsMenu.Share -> shareFile(result.node) is CellFileActionsMenu.CancelLoading -> cancelDownload(result.node.uuid) - is CellFileActionsMenu.CancelDownload -> offlineFileDownloadController.cancel(result.node.uuid) + is CellFileActionsMenu.CancelDownload -> offlineFileDownloadController.cancel(result.node.uuid, viewModelScope) is CellFileActionsMenu.MakeAvailableOffline -> makeAvailableOffline(result.node) is CellFileActionsMenu.RemoveOfflineAccess -> removeOfflineAccess(result.node) is CellFileActionsMenu.Download -> Unit // unused in this context @@ -406,7 +406,22 @@ class CellViewModel @Inject constructor( } private fun removeOfflineAccess(node: CellNodeUi.File) = viewModelScope.launch { + val localPath = getOfflineFile(node.uuid)?.localPath + ?: node.localPath + ?: sharedPathCache.getCompletedPath(node.uuid) + + // Remove the DB record so the UI stops showing the offline indicator. deleteOfflineFile(node.uuid) + + // Delete the physical file from device storage + localPath?.takeIf { it.isNotBlank() }?.let { path -> + withContext(kotlinx.coroutines.Dispatchers.IO) { + File(path).delete() + } + } + + sharedPathCache.clearCompletedPath(node.uuid) + sharedPathCache.clearOpenLoadState(node.uuid) } internal fun editNode(nodeUuid: String) = viewModelScope.launch { @@ -549,7 +564,9 @@ internal data object ShowOfflineFileSaved : CellViewAction internal enum class CellError(val message: Int) { NO_APP_FOUND(R.string.no_app_found), - OTHER_ERROR(R.string.action_failed) + OTHER_ERROR(R.string.action_failed), + DOWNLOAD_FAILED(R.string.action_failed), + NO_SPACE_LEFT(R.string.no_space_left_error), } data class MenuOptions( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt new file mode 100644 index 00000000000..f68254422c0 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt @@ -0,0 +1,63 @@ +/* + * 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 + +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.error.StorageFailure +import java.io.IOException + +/** + * Returns true when this [CoreFailure] represents a "no space left on device" condition. + * + * Detection strategy: + * 1. [StorageFailure.Generic] — Kalium wraps file-write IOExceptions here in some paths. + * 2. [NetworkFailure.ServerMiscommunication] — CellsDataSource.downloadFile catches all + * Exceptions (including ENOSPC IOExceptions from okio sink writes) and wraps them as + * ServerMiscommunication. We traverse rootCause to detect the underlying IOException. + * 3. Full cause-chain walk so any wrapping layer doesn't hide the ENOSPC signal. + * 4. Match against multiple OS-level ENOSPC message variants (POSIX, Android, Windows-like). + */ +internal fun CoreFailure.isNoSpaceLeft(): Boolean = when (this) { + is StorageFailure.Generic -> rootCause.causedByNoSpace() + is NetworkFailure.ServerMiscommunication -> rootCause.causedByNoSpace() + else -> false +} + +/** + * Walks the full [Throwable] cause chain searching for an [IOException] whose message + * indicates a full-disk condition. + */ +private fun Throwable.causedByNoSpace(): Boolean { + var current: Throwable? = this + while (current != null) { + if (current is IOException && current.message.isNoSpaceMessage()) return true + current = current.cause + } + return false +} + +private fun String?.isNoSpaceMessage(): Boolean { + if (this == null) return false + return contains("ENOSPC", ignoreCase = true) || + contains("no space left", ignoreCase = true) || + contains("not enough space", ignoreCase = true) || + contains("device is full", ignoreCase = true) || + contains("disk full", ignoreCase = true) +} + 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 34bca88ad07..e7895442d52 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 @@ -23,17 +23,22 @@ import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.offline.OfflineFileInfo import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase -import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.onSuccess import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.Path import okio.Path.Companion.toOkioPath +import java.io.File import javax.inject.Inject /** @@ -45,34 +50,55 @@ class OfflineFileDownloadController @Inject constructor( private val fileHelper: FileHelper, private val fileNameResolver: FileNameResolver, private val saveOfflineFile: SaveOfflineFileUseCase, + private val sharedPathCache: CellFileLocalPathCache, ) { private val _downloadProgresses = MutableStateFlow>(emptyMap()) - private val _activeJobs = MutableStateFlow>(emptyMap()) - - /** Maps uuid → download progress (0f–1f) for all active offline downloads. */ internal val downloadProgresses: StateFlow> = _downloadProgresses.asStateFlow() + private data class ActiveDownload(val job: Job, val filePath: Path) + private val activeJobs = mutableMapOf() + internal fun start( scope: CoroutineScope, cellNode: CellNodeUi.File, onSuccess: (localPath: String) -> Unit, onError: (CellError) -> Unit, ) { - // Cancel any previous download for this node - _activeJobs.value[cellNode.uuid]?.cancel() - - val job = scope.launch { - val nodeName = cellNode.name ?: run { - onError(CellError.OTHER_ERROR) - return@launch + // If the file already exists locally (loaded this session or stored in DB), + // skip the download and just persist the offline metadata. + val existingPath = cellNode.localPath ?: sharedPathCache.getCompletedPath(cellNode.uuid) + if (existingPath != null) { + val nodeName = cellNode.name ?: run { onError(CellError.OTHER_ERROR); return } + scope.launch { + saveOfflineFile( + OfflineFileInfo( + id = cellNode.uuid, + name = nodeName, + owner = cellNode.ownerUserId ?: "", + localPath = existingPath, + size = cellNode.size, + downloadedAt = System.currentTimeMillis(), + ) + ) + onSuccess(existingPath) } + return + } + + // Cancel any previous download for this node. + activeJobs.remove(cellNode.uuid)?.job?.cancel() - val externalDir = fileHelper.getExternalFilesDir() - val filePath = fileNameResolver.getUniqueFile(externalDir, nodeName).toPath().toOkioPath() + val nodeName = cellNode.name ?: run { onError(CellError.OTHER_ERROR); return } + val filePath = fileNameResolver + .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) + .toPath() + .toOkioPath() - _downloadProgresses.update { it.toMutableMap().apply { put(cellNode.uuid, null) }.toImmutableMap() } + val job = scope.launch { + val thisJob = coroutineContext.job + setProgress(cellNode.uuid, null) - download( + val result = download( assetId = cellNode.uuid, outFilePath = filePath, remoteFilePath = cellNode.remotePath, @@ -80,49 +106,58 @@ class OfflineFileDownloadController @Inject constructor( name = cellNode.name, ownerId = cellNode.ownerUserId, ) { progress -> - launch { + if (thisJob.isActive) { val assetSize = cellNode.size ?: 0 if (assetSize > 0) { val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) - _downloadProgresses.update { - it.toMutableMap().apply { put(cellNode.uuid, progressValue) }.toImmutableMap() - } + setProgress(cellNode.uuid, progressValue) } } } - .onSuccess { - _downloadProgresses.update { it.toMutableMap().apply { remove(cellNode.uuid) }.toImmutableMap() } - saveOfflineFile( - OfflineFileInfo( - id = cellNode.uuid, - name = nodeName, - owner = cellNode.ownerUserId ?: "", - localPath = filePath.toString(), - size = cellNode.size, - downloadedAt = System.currentTimeMillis(), - ) + + result.onSuccess { + clearProgress(cellNode.uuid) + sharedPathCache.recordCompletedPath(cellNode.uuid, filePath.toString()) + saveOfflineFile( + OfflineFileInfo( + id = cellNode.uuid, + name = nodeName, + owner = cellNode.ownerUserId ?: "", + localPath = filePath.toString(), + size = cellNode.size, + downloadedAt = System.currentTimeMillis(), ) - onSuccess(filePath.toString()) - } - .onFailure { - _downloadProgresses.update { it.toMutableMap().apply { remove(cellNode.uuid) }.toImmutableMap() } - onError(CellError.DOWNLOAD_FAILED) - } - } + ) + onSuccess(filePath.toString()) + } - _activeJobs.update { it.toMutableMap().apply { put(cellNode.uuid, job) }.toImmutableMap() } - job.invokeOnCompletion { - _activeJobs.update { it.toMutableMap().apply { remove(cellNode.uuid) }.toImmutableMap() } + if (result is Either.Left) { + clearProgress(cellNode.uuid) + // Delete the partial file so no disk space is wasted + withContext(Dispatchers.IO) { File(filePath.toString()).delete() } + onError(if (result.value.isNoSpaceLeft()) CellError.NO_SPACE_LEFT else CellError.DOWNLOAD_FAILED) + } } + + activeJobs[cellNode.uuid] = ActiveDownload(job, filePath) + job.invokeOnCompletion { activeJobs.remove(cellNode.uuid) } } - internal fun cancel(uuid: String) { - var job: Job? = null - _activeJobs.update { current -> - job = current[uuid] - current.toMutableMap().apply { remove(uuid) }.toImmutableMap() - } - job?.cancel() + internal fun cancel(uuid: String, scope: CoroutineScope) { + val active = activeJobs.remove(uuid) ?: return + active.job.cancel() + clearProgress(uuid) + // Delete the partial file left by the cancelled download. + scope.launch(Dispatchers.IO) { File(active.filePath.toString()).delete() } + } + + private fun setProgress(uuid: String, progress: Float?) { + _downloadProgresses.update { it.toMutableMap().apply { put(uuid, progress) }.toImmutableMap() } + sharedPathCache.setDownloadProgress(uuid, progress) + } + + private fun clearProgress(uuid: String) { _downloadProgresses.update { it.toMutableMap().apply { remove(uuid) }.toImmutableMap() } + sharedPathCache.clearDownloadProgress(uuid) } } 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 77f8c573f2a..685f4639c61 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 @@ -17,23 +17,24 @@ */ package com.wire.android.feature.cells.ui +import com.wire.android.feature.cells.ui.OpenFileDownloadController.Companion.SPINNER_THRESHOLD_MS import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.util.FileHelper import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase -import com.wire.kalium.common.functional.onFailure +import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.onSuccess -import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.Path import okio.Path.Companion.toOkioPath +import java.io.File import javax.inject.Inject /** @@ -52,14 +53,10 @@ class OpenFileDownloadController @Inject constructor( private val fileNameResolver: FileNameResolver, private val sharedPathCache: CellFileLocalPathCache, ) { - // Active download jobs keyed by asset uuid. All access is from viewModelScope (main thread). - private val activeDownloads = mutableMapOf() + private data class ActiveDownload(val job: Job, val filePath: Path) + private val activeDownloads = mutableMapOf() - private val _openDownloads = MutableStateFlow>(emptyMap()) - - private val _openLoadStates = MutableStateFlow>(emptyMap()) - - internal val openLoadStates: StateFlow> = _openLoadStates.asStateFlow() + internal val openLoadStates = sharedPathCache.openLoadStates internal fun start( scope: CoroutineScope, @@ -77,18 +74,16 @@ class OpenFileDownloadController @Inject constructor( } // Cancel any in-progress download for this file (e.g. rapid retries after an error). - activeDownloads.remove(cellNode.uuid)?.cancel() + activeDownloads.remove(cellNode.uuid)?.job?.cancel() - activeDownloads[cellNode.uuid] = scope.launch { - val nodeName = cellNode.name ?: run { - onError(CellError.OTHER_ERROR) - return@launch - } + val nodeName = cellNode.name ?: run { onError(CellError.OTHER_ERROR); return } + val filePath = fileNameResolver + .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) + .toPath() + .toOkioPath() - val filePath = fileNameResolver - .getUniqueFile(fileHelper.getCacheDir(), nodeName) - .toPath() - .toOkioPath() + val job = scope.launch { + val thisJob = coroutineContext.job // After SPINNER_THRESHOLD_MS show the spinner. Cancelled immediately if the download finishes first. val showSpinnerJob = launch { @@ -96,14 +91,15 @@ class OpenFileDownloadController @Inject constructor( sharedPathCache.setOpenLoadState(cellNode.uuid, OpenLoadState.Loading()) } - download( + val result = download( assetId = cellNode.uuid, outFilePath = filePath, remoteFilePath = cellNode.remotePath, assetSize = cellNode.size ?: 0, ) { bytesDownloaded -> - // Only emit progress updates after the spinner threshold has been crossed. - if (sharedPathCache.openLoadStates.value.containsKey(cellNode.uuid)) { + if (thisJob.isActive && + sharedPathCache.openLoadStates.value.containsKey(cellNode.uuid) + ) { val total = cellNode.size ?: 0 if (total > 0) { val progress = (bytesDownloaded.toFloat() / total).coerceIn(0f, 1f) @@ -111,56 +107,55 @@ class OpenFileDownloadController @Inject constructor( } } } - .onSuccess { - val pathStr = filePath.toString() - // Record in session guard so repeat taps open immediately even if the - // paging source hasn't refreshed yet with the new localPath from the DB. - sharedPathCache.recordCompletedPath(cellNode.uuid, pathStr) - val spinnerWasShown = sharedPathCache.openLoadStates.value.containsKey(cellNode.uuid) - showSpinnerJob.cancel() - activeDownloads -= cellNode.uuid - if (!spinnerWasShown) { - // Fast path ( - states.filterValues { it !is OpenLoadState.Error }.toImmutableMap() - } - - private fun setLoadState(uuid: String, state: OpenLoadState) = - _openLoadStates.update { it.toMutableMap().apply { put(uuid, state) }.toImmutableMap() } - - private fun clearLoadState(uuid: String) = - _openLoadStates.update { it.toMutableMap().apply { remove(uuid) }.toImmutableMap() } - companion object { - private const val SPINNER_THRESHOLD_MS = 400L + internal const val SPINNER_THRESHOLD_MS = 400L private const val READY_BADGE_DISMISS_MS = 3_000L } } 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 535850d2de0..0186e14b81e 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 @@ -37,14 +37,16 @@ sealed class CellNodeUi { abstract val remotePath: String? abstract val size: Long? abstract val tags: List - abstract val openLoadState: OpenLoadState?, + internal abstract val openLoadState: OpenLoadState? abstract val downloadProgress: Float? /** True when this file has been saved for offline use (persisted in the offline files DB). */ abstract val isAvailableOffline: Boolean + val isOpenLoading: Boolean get() = openLoadState is OpenLoadState.Loading + val openLoadProgress: Float? get() = (openLoadState as? OpenLoadState.Loading)?.progress - data class Folder( + data class Folder internal constructor( override val name: String?, override val uuid: String, override val userName: String?, @@ -56,12 +58,12 @@ sealed class CellNodeUi { override val remotePath: String? = null, override val size: Long?, override val tags: List = emptyList(), - override val openLoadState: OpenLoadState? = null, + internal override val openLoadState: OpenLoadState? = null, override val downloadProgress: Float? = null, override val isAvailableOffline: Boolean = false, ) : CellNodeUi() - data class File( + data class File internal constructor( override val name: String?, override val uuid: String, override val userName: String?, @@ -80,19 +82,23 @@ sealed class CellNodeUi { val previewUrl: String? = null, override val tags: List = emptyList(), val isEditSupported: Boolean = false, - override val openLoadState: OpenLoadState? = null, + internal override val openLoadState: OpenLoadState? = null, override val downloadProgress: Float? = null, override val isAvailableOffline: Boolean = false, ) : CellNodeUi() } -internal fun Node.File.toUiModel() = CellNodeUi.File( +internal fun Node.File.toUiModel( + openLoadState: OpenLoadState? = null, + downloadProgress: Float? = null, + isAvailableOffline: Boolean = false, +) = CellNodeUi.File( uuid = uuid, name = name, mimeType = mimeType, assetType = AttachmentFileType.fromMimeType(mimeType), size = size, - localPath = localPath, + localPath = (openLoadState as? OpenLoadState.Ready)?.localPath?.toString() ?: localPath, remotePath = remotePath, contentHash = contentHash, contentUrl = contentUrl, @@ -105,6 +111,9 @@ internal fun Node.File.toUiModel() = CellNodeUi.File( modifiedTime = formattedModifiedTime(), tags = tags, isEditSupported = isEditSupported, + openLoadState = openLoadState, + downloadProgress = downloadProgress, + isAvailableOffline = isAvailableOffline, ) internal fun Node.Folder.toUiModel() = CellNodeUi.Folder( @@ -129,11 +138,15 @@ private fun Node.Folder.formattedModifiedTime() = modifiedTime?.let { Instant.fromEpochMilliseconds(it).cellFileDateTime() } -internal fun CellNodeUi.File.withOpenLoadState( - state: OpenLoadState?, +internal fun CellNodeUi.File.withSessionState( + openLoadState: OpenLoadState?, + downloadProgress: Float?, + isAvailableOffline: Boolean, ): CellNodeUi.File = copy( - openLoadState = state, - localPath = (state as? OpenLoadState.Ready)?.localPath?.toString() ?: localPath, + openLoadState = openLoadState, + localPath = (openLoadState as? OpenLoadState.Ready)?.localPath?.toString() ?: localPath, + downloadProgress = downloadProgress, + isAvailableOffline = isAvailableOffline, ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt index 321b827f6c3..fbb8e63e148 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreenViewModel.kt @@ -29,7 +29,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.SearchScree import com.wire.android.feature.cells.ui.CellFileLocalPathCache import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.toUiModel -import com.wire.android.feature.cells.ui.model.withOpenLoadState +import com.wire.android.feature.cells.ui.model.withSessionState import com.wire.android.feature.cells.ui.search.filter.data.FilterConversationUi import com.wire.android.feature.cells.ui.search.filter.data.FilterOwnerUi import com.wire.android.feature.cells.ui.search.filter.data.FilterTagUi @@ -48,6 +48,7 @@ import com.wire.kalium.cells.domain.usecase.GetOwnersUseCase import com.wire.kalium.cells.domain.usecase.GetOwnersUseCaseResult import com.wire.kalium.cells.domain.usecase.GetPaginatedCellConversationsFlowUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase +import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.user.UserAssetId @@ -78,6 +79,7 @@ class SearchScreenViewModel @Inject constructor( private val getOwners: GetOwnersUseCase, private val getPaginatedConversations: GetPaginatedCellConversationsFlowUseCase, private val sharedPathCache: CellFileLocalPathCache, + private val observeOfflineFiles: ObserveOfflineFilesUseCase, ) : ViewModel() { private data class SearchParams( @@ -178,10 +180,17 @@ class SearchScreenViewModel @Inject constructor( } }.cachedIn(viewModelScope), sharedPathCache.openLoadStates, - ) { pagingData, states -> + sharedPathCache.downloadProgresses, + observeOfflineFiles(), + ) { pagingData, openLoadStates, downloadProgresses, offlineFiles -> + val offlineUuids = offlineFiles.map { it.id }.toSet() pagingData.map { node -> if (node is CellNodeUi.File) { - node.withOpenLoadState(states[node.uuid]) + node.withSessionState( + openLoadState = openLoadStates[node.uuid], + downloadProgress = downloadProgresses[node.uuid], + isAvailableOffline = node.uuid in offlineUuids, + ) } else { node } diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 125038029a1..5cc6a00a466 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -64,6 +64,7 @@ Unable to load public link. Unable to create public link. Download failed. + Not enough storage space to download the file. This file is not downloaded yet. Do you want to download it now? Download Downloading file… @@ -76,6 +77,7 @@ Folder Name Loading files… Tap to cancel loading + Tap to cancel download Unable to load, retry Ready to open Cancel loading… 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 ba82692c7ee..1e8edd502b8 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 @@ -38,15 +38,20 @@ 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.download.DownloadCellFileUseCase +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 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 io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -111,7 +116,8 @@ class CellViewModelTest { .withLoadSuccess() .arrange() - val items = viewModel.nodesFlow.asSnapshot() + val pagingData = viewModel.nodesFlow.first() + val items = flowOf(pagingData).asSnapshot() assertEquals(items.size, 2) coVerify(exactly = 1) { arrangement.getCellFilesPagedUseCase(any(), any(), any(), any()) } @@ -161,7 +167,7 @@ class CellViewModelTest { advanceUntilIdle() // Download use case was called - coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } } @Test @@ -177,7 +183,7 @@ class CellViewModelTest { viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) advanceUntilIdle() - coVerify(exactly = 0) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } } @@ -213,8 +219,11 @@ class CellViewModelTest { .toUiModel() viewModel.sendIntent(CellViewIntent.OnNodeDeleteConfirmed(testFile)) + advanceUntilIdle() - with(viewModel.nodesFlow.asSnapshot()) { + // nodesFlow is hot — take the current PagingData and wrap it for asSnapshot() to terminate. + val pagingData = viewModel.nodesFlow.first() + with(flowOf(pagingData).asSnapshot()) { assertFalse(contains(testFile)) } } @@ -287,6 +296,15 @@ class CellViewModelTest { @MockK lateinit var getWireCellsConfig: GetWireCellConfigurationUseCase + @MockK + lateinit var observeOfflineFiles: ObserveOfflineFilesUseCase + + @MockK + lateinit var deleteOfflineFile: DeleteOfflineFileUseCase + + @MockK + lateinit var getOfflineFile: GetOfflineFileUseCase + init { MockKAnnotations.init(this, relaxUnitFun = true) @@ -301,6 +319,9 @@ class CellViewModelTest { coEvery { isCellAvailableUseCase.invoke() } returns true.right() + every { observeOfflineFiles() } returns emptyFlow() + coEvery { getOfflineFile(any()) } returns null + coEvery { getCellFilesPagedUseCase.invoke(any(), any(), any(), any()) } returns flowOf( PagingData.from( data = listOf( @@ -329,11 +350,11 @@ class CellViewModelTest { } fun withDownloadSuccess() = apply { - coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any()) } returns Unit.right() + coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() } fun withSlowDownloadSuccess() = apply { - coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any()) } coAnswers { + coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } coAnswers { delay(500) // Simulate download taking 500ms (longer than the 300ms threshold) Unit.right() } @@ -349,7 +370,7 @@ class CellViewModelTest { fun arrange(): Pair { - every { fileHelper.getCacheDir() } returns File("") + every { fileHelper.getExternalFilesDir() } returns File("") every { fileNameResolver.getUniqueFile(any(), any()) } returns File("") coEvery { getWireCellsConfig() } returns null @@ -361,6 +382,14 @@ class CellViewModelTest { sharedPathCache = sharedPathCache, ) + val offlineFileDownloadController = OfflineFileDownloadController( + download = downloadCellFileUseCase, + fileHelper = fileHelper, + fileNameResolver = fileNameResolver, + saveOfflineFile = mockk(relaxUnitFun = true), + sharedPathCache = sharedPathCache, + ) + return this to CellViewModel( savedStateHandle = savedStateHandle, getCellFilesPaged = getCellFilesPagedUseCase, @@ -374,6 +403,10 @@ class CellViewModelTest { getWireCellsConfig = getWireCellsConfig, sharedPathCache = sharedPathCache, openFileDownloadController = openFileDownloadController, + offlineFileDownloadController = offlineFileDownloadController, + observeOfflineFiles = observeOfflineFiles, + deleteOfflineFile = deleteOfflineFile, + getOfflineFile = getOfflineFile, ) } } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index 35a79a0a951..a69c90bda56 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -95,7 +95,7 @@ class OpenFileDownloadControllerTest { assertEquals(fileWithLocalPath.uuid, openedFile?.uuid) assertTrue(controller.openLoadStates.value.isEmpty(), "No load state should be set") - coVerify(exactly = 0) { arrangement.downloadUseCase(any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { arrangement.downloadUseCase(any(), any(), any(), any(), any(), any(), any()) } } @Test @@ -126,12 +126,11 @@ class OpenFileDownloadControllerTest { arrangement.sharedPathCache.fileReadyEvents.test { controller.start( scope = this@runTest, - scope = this, - cellNode = testFile, - onOpenFile = {}, - onError = {}, - ) - advanceUntilIdle() + cellNode = testFile, + onOpenFile = {}, + onError = {}, + ) + advanceUntilIdle() expectNoEvents() // fast path must NOT show snackbar } @@ -167,7 +166,7 @@ class OpenFileDownloadControllerTest { onError = {}, ) // Advance past the spinner (400 ms) and the download (500 ms) but NOT past the - // auto-dismiss delay (3 000 ms) — otherwise the state would already be cleared. + // auto-dismiss delay (3 000 ms) —otherwise the state would already be cleared. advanceTimeBy(501) assertTrue( @@ -282,6 +281,56 @@ class OpenFileDownloadControllerTest { ) } + @Test + fun givenDownloadCancelledThenRestarted_whenStaleProgressCallbackFires_thenProgressResetsToZero() = runTest { + // Capture the progress callback from the FIRST (cancelled) download so we can invoke + // it manually after the second download has already started — simulating a slow network + // layer that keeps delivering bytes after the coroutine job was cancelled. + var capturedOldProgressCallback: ((Long) -> Unit)? = null + val (arrangement, controller) = Arrangement() + .also { arr -> + coEvery { arr.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any()) } coAnswers { + val onProgressUpdate = arg<(Long) -> Unit>(6) + capturedOldProgressCallback = onProgressUpdate + delay(10_000L) // very long — cancelled before completing + Unit.right() + } + } + .arrange() + + // Start first download and advance past the spinner so Loading state is shown. + controller.start(scope = this, cellNode = testFile.copy(size = 1024L), onOpenFile = {}, onError = {}) + advanceTimeBy(SPINNER_THRESHOLD_MS + 1) + assertEquals(OpenLoadState.Loading(), controller.openLoadStates.value[testFile.uuid]) + + // Cancel the first download. + controller.cancel(testFile.uuid) + assertNull(controller.openLoadStates.value[testFile.uuid], "State must be cleared on cancel") + + // Re-configure the mock so the second download is also slow (but we control it). + coEvery { arrangement.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any()) } coAnswers { + delay(10_000L) // stays in-flight during the test + Unit.right() + } + + // Start the second download. + controller.start(scope = this, cellNode = testFile.copy(size = 1024L), onOpenFile = {}, onError = {}) + advanceTimeBy(SPINNER_THRESHOLD_MS + 1) + // The new download shows the spinner at 0 progress. + assertEquals(OpenLoadState.Loading(), controller.openLoadStates.value[testFile.uuid]) + + // NOW simulate the stale callback from the first (cancelled) download firing at 75%. + capturedOldProgressCallback?.invoke(768L) // 768/1024 = 0.75f + + // The stale callback must be silently dropped — progress stays at 0, not 0.75. + val stateAfterStaleCallback = controller.openLoadStates.value[testFile.uuid] + assertEquals( + OpenLoadState.Loading(), + stateAfterStaleCallback, + "Stale progress from cancelled download must not update the new download's progress" + ) + } + @Test fun givenProgressUpdate_whenDownloadProgresses_thenLoadingProgressReflected() = runTest { val (_, controller) = Arrangement() @@ -314,8 +363,6 @@ class OpenFileDownloadControllerTest { // 500 ms slow download so testFile stays in Loading. advanceTimeBy(1) // anotherFile failure is already settled; testFile still downloading - controller.clearAllErrorStates() - assertNull(controller.openLoadStates.value[anotherFile.uuid], "Error state should be removed") assertNotNull(controller.openLoadStates.value[testFile.uuid], "Loading state should be preserved") } @@ -357,29 +404,29 @@ class OpenFileDownloadControllerTest { init { MockKAnnotations.init(this, relaxUnitFun = true) - every { fileHelper.getCacheDir() } returns File("") + every { fileHelper.getExternalFilesDir() } returns File("") every { fileNameResolver.getUniqueFile(any(), any()) } returns File("report.pdf") } fun withDownloadSuccess(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } returns Unit.right() + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } returns Unit.right() } fun withSlowDownloadSuccess(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } coAnswers { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } coAnswers { delay(500) // Exceeds 400 ms spinner threshold Unit.right() } } fun withDownloadFailure(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } returns + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } returns StorageFailure.DataNotFound.left() } fun withProgressThenSuccess(progress: Long, uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any()) } coAnswers { - val onProgressUpdate = arg<(Long) -> Unit>(4) + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } coAnswers { + val onProgressUpdate = arg<(Long) -> Unit>(6) delay(450) // Clearly after spinner threshold (400 ms) — progress updates Loading() onProgressUpdate(progress) delay(50) // download finishes at 500 ms total From e629614049c8f48bdb51ed65b94487ab8d7634bb Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 7 May 2026 16:22:17 +0200 Subject: [PATCH 17/44] chore: cleanup --- .../feature/cells/ui/CellFileActionsMenu.kt | 1 - .../android/feature/cells/ui/CellListItem.kt | 1 - .../android/feature/cells/ui/CellViewModel.kt | 21 +++---------------- 3 files changed, 3 insertions(+), 20 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 f247ee0c865..0327daf67fa 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 @@ -114,7 +114,6 @@ class CellFileActionsMenu @Inject constructor( internal data class Edit(val node: CellNodeUi) : MenuActionResult internal data class CancelLoading(val node: CellNodeUi) : MenuActionResult internal data class CancelDownload(val node: CellNodeUi) : MenuActionResult - internal data class Download(val node: CellNodeUi) : MenuActionResult internal data class MakeAvailableOffline(val node: CellNodeUi.File) : MenuActionResult internal data class RemoveOfflineAccess(val node: CellNodeUi.File) : MenuActionResult 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 93777d28f08..d377b7f0210 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 @@ -82,7 +82,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import com.wire.android.ui.common.R as commonR -@Suppress("CyclomaticComplexMethod") @Composable internal fun CellListItem( cell: CellNodeUi, 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 3f64babccee..c2267803fb1 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 @@ -154,21 +154,6 @@ class CellViewModel @Inject constructor( checkCellAvailabilityAndRefresh() } - /** Offline paths from db: uuid → localPath. Shared across all CellViewModel instances via the Flow. */ - private val offlinePathsFlow = observeOfflineFiles() - .shareIn(viewModelScope, started = SharingStarted.Eagerly, replay = 1) - - /** - * Combined offline data: pair of (uuid → localPath from DB) and (uuid → download progress). - * Merges two related sources into one so we don't exceed the 5-flow combine limit. - */ - private val offlineDataFlow = combine( - offlinePathsFlow, - offlineFileDownloadController.downloadProgresses, - ) { offlineFiles, downloadProgresses -> - Pair(offlineFiles, downloadProgresses) - } - private fun checkCellAvailabilityAndRefresh() = viewModelScope.launch { val cellAvailable = isCellAvailable().fold({ false }, { it }) cellAvailableFlow.value = cellAvailable @@ -200,8 +185,9 @@ class CellViewModel @Inject constructor( ).cachedIn(viewModelScope), removedItemsFlow, sharedPathCache.openLoadStates, - offlineDataFlow, - ) { pagingData, removedItems, openLoadStates, (offlineFiles, downloadProgresses) -> + observeOfflineFiles(), + offlineFileDownloadController.downloadProgresses, + ) { pagingData, removedItems, openLoadStates, offlineFiles, downloadProgresses -> val offlineUuids = offlineFiles.map { it.id }.toSet() var emittedRefreshDone = false @@ -391,7 +377,6 @@ class CellViewModel @Inject constructor( is CellFileActionsMenu.CancelDownload -> offlineFileDownloadController.cancel(result.node.uuid, viewModelScope) is CellFileActionsMenu.MakeAvailableOffline -> makeAvailableOffline(result.node) is CellFileActionsMenu.RemoveOfflineAccess -> removeOfflineAccess(result.node) - is CellFileActionsMenu.Download -> Unit // unused in this context } } } From a6852d9a6b71282df9598c94fcfc79836e016f24 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 7 May 2026 17:22:52 +0200 Subject: [PATCH 18/44] chore: cleanup --- .../cells/ui/OfflineFileDownloadController.kt | 5 ++- .../cells/ui/OpenFileDownloadController.kt | 5 ++- .../feature/cells/ui/CellViewModelTest.kt | 1 + .../ui/OpenFileDownloadControllerTest.kt | 31 ++++++++++++------- .../ui/search/SearchScreenViewModelTest.kt | 8 +++++ 5 files changed, 32 insertions(+), 18 deletions(-) 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 e7895442d52..85c982578cc 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 @@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.job import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okio.Path import okio.Path.Companion.toOkioPath import java.io.File @@ -133,8 +132,8 @@ class OfflineFileDownloadController @Inject constructor( if (result is Either.Left) { clearProgress(cellNode.uuid) - // Delete the partial file so no disk space is wasted - withContext(Dispatchers.IO) { File(filePath.toString()).delete() } + // Fire-and-forget delete so the error callback is not blocked by IO. + launch(Dispatchers.IO) { File(filePath.toString()).delete() } onError(if (result.value.isNoSpaceLeft()) CellError.NO_SPACE_LEFT else CellError.DOWNLOAD_FAILED) } } 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 685f4639c61..08d91375300 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 @@ -31,7 +31,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.job import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okio.Path import okio.Path.Companion.toOkioPath import java.io.File @@ -133,8 +132,8 @@ class OpenFileDownloadController @Inject constructor( if (result is Either.Left) { showSpinnerJob.cancel() activeDownloads.remove(cellNode.uuid) - // Delete the partial file so no disk space is wasted - withContext(Dispatchers.IO) { File(filePath.toString()).delete() } + // Fire-and-forget delete so the state update below is not blocked by IO. + launch(Dispatchers.IO) { File(filePath.toString()).delete() } if (result.value.isNoSpaceLeft()) { sharedPathCache.clearOpenLoadState(cellNode.uuid) onError(CellError.NO_SPACE_LEFT) 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 1e8edd502b8..7bda85948ef 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 @@ -52,6 +52,7 @@ import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index a69c90bda56..53f50d36964 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -239,7 +239,7 @@ class OpenFileDownloadControllerTest { controller.start(scope = this, cellNode = testFile, onOpenFile = {}, onError = {}) advanceTimeBy(SPINNER_THRESHOLD_MS + 1) // spinner shown → Loading state - controller.cancel(testFile.uuid) + controller.cancel(testFile.uuid, this) assertNull(controller.openLoadStates.value[testFile.uuid], "Cancel should clear load state") } @@ -254,7 +254,7 @@ class OpenFileDownloadControllerTest { controller.start(scope = this, cellNode = testFile, onOpenFile = { openedFiles += it }, onError = {}) advanceTimeBy(100) - controller.cancel(testFile.uuid) + controller.cancel(testFile.uuid, this) advanceUntilIdle() assertTrue(openedFiles.isEmpty(), "File must not be opened after cancel") @@ -304,7 +304,7 @@ class OpenFileDownloadControllerTest { assertEquals(OpenLoadState.Loading(), controller.openLoadStates.value[testFile.uuid]) // Cancel the first download. - controller.cancel(testFile.uuid) + controller.cancel(testFile.uuid, this) assertNull(controller.openLoadStates.value[testFile.uuid], "State must be cleared on cancel") // Re-configure the mock so the second download is also slow (but we control it). @@ -349,22 +349,29 @@ class OpenFileDownloadControllerTest { } @Test - fun givenErrorAndLoadingStates_whenClearAllErrorStatesCalled_thenOnlyErrorsRemoved() = runTest { + fun givenSimultaneousDownloads_whenOneFails_thenErrorStateSetAndInProgressLoadingPreserved() = runTest { val (_, controller) = Arrangement() .withSlowDownloadSuccess(uuid = testFile.uuid) .withDownloadFailure(uuid = anotherFile.uuid) .arrange() - // testFile → Loading (slow, timer fires), anotherFile → Error (immediate failure) + // testFile → slow download (Loading after spinner), anotherFile → immediate failure (Error) controller.start(scope = this, cellNode = testFile, onOpenFile = {}, onError = {}) controller.start(scope = this, cellNode = anotherFile, onOpenFile = {}, onError = {}) - advanceTimeBy(SPINNER_THRESHOLD_MS + 1) // spinner for testFile shows Loading - // Advance only enough for anotherFile's (instant) download to fail — but NOT past the - // 500 ms slow download so testFile stays in Loading. - advanceTimeBy(1) // anotherFile failure is already settled; testFile still downloading + // Advance past spinner (400 ms) but NOT past testFile's download (500 ms) or its + // auto-dismiss delay (3 000 ms) — otherwise testFile's state would be cleared before asserts. + // anotherFile's failure is settled synchronously via UnconfinedTestDispatcher (no delay). + advanceTimeBy(SPINNER_THRESHOLD_MS + 1) - assertNull(controller.openLoadStates.value[anotherFile.uuid], "Error state should be removed") - assertNotNull(controller.openLoadStates.value[testFile.uuid], "Loading state should be preserved") + assertEquals( + OpenLoadState.Error, + controller.openLoadStates.value[anotherFile.uuid], + "Failed download should set Error state so the user can retry" + ) + assertNotNull( + controller.openLoadStates.value[testFile.uuid], + "In-progress download's Loading state should be preserved" + ) } private companion object { @@ -427,7 +434,7 @@ class OpenFileDownloadControllerTest { fun withProgressThenSuccess(progress: Long, uuid: String = testFile.uuid) = apply { coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } coAnswers { val onProgressUpdate = arg<(Long) -> Unit>(6) - delay(450) // Clearly after spinner threshold (400 ms) — progress updates Loading() + delay(450) onProgressUpdate(progress) delay(50) // download finishes at 500 ms total Unit.right() diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt index 7cc08ffd1d8..c119002f775 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/search/SearchScreenViewModelTest.kt @@ -30,6 +30,7 @@ import com.wire.kalium.cells.domain.usecase.GetOwnersUseCase import com.wire.kalium.cells.domain.usecase.GetOwnersUseCaseResult import com.wire.kalium.cells.domain.usecase.GetPaginatedCellConversationsFlowUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase +import com.wire.kalium.cells.domain.usecase.offline.ObserveOfflineFilesUseCase import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.functional.Either import com.wire.kalium.logic.data.id.ConversationId @@ -77,6 +78,9 @@ class SearchScreenViewModelTest { @MockK private lateinit var getPaginatedConversations: GetPaginatedCellConversationsFlowUseCase + @MockK + private lateinit var observeOfflineFiles: ObserveOfflineFilesUseCase + private val sharedPathCache = CellFileLocalPathCache() private lateinit var savedStateHandle: SavedStateHandle @@ -103,6 +107,7 @@ class SearchScreenViewModelTest { coEvery { getCellFilesPaged(any(), any(), any(), any()) } returns emptyFlow>() coEvery { getOwners(any()) } returns GetOwnersUseCaseResult.Failure(CoreFailure.InvalidEventSenderID) every { getPaginatedConversations(any()) } returns emptyFlow() + every { observeOfflineFiles() } returns emptyFlow() } @AfterEach @@ -166,6 +171,7 @@ class SearchScreenViewModelTest { getOwners = getOwners, getPaginatedConversations = getPaginatedConversations, sharedPathCache = sharedPathCache, + observeOfflineFiles = observeOfflineFiles, ) advanceUntilIdle() @@ -365,6 +371,7 @@ class SearchScreenViewModelTest { getOwners = getOwners, getPaginatedConversations = getPaginatedConversations, sharedPathCache = sharedPathCache, + observeOfflineFiles = observeOfflineFiles, ) } @@ -382,6 +389,7 @@ class SearchScreenViewModelTest { getOwners = getOwners, getPaginatedConversations = getPaginatedConversations, sharedPathCache = sharedPathCache, + observeOfflineFiles = observeOfflineFiles, ) } } From 5db198b9725f4451b28e84a1e6291f98a624a048 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 7 May 2026 17:54:31 +0200 Subject: [PATCH 19/44] chore: detekt --- .../feature/cells/ui/CellFileActionsMenu.kt | 15 ++++++++++----- .../android/feature/cells/ui/CellScreenContent.kt | 4 ---- .../feature/cells/ui/ConversationFilesScreen.kt | 2 -- .../feature/cells/ui/DownloadFailureUtils.kt | 1 - .../cells/ui/OfflineFileDownloadController.kt | 11 +++++++++-- .../cells/ui/OpenFileDownloadController.kt | 7 ++++++- .../android/feature/cells/ui/model/CellNodeUi.kt | 1 - 7 files changed, 25 insertions(+), 16 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 0327daf67fa..c1df86a5dc1 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 @@ -19,7 +19,6 @@ package com.wire.android.feature.cells.ui import com.wire.android.feature.cells.ui.model.CellNodeUi 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.isEditSupported import com.wire.android.feature.cells.ui.model.localFileAvailable import com.wire.kalium.logic.featureFlags.KaliumConfigs @@ -161,13 +160,19 @@ class CellFileActionsMenu @Inject constructor( NodeBottomSheetAction.CANCEL_LOADING -> CancelLoading(node) NodeBottomSheetAction.CANCEL_DOWNLOAD -> CancelDownload(node) NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE -> { - if (node is CellNodeUi.File) MakeAvailableOffline(node) - else Action(ShowPublicLinkScreen(node)) + if (node is CellNodeUi.File) { + MakeAvailableOffline(node) + } else { + Action(ShowPublicLinkScreen(node)) + } } NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS -> { - if (node is CellNodeUi.File) RemoveOfflineAccess(node) - else Action(ShowPublicLinkScreen(node)) + if (node is CellNodeUi.File) { + RemoveOfflineAccess(node) + } else { + Action(ShowPublicLinkScreen(node)) + } } } 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 3081aae7020..4e36950e243 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 @@ -34,7 +34,6 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -60,7 +59,6 @@ import com.wire.android.feature.cells.ui.dialog.DeleteConfirmationDialog import com.wire.android.feature.cells.ui.dialog.NodeActionsBottomSheet import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.CellNodeUi -import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData import com.wire.android.feature.cells.ui.recyclebin.RestoreConfirmationDialog import com.wire.android.feature.cells.ui.recyclebin.RestoreParentFolderConfirmationDialog @@ -74,8 +72,6 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.kalium.cells.domain.paging.FileListLoadError import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow @Suppress("CyclomaticComplexMethod") 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 07ab4d95f8d..3cba2524e53 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 @@ -61,7 +61,6 @@ import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet import com.wire.android.feature.cells.ui.model.CellNodeUi -import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.PreviewNavigator @@ -86,7 +85,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt index f68254422c0..b9735bf56c7 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt @@ -60,4 +60,3 @@ private fun String?.isNoSpaceMessage(): Boolean { contains("device is full", ignoreCase = true) || contains("disk full", ignoreCase = true) } - 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 85c982578cc..73a6931c519 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 @@ -57,6 +57,7 @@ class OfflineFileDownloadController @Inject constructor( private data class ActiveDownload(val job: Job, val filePath: Path) private val activeJobs = mutableMapOf() + @Suppress("LongMethod") internal fun start( scope: CoroutineScope, cellNode: CellNodeUi.File, @@ -67,7 +68,10 @@ class OfflineFileDownloadController @Inject constructor( // skip the download and just persist the offline metadata. val existingPath = cellNode.localPath ?: sharedPathCache.getCompletedPath(cellNode.uuid) if (existingPath != null) { - val nodeName = cellNode.name ?: run { onError(CellError.OTHER_ERROR); return } + val nodeName = cellNode.name ?: run { + onError(CellError.OTHER_ERROR) + return + } scope.launch { saveOfflineFile( OfflineFileInfo( @@ -87,7 +91,10 @@ class OfflineFileDownloadController @Inject constructor( // Cancel any previous download for this node. activeJobs.remove(cellNode.uuid)?.job?.cancel() - val nodeName = cellNode.name ?: run { onError(CellError.OTHER_ERROR); return } + val nodeName = cellNode.name ?: run { + onError(CellError.OTHER_ERROR) + return + } val filePath = fileNameResolver .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) .toPath() 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 08d91375300..8b658b2550b 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 @@ -53,6 +53,7 @@ class OpenFileDownloadController @Inject constructor( private val sharedPathCache: CellFileLocalPathCache, ) { private data class ActiveDownload(val job: Job, val filePath: Path) + private val activeDownloads = mutableMapOf() internal val openLoadStates = sharedPathCache.openLoadStates @@ -75,7 +76,11 @@ class OpenFileDownloadController @Inject constructor( // Cancel any in-progress download for this file (e.g. rapid retries after an error). activeDownloads.remove(cellNode.uuid)?.job?.cancel() - val nodeName = cellNode.name ?: run { onError(CellError.OTHER_ERROR); return } + val nodeName = cellNode.name ?: run { + onError(CellError.OTHER_ERROR) + return + } + val filePath = fileNameResolver .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) .toPath() 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 0186e14b81e..d7051b7f8b1 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 @@ -149,7 +149,6 @@ internal fun CellNodeUi.File.withSessionState( isAvailableOffline = isAvailableOffline, ) - internal fun CellNodeUi.File.localFileAvailable() = localPath != null internal fun CellNodeUi.File.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO, PDF) internal fun CellNodeUi.isEditSupported() = (this as? CellNodeUi.File)?.isEditSupported == true From 2a1bd5f55186a1670e987106ece570edd2019841 Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 8 May 2026 09:08:10 +0200 Subject: [PATCH 20/44] chore: detekt --- .../feature/cells/ui/CellFileActionsMenu.kt | 144 ++++++++++-------- 1 file changed, 83 insertions(+), 61 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 c1df86a5dc1..8e604ec5323 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,78 +35,100 @@ class CellFileActionsMenu @Inject constructor( isAllFiles: Boolean, isSearching: Boolean, isCollaboraEnabled: Boolean, - ): List = - when { - isRecycleBin -> { - buildList { - add(NodeBottomSheetAction.RESTORE) - add(NodeBottomSheetAction.DELETE_PERMANENTLY) - } - } + ): List { + return when { + isRecycleBin -> recycleBinActions() isAllFiles || isSearching -> { - buildList { - if (cellNode is CellNodeUi.File && cellNode.isOpenLoading) { - add(NodeBottomSheetAction.CANCEL_LOADING) - } else if (cellNode is CellNodeUi.File && cellNode.downloadProgress != null) { - add(NodeBottomSheetAction.CANCEL_DOWNLOAD) - } else { - if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { - add(NodeBottomSheetAction.SHARE) - } - add(NodeBottomSheetAction.PUBLIC_LINK) - if (cellNode is CellNodeUi.File) { - if (cellNode.isAvailableOffline) { - add(NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS) - } else { - add(NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE) - } - } - } - } + commonActions(cellNode) } isConversationFiles -> { - buildList { - if (cellNode is CellNodeUi.File && cellNode.isOpenLoading) { - add(NodeBottomSheetAction.CANCEL_LOADING) - } else if (cellNode is CellNodeUi.File && cellNode.downloadProgress != null) { - add(NodeBottomSheetAction.CANCEL_DOWNLOAD) - } else { - if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { - add(NodeBottomSheetAction.SHARE) - } - add(NodeBottomSheetAction.PUBLIC_LINK) - - if (cellNode is CellNodeUi.File) { - if (cellNode.isAvailableOffline) { - add(NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS) - } else { - add(NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE) - } - } - - if (isCollaboraEnabled && featureFlags.collaboraIntegration && cellNode.isEditSupported()) { - add(NodeBottomSheetAction.EDIT) - } - - if (featureFlags.collaboraIntegration && cellNode.isEditSupported()) { - add(NodeBottomSheetAction.VERSION_HISTORY) - } - - add(NodeBottomSheetAction.ADD_REMOVE_TAGS) - add(NodeBottomSheetAction.MOVE) - add(NodeBottomSheetAction.RENAME) - add(NodeBottomSheetAction.DELETE) + commonActions(cellNode) + + conversationActions( + cellNode = cellNode, + isCollaboraEnabled = isCollaboraEnabled, + ) + } + + else -> emptyList() + } + } + + private fun recycleBinActions(): List = listOf( + NodeBottomSheetAction.RESTORE, + NodeBottomSheetAction.DELETE_PERMANENTLY, + ) + + private fun commonActions( + cellNode: CellNodeUi, + ): List = buildList { + + if (cellNode is CellNodeUi.File) { + + when { + cellNode.isOpenLoading -> { + add(NodeBottomSheetAction.CANCEL_LOADING) + return@buildList + } + + cellNode.downloadProgress != null -> { + add(NodeBottomSheetAction.CANCEL_DOWNLOAD) + return@buildList + } + + else -> { + + if (cellNode.localFileAvailable()) { + add(NodeBottomSheetAction.SHARE) } + + add( + if (cellNode.isAvailableOffline) { + NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS + } else { + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE + }, + ) } } + } - else -> { - emptyList() - } + add(NodeBottomSheetAction.PUBLIC_LINK) + } + + private fun conversationActions( + cellNode: CellNodeUi, + isCollaboraEnabled: Boolean, + ): List = buildList { + + val canEdit = cellNode is CellNodeUi.File && + isCollaboraEnabled && + featureFlags.collaboraIntegration && + cellNode.isEditSupported() + + if (canEdit) { + add(NodeBottomSheetAction.EDIT) } + if ( + cellNode is CellNodeUi.File && + featureFlags.collaboraIntegration && + cellNode.isEditSupported() + ) { + add(NodeBottomSheetAction.VERSION_HISTORY) + } + + addAll( + listOf( + NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.MOVE, + NodeBottomSheetAction.RENAME, + NodeBottomSheetAction.DELETE, + ), + ) + } + internal sealed interface MenuActionResult internal data class Action(val action: CellViewAction) : MenuActionResult internal data class Share(val node: CellNodeUi.File) : MenuActionResult From fcd8bb1feebe26d06bab574c2360e1d0a861d2be Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 8 May 2026 09:52:54 +0200 Subject: [PATCH 21/44] chore: cleanup --- .../cells/ui/OfflineFileDownloadController.kt | 135 ++++++++++-------- .../cells/ui/OpenFileDownloadController.kt | 110 +++++++------- 2 files changed, 136 insertions(+), 109 deletions(-) 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 73a6931c519..41a20e507f5 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 @@ -57,7 +57,6 @@ class OfflineFileDownloadController @Inject constructor( private data class ActiveDownload(val job: Job, val filePath: Path) private val activeJobs = mutableMapOf() - @Suppress("LongMethod") internal fun start( scope: CoroutineScope, cellNode: CellNodeUi.File, @@ -68,23 +67,7 @@ class OfflineFileDownloadController @Inject constructor( // skip the download and just persist the offline metadata. val existingPath = cellNode.localPath ?: sharedPathCache.getCompletedPath(cellNode.uuid) if (existingPath != null) { - val nodeName = cellNode.name ?: run { - onError(CellError.OTHER_ERROR) - return - } - scope.launch { - saveOfflineFile( - OfflineFileInfo( - id = cellNode.uuid, - name = nodeName, - owner = cellNode.ownerUserId ?: "", - localPath = existingPath, - size = cellNode.size, - downloadedAt = System.currentTimeMillis(), - ) - ) - onSuccess(existingPath) - } + saveExistingOfflineFile(scope, cellNode, existingPath, onSuccess, onError) return } @@ -101,52 +84,88 @@ class OfflineFileDownloadController @Inject constructor( .toOkioPath() val job = scope.launch { - val thisJob = coroutineContext.job - setProgress(cellNode.uuid, null) - - val result = download( - assetId = cellNode.uuid, - outFilePath = filePath, - remoteFilePath = cellNode.remotePath, - assetSize = cellNode.size ?: 0, - name = cellNode.name, - ownerId = cellNode.ownerUserId, - ) { progress -> - if (thisJob.isActive) { - val assetSize = cellNode.size ?: 0 - if (assetSize > 0) { - val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) - setProgress(cellNode.uuid, progressValue) - } - } - } + performDownload(cellNode, nodeName, filePath, onSuccess, onError) + } - result.onSuccess { - clearProgress(cellNode.uuid) - sharedPathCache.recordCompletedPath(cellNode.uuid, filePath.toString()) - saveOfflineFile( - OfflineFileInfo( - id = cellNode.uuid, - name = nodeName, - owner = cellNode.ownerUserId ?: "", - localPath = filePath.toString(), - size = cellNode.size, - downloadedAt = System.currentTimeMillis(), - ) + activeJobs[cellNode.uuid] = ActiveDownload(job, filePath) + job.invokeOnCompletion { activeJobs.remove(cellNode.uuid) } + } + + private fun saveExistingOfflineFile( + scope: CoroutineScope, + cellNode: CellNodeUi.File, + existingPath: String, + onSuccess: (localPath: String) -> Unit, + onError: (CellError) -> Unit, + ) { + val nodeName = cellNode.name ?: run { + onError(CellError.OTHER_ERROR) + return + } + scope.launch { + saveOfflineFile( + OfflineFileInfo( + id = cellNode.uuid, + name = nodeName, + owner = cellNode.ownerUserId ?: "", + localPath = existingPath, + size = cellNode.size, + downloadedAt = System.currentTimeMillis(), ) - onSuccess(filePath.toString()) - } + ) + onSuccess(existingPath) + } + } - if (result is Either.Left) { - clearProgress(cellNode.uuid) - // Fire-and-forget delete so the error callback is not blocked by IO. - launch(Dispatchers.IO) { File(filePath.toString()).delete() } - onError(if (result.value.isNoSpaceLeft()) CellError.NO_SPACE_LEFT else CellError.DOWNLOAD_FAILED) + private suspend fun CoroutineScope.performDownload( + cellNode: CellNodeUi.File, + nodeName: String, + filePath: Path, + onSuccess: (localPath: String) -> Unit, + onError: (CellError) -> Unit, + ) { + val thisJob = coroutineContext.job + setProgress(cellNode.uuid, null) + + val result = download( + assetId = cellNode.uuid, + outFilePath = filePath, + remoteFilePath = cellNode.remotePath, + assetSize = cellNode.size ?: 0, + name = cellNode.name, + ownerId = cellNode.ownerUserId, + ) { progress -> + if (thisJob.isActive) { + val assetSize = cellNode.size ?: 0 + if (assetSize > 0) { + val progressValue = (progress.toFloat() / assetSize).coerceIn(0f, 1f) + setProgress(cellNode.uuid, progressValue) + } } } - activeJobs[cellNode.uuid] = ActiveDownload(job, filePath) - job.invokeOnCompletion { activeJobs.remove(cellNode.uuid) } + result.onSuccess { + clearProgress(cellNode.uuid) + sharedPathCache.recordCompletedPath(cellNode.uuid, filePath.toString()) + saveOfflineFile( + OfflineFileInfo( + id = cellNode.uuid, + name = nodeName, + owner = cellNode.ownerUserId ?: "", + localPath = filePath.toString(), + size = cellNode.size, + downloadedAt = System.currentTimeMillis(), + ) + ) + onSuccess(filePath.toString()) + } + + if (result is Either.Left) { + clearProgress(cellNode.uuid) + // Fire-and-forget delete so the error callback is not blocked by IO. + launch(Dispatchers.IO) { File(filePath.toString()).delete() } + onError(if (result.value.isNoSpaceLeft()) CellError.NO_SPACE_LEFT else CellError.DOWNLOAD_FAILED) + } } internal fun cancel(uuid: String, scope: CoroutineScope) { 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 8b658b2550b..00637059556 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 @@ -87,67 +87,75 @@ class OpenFileDownloadController @Inject constructor( .toOkioPath() val job = scope.launch { - val thisJob = coroutineContext.job + performDownload(cellNode, filePath, onOpenFile, onError) + } + activeDownloads[cellNode.uuid] = ActiveDownload(job, filePath) + } - // After SPINNER_THRESHOLD_MS show the spinner. Cancelled immediately if the download finishes first. - val showSpinnerJob = launch { - delay(SPINNER_THRESHOLD_MS) - sharedPathCache.setOpenLoadState(cellNode.uuid, OpenLoadState.Loading()) - } + private suspend fun CoroutineScope.performDownload( + cellNode: CellNodeUi.File, + filePath: Path, + onOpenFile: (CellNodeUi.File) -> Unit, + onError: (CellError) -> Unit, + ) { + val thisJob = coroutineContext.job - val result = download( - assetId = cellNode.uuid, - outFilePath = filePath, - remoteFilePath = cellNode.remotePath, - assetSize = cellNode.size ?: 0, - ) { bytesDownloaded -> - if (thisJob.isActive && - sharedPathCache.openLoadStates.value.containsKey(cellNode.uuid) - ) { - val total = cellNode.size ?: 0 - if (total > 0) { - val progress = (bytesDownloaded.toFloat() / total).coerceIn(0f, 1f) - sharedPathCache.setOpenLoadState(cellNode.uuid, OpenLoadState.Loading(progress)) - } - } - } + // After SPINNER_THRESHOLD_MS show the spinner. Cancelled immediately if the download finishes first. + val showSpinnerJob = launch { + delay(SPINNER_THRESHOLD_MS) + sharedPathCache.setOpenLoadState(cellNode.uuid, OpenLoadState.Loading()) + } - result.onSuccess { - val pathStr = filePath.toString() - - sharedPathCache.recordCompletedPath(cellNode.uuid, pathStr) - val spinnerWasShown = sharedPathCache.openLoadStates.value.containsKey(cellNode.uuid) - showSpinnerJob.cancel() - activeDownloads.remove(cellNode.uuid) - - if (!spinnerWasShown) { - // Fast path ( + if (thisJob.isActive && + sharedPathCache.openLoadStates.value.containsKey(cellNode.uuid) + ) { + val total = cellNode.size ?: 0 + if (total > 0) { + val progress = (bytesDownloaded.toFloat() / total).coerceIn(0f, 1f) + sharedPathCache.setOpenLoadState(cellNode.uuid, OpenLoadState.Loading(progress)) } } + } - if (result is Either.Left) { - showSpinnerJob.cancel() - activeDownloads.remove(cellNode.uuid) - // Fire-and-forget delete so the state update below is not blocked by IO. - launch(Dispatchers.IO) { File(filePath.toString()).delete() } - if (result.value.isNoSpaceLeft()) { + result.onSuccess { + val pathStr = filePath.toString() + sharedPathCache.recordCompletedPath(cellNode.uuid, pathStr) + val spinnerWasShown = sharedPathCache.openLoadStates.value.containsKey(cellNode.uuid) + showSpinnerJob.cancel() + activeDownloads.remove(cellNode.uuid) + + if (!spinnerWasShown) { + // Fast path ( Date: Tue, 12 May 2026 09:27:02 +0200 Subject: [PATCH 22/44] feat: modifiedAt from String to Long --- .../android/feature/cells/ui/CellListItem.kt | 23 ++++++++++--------- .../cells/ui/ConversationFilesScreen.kt | 4 ++-- .../cells/ui/OfflineFileDownloadController.kt | 2 ++ .../feature/cells/ui/model/CellNodeUi.kt | 20 ++++------------ .../cells/ui/CellFileActionsMenuTest.kt | 2 +- 5 files changed, 22 insertions(+), 29 deletions(-) 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 d377b7f0210..b31447e33b8 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 @@ -23,6 +23,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith +import com.wire.android.util.cellFileDateTime +import kotlinx.datetime.Instant import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -398,21 +400,21 @@ private fun PublicLinkIcon( } @Composable -private fun CellNodeUi.subtitle() = - when { - userName != null && conversationName != null -> { +private fun CellNodeUi.subtitle(): String? { + val formattedTime = modifiedTime?.let { + remember(it) { Instant.fromEpochMilliseconds(it).cellFileDateTime() } + } + return when { + userName != null && conversationName != null -> stringResource(R.string.file_subtitle, userName!!, conversationName!!) - } - - userName != null && modifiedTime != null -> { - stringResource(R.string.file_subtitle_modified, modifiedTime!!, userName!!) - } - + userName != null && formattedTime != null -> + stringResource(R.string.file_subtitle_modified, formattedTime, userName!!) userName != null -> userName conversationName != null -> conversationName - modifiedTime != null -> modifiedTime + formattedTime != null -> formattedTime else -> null } +} @PreviewMultipleThemes @Composable @@ -433,7 +435,6 @@ private fun PreviewCellListItem() { conversationName = "Test Conversation", modifiedTime = null, remotePath = null, - contentHash = null, contentUrl = null, previewUrl = null ), 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..07bd1d12579 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 @@ -387,7 +387,7 @@ fun PreviewConversationFilesScreen() { userHandle = "userHandle", ownerUserId = "userA", conversationName = "Conversation A", - modifiedTime = "2023-10-01T12:00:00Z", + modifiedTime = 1696154400000L, remotePath = "/path/to/file1.png", contentHash = null, contentUrl = null, @@ -401,7 +401,7 @@ fun PreviewConversationFilesScreen() { userHandle = "userHandle", ownerUserId = "userB", conversationName = "Conversation B", - modifiedTime = "2023-10-01T12:00:00Z", + modifiedTime = 1696154400000L, size = 123456, ) ) 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..7f8a7ac57a7 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,7 @@ class OfflineFileDownloadController @Inject constructor( localPath = existingPath, size = cellNode.size, downloadedAt = System.currentTimeMillis(), + modifiedAt = cellNode.modifiedTime, ) ) onSuccess(existingPath) @@ -155,6 +156,7 @@ class OfflineFileDownloadController @Inject constructor( localPath = filePath.toString(), size = cellNode.size, downloadedAt = System.currentTimeMillis(), + modifiedAt = cellNode.modifiedTime, ) ) onSuccess(filePath.toString()) 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..4036f9e5076 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 @@ -21,9 +21,7 @@ import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.domain.model.AttachmentFileType.IMAGE import com.wire.android.feature.cells.domain.model.AttachmentFileType.PDF import com.wire.android.feature.cells.domain.model.AttachmentFileType.VIDEO -import com.wire.android.util.cellFileDateTime import com.wire.kalium.cells.domain.model.Node -import kotlinx.datetime.Instant sealed class CellNodeUi { abstract val name: String? @@ -32,7 +30,7 @@ sealed class CellNodeUi { abstract val userHandle: String? abstract val ownerUserId: String? abstract val conversationName: String? - abstract val modifiedTime: String? + abstract val modifiedTime: Long? abstract val publicLinkId: String? abstract val remotePath: String? abstract val size: Long? @@ -53,7 +51,7 @@ sealed class CellNodeUi { override val userHandle: String?, override val ownerUserId: String?, override val conversationName: String?, - override val modifiedTime: String?, + override val modifiedTime: Long?, override val publicLinkId: String? = null, override val remotePath: String? = null, override val size: Long?, @@ -70,7 +68,7 @@ sealed class CellNodeUi { override val userHandle: String?, override val ownerUserId: String?, override val conversationName: String?, - override val modifiedTime: String?, + override val modifiedTime: Long?, override val publicLinkId: String? = null, override val remotePath: String? = null, override val size: Long?, @@ -108,7 +106,7 @@ internal fun Node.File.toUiModel( ownerUserId = ownerUserId, conversationName = conversationName, publicLinkId = publicLinkId, - modifiedTime = formattedModifiedTime(), + modifiedTime = modifiedTime, tags = tags, isEditSupported = isEditSupported, openLoadState = openLoadState, @@ -123,21 +121,13 @@ internal fun Node.Folder.toUiModel() = CellNodeUi.Folder( userHandle = userHandle, ownerUserId = ownerUserId, conversationName = conversationName, - modifiedTime = formattedModifiedTime(), + modifiedTime = modifiedTime, remotePath = remotePath, size = size, tags = tags, publicLinkId = publicLinkId, ) -private fun Node.File.formattedModifiedTime() = modifiedTime?.let { - Instant.fromEpochMilliseconds(it).cellFileDateTime() -} - -private fun Node.Folder.formattedModifiedTime() = modifiedTime?.let { - Instant.fromEpochMilliseconds(it).cellFileDateTime() -} - internal fun CellNodeUi.File.withSessionState( openLoadState: OpenLoadState?, downloadProgress: Float?, diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index 7025c8b5a40..e8aad0af1bb 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -581,7 +581,7 @@ class CellFileActionsMenuTest { uuid = "uuid", userName = "user", conversationName = "conversation", - modifiedTime = "time", + modifiedTime = 1696154400000L, size = 1, userHandle = null, ownerUserId = null, From 6576a509aacb225017294b232d6880abd0c29af1 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 09:47:45 +0200 Subject: [PATCH 23/44] feat: cleanup --- .../com/wire/android/feature/cells/ui/DownloadFailureUtils.kt | 4 ---- .../android/feature/cells/ui/OpenFileDownloadController.kt | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt index b9735bf56c7..7fde24ff210 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt @@ -39,10 +39,6 @@ internal fun CoreFailure.isNoSpaceLeft(): Boolean = when (this) { else -> false } -/** - * Walks the full [Throwable] cause chain searching for an [IOException] whose message - * indicates a full-disk condition. - */ private fun Throwable.causedByNoSpace(): Boolean { var current: Throwable? = this while (current != null) { 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 00637059556..bfbad9982bc 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 @@ -147,7 +147,7 @@ class OpenFileDownloadController @Inject constructor( if (result is Either.Left) { showSpinnerJob.cancel() activeDownloads.remove(cellNode.uuid) - // Fire-and-forget delete so the state update below is not blocked by IO. + launch(Dispatchers.IO) { File(filePath.toString()).delete() } if (result.value.isNoSpaceLeft()) { sharedPathCache.clearOpenLoadState(cellNode.uuid) From c4c8be5545590119f4233472a83232dcbb6b6e04 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 11:28:04 +0200 Subject: [PATCH 24/44] feat: cleanup --- .../feature/cells/ui/OfflineFileDownloadController.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 7f8a7ac57a7..b492aa8681d 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 @@ -25,14 +25,10 @@ import com.wire.kalium.cells.domain.usecase.offline.OfflineFileInfo import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.onSuccess -import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.job import kotlinx.coroutines.launch import okio.Path @@ -51,8 +47,8 @@ class OfflineFileDownloadController @Inject constructor( private val saveOfflineFile: SaveOfflineFileUseCase, private val sharedPathCache: CellFileLocalPathCache, ) { - private val _downloadProgresses = MutableStateFlow>(emptyMap()) - internal val downloadProgresses: StateFlow> = _downloadProgresses.asStateFlow() + + internal val downloadProgresses: StateFlow> = sharedPathCache.downloadProgresses private data class ActiveDownload(val job: Job, val filePath: Path) private val activeJobs = mutableMapOf() @@ -179,12 +175,10 @@ class OfflineFileDownloadController @Inject constructor( } private fun setProgress(uuid: String, progress: Float?) { - _downloadProgresses.update { it.toMutableMap().apply { put(uuid, progress) }.toImmutableMap() } sharedPathCache.setDownloadProgress(uuid, progress) } private fun clearProgress(uuid: String) { - _downloadProgresses.update { it.toMutableMap().apply { remove(uuid) }.toImmutableMap() } sharedPathCache.clearDownloadProgress(uuid) } } From 22fd15fe02caeb6aa9d532c00f8a8379fe2819c7 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 11:28:18 +0200 Subject: [PATCH 25/44] feat: kalium reference --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index e854de386df..9508f8a2673 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit e854de386df9e1d3b3d26e5a01132fdd86d3842d +Subproject commit 9508f8a267301afc01040331162d5383063f300c From d050772106de93f376644bf3b2427d452a71a72e Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 11:32:12 +0200 Subject: [PATCH 26/44] feat: cleanup --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 9508f8a2673..8cb94930725 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 9508f8a267301afc01040331162d5383063f300c +Subproject commit 8cb949307259386c05a9ef49a7e99dee37fd52cc From d2d0998aaa9371bbe9b24c8ec47c9a7339afd4d0 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 12:25:10 +0200 Subject: [PATCH 27/44] feat: test --- .../com/wire/android/feature/cells/ui/CellViewModelTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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..fc7d8a5268a 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 @@ -51,7 +51,6 @@ import io.mockk.mockk import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -320,7 +319,7 @@ class CellViewModelTest { coEvery { isCellAvailableUseCase.invoke() } returns true.right() - every { observeOfflineFiles() } returns emptyFlow() + every { observeOfflineFiles() } returns flowOf(emptyList()) coEvery { getOfflineFile(any()) } returns null coEvery { getCellFilesPagedUseCase.invoke(any(), any(), any(), any()) } returns flowOf( From 12e636581873e842a2ca8487976e326df82d2baa Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 13:20:44 +0200 Subject: [PATCH 28/44] fix: incorrect icon usage --- .../feature/cells/ui/CellFileActionsMenu.kt | 23 ++- .../android/feature/cells/ui/CellListItem.kt | 150 +++++++++++++++++- .../cells/ui/model/NodeBottomSheetAction.kt | 4 +- .../cells/src/main/res/drawable/ic_arrow.xml | 27 ++++ .../res/drawable/ic_arrow_down_circle.xml | 30 ++++ .../main/res/drawable/ic_cross_in_circle.xml | 30 ++++ 6 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 features/cells/src/main/res/drawable/ic_arrow.xml create mode 100644 features/cells/src/main/res/drawable/ic_arrow_down_circle.xml create mode 100644 features/cells/src/main/res/drawable/ic_cross_in_circle.xml 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..3600ed8bf6f 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 @@ -44,11 +44,17 @@ class CellFileActionsMenu @Inject constructor( } isConversationFiles -> { - commonActions(cellNode) + - conversationActions( - cellNode = cellNode, - isCollaboraEnabled = isCollaboraEnabled, - ) + val common = commonActions(cellNode) + val isTerminal = cellNode is CellNodeUi.File && + (cellNode.isOpenLoading || cellNode.downloadProgress != null) + if (isTerminal) { + common + } else { + common + conversationActions( + cellNode = cellNode, + isCollaboraEnabled = isCollaboraEnabled, + ) + } } else -> emptyList() @@ -78,11 +84,12 @@ class CellFileActionsMenu @Inject constructor( } else -> { - if (cellNode.localFileAvailable()) { add(NodeBottomSheetAction.SHARE) } + add(NodeBottomSheetAction.PUBLIC_LINK) + add( if (cellNode.isAvailableOffline) { NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS @@ -92,9 +99,9 @@ class CellFileActionsMenu @Inject constructor( ) } } + } else { + add(NodeBottomSheetAction.PUBLIC_LINK) } - - add(NodeBottomSheetAction.PUBLIC_LINK) } private fun conversationActions( 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 b31447e33b8..10bef8e69b2 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 @@ -58,10 +58,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -82,6 +84,8 @@ import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter +import okio.Path.Companion.toOkioPath +import kotlin.io.path.Path import com.wire.android.ui.common.R as commonR @Composable @@ -169,7 +173,7 @@ private fun CellItemIcon(cell: CellNodeUi, showReadyState: Boolean) { ) { state -> when (state) { is CellIconState.Loading -> LoadingIconPreview(progress = state.progress) - is CellIconState.Downloading -> LoadingIconPreview(progress = state.progress) + is CellIconState.Downloading -> DownloadingIconPreview(progress = state.progress) is CellIconState.Ready -> ReadyIconPreview() is CellIconState.FileIcon -> FileIconPreview(state.cell) is CellIconState.FolderIcon -> FolderIconPreview(state.cell) @@ -285,6 +289,43 @@ internal fun LoadingIconPreview(progress: Float?) { } } +@Composable +internal fun DownloadingIconPreview(progress: Float?) { + val modifier = Modifier.size(dimensions().spacing32x) + val color = colorsScheme().primary + val trackColor = colorsScheme().primaryVariant + val strokeWidth = dimensions().spacing2x + Box( + modifier = Modifier.size(dimensions().spacing56x), + contentAlignment = Alignment.Center + ) { + if (progress != null) { + CircularProgressIndicator( + progress = { progress }, + modifier = modifier, + color = color, + trackColor = trackColor, + strokeWidth = strokeWidth, + strokeCap = StrokeCap.Round, + ) + Icon( + imageVector = ImageVector.vectorResource(id =R.drawable.ic_arrow), + contentDescription = null, + tint = color, + modifier = Modifier.size(dimensions().spacing16x) + ) + } else { + CircularProgressIndicator( + modifier = modifier, + color = color, + trackColor = trackColor, + strokeWidth = strokeWidth, + strokeCap = StrokeCap.Round, + ) + } + } +} + @Composable internal fun ReadyIconPreview() { Box( @@ -442,3 +483,110 @@ private fun PreviewCellListItem() { ) } } + +@PreviewMultipleThemes +@Composable +private fun PreviewCellListItemLoading() { + WireTheme { + CellListItem( + cell = CellNodeUi.File( + uuid = "", + name = "file name", + assetType = AttachmentFileType.IMAGE, + size = 123214, + localPath = null, + mimeType = "image/jpg", + publicLinkId = "", + userName = "Test User", + userHandle = "userId", + ownerUserId = "userId", + conversationName = "Test Conversation", + modifiedTime = null, + remotePath = null, + contentUrl = null, + previewUrl = null, + openLoadState = OpenLoadState.Loading(0.5f) + ), + onMenuClick = {}, + ) + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewCellListItemReady() { + WireTheme { + CellListItem( + cell = CellNodeUi.File( + uuid = "", + name = "file name", + assetType = AttachmentFileType.IMAGE, + size = 123214, + localPath = null, + mimeType = "image/jpg", + publicLinkId = "", + userName = "Test User", + userHandle = "userId", + ownerUserId = "userId", + conversationName = "Test Conversation", + modifiedTime = null, + remotePath = null, + contentUrl = null, + previewUrl = null, + openLoadState = OpenLoadState.Ready(Path("localPath").toOkioPath()) + ), + onMenuClick = {}, + ) + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewCellListItemDownloading() { + WireTheme { + CellListItem( + cell = CellNodeUi.File( + uuid = "", + name = "file name", + assetType = AttachmentFileType.IMAGE, + size = 123214, + localPath = null, + mimeType = "image/jpg", + publicLinkId = "", + userName = "Test User", + userHandle = "userId", + ownerUserId = "userId", + conversationName = "Test Conversation", + modifiedTime = null, + remotePath = null, + contentUrl = null, + previewUrl = null, + downloadProgress = 0.75f + ), + onMenuClick = {}, + ) + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewCellListItemFolder() { + WireTheme { + CellListItem( + cell = CellNodeUi.Folder( + uuid = "", + name = "folder name", + userName = "Test User", + userHandle = "userId", + ownerUserId = "userId", + conversationName = "Test Conversation", + modifiedTime = null, + remotePath = null, + size = null, + tags = emptyList(), + publicLinkId = null, + ), + onMenuClick = {}, + ) + } +} 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..71da93176f0 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 @@ -36,6 +36,6 @@ enum class NodeBottomSheetAction( VERSION_HISTORY(R.string.see_version_history_bottom_sheet, R.drawable.ic_version_history), 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), + MAKE_AVAILABLE_OFFLINE(R.string.make_available_offline_label, R.drawable.ic_arrow_down_circle), + 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_arrow.xml b/features/cells/src/main/res/drawable/ic_arrow.xml new file mode 100644 index 00000000000..13009364240 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_arrow.xml @@ -0,0 +1,27 @@ + + + + diff --git a/features/cells/src/main/res/drawable/ic_arrow_down_circle.xml b/features/cells/src/main/res/drawable/ic_arrow_down_circle.xml new file mode 100644 index 00000000000..b29cb97e073 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_arrow_down_circle.xml @@ -0,0 +1,30 @@ + + + + + + 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..646b6668fb0 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cross_in_circle.xml @@ -0,0 +1,30 @@ + + + + + + From 204ae285d3b57277658509ba6e83359ac41648ce Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 14:34:34 +0200 Subject: [PATCH 29/44] fix: detekt --- .../wire/android/feature/cells/ui/CellListItem.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 10bef8e69b2..c2f82dc2d3f 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 @@ -15,6 +15,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:Suppress("TooManyFunctions") + package com.wire.android.feature.cells.ui import androidx.compose.animation.AnimatedContent @@ -23,8 +25,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith -import com.wire.android.util.cellFileDateTime -import kotlinx.datetime.Instant import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -82,8 +82,10 @@ import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.cellFileDateTime import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter +import kotlinx.datetime.Instant import okio.Path.Companion.toOkioPath import kotlin.io.path.Path import com.wire.android.ui.common.R as commonR @@ -192,6 +194,7 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { color = colorsScheme().secondaryText, maxLines = 1, ) + cell.openLoadState is OpenLoadState.Error -> Text( text = stringResource(R.string.unable_to_load_retry), textAlign = TextAlign.Left, @@ -210,6 +213,7 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { color = colorsScheme().secondaryText, maxLines = 1, ) + showReadyState -> Text( text = stringResource(R.string.ready_to_open), textAlign = TextAlign.Left, @@ -218,6 +222,7 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { color = colorsScheme().primary, maxLines = 1, ) + else -> { if (cell.isAvailableOffline) { Icon( @@ -309,7 +314,7 @@ internal fun DownloadingIconPreview(progress: Float?) { strokeCap = StrokeCap.Round, ) Icon( - imageVector = ImageVector.vectorResource(id =R.drawable.ic_arrow), + imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow), contentDescription = null, tint = color, modifier = Modifier.size(dimensions().spacing16x) @@ -448,8 +453,10 @@ private fun CellNodeUi.subtitle(): String? { return when { 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 formattedTime != null -> formattedTime From bd1a0df21c34eb667633ca9d05cd10fe0d6f68d8 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 12 May 2026 17:14:29 +0200 Subject: [PATCH 30/44] fix: lint --- .../android/feature/cells/ui/CellScreenContent.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 4e36950e243..4a71aab73ed 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 @@ -245,11 +245,14 @@ 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 -> Toast.makeText( - context, - context.getString(com.wire.android.feature.cells.R.string.offline_file_saved_message), - Toast.LENGTH_SHORT - ).show() + is ShowOfflineFileSaved -> { + val description = stringResource(R.string.offline_file_saved_message) + Toast.makeText( + context, + description, + Toast.LENGTH_SHORT + ).show() + } } } From 952a1aefec9f0519880a92252e5e24a6c2e5fe64 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 13:04:03 +0200 Subject: [PATCH 31/44] chore: lint --- .../com/wire/android/feature/cells/ui/CellScreenContent.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..c13a7e54316 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) { @@ -246,10 +247,9 @@ internal fun CellScreenContent( 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) Toast.makeText( context, - description, + offlineFileSavedToastDescription, Toast.LENGTH_SHORT ).show() } From 065917de91d9c39dbafb35333f6f589b52eb7b62 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 13:33:34 +0200 Subject: [PATCH 32/44] chore: add conversationId --- .../multipart/MultipartAttachmentsViewModel.kt | 9 +++++++-- .../feature/cells/ui/OfflineFileDownloadController.kt | 3 +++ .../feature/cells/ui/OpenFileDownloadController.kt | 1 + .../wire/android/feature/cells/ui/model/CellNodeUi.kt | 2 ++ 4 files changed, 13 insertions(+), 2 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..25347a5ab34 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 @@ -29,9 +29,9 @@ import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.ui.common.multipart.MultipartAttachmentUi import com.wire.android.ui.common.multipart.toUiModel import com.wire.android.util.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.download.DownloadCellFileUseCase import com.wire.kalium.common.functional.onSuccess import com.wire.kalium.logic.data.asset.AssetTransferStatus import com.wire.kalium.logic.data.asset.KaliumFileSystem @@ -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) @@ -172,6 +176,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( assetId = attachment.uuid, 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 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 b492aa8681d..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 @@ -107,6 +107,7 @@ class OfflineFileDownloadController @Inject constructor( localPath = existingPath, size = cellNode.size, downloadedAt = System.currentTimeMillis(), + conversationId = cellNode.conversationId, modifiedAt = cellNode.modifiedTime, ) ) @@ -126,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, @@ -152,6 +154,7 @@ class OfflineFileDownloadController @Inject constructor( localPath = filePath.toString(), size = cellNode.size, downloadedAt = System.currentTimeMillis(), + conversationId = cellNode.conversationId, modifiedAt = cellNode.modifiedTime, ) ) 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, 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 4036f9e5076..91440c875cf 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 @@ -83,6 +83,7 @@ sealed class CellNodeUi { internal override val openLoadState: OpenLoadState? = null, override val downloadProgress: Float? = null, override val isAvailableOffline: Boolean = false, + val conversationId: String?, ) : CellNodeUi() } @@ -105,6 +106,7 @@ internal fun Node.File.toUiModel( userHandle = userHandle, ownerUserId = ownerUserId, conversationName = conversationName, + conversationId = conversationId, publicLinkId = publicLinkId, modifiedTime = modifiedTime, tags = tags, From bea8de109dab2fd18908d0b35760ef757866a53a Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 14:02:59 +0200 Subject: [PATCH 33/44] chore: add conversationId --- .../java/com/wire/android/feature/cells/ui/CellListItem.kt | 4 ++++ .../wire/android/feature/cells/ui/ConversationFilesScreen.kt | 1 + .../android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt | 1 + 3 files changed, 6 insertions(+) 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..5c7cf8f968a 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 @@ -471,6 +471,7 @@ private fun PreviewCellListItem() { CellListItem( cell = CellNodeUi.File( uuid = "", + conversationId = "conversationId", name = "file name", assetType = AttachmentFileType.IMAGE, size = 123214, @@ -498,6 +499,7 @@ private fun PreviewCellListItemLoading() { CellListItem( cell = CellNodeUi.File( uuid = "", + conversationId = "conversationId", name = "file name", assetType = AttachmentFileType.IMAGE, size = 123214, @@ -526,6 +528,7 @@ private fun PreviewCellListItemReady() { CellListItem( cell = CellNodeUi.File( uuid = "", + conversationId = "conversationId", name = "file name", assetType = AttachmentFileType.IMAGE, size = 123214, @@ -554,6 +557,7 @@ private fun PreviewCellListItemDownloading() { CellListItem( cell = CellNodeUi.File( uuid = "", + conversationId = "conversationId", name = "file name", assetType = AttachmentFileType.IMAGE, size = 123214, 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 07bd1d12579..c39395edaea 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 @@ -377,6 +377,7 @@ fun PreviewConversationFilesScreen() { listOf( CellNodeUi.File( uuid = "file1", + conversationId = "conversationId", name = "File 1", assetType = AttachmentFileType.IMAGE, size = 123456, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt index 2d6bf464041..fa5ab3d1795 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/dialog/NodeActionsBottomSheet.kt @@ -136,6 +136,7 @@ private fun PreviewFileActionsBottomSheet() { menuOptions = MenuOptions( node = CellNodeUi.File( uuid = "", + conversationId = "conversationId", name = "test file.pdf", mimeType = "application/pdf", assetType = AttachmentFileType.PDF, From 9cc0b5153693646f93ecc8b85d46690947477fc6 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 14:03:54 +0200 Subject: [PATCH 34/44] chore: kalium reference --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 6d9667839a6..79ebff4e595 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 6d9667839a6baad587a27f3fb693da2a6a947a62 +Subproject commit 79ebff4e595fffc136114a5fc5bd2138db4c0b8b From b9cdfef7fd221e22c104018f46486300117fc1e0 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 14:38:44 +0200 Subject: [PATCH 35/44] chore: test --- .../cells/ui/CellFileActionsMenuTest.kt | 1 + .../feature/cells/ui/CellViewModelTest.kt | 41 ++++++++++--------- .../ui/OpenFileDownloadControllerTest.kt | 15 +++---- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index e8aad0af1bb..b69d0fad5e4 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -560,6 +560,7 @@ class CellFileActionsMenuTest { private companion object { val fileNode = CellNodeUi.File( name = "file.txt", + conversationId = "conversationId", conversationName = "Conversation", uuid = "fileUuid", mimeType = "video/mp4", 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 fc7d8a5268a..fc32810c05a 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 @@ -74,6 +74,7 @@ class CellViewModelTest { val testFiles = listOf( Node.File( uuid = "fileUuid", + conversationId = "conversationId", versionId = "versionId", name = "fileName", mimeType = "image/png", @@ -85,6 +86,7 @@ class CellViewModelTest { ), Node.File( uuid = "fileUuid2", + conversationId = "conversationId", versionId = "versionId2", name = "fileName2", mimeType = "image/png", @@ -151,24 +153,25 @@ class CellViewModelTest { } @Test - fun `given view model when file clicked and local file is not present and url is not openable then download starts immediately`() = runTest { - val (arrangement, viewModel) = Arrangement() - .withLoadSuccess() - .withDownloadSuccess() - .arrange() - - val testFile = testFiles[0].copy( - localPath = null, - contentUrl = null - ).toUiModel() + fun `given view model when file clicked and local file is not present and url is not openable then download starts immediately`() = + runTest { + val (arrangement, viewModel) = Arrangement() + .withLoadSuccess() + .withDownloadSuccess() + .arrange() + + val testFile = testFiles[0].copy( + localPath = null, + contentUrl = null + ).toUiModel() - viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) - // Advance time so download coroutine can complete - advanceUntilIdle() + viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) + // Advance time so download coroutine can complete + advanceUntilIdle() - // Download use case was called - coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } - } + // Download use case was called + coVerify(exactly = 1) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } + } @Test fun `given file has local path in DB when clicked with error state then file opened without re-downloading`() = runTest { @@ -183,7 +186,7 @@ class CellViewModelTest { viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) advanceUntilIdle() - coVerify(exactly = 0) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { arrangement.downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } } @@ -350,11 +353,11 @@ class CellViewModelTest { } fun withDownloadSuccess() = apply { - coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() + coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() } fun withSlowDownloadSuccess() = apply { - coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any()) } coAnswers { + coEvery { downloadCellFileUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { delay(500) // Simulate download taking 500ms (longer than the 300ms threshold) Unit.right() } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index 53f50d36964..2f72fc91de9 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -95,7 +95,7 @@ class OpenFileDownloadControllerTest { assertEquals(fileWithLocalPath.uuid, openedFile?.uuid) assertTrue(controller.openLoadStates.value.isEmpty(), "No load state should be set") - coVerify(exactly = 0) { arrangement.downloadUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { arrangement.downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } } @Test @@ -289,7 +289,7 @@ class OpenFileDownloadControllerTest { var capturedOldProgressCallback: ((Long) -> Unit)? = null val (arrangement, controller) = Arrangement() .also { arr -> - coEvery { arr.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any()) } coAnswers { + coEvery { arr.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any(), any()) } coAnswers { val onProgressUpdate = arg<(Long) -> Unit>(6) capturedOldProgressCallback = onProgressUpdate delay(10_000L) // very long — cancelled before completing @@ -308,7 +308,7 @@ class OpenFileDownloadControllerTest { assertNull(controller.openLoadStates.value[testFile.uuid], "State must be cleared on cancel") // Re-configure the mock so the second download is also slow (but we control it). - coEvery { arrangement.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any()) } coAnswers { + coEvery { arrangement.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any(), any()) } coAnswers { delay(10_000L) // stays in-flight during the test Unit.right() } @@ -380,6 +380,7 @@ class OpenFileDownloadControllerTest { val testFile = CellNodeUi.File( uuid = "test-uuid", + conversationId = "conversation-id", name = "report.pdf", mimeType = "application/pdf", assetType = AttachmentFileType.OTHER, @@ -416,23 +417,23 @@ class OpenFileDownloadControllerTest { } fun withDownloadSuccess(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } returns Unit.right() + coEvery { downloadUseCase(eq(uuid), any(), any(),any(), any(), any(), any(), any()) } returns Unit.right() } fun withSlowDownloadSuccess(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } coAnswers { + coEvery { downloadUseCase(eq(uuid), any(), any(),any(), any(), any(), any(), any()) } coAnswers { delay(500) // Exceeds 400 ms spinner threshold Unit.right() } } fun withDownloadFailure(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } returns + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(),any(), any()) } returns StorageFailure.DataNotFound.left() } fun withProgressThenSuccess(progress: Long, uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any()) } coAnswers { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(),any(), any()) } coAnswers { val onProgressUpdate = arg<(Long) -> Unit>(6) delay(450) onProgressUpdate(progress) From e4aadf2aab0cc0f9e2850fca6e66a678639fb390 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 14:52:14 +0200 Subject: [PATCH 36/44] chore: detekt --- .../feature/cells/ui/OpenFileDownloadControllerTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index 2f72fc91de9..2f391e3371c 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -417,23 +417,23 @@ class OpenFileDownloadControllerTest { } fun withDownloadSuccess(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(),any(), any(), any(), any(), any()) } returns Unit.right() + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() } fun withSlowDownloadSuccess(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(),any(), any(), any(), any(), any()) } coAnswers { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any(), any()) } coAnswers { delay(500) // Exceeds 400 ms spinner threshold Unit.right() } } fun withDownloadFailure(uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(),any(), any()) } returns + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any(), any()) } returns StorageFailure.DataNotFound.left() } fun withProgressThenSuccess(progress: Long, uuid: String = testFile.uuid) = apply { - coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(),any(), any()) } coAnswers { + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any(), any()) } coAnswers { val onProgressUpdate = arg<(Long) -> Unit>(6) delay(450) onProgressUpdate(progress) From 27f686e3806a40b60fa90b867ce5fbf55b926e0a Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 15:31:39 +0200 Subject: [PATCH 37/44] chore: test --- .../feature/cells/ui/OpenFileDownloadControllerTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index 2f391e3371c..ac0af348d3d 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -290,7 +290,7 @@ class OpenFileDownloadControllerTest { val (arrangement, controller) = Arrangement() .also { arr -> coEvery { arr.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any(), any()) } coAnswers { - val onProgressUpdate = arg<(Long) -> Unit>(6) + val onProgressUpdate = arg<(Long) -> Unit>(7) capturedOldProgressCallback = onProgressUpdate delay(10_000L) // very long — cancelled before completing Unit.right() @@ -434,7 +434,7 @@ class OpenFileDownloadControllerTest { fun withProgressThenSuccess(progress: Long, uuid: String = testFile.uuid) = apply { coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any(), any()) } coAnswers { - val onProgressUpdate = arg<(Long) -> Unit>(6) + val onProgressUpdate = arg<(Long) -> Unit>(7) delay(450) onProgressUpdate(progress) delay(50) // download finishes at 500 ms total From d4c75c1414a611fc9afc7498faf64537233b5b92 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 15:49:28 +0200 Subject: [PATCH 38/44] chore: test --- .../messagetypes/multipart/MultipartAttachmentsViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fd48019701b..3233db7a834 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 @@ -258,7 +258,7 @@ class MultipartAttachmentsViewModelTest { coEvery { refreshHelper.refresh(any()) } returns Unit coEvery { fileManager.openWithExternalApp(any(), any(), any(), any()) } returns Unit coEvery { fileManager.openUrlWithExternalApp(any(), any(), any()) } returns Unit - coEvery { download(any(), any(), any(), any(), any()) } returns Unit.right() + coEvery { download(any(), any(), any(), any(), any(),any(),any(),any()) } returns Unit.right() coEvery { getWireCellsConfig() } returns null return this to MultipartAttachmentsViewModelImpl( From 8c63ab4bca32487642cec086d43bd8e1f53d0ba0 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 16:03:12 +0200 Subject: [PATCH 39/44] chore: detekt --- .../messagetypes/multipart/MultipartAttachmentsViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3233db7a834..f23b308cf30 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 @@ -258,7 +258,7 @@ class MultipartAttachmentsViewModelTest { 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 { download(any(), any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() coEvery { getWireCellsConfig() } returns null return this to MultipartAttachmentsViewModelImpl( From 6776d9be3e99a6dafff009123bea13fbf9600349 Mon Sep 17 00:00:00 2001 From: Oussama Hassine Date: Wed, 20 May 2026 10:00:20 +0200 Subject: [PATCH 40/44] feat: Display offline files (WPB-23968) (#4822) --- .../android/di/accountScoped/CellsModule.kt | 10 ++ .../MultipartAttachmentsViewModel.kt | 2 +- .../feature/cells/ui/AllFilesScreen.kt | 37 +++-- .../feature/cells/ui/CellFileActionsMenu.kt | 23 ++- .../feature/cells/ui/CellFilesScreen.kt | 5 + .../android/feature/cells/ui/CellListItem.kt | 13 +- .../feature/cells/ui/CellScreenContent.kt | 4 +- .../android/feature/cells/ui/CellViewModel.kt | 98 ++++++++++++- .../cells/ui/ConversationFilesScreen.kt | 47 +++--- .../cells/ui/OfflineFileDownloadController.kt | 2 + .../feature/cells/ui/common/OfflineBanner.kt | 79 +++++++++++ .../cells/ui/model/NodeBottomSheetAction.kt | 1 + .../feature/cells/ui/search/SearchScreen.kt | 134 ++++++++++-------- .../main/res/drawable/ic_cross_in_circle.xml | 12 +- .../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 + .../feature/cells/ui/CellViewModelTest.kt | 20 +++ kalium | 2 +- 19 files changed, 430 insertions(+), 113 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_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..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 @@ -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.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 @@ -279,4 +281,12 @@ class CellsModule { @ViewModelScoped @Provides fun provideGetOfflineFileUseCase(cellsScope: CellsScope): GetOfflineFileUseCase = cellsScope.getOfflineFile + + @ViewModelScoped + @Provides + fun provideGetConversationNamesUseCase(cellsScope: CellsScope): GetConversationNameUseCase = cellsScope.getConversationName + + @ViewModelScoped + @Provides + fun provideGetUserNamesUseCase(cellsScope: CellsScope): GetUserNameUseCase = cellsScope.getUserName } 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 25347a5ab34..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 @@ -174,9 +174,9 @@ 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, - conversationId = null, // TODO to replace with real conversation id in next PR ) { progress -> attachment.assetSize?.let { val value = progress.toFloat() / it 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..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 @@ -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,33 @@ 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() + } + } } }, ) { innerPadding -> @@ -79,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/CellFileActionsMenu.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellFileActionsMenu.kt index 3600ed8bf6f..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 @@ -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() @@ -84,12 +97,13 @@ class CellFileActionsMenu @Inject constructor( } else -> { + + add(NodeBottomSheetAction.OPEN) + if (cellNode.localFileAvailable()) { add(NodeBottomSheetAction.SHARE) } - add(NodeBottomSheetAction.PUBLIC_LINK) - add( if (cellNode.isAvailableOffline) { NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS @@ -100,7 +114,7 @@ class CellFileActionsMenu @Inject constructor( } } } else { - add(NodeBottomSheetAction.PUBLIC_LINK) + add(NodeBottomSheetAction.OPEN) } } @@ -129,6 +143,7 @@ class CellFileActionsMenu @Inject constructor( addAll( listOf( NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, NodeBottomSheetAction.DELETE, @@ -138,6 +153,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 @@ -153,6 +169,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/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 5c7cf8f968a..a1435da6901 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 c13a7e54316..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, ) } @@ -246,7 +248,7 @@ 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 -> { + is ShowOfflineFileSaved -> { Toast.makeText( context, offlineFileSavedToastDescription, 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..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 @@ -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.GetConversationNameUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase import com.wire.kalium.cells.domain.usecase.GetPaginatedFilesFlowUseCase +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 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,9 @@ 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.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -99,6 +107,9 @@ class CellViewModel @Inject constructor( private val observeOfflineFiles: ObserveOfflineFilesUseCase, private val deleteOfflineFile: DeleteOfflineFileUseCase, private val getOfflineFile: GetOfflineFileUseCase, + private val networkStateObserver: NetworkStateObserver, + private val getConversationName: GetConversationNameUseCase, + private val getUserName: GetUserNameUseCase, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -147,6 +158,14 @@ class CellViewModel @Inject constructor( } ) + val isOnline: StateFlow = networkStateObserver.observeNetworkState() + .map { it is NetworkState.ConnectedWithInternet } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = networkStateObserver.observeNetworkState().value is NetworkState.ConnectedWithInternet, + ) + private var isCollaboraEnabled: Boolean = false init { @@ -219,15 +238,47 @@ 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 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 { getConversationName(it) }, + userName = info.owner.ifEmpty { null }?.let { getUserName(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 +408,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 +423,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 +437,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 +561,37 @@ 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 resolvedMimeType = mimeType.orEmpty() + val extension = name.substringAfterLast('.', "") + return CellNodeUi.File( + uuid = id, + conversationId = conversationId, + name = name, + mimeType = resolvedMimeType, + assetType = if (resolvedMimeType.isNotBlank()) { + AttachmentFileType.fromMimeType(resolvedMimeType) + } else { + AttachmentFileType.fromExtension(extension) + }, + size = size, + localPath = localPath, + ownerUserId = owner.ifEmpty { null }, + userName = userName, + userHandle = null, + conversationName = conversationName, + modifiedTime = modifiedAt, + isAvailableOffline = true, + openLoadState = openLoadState, + downloadProgress = downloadProgress, + ) + } + companion object { private val emptyData: PagingData = PagingData.empty( LoadStates( 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 c39395edaea..84d58aab39d 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(), ) { @@ -223,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() } @@ -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 = { @@ -290,6 +300,7 @@ internal fun ConversationFilesScreenContent( isRestoreInProgress = isRestoreInProgress, isDeleteInProgress = isDeleteInProgress, isRecycleBin = isRecycleBin, + isOffline = !isOnline, openFolder = { path, title, parentFolderUuid -> navigator.navigate( NavigationCommand( 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/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/NodeBottomSheetAction.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/NodeBottomSheetAction.kt index 71da93176f0..ed934ba2f52 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), 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( 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 index 646b6668fb0..a728332c130 100644 --- a/features/cells/src/main/res/drawable/ic_cross_in_circle.xml +++ b/features/cells/src/main/res/drawable/ic_cross_in_circle.xml @@ -19,12 +19,12 @@ android:viewportWidth="16" android:viewportHeight="16"> + android:fillColor="#000000" + android:pathData="M8,0C12.418,0 16,3.582 16,8C16,12.418 12.418,16 8,16C3.582,16 0,12.418 0,8C0,3.582 3.582,0 8,0ZM8,2C4.686,2 2,4.686 2,8C2,11.314 4.686,14 8,14C11.314,14 14,11.314 14,8C14,4.686 11.314,2 8,2Z" /> + android:fillColor="#000000" + android:pathData="M4.667,5.86L5.86,4.667L11.537,10.343L10.343,11.536L4.667,5.86Z" /> + android:fillColor="#000000" + android:pathData="M10.343,4.667L11.536,5.86L5.86,11.536L4.666,10.343L10.343,4.667Z" /> 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/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 fc32810c05a..ed61f9985cc 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 @@ -308,6 +313,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) @@ -324,6 +338,9 @@ class CellViewModelTest { every { observeOfflineFiles() } returns flowOf(emptyList()) 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( @@ -410,6 +427,9 @@ class CellViewModelTest { observeOfflineFiles = observeOfflineFiles, deleteOfflineFile = deleteOfflineFile, getOfflineFile = getOfflineFile, + networkStateObserver = networkStateObserver, + getConversationName = getConversationNames, + getUserName = getUserNames, ) } } diff --git a/kalium b/kalium index 79ebff4e595..012b5dee559 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 79ebff4e595fffc136114a5fc5bd2138db4c0b8b +Subproject commit 012b5dee559d8f3111e948d9b83e8d84f0e1e9de From 9b8a5bbf33dc1069fa8960c453c15c121c71effa Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 20 May 2026 10:21:38 +0200 Subject: [PATCH 41/44] chore: detekt --- .../com/wire/android/feature/cells/ui/ConversationFilesScreen.kt | 1 + 1 file changed, 1 insertion(+) 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 84d58aab39d..86753581dc8 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 @@ -133,6 +133,7 @@ fun ConversationFilesScreen( } @OptIn(ExperimentalSharedTransitionApi::class) +@Suppress("CyclomaticComplexMethod") @Composable internal fun ConversationFilesScreenContent( animatedVisibilityScope: AnimatedVisibilityScope, From d867d23b2bc5bd7ad34ca99de0765904e9cf309b Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 20 May 2026 12:39:11 +0200 Subject: [PATCH 42/44] chore: kalium --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 6797315be00..d1ad0c3f47a 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 6797315be005e60f570c5968a1e153e07881e0aa +Subproject commit d1ad0c3f47acdbec789b859978bb45dd175fc998 From addc7c8fce81aa3aa731c5cc3a838a5388b3d0f9 Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 21 May 2026 08:10:21 +0200 Subject: [PATCH 43/44] feat: test --- .../cells/ui/CellFileActionsMenuTest.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index b69d0fad5e4..9f0a1774881 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt @@ -40,8 +40,8 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, ), items @@ -83,9 +83,10 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( - NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, NodeBottomSheetAction.DELETE, @@ -106,8 +107,8 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, ), items @@ -129,12 +130,13 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, NodeBottomSheetAction.EDIT, NodeBottomSheetAction.VERSION_HISTORY, NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, NodeBottomSheetAction.DELETE, @@ -157,10 +159,11 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, NodeBottomSheetAction.DELETE, @@ -460,8 +463,8 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS, ), items @@ -480,10 +483,11 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS, NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, NodeBottomSheetAction.MOVE, NodeBottomSheetAction.RENAME, NodeBottomSheetAction.DELETE, From a4094874672b0027b44bb573e10fa01f4cdcab4d Mon Sep 17 00:00:00 2001 From: ohassine Date: Thu, 21 May 2026 12:19:36 +0200 Subject: [PATCH 44/44] feat: kalium ref --- kalium | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalium b/kalium index 3f8cc48c546..227ebac8faa 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 3f8cc48c54677a5cc1f1cb6a4f10809352fca0b5 +Subproject commit 227ebac8faac06c08139cfdf5062f26c38974bf9