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 5adfe59138e..144875798bc 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 @@ -27,6 +27,7 @@ import com.wire.kalium.cells.domain.usecase.AddAttachmentDraftUseCase import com.wire.kalium.cells.domain.usecase.CreateFolderUseCase import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase import com.wire.kalium.cells.domain.usecase.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase import com.wire.kalium.cells.domain.usecase.GetCellFileUseCase import com.wire.kalium.cells.domain.usecase.GetEditorUrlUseCase @@ -111,7 +112,7 @@ class CellsModule { @ViewModelScoped @Provides - fun provideDownloadUseCase(cellsScope: CellsScope): DownloadCellFileUseCase = cellsScope.downloadFile + fun provideDownloadUseCase(cellsScope: CellsScope): DownloadCellFileUseCase = cellsScope.downloadCellFile @ViewModelScoped @Provides @@ -220,4 +221,9 @@ class CellsModule { @Provides fun provideRestoreNodeVersionUseCase(cellsScope: CellsScope): RestoreNodeVersionUseCase = cellsScope.restoreNodeVersion + + @ViewModelScoped + @Provides + fun provideDownloadCellVersionUseCase(cellsScope: CellsScope): DownloadCellVersionUseCase = + cellsScope.downloadCellVersion } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 7c436830854..28d7b25b924 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -184,7 +184,7 @@ import com.wire.android.util.normalizeLink import com.wire.android.util.serverDate import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.UIText -import com.wire.android.util.ui.openDownloadFolder +import com.wire.android.util.openDownloadFolder import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.Conversation.TypingIndicatorMode diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 098924dfeaf..5048866d0b7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -75,7 +75,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText -import com.wire.android.util.ui.openDownloadFolder +import com.wire.android.util.openDownloadFolder import com.wire.kalium.logic.data.id.ConversationId import kotlinx.coroutines.launch import kotlinx.serialization.Serializable diff --git a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt index 5f3daa79915..b10e1d08520 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/gallery/MediaGalleryScreen.kt @@ -66,7 +66,7 @@ import com.wire.android.util.permission.rememberWriteStoragePermissionFlow import com.wire.android.util.startFileShareIntent import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.SnackBarMessageHandler -import com.wire.android.util.ui.openDownloadFolder +import com.wire.android.util.openDownloadFolder @OptIn(ExperimentalCoilApi::class) @WireDestination( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9529be9aab8..90f5469d9c4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1516,7 +1516,6 @@ registriert. Bitte versuchen Sie es mit einer anderen. 10 Sek Daten zur Fehlerbehebung "Dadurch werden anonymisierte Informationen zur Fehlerbehebung lokal gespeichert. " - "Keine Anwendung zum Öffnen des Download-Ordners gefunden" Jemand Hat eine selbstlöschende Nachricht gesendet Lizenzen diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ec796779e18..7ac7a78b6bb 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1418,7 +1418,6 @@ Kérjük, próbálja meg újra. Hibakeresési információ Belső hibakeresés "Ez helyileg tárolja az anonimizált hibaelhárítási információkat. " - "Nem található alkalmazás a letöltési mappa megnyitásához" Valaki Önmegsemmisítő üzenetet küldött Licenc információ diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 63a5798121f..4aa1f66f56c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1098,7 +1098,6 @@ registrato. Sei pregato di riprovare. Dati di debug Debug interno "Questo memorizza localmente le informazioni anonime di risoluzione dei problemi. " - "Nessun'applicazione trovata per aprire la cartella dei download" Qualcuno Ha inviato un messaggio a eliminazione automatica Informazioni sulla licenza diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3e8f0ae8a4e..936fbbc949d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1358,7 +1358,6 @@ registrado. Tente novamente. Dados de depuração Depuração interna "Isso armazena informações anônimas de solução de problemas localmente. " - "Nenhum aplicativo encontrado para abrir a pasta de downloads" Alguém Enviou uma mensagem de auto-exclusão Informações de Licença diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7ea51bdafa8..ce24773f507 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1658,7 +1658,6 @@ Отладочные данные Внутренняя отладка "Это позволяет локально сохранять анонимизированную информацию о неполадках. " - "Не найдено приложение для открытия папки загрузок" Кто-то Отправил самоудаляющееся сообщение Информация о лицензии diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index f986c5848b6..ccb180d0b43 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -1427,7 +1427,6 @@ නිදෝස්කරණ දත්ත අභ්‍යන්තර නිදොස්කරණය "මෙය නිර්නාමික දෝෂ නිරාකරණ තොරතුරු ස්ථානීයව ගබඩා කරයි. " - "බාගැනීමේ බහාලුම ඇරීමට සුදුසු යෙදුමක් හමු නොවිණි" යමෙක් ඉබේ මැකෙන පණිවිඩයක් එවා ඇත බලපත්‍රයෙහි තොරතුරු diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49bd692e4d3..53adc32446f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1624,7 +1624,6 @@ In group conversations, the group admin can overwrite this setting. Debug data Internal debugging "This stores anonymized troubleshooting information locally. " - "No application found to open downloads folder" Someone Sent a self-deleting message License Information diff --git a/app/src/main/kotlin/com/wire/android/util/ui/DownloadFolderOpener.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/DownloadFolderOpener.kt similarity index 79% rename from app/src/main/kotlin/com/wire/android/util/ui/DownloadFolderOpener.kt rename to core/ui-common/src/main/kotlin/com/wire/android/util/DownloadFolderOpener.kt index 8f41eb9505b..5b195a4d2e5 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/DownloadFolderOpener.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/DownloadFolderOpener.kt @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2024 Wire Swiss GmbH + * Copyright (C) 2025 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,16 +15,19 @@ * 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.util.ui +package com.wire.android.util import android.app.DownloadManager import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.widget.Toast +import com.wire.android.ui.common.R -fun openDownloadFolder(context: Context) { - val errorToastMessage = context.resources.getString(com.wire.android.R.string.label_no_application_found_open_downloads_folder) +fun openDownloadFolder( + context: Context, +) { + val errorToastMessage = context.resources.getString(R.string.label_no_application_found_open_downloads_folder) try { context.startActivity(Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)) } catch (e: ActivityNotFoundException) { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt index 296c8dcacf2..6b789982c51 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt @@ -47,3 +47,14 @@ fun String.toTitleCase(delimiter: String = " ", separator: String = " "): String fun String.capitalizeFirstLetter(): String = lowercase().replaceFirstChar(Char::titlecaseChar) fun String.normalizeFileName(): String = this.replace("/", "") + +fun String.addBeforeExtension(insert: String): String { + val dotIndex = this.lastIndexOf('.') + return if (dotIndex != -1) { + val name = this.take(dotIndex) + val ext = this.substring(dotIndex) + "${name}_$insert$ext" + } else { + this + insert + } +} diff --git a/core/ui-common/src/main/res/values-de/strings.xml b/core/ui-common/src/main/res/values-de/strings.xml index 68d8fa3e08c..bf30dde6901 100644 --- a/core/ui-common/src/main/res/values-de/strings.xml +++ b/core/ui-common/src/main/res/values-de/strings.xml @@ -54,4 +54,5 @@ GB TB Haken + "Keine Anwendung zum Öffnen des Download-Ordners gefunden" diff --git a/core/ui-common/src/main/res/values-hu/strings.xml b/core/ui-common/src/main/res/values-hu/strings.xml index bc967ead3f2..ced9cd16ded 100644 --- a/core/ui-common/src/main/res/values-hu/strings.xml +++ b/core/ui-common/src/main/res/values-hu/strings.xml @@ -18,4 +18,5 @@ --> Pipa jel + "Nem található alkalmazás a letöltési mappa megnyitásához" diff --git a/core/ui-common/src/main/res/values-it/strings.xml b/core/ui-common/src/main/res/values-it/strings.xml index 7d60fd033e6..5a8cd8e7612 100644 --- a/core/ui-common/src/main/res/values-it/strings.xml +++ b/core/ui-common/src/main/res/values-it/strings.xml @@ -18,4 +18,5 @@ --> Segno di spunta + "Nessun'applicazione trovata per aprire la cartella dei download" diff --git a/core/ui-common/src/main/res/values-pt/strings.xml b/core/ui-common/src/main/res/values-pt/strings.xml index d4b996de5b9..60ccc0f7f86 100644 --- a/core/ui-common/src/main/res/values-pt/strings.xml +++ b/core/ui-common/src/main/res/values-pt/strings.xml @@ -18,4 +18,5 @@ --> Marca de seleção + "Nenhum aplicativo encontrado para abrir a pasta de downloads" diff --git a/core/ui-common/src/main/res/values-ru/strings.xml b/core/ui-common/src/main/res/values-ru/strings.xml index cbb4a81b0c4..0d0177db174 100644 --- a/core/ui-common/src/main/res/values-ru/strings.xml +++ b/core/ui-common/src/main/res/values-ru/strings.xml @@ -61,4 +61,5 @@ GB TB Отметка + "Не найдено приложение для открытия папки загрузок" diff --git a/core/ui-common/src/main/res/values-si/strings.xml b/core/ui-common/src/main/res/values-si/strings.xml index 16c81769eeb..988ad29dd3d 100644 --- a/core/ui-common/src/main/res/values-si/strings.xml +++ b/core/ui-common/src/main/res/values-si/strings.xml @@ -18,4 +18,5 @@ --> හරි ලකුණ + "බාගැනීමේ බහාලුම ඇරීමට සුදුසු යෙදුමක් හමු නොවිණි" diff --git a/core/ui-common/src/main/res/values/strings.xml b/core/ui-common/src/main/res/values/strings.xml index 0e8cf323cb3..92b118eba05 100644 --- a/core/ui-common/src/main/res/values/strings.xml +++ b/core/ui-common/src/main/res/values/strings.xml @@ -58,8 +58,8 @@ Ok Cancel No selected date - Check mark + "No application found to open downloads folder" B KB diff --git a/default.json b/default.json index 500d8d831c9..86fb3549fbf 100644 --- a/default.json +++ b/default.json @@ -72,7 +72,8 @@ "analytics_app_key": "8ffae535f1836ed5f58fd5c8a11c00eca07c5438", "analytics_server_url": "https://wire.count.ly/", "enable_new_registration": true, - "use_async_flush_logging": true + "use_async_flush_logging": true, + "collabora_integration": true }, "internal": { "application_id": "com.wire.internal", 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 f76df14a655..4852da8bd6e 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 @@ -87,7 +87,7 @@ internal fun CellScreenContent( isSearchResult: Boolean = false, isFiltering: Boolean = false, retryEditNodeError: (String) -> Unit = {}, - showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> } + showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> }, ) { val context = LocalContext.current diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/CellVersion.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/CellVersion.kt index b2ebd5bfd0a..745211335e6 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/CellVersion.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/CellVersion.kt @@ -24,7 +24,8 @@ data class CellVersion( val modifiedAt: String = "", val modifiedBy: String = "", val fileSize: String = "", - val isCurrentVersion: Boolean = false + val isCurrentVersion: Boolean = false, + val presignedUrl: String? = null ) data class VersionGroup( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionActionBottomSheet.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionActionBottomSheet.kt index 078703da608..df169cb24fe 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionActionBottomSheet.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionActionBottomSheet.kt @@ -38,10 +38,10 @@ import com.wire.android.ui.theme.wireDimensions @Composable fun VersionActionBottomSheet( - sheetState: WireModalSheetState, + sheetState: WireModalSheetState>, onDismiss: () -> Unit, onRestoreVersionClicked: (String) -> Unit, - onDownloadVersionClicked: (String) -> Unit, + onDownloadVersionClicked: (String, String) -> Unit, ) { WireModalSheetLayout( onDismissRequest = onDismiss, @@ -52,13 +52,13 @@ fun VersionActionBottomSheet( add { RestoreVersionModalItem( title = stringResource(R.string.restore_version_bottom_sheet_item_label), - onClicked = { onRestoreVersionClicked(state.versionId) }, + onClicked = { onRestoreVersionClicked(state.second.versionId) }, ) } add { DownloadVersionModalItem( title = stringResource(R.string.download_version_bottom_sheet_item_label), - onClicked = { onDownloadVersionClicked(state.versionId) }, + onClicked = { onDownloadVersionClicked(state.second.versionId, state.first) }, ) } } @@ -111,10 +111,10 @@ private fun DownloadVersionModalItem( private fun PreviewCellsOptionsBottomSheet() { WireTheme { VersionActionBottomSheet( - sheetState = rememberWireModalSheetState(WireSheetValue.Expanded(value = CellVersion())), + sheetState = rememberWireModalSheetState(WireSheetValue.Expanded(value = "" to CellVersion())), onDismiss = {}, onRestoreVersionClicked = {}, - onDownloadVersionClicked = {} + onDownloadVersionClicked = { _, _ -> } ) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryNavArgs.kt index cfd1a803156..70e3ca879f6 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryNavArgs.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryNavArgs.kt @@ -18,6 +18,6 @@ package com.wire.android.feature.cells.ui.versioning class VersionHistoryNavArgs( - val uuid: String? = null, - val fileName: String? = null + val uuid: String, + val fileName: String ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt index b01a4e501ad..aea842070ce 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt @@ -25,19 +25,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.ErrorScreen import com.wire.android.feature.cells.ui.common.LoadingScreen +import com.wire.android.feature.cells.ui.versioning.download.DownloadState import com.wire.android.feature.cells.ui.versioning.restore.RestoreDialogState import com.wire.android.feature.cells.ui.versioning.restore.RestoreNodeVersionConfirmationDialog import com.wire.android.navigation.WireNavigator @@ -50,10 +55,12 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.divider.WireDivider import com.wire.android.ui.common.preview.MultipleThemePreviews import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.openDownloadFolder import com.wire.android.util.ui.toUIText import kotlinx.coroutines.launch @@ -67,7 +74,13 @@ fun VersionHistoryScreen( modifier: Modifier = Modifier, versionHistoryViewModel: VersionHistoryViewModel = hiltViewModel() ) { - val optionsBottomSheetState = rememberWireModalSheetState() + val optionsBottomSheetState = rememberWireModalSheetState>() + val snackbarHostState = LocalSnackbarHostState.current + val context = LocalContext.current + val downloadState = versionHistoryViewModel.downloadState.value + val savedToDownloads = stringResource(R.string.snackbar_download_cell_version_saved_to_downloads_folder_label) + val showLabel = stringResource(R.string.snackbar_download_cell_version_show_label) + val downloadingLabel = stringResource(R.string.snackbar_download_cell_version_downloading_label) val scope = rememberCoroutineScope() VersionHistoryScreenContent( @@ -81,8 +94,9 @@ fun VersionHistoryScreen( restoreVersion = { versionHistoryViewModel.restoreVersion() }, - downloadVersion = { + downloadVersion = { versionId, versionDate -> optionsBottomSheetState.hide() + versionHistoryViewModel.downloadVersion(versionId, versionDate) }, showRestoreConfirmationDialog = { versionId -> optionsBottomSheetState.hide() @@ -91,13 +105,39 @@ fun VersionHistoryScreen( onDismissRestoreConfirmationDialog = { versionHistoryViewModel.hideRestoreConfirmationDialog() }, - onGoToFileClicked = {}, + onGoToFileClicked = { + versionHistoryViewModel.openOnlineEditor() + }, onRefresh = { scope.launch { versionHistoryViewModel.refreshVersions() } } ) + + LaunchedEffect(downloadState) { + when (downloadState) { + is DownloadState.Downloaded -> { + val snackbarResult = snackbarHostState.showSnackbar( + message = "\"${downloadState.fileName}\" $savedToDownloads", + actionLabel = showLabel, + duration = SnackbarDuration.Short, + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + openDownloadFolder(context) + } + } + + is DownloadState.Downloading -> { + snackbarHostState.showSnackbar( + message = downloadingLabel, + duration = SnackbarDuration.Long, + ) + } + + else -> Unit + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -105,18 +145,19 @@ fun VersionHistoryScreen( private fun VersionHistoryScreenContent( versionsGroupedByTime: List, versionHistoryState: State, - optionsBottomSheetState: WireModalSheetState, - restoreDialogState: RestoreDialogState, onRefresh: () -> Unit, + optionsBottomSheetState: WireModalSheetState>, + restoreDialogState: RestoreDialogState, modifier: Modifier = Modifier, fileName: String? = null, restoreVersion: () -> Unit = {}, - downloadVersion: (String) -> Unit = {}, + downloadVersion: (String, String) -> Unit = { _, _ -> }, showRestoreConfirmationDialog: (String) -> Unit = {}, onDismissRestoreConfirmationDialog: () -> Unit = {}, onGoToFileClicked: () -> Unit = {}, navigateBack: () -> Unit = {} ) { + WireScaffold( modifier = modifier, topBar = { @@ -180,10 +221,11 @@ private fun VersionHistoryScreenContent( } group.versions.forEach { item(it.versionId) { + val versionDate = group.dateLabel.asString() VersionItem( cellVersion = it, onActionClick = { cellVersion -> - optionsBottomSheetState.show(cellVersion) + optionsBottomSheetState.show(versionDate to cellVersion) } ) WireDivider( @@ -216,6 +258,25 @@ private fun VersionHistoryScreenContent( } } } + + VersionActionBottomSheet( + sheetState = optionsBottomSheetState, + onDismiss = { optionsBottomSheetState.hide() }, + onRestoreVersionClicked = showRestoreConfirmationDialog, + onDownloadVersionClicked = downloadVersion + ) + + with(restoreDialogState) { + if (visible) { + RestoreNodeVersionConfirmationDialog( + restoreVersionState = restoreVersionState, + restoreProgress = restoreProgress, + onConfirm = restoreVersion, + onDismiss = onDismissRestoreConfirmationDialog, + onGoToFileClicked = onGoToFileClicked, + ) + } + } } @MultipleThemePreviews @@ -243,7 +304,7 @@ fun PreviewVersionHistoryScreenContent() { ) ) ), - optionsBottomSheetState = rememberWireModalSheetState(), + optionsBottomSheetState = rememberWireModalSheetState>(), restoreDialogState = RestoreDialogState(), onRefresh = {} ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt index 7e17a7ce667..e958024f333 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt @@ -23,12 +23,18 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.navArgs +import com.wire.android.feature.cells.ui.versioning.download.DownloadState import com.wire.android.feature.cells.ui.versioning.restore.RestoreDialogState import com.wire.android.feature.cells.ui.versioning.restore.RestoreVersionState +import com.wire.android.feature.cells.util.FileHelper import com.wire.android.util.FileSizeFormatter +import com.wire.android.util.addBeforeExtension +import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UIText import com.wire.kalium.cells.domain.model.NodeVersion +import com.wire.kalium.cells.domain.usecase.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase import com.wire.kalium.common.functional.onFailure @@ -36,6 +42,8 @@ import com.wire.kalium.common.functional.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.sink import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -49,6 +57,10 @@ class VersionHistoryViewModel @Inject constructor( private val getNodeVersionsUseCase: GetNodeVersionsUseCase, private val fileSizeFormatter: FileSizeFormatter, private val restoreNodeVersionUseCase: RestoreNodeVersionUseCase, + private val downloadCellVersionUseCase: DownloadCellVersionUseCase, + private val fileHelper: FileHelper, + private val onlineEditor: OnlineEditor, + private val dispatchers: DispatcherProvider, ) : ViewModel() { private val navArgs: VersionHistoryNavArgs = savedStateHandle.navArgs() @@ -61,10 +73,17 @@ class VersionHistoryViewModel @Inject constructor( var versionsGroupedByTime: MutableState> = mutableStateOf(listOf()) private set - var restoreDialogState: MutableState = - mutableStateOf(RestoreDialogState()) + var restoreDialogState: MutableState = mutableStateOf(RestoreDialogState()) + private set + + var downloadState: MutableState = mutableStateOf(DownloadState.Idle) + private set init { + initVersions() + } + + fun initVersions() { viewModelScope.launch { versionHistoryState.value = VersionHistoryState.Loading fetchNodeVersionsGroupedByDate() @@ -77,17 +96,15 @@ class VersionHistoryViewModel @Inject constructor( } private suspend fun fetchNodeVersionsGroupedByDate() = - navArgs.uuid?.let { - getNodeVersionsUseCase(navArgs.uuid) - .onSuccess { - versionHistoryState.value = VersionHistoryState.Success - versionsGroupedByTime.value = it.groupByDay() - } - // TODO: Handle error on UI - .onFailure { - versionHistoryState.value = VersionHistoryState.Failed - } - } + getNodeVersionsUseCase(navArgs.uuid) + .onSuccess { + versionHistoryState.value = VersionHistoryState.Success + versionsGroupedByTime.value = it.groupByDay() + } + // TODO: Handle error on UI + .onFailure { + versionHistoryState.value = VersionHistoryState.Failed + } private fun List.groupByDay(): List { val today = LocalDate.now() @@ -131,7 +148,8 @@ class VersionHistoryViewModel @Inject constructor( modifiedBy = apiItem.ownerName ?: "", fileSize = fileSizeFormatter.formatSize(apiItem.size?.toLong() ?: 0), modifiedAt = formattedTime, - isCurrentVersion = groupIndex == 0 && itemIndex == 0 + isCurrentVersion = groupIndex == 0 && itemIndex == 0, + presignedUrl = apiItem.getUrl?.url ) } VersionGroup(dateLabel, uiItems) @@ -170,7 +188,7 @@ class VersionHistoryViewModel @Inject constructor( restoreNodeVersionUseCase(navArgs.uuid ?: "", value.versionId) .onSuccess { delay(DELAY_500_MS) // delay since server takes some time to restore the version - refreshVersions() + initVersions() progressJob.cancel() restoreDialogState.value = value.copy( restoreVersionState = RestoreVersionState.Completed, @@ -187,6 +205,57 @@ class VersionHistoryViewModel @Inject constructor( } } + // TODO: Unit test coming in another PR + fun downloadVersion(versionId: String, versionDate: String) { + viewModelScope.launch { + downloadState.value = DownloadState.Downloading(0, 0) + + val cellVersion = findVersionById(versionId) + ?: return@launch run { downloadState.value = DownloadState.Failed } + + val newFileName = fileName.addBeforeExtension("${versionDate}_${cellVersion.modifiedAt}") + + val outputStream = withContext(dispatchers.io()) { + fileHelper.createDownloadFileStream(newFileName) + } ?: run { + downloadState.value = DownloadState.Failed + return@launch + } + + val presignedUrl = cellVersion.presignedUrl + ?: return@launch run { downloadState.value = DownloadState.Failed } + + outputStream.sink().use { sink -> + downloadCellVersionUseCase( + bufferedSink = sink, + preSignedUrl = presignedUrl, + onProgressUpdate = { progress, total -> + downloadState.value = DownloadState.Downloading(progress.toInt(), total) + } + ) + .onSuccess { + downloadState.value = DownloadState.Downloaded(newFileName) + } + .onFailure { + downloadState.value = DownloadState.Failed + } + } + } + } + + fun openOnlineEditor() { + val cellVersion = findVersionById(restoreDialogState.value.versionId) + cellVersion?.presignedUrl?.let { + onlineEditor.open(it) + } + } + + private fun findVersionById(versionId: String): CellVersion? { + return versionsGroupedByTime.value + .flatMap { it.versions } + .find { it.versionId == versionId } + } + @Suppress("MagicNumber") private fun simulateRestoreProgress() = viewModelScope.launch { with(restoreDialogState) { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/download/DownloadState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/download/DownloadState.kt new file mode 100644 index 00000000000..90f34a513d3 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/download/DownloadState.kt @@ -0,0 +1,25 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.versioning.download + +sealed class DownloadState { + data object Idle : DownloadState() + data class Downloading(val progress: Int, val total: Long) : DownloadState() + data class Downloaded(val fileName: String) : DownloadState() + data object Failed : DownloadState() +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreNodeVersionConfirmationDialog.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreNodeVersionConfirmationDialog.kt index 93d2ab74de2..90515c62059 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreNodeVersionConfirmationDialog.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreNodeVersionConfirmationDialog.kt @@ -91,9 +91,9 @@ fun RestoreNodeVersionConfirmationDialog( ) { when (restoreVersionState) { RestoreVersionState.Completed, RestoreVersionState.Restoring -> GoToFileContent( - restoreVersionState, - restoreProgress, - onGoToFileClicked + restoreVersionState = restoreVersionState, + restoreProgress = restoreProgress, + onGoToFileClicked = onGoToFileClicked ) RestoreVersionState.Failed -> RestoreFailedContent( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreVersionState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreVersionState.kt index 09afce66b29..27d998be6ed 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreVersionState.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/restore/RestoreVersionState.kt @@ -15,6 +15,7 @@ * 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.versioning.restore enum class RestoreVersionState { 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 1ce3ed7b1c8..466961796ef 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 @@ -18,18 +18,50 @@ package com.wire.android.feature.cells.util import android.content.ActivityNotFoundException +import android.content.ContentValues import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import androidx.core.content.FileProvider import dagger.hilt.android.qualifiers.ApplicationContext import okio.Path +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream import javax.inject.Inject class FileHelper @Inject constructor( @ApplicationContext private val context: Context ) { + fun createDownloadFileStream(nodeName: String): OutputStream? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android 10+ + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, nodeName) + put(MediaStore.Downloads.MIME_TYPE, "application/octet-stream") + put( + MediaStore.Downloads.RELATIVE_PATH, + Environment.DIRECTORY_DOWNLOADS + ) + } + + val uri = context.contentResolver.insert( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + values + ) + uri?.let { context.contentResolver.openOutputStream(it) } + } else { + // Android 8–9 (API 26–28) + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + + val file = File(downloadsDir, nodeName) + FileOutputStream(file) + } + fun openAssetFileWithExternalApp( localPath: Path, assetName: String?, diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index b24e598c5f9..11ac27a2579 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -191,4 +191,7 @@ Yesterday, %1$s Failed to load versions Something went wrong while loading version list. Please retry. + saved to Downloads + Show + Downloading… diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt index d2dc9c39998..327878a19c3 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt @@ -19,10 +19,14 @@ package com.wire.android.feature.cells.ui.versioning import androidx.lifecycle.SavedStateHandle import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.edit.OnlineEditor +import com.wire.android.feature.cells.util.FileHelper import com.wire.android.util.FileSizeFormatter +import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.resolveForTest import com.wire.android.util.ui.toUIText import com.wire.kalium.cells.domain.model.NodeVersion +import com.wire.kalium.cells.domain.usecase.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase import com.wire.kalium.common.error.CoreFailure @@ -57,8 +61,11 @@ class VersionHistoryViewModelTest { private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) private val getNodeVersionsUseCase: GetNodeVersionsUseCase = mockk() private val restoreNodeVersionUseCase: RestoreNodeVersionUseCase = mockk() + private val downloadCellVersionUseCase: DownloadCellVersionUseCase = mockk() private val fileSizeFormatter: FileSizeFormatter = mockk() - + val fileHelper: FileHelper = mockk() + val onlineEditor: OnlineEditor = mockk() + private val testDispatcherProvider: DispatcherProvider = mockk() private val testNodeUuid = "test-node-uuid" @BeforeEach @@ -78,7 +85,16 @@ class VersionHistoryViewModelTest { fun givenViewModel_whenItInits_thenIsFetchingStateIsManagedCorrectly() = runTest { coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Right(emptyList()) - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) + val viewModel = VersionHistoryViewModel( + savedStateHandle = savedStateHandle, + getNodeVersionsUseCase = getNodeVersionsUseCase, + fileSizeFormatter = fileSizeFormatter, + restoreNodeVersionUseCase = restoreNodeVersionUseCase, + downloadCellVersionUseCase = downloadCellVersionUseCase, + fileHelper = fileHelper, + onlineEditor = onlineEditor, + dispatchers = testDispatcherProvider, + ) assertEquals(VersionHistoryState.Idle, viewModel.versionHistoryState.value) advanceUntilIdle() @@ -131,7 +147,17 @@ class VersionHistoryViewModelTest { coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Right(versionsFromApi) every { fileSizeFormatter.formatSize(any()) } returns "30 MB" - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) + val viewModel = VersionHistoryViewModel( + savedStateHandle = savedStateHandle, + getNodeVersionsUseCase = getNodeVersionsUseCase, + fileSizeFormatter = fileSizeFormatter, + restoreNodeVersionUseCase = restoreNodeVersionUseCase, + downloadCellVersionUseCase = downloadCellVersionUseCase, + fileHelper = fileHelper, + onlineEditor = onlineEditor, + dispatchers = testDispatcherProvider, + ) + testDispatcher.scheduler.advanceUntilIdle() // Versions should be grouped into three sections (Today, Yesterday, and an older date) @@ -176,21 +202,19 @@ class VersionHistoryViewModelTest { fun givenApiFailure_whenViewModelInits_thenVersionListIsEmpty() = runTest { coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Left(CoreFailure.MissingClientRegistration) - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) + val viewModel = VersionHistoryViewModel( + savedStateHandle = savedStateHandle, + getNodeVersionsUseCase = getNodeVersionsUseCase, + fileSizeFormatter = fileSizeFormatter, + restoreNodeVersionUseCase = restoreNodeVersionUseCase, + downloadCellVersionUseCase = downloadCellVersionUseCase, + fileHelper = fileHelper, + onlineEditor = onlineEditor, + dispatchers = testDispatcherProvider, + ) advanceUntilIdle() assertTrue(viewModel.versionsGroupedByTime.value.isEmpty()) assertEquals(VersionHistoryState.Failed, viewModel.versionHistoryState.value) } - - @Test - fun givenMissingUuid_whenViewModelInits_thenNoFetchIsAttempted() = runTest { - every { savedStateHandle.get("uuid") } returns null - - val viewModel = VersionHistoryViewModel(savedStateHandle, getNodeVersionsUseCase, fileSizeFormatter, restoreNodeVersionUseCase) - advanceUntilIdle() - - assertTrue(viewModel.versionsGroupedByTime.value.isEmpty()) - assertEquals(VersionHistoryState.Loading, viewModel.versionHistoryState.value) - } } diff --git a/kalium b/kalium index 35685adde44..c15237a855f 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 35685adde4487d788ad049ebe6fadc456e091605 +Subproject commit c15237a855f52138ad00746b34285efd6cf6725f