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..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,12 @@ 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 +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 +265,28 @@ 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 + + @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 abff1e8ad23..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 @@ -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) @@ -170,6 +174,7 @@ class MultipartAttachmentsViewModelImpl @Inject constructor( download( assetId = attachment.uuid, + conversationId = null, // TODO to replace with real conversation id in next PR outFilePath = path, assetSize = attachment.assetSize ?: 0, ) { progress -> diff --git a/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..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()) } returns Unit.right() + coEvery { download(any(), any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() coEvery { getWireCellsConfig() } returns null return this to MultipartAttachmentsViewModelImpl( 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 c39c850c915..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 @@ -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 @@ -36,64 +35,131 @@ class CellFileActionsMenu @Inject constructor( isAllFiles: Boolean, isSearching: Boolean, isCollaboraEnabled: Boolean, - ): List = - when { - isRecycleBin -> { - buildList { - add(NodeBottomSheetAction.RESTORE) - add(NodeBottomSheetAction.DELETE_PERMANENTLY) + 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() isAllFiles || isSearching -> { - buildList { - if (cellNode is CellNodeUi.File && cellNode.openLoadState is OpenLoadState.Loading) { - add(NodeBottomSheetAction.CANCEL_LOADING) - } else { - if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { - add(NodeBottomSheetAction.SHARE) - } - add(NodeBottomSheetAction.PUBLIC_LINK) - } - } + commonActions(cellNode) } isConversationFiles -> { - buildList { - if (cellNode is CellNodeUi.File && cellNode.openLoadState is OpenLoadState.Loading) { - add(NodeBottomSheetAction.CANCEL_LOADING) - } else { - if (cellNode is CellNodeUi.File && cellNode.localFileAvailable()) { - add(NodeBottomSheetAction.SHARE) - } - add(NodeBottomSheetAction.PUBLIC_LINK) - - 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) - } + 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() + 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 -> { + + add(NodeBottomSheetAction.OPEN) + + if (cellNode.localFileAvailable()) { + add(NodeBottomSheetAction.SHARE) + } + + add( + if (cellNode.isAvailableOffline) { + NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS + } else { + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE + }, + ) + } } + } else { + add(NodeBottomSheetAction.OPEN) + } + } + + 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.PUBLIC_LINK, + NodeBottomSheetAction.MOVE, + NodeBottomSheetAction.RENAME, + NodeBottomSheetAction.DELETE, + ), + ) + } + 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 + internal data class CancelDownload(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?, @@ -103,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) @@ -137,6 +204,22 @@ 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/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 09d5a5ee328..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 @@ -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 @@ -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 f47ffcbb0f6..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 @@ -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 @@ -56,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 @@ -78,8 +82,12 @@ 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 @Composable @@ -87,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) } @@ -124,7 +133,7 @@ internal fun CellListItem( ) Row(verticalAlignment = Alignment.CenterVertically) { - CellItemSubtitle(cell = cell, showReadyState = showReadyState) + CellItemSubtitle(cell = cell, showReadyState = showReadyState, showConversationName = showConversationName) } } @@ -150,7 +159,8 @@ internal fun CellListItem( @Composable private fun CellItemIcon(cell: CellNodeUi, showReadyState: Boolean) { val iconState = when { - cell.openLoadState is OpenLoadState.Loading -> CellIconState.Loading((cell.openLoadState as OpenLoadState.Loading).progress) + 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) @@ -166,6 +176,7 @@ private fun CellItemIcon(cell: CellNodeUi, showReadyState: Boolean) { ) { state -> when (state) { is CellIconState.Loading -> 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) @@ -174,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), @@ -184,6 +195,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, @@ -192,6 +204,17 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { color = colorsScheme().error, maxLines = 1, ) + + cell.downloadProgress != null -> + Text( + text = stringResource(R.string.tap_to_cancel_download), + textAlign = TextAlign.Left, + overflow = TextOverflow.Ellipsis, + style = typography().label04, + color = colorsScheme().secondaryText, + maxLines = 1, + ) + showReadyState -> Text( text = stringResource(R.string.ready_to_open), textAlign = TextAlign.Left, @@ -200,7 +223,18 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { color = colorsScheme().primary, 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(), @@ -208,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, @@ -224,6 +258,7 @@ private fun CellItemSubtitle(cell: CellNodeUi, showReadyState: Boolean) { 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() @@ -260,6 +295,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( @@ -375,21 +447,23 @@ private fun PublicLinkIcon( } @Composable -private fun CellNodeUi.subtitle() = - when { - userName != null && conversationName != null -> { +private fun CellNodeUi.subtitle(showConversationName: Boolean): String? { + val formattedTime = modifiedTime?.let { + remember(it) { Instant.fromEpochMilliseconds(it).cellFileDateTime() } + } + return when { + showConversationName && 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 + showConversationName && conversationName != null -> conversationName + formattedTime != null -> formattedTime else -> null } +} @PreviewMultipleThemes @Composable @@ -398,6 +472,7 @@ private fun PreviewCellListItem() { CellListItem( cell = CellNodeUi.File( uuid = "", + conversationId = "conversationId", name = "file name", assetType = AttachmentFileType.IMAGE, size = 123214, @@ -410,7 +485,6 @@ private fun PreviewCellListItem() { conversationName = "Test Conversation", modifiedTime = null, remotePath = null, - contentHash = null, contentUrl = null, previewUrl = null ), @@ -418,3 +492,113 @@ private fun PreviewCellListItem() { ) } } + +@PreviewMultipleThemes +@Composable +private fun PreviewCellListItemLoading() { + WireTheme { + CellListItem( + cell = CellNodeUi.File( + uuid = "", + conversationId = "conversationId", + 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 = "", + conversationId = "conversationId", + 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 = "", + conversationId = "conversationId", + 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/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 96ffe31f2b3..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, ) } @@ -217,6 +219,7 @@ internal fun CellScreenContent( } ) } + val offlineFileSavedToastDescription = stringResource(R.string.offline_file_saved_message) HandleActions(actionsFlow) { action -> when (action) { @@ -245,6 +248,13 @@ 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, + offlineFileSavedToastDescription, + Toast.LENGTH_SHORT + ).show() + } } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 5171fd7c61c..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,8 +34,8 @@ 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.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 @@ -46,15 +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 @@ -70,10 +78,14 @@ 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 import okio.Path.Companion.toPath +import java.io.File import javax.inject.Inject @Suppress("TooManyFunctions", "LongParameterList") @@ -91,6 +103,13 @@ 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, + private val getOfflineFile: GetOfflineFileUseCase, + private val networkStateObserver: NetworkStateObserver, + private val getConversationName: GetConversationNameUseCase, + private val getUserName: GetUserNameUseCase, ) : ActionsViewModel() { private val navArgs: CellFilesNavArgs = ConversationFilesScreenDestination.argsFrom(savedStateHandle) @@ -111,8 +130,6 @@ 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 fileReadyFlow: Flow = sharedPathCache.fileReadyEvents private val removedItemsFlow: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -141,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 { @@ -167,54 +192,93 @@ class CellViewModel @Inject constructor( refreshTrigger.flatMapLatest { _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, - openFileDownloadController.openLoadStates, - ) { pagingData, removedItems, openLoadStates -> - 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) + getCellFilesPaged( + conversationId = navArgs.conversationId, + fileFilters = FileFilters( + onlyDeleted = navArgs.isRecycleBin ?: false, + ), + sortingSpec = SortingSpec( + criteria = sortingCriteria.toKaliumCriteria(), + descending = sortingCriteria.isDescending, + ), + ).cachedIn(viewModelScope), + removedItemsFlow, + sharedPathCache.openLoadStates, + observeOfflineFiles(), + offlineFileDownloadController.downloadProgresses, + ) { 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 } - val openLoadState = openLoadStates[node.uuid] - when (node) { - is Node.Folder -> node.toUiModel() - is Node.File -> node.toUiModel().withOpenLoadState(openLoadState) - } + _pagingRefreshDone.tryEmit(Unit) } - } + + val openLoadState = openLoadStates[node.uuid] + when (node) { + is Node.Folder -> node.toUiModel() + is Node.File -> node.toUiModel( + openLoadState = openLoadState, + downloadProgress = downloadProgresses[node.uuid], + isAvailableOffline = node.uuid in offlineUuids, + ) + } + } + } } } }.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() } @@ -253,6 +317,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) @@ -270,7 +335,7 @@ class CellViewModel @Inject constructor( } internal fun cancelOpenDownload(uuid: String) { - openFileDownloadController.cancel(uuid) + openFileDownloadController.cancel(uuid, viewModelScope) } private fun onFolderClick(cellNode: CellNodeUi.Folder) { @@ -343,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)) @@ -357,13 +423,47 @@ 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) + is CellFileActionsMenu.CancelDownload -> offlineFileDownloadController.cancel(result.node.uuid, viewModelScope) + is CellFileActionsMenu.MakeAvailableOffline -> makeAvailableOffline(result.node) + is CellFileActionsMenu.RemoveOfflineAccess -> removeOfflineAccess(result.node) } } } + private fun makeAvailableOffline(node: CellNodeUi.File) { + offlineFileDownloadController.start( + scope = viewModelScope, + cellNode = node.copy( + conversationId = navArgs.conversationId + ), + onSuccess = { _ -> sendAction(ShowOfflineFileSaved) }, + onError = { sendAction(ShowError(it)) }, + ) + } + + 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 { getEditorUrl(nodeUuid) .onSuccess { url -> @@ -461,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( @@ -500,10 +631,13 @@ 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) { 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/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index 3cba2524e53..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 @@ -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(), @@ -128,6 +133,7 @@ fun ConversationFilesScreen( } @OptIn(ExperimentalSharedTransitionApi::class) +@Suppress("CyclomaticComplexMethod") @Composable internal fun ConversationFilesScreenContent( animatedVisibilityScope: AnimatedVisibilityScope, @@ -146,6 +152,7 @@ internal fun ConversationFilesScreenContent( screenTitle: String? = null, isRecycleBin: Boolean = false, isRestoreInProgress: Boolean = false, + isOnline: Boolean = true, breadcrumbs: Array? = emptyArray(), fileReadyFlow: Flow = emptyFlow(), ) { @@ -223,7 +230,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 +239,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 +301,7 @@ internal fun ConversationFilesScreenContent( isRestoreInProgress = isRestoreInProgress, isDeleteInProgress = isDeleteInProgress, isRecycleBin = isRecycleBin, + isOffline = !isOnline, openFolder = { path, title, parentFolderUuid -> navigator.navigate( NavigationCommand( @@ -377,6 +389,7 @@ fun PreviewConversationFilesScreen() { listOf( CellNodeUi.File( uuid = "file1", + conversationId = "conversationId", name = "File 1", assetType = AttachmentFileType.IMAGE, size = 123456, @@ -387,7 +400,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 +414,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/DownloadFailureUtils.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt new file mode 100644 index 00000000000..7fde24ff210 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/DownloadFailureUtils.kt @@ -0,0 +1,58 @@ +/* + * 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 +} + +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 new file mode 100644 index 00000000000..31263a60eb9 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt @@ -0,0 +1,189 @@ +/* + * 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.Either +import com.wire.kalium.common.functional.onSuccess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import okio.Path +import okio.Path.Companion.toOkioPath +import java.io.File +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 sharedPathCache: CellFileLocalPathCache, +) { + + internal val downloadProgresses: StateFlow> = sharedPathCache.downloadProgresses + + 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, + ) { + // 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) { + saveExistingOfflineFile(scope, cellNode, existingPath, onSuccess, onError) + return + } + + // Cancel any previous download for this node. + activeJobs.remove(cellNode.uuid)?.job?.cancel() + + val nodeName = cellNode.name ?: run { + onError(CellError.OTHER_ERROR) + return + } + val filePath = fileNameResolver + .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) + .toPath() + .toOkioPath() + + val job = scope.launch { + performDownload(cellNode, nodeName, filePath, onSuccess, onError) + } + + 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, + mimeType = cellNode.mimeType, + owner = cellNode.ownerUserId ?: "", + localPath = existingPath, + size = cellNode.size, + downloadedAt = System.currentTimeMillis(), + conversationId = cellNode.conversationId, + modifiedAt = cellNode.modifiedTime, + ) + ) + onSuccess(existingPath) + } + } + + 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, + conversationId = cellNode.conversationId, + 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) + } + } + } + + result.onSuccess { + clearProgress(cellNode.uuid) + sharedPathCache.recordCompletedPath(cellNode.uuid, filePath.toString()) + saveOfflineFile( + OfflineFileInfo( + id = cellNode.uuid, + name = nodeName, + mimeType = cellNode.mimeType, + owner = cellNode.ownerUserId ?: "", + localPath = filePath.toString(), + size = cellNode.size, + downloadedAt = System.currentTimeMillis(), + conversationId = cellNode.conversationId, + modifiedAt = cellNode.modifiedTime, + ) + ) + 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) { + 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?) { + sharedPathCache.setDownloadProgress(uuid, progress) + } + + private fun clearProgress(uuid: String) { + 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 04b926590f0..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 @@ -17,19 +17,23 @@ */ 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.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.job import kotlinx.coroutines.launch +import okio.Path import okio.Path.Companion.toOkioPath +import java.io.File import javax.inject.Inject /** @@ -48,11 +52,11 @@ 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) - /** Delegates to [CellFileLocalPathCache.openLoadStates] — the singleton source of truth. */ - internal val openLoadStates: StateFlow> = sharedPathCache.openLoadStates + private val activeDownloads = mutableMapOf() + + internal val openLoadStates = sharedPathCache.openLoadStates internal fun start( scope: CoroutineScope, @@ -70,77 +74,101 @@ 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.getCacheDir(), nodeName) - .toPath() - .toOkioPath() + val filePath = fileNameResolver + .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) + .toPath() + .toOkioPath() - // 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()) - } + val job = scope.launch { + performDownload(cellNode, filePath, onOpenFile, onError) + } + activeDownloads[cellNode.uuid] = ActiveDownload(job, filePath) + } + + private suspend fun CoroutineScope.performDownload( + cellNode: CellNodeUi.File, + filePath: Path, + onOpenFile: (CellNodeUi.File) -> Unit, + onError: (CellError) -> Unit, + ) { + val thisJob = coroutineContext.job - 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)) { - 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()) + } + + val result = download( + assetId = cellNode.uuid, + conversationId = cellNode.conversationId, + 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)) } } - .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 ( - abstract val openLoadState: OpenLoadState? + internal abstract val openLoadState: OpenLoadState? + abstract val downloadProgress: Float? - data class Folder( + /** 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 internal constructor( override val name: String?, override val uuid: String, override val userName: String?, 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?, 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?, 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?, @@ -73,17 +80,24 @@ 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, + val conversationId: String?, ) : 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, @@ -92,10 +106,14 @@ internal fun Node.File.toUiModel() = CellNodeUi.File( userHandle = userHandle, ownerUserId = ownerUserId, conversationName = conversationName, + conversationId = conversationId, publicLinkId = publicLinkId, - modifiedTime = formattedModifiedTime(), + modifiedTime = modifiedTime, tags = tags, isEditSupported = isEditSupported, + openLoadState = openLoadState, + downloadProgress = downloadProgress, + isAvailableOffline = isAvailableOffline, ) internal fun Node.Folder.toUiModel() = CellNodeUi.Folder( @@ -105,26 +123,22 @@ 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.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, ) internal fun CellNodeUi.File.localFileAvailable() = localPath != null 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..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), @@ -34,5 +35,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_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/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 index 1ab13b0eb59..4bd58e31daf 100644 --- 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 @@ -19,7 +19,7 @@ package com.wire.android.feature.cells.ui.model import okio.Path -sealed interface OpenLoadState { +internal sealed interface OpenLoadState { data class Loading(val progress: Float = 0f) : OpenLoadState data class Ready(val localPath: Path) : OpenLoadState data object Error : OpenLoadState 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/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/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_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..a728332c130 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cross_in_circle.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_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/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 29c5cce3a2f..a0cee253cd3 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,11 +77,18 @@ Folder Name Loading files… Tap to cancel loading + Tap to cancel download Unable to load, retry Ready to open Cancel loading… + Cancel download + 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/CellFileActionsMenuTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellFileActionsMenuTest.kt index a8b7529e85b..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,9 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, ), items ) @@ -82,8 +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, @@ -104,8 +107,9 @@ class CellFileActionsMenuTest { // THEN assertEquals( listOf( + NodeBottomSheetAction.OPEN, NodeBottomSheetAction.SHARE, - NodeBottomSheetAction.PUBLIC_LINK, + NodeBottomSheetAction.MAKE_AVAILABLE_OFFLINE, ), items ) @@ -126,11 +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, @@ -153,9 +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, @@ -392,6 +400,121 @@ 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.OPEN, + NodeBottomSheetAction.SHARE, + 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.OPEN, + NodeBottomSheetAction.SHARE, + NodeBottomSheetAction.REMOVE_OFFLINE_ACCESS, + NodeBottomSheetAction.ADD_REMOVE_TAGS, + NodeBottomSheetAction.PUBLIC_LINK, + 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 { @@ -441,6 +564,7 @@ class CellFileActionsMenuTest { private companion object { val fileNode = CellNodeUi.File( name = "file.txt", + conversationId = "conversationId", conversationName = "Conversation", uuid = "fileUuid", mimeType = "video/mp4", @@ -462,7 +586,7 @@ class CellFileActionsMenuTest { uuid = "uuid", userName = "user", conversationName = "conversation", - modifiedTime = "time", + modifiedTime = 1696154400000L, size = 1, userHandle = null, ownerUserId = null, 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..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,18 +35,28 @@ 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 +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 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.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -69,6 +79,7 @@ class CellViewModelTest { val testFiles = listOf( Node.File( uuid = "fileUuid", + conversationId = "conversationId", versionId = "versionId", name = "fileName", mimeType = "image/png", @@ -80,6 +91,7 @@ class CellViewModelTest { ), Node.File( uuid = "fileUuid2", + conversationId = "conversationId", versionId = "versionId2", name = "fileName2", mimeType = "image/png", @@ -111,7 +123,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()) } @@ -145,24 +158,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()) } - } + // 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 { @@ -177,7 +191,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(), any()) } coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } } @@ -213,8 +227,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 +304,24 @@ class CellViewModelTest { @MockK lateinit var getWireCellsConfig: GetWireCellConfigurationUseCase + @MockK + lateinit var observeOfflineFiles: ObserveOfflineFilesUseCase + + @MockK + lateinit var deleteOfflineFile: DeleteOfflineFileUseCase + + @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) @@ -301,6 +336,12 @@ class CellViewModelTest { coEvery { isCellAvailableUseCase.invoke() } returns true.right() + 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( data = listOf( @@ -329,11 +370,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(), any()) } returns Unit.right() } fun withSlowDownloadSuccess() = apply { - coEvery { downloadCellFileUseCase(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() } @@ -349,7 +390,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 +402,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 +423,13 @@ class CellViewModelTest { getWireCellsConfig = getWireCellsConfig, sharedPathCache = sharedPathCache, openFileDownloadController = openFileDownloadController, + offlineFileDownloadController = offlineFileDownloadController, + observeOfflineFiles = observeOfflineFiles, + deleteOfflineFile = deleteOfflineFile, + getOfflineFile = getOfflineFile, + networkStateObserver = networkStateObserver, + getConversationName = getConversationNames, + getUserName = getUserNames, ) } } 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 9abf2d5b382..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 @@ -42,6 +42,7 @@ 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 @@ -94,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(), any()) } } @Test @@ -117,7 +118,7 @@ class OpenFileDownloadControllerTest { } @Test - fun givenFastDownloadSuccess_whenStartCalled_thenNoSnackbarEventEmitted() = runTest { + fun givenFastDownloadSuccess_whenStartCalled_thenLocalPathStoredInSharedCache() = runTest { val (arrangement, controller) = Arrangement() .withDownloadSuccess() .arrange() @@ -165,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( @@ -238,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") } @@ -253,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") @@ -280,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(), any()) } coAnswers { + val onProgressUpdate = arg<(Long) -> Unit>(7) + 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, 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). + 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() + } + + // 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() @@ -297,12 +348,39 @@ class OpenFileDownloadControllerTest { assertEquals(0.5f, (state as? OpenLoadState.Loading)?.progress) } + @Test + fun givenSimultaneousDownloads_whenOneFails_thenErrorStateSetAndInProgressLoadingPreserved() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess(uuid = testFile.uuid) + .withDownloadFailure(uuid = anotherFile.uuid) + .arrange() + + // 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 = {}) + // 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) + + 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 { const val SPINNER_THRESHOLD_MS = 400L const val AUTO_DISMISS_MS = 3_000L val testFile = CellNodeUi.File( uuid = "test-uuid", + conversationId = "conversation-id", name = "report.pdf", mimeType = "application/pdf", assetType = AttachmentFileType.OTHER, @@ -315,6 +393,8 @@ class OpenFileDownloadControllerTest { conversationName = null, modifiedTime = null, ) + + val anotherFile = testFile.copy(uuid = "another-uuid", name = "photo.jpg") } private inner class Arrangement { @@ -332,30 +412,30 @@ 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(), 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(), 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(), 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(450) // Clearly after spinner threshold (400 ms) — progress updates Loading() + coEvery { downloadUseCase(eq(uuid), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + val onProgressUpdate = arg<(Long) -> Unit>(7) + 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, ) } } diff --git a/kalium b/kalium index 122f2bd1c5e..227ebac8faa 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 122f2bd1c5e31459d5d090aeeee0a808064a5ef1 +Subproject commit 227ebac8faac06c08139cfdf5062f26c38974bf9