Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: save backup file locally [WPB-1827] [WPB-762] #1921

Merged
merged 2 commits into from
Jul 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ fun BackupAndRestoreScreen(viewModel: BackupAndRestoreViewModel = hiltViewModel(
onValidateBackupPassword = viewModel::validateBackupCreationPassword,
onCreateBackup = viewModel::createBackup,
onSaveBackup = viewModel::saveBackup,
onShareBackup = viewModel::shareBackup,
onChooseBackupFile = viewModel::chooseBackupFileToRestore,
onRestoreBackup = viewModel::restorePasswordProtectedBackup,
onCancelBackupRestore = viewModel::cancelBackupRestore,
Expand All @@ -69,7 +70,8 @@ fun BackupAndRestoreContent(
backUpAndRestoreState: BackupAndRestoreState,
onValidateBackupPassword: (TextFieldValue) -> Unit,
onCreateBackup: (String) -> Unit,
onSaveBackup: () -> Unit,
onSaveBackup: (Uri) -> Unit,
onShareBackup: () -> Unit,
onCancelBackupCreation: () -> Unit,
onCancelBackupRestore: () -> Unit,
onChooseBackupFile: (Uri) -> Unit,
Expand Down Expand Up @@ -134,6 +136,7 @@ fun BackupAndRestoreContent(
onValidateBackupPassword = onValidateBackupPassword,
onCreateBackup = onCreateBackup,
onSaveBackup = onSaveBackup,
onShareBackup = onShareBackup,
onCancelCreateBackup = {
backupAndRestoreStateHolder.dismissDialog()
onCancelBackupCreation()
Expand Down Expand Up @@ -166,6 +169,7 @@ fun PreviewBackupAndRestoreScreen() {
onValidateBackupPassword = {},
onCreateBackup = {},
onSaveBackup = {},
onShareBackup = {},
onCancelBackupCreation = {},
onCancelBackupRestore = {},
onChooseBackupFile = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ sealed interface PasswordValidation {
}

sealed interface BackupCreationProgress {
object Finished : BackupCreationProgress
data class Finished(val fileName: String) : BackupCreationProgress
data class InProgress(val value: Float = 0f) : BackupCreationProgress
object Failed : BackupCreationProgress
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class BackupAndRestoreViewModel

when (val result = createBackupFile(password)) {
is CreateBackupResult.Success -> {
state = state.copy(backupCreationProgress = BackupCreationProgress.Finished)
state = state.copy(backupCreationProgress = BackupCreationProgress.Finished(result.backupFileName))
latestCreatedBackup = BackupAndRestoreState.CreatedBackup(
result.backupFilePath,
result.backupFileName,
Expand All @@ -102,7 +102,7 @@ class BackupAndRestoreViewModel
}
}

fun saveBackup() = viewModelScope.launch(dispatcher.main()) {
fun shareBackup() = viewModelScope.launch(dispatcher.main()) {
latestCreatedBackup?.let { backupData ->
withContext(dispatcher.io()) {
fileManager.shareWithExternalApp(backupData.path, backupData.assetName) {}
Expand All @@ -111,6 +111,13 @@ class BackupAndRestoreViewModel
state = BackupAndRestoreState.INITIAL_STATE
}

fun saveBackup(uri: Uri) = viewModelScope.launch(dispatcher.main()) {
latestCreatedBackup?.let { backupData ->
fileManager.copyToUri(backupData.path, uri, dispatcher)
}
state = BackupAndRestoreState.INITIAL_STATE
}

fun chooseBackupFileToRestore(uri: Uri) = viewModelScope.launch {
latestImportedBackupTempPath = kaliumFileSystem.tempFilePath(TEMP_IMPORTED_BACKUP_FILE_NAME)
fileManager.copyToPath(uri, latestImportedBackupTempPath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

package com.wire.android.ui.home.settings.backup.dialog.create

import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -36,7 +37,8 @@ fun CreateBackupDialogFlow(
backUpAndRestoreState: BackupAndRestoreState,
onValidateBackupPassword: (TextFieldValue) -> Unit,
onCreateBackup: (String) -> Unit,
onSaveBackup: () -> Unit,
onSaveBackup: (Uri) -> Unit,
onShareBackup: () -> Unit,
onCancelCreateBackup: () -> Unit
) {
val backupDialogStateHolder = rememberBackUpDialogState()
Expand All @@ -48,18 +50,20 @@ fun CreateBackupDialogFlow(
isBackupPasswordValid = backUpAndRestoreState.backupCreationPasswordValidation is PasswordValidation.Valid,
onBackupPasswordChanged = onValidateBackupPassword,
onCreateBackup = { password ->
toCreateBackup()
toCreatingBackup()
onCreateBackup(password)
},
onDismissDialog = onCancelCreateBackup
)
}

BackUpDialogStep.CreatingBackup -> {
is BackUpDialogStep.CreatingBackup,
is BackUpDialogStep.Finished -> {
CreateBackupStep(
backUpAndRestoreState = backUpAndRestoreState,
backupDialogStateHolder = backupDialogStateHolder,
onSaveBackup = onSaveBackup,
onShareBackup = onShareBackup,
onCancelCreateBackup = onCancelCreateBackup
)
}
Expand All @@ -74,7 +78,7 @@ fun CreateBackupDialogFlow(
}
}

BackHandler(backupDialogStateHolder.currentBackupDialogStep != BackUpDialogStep.CreatingBackup) {
BackHandler(backupDialogStateHolder.currentBackupDialogStep !is BackUpDialogStep.CreatingBackup) {
onCancelCreateBackup()
}
}
Expand All @@ -83,24 +87,25 @@ fun CreateBackupDialogFlow(
private fun CreateBackupStep(
backUpAndRestoreState: BackupAndRestoreState,
backupDialogStateHolder: CreateBackupDialogStateHolder,
onSaveBackup: () -> Unit,
onSaveBackup: (Uri) -> Unit,
onShareBackup: () -> Unit,
onCancelCreateBackup: () -> Unit
) {
with(backupDialogStateHolder) {
LaunchedEffect(backUpAndRestoreState.backupCreationProgress) {
when (val progress = backUpAndRestoreState.backupCreationProgress) {
BackupCreationProgress.Failed -> toBackupFailure()
BackupCreationProgress.Finished -> toFinished()
is BackupCreationProgress.InProgress -> {
backupProgress = progress.value
}
is BackupCreationProgress.Finished -> toFinished(progress.fileName)
is BackupCreationProgress.InProgress -> toCreatingBackup(progress.value)
}
}

CreateBackupDialog(
isBackupCreationCompleted = isBackupFinished,
createBackupProgress = backupProgress,
onSaveBackup = onSaveBackup,
onShareBackup = onShareBackup,
backupFileName = backupFileName,
onDismissDialog = onCancelCreateBackup
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue

@Stable
class CreateBackupDialogStateHolder {
Expand All @@ -36,21 +35,30 @@ class CreateBackupDialogStateHolder {

var currentBackupDialogStep: BackUpDialogStep by mutableStateOf(INITIAL_STEP)

var isBackupFinished: Boolean by mutableStateOf(false)
val isBackupFinished: Boolean
get() = currentBackupDialogStep is BackUpDialogStep.Finished

var backupProgress: Float by mutableStateOf(0.0f)
val backupProgress: Float
get() = when (val step = currentBackupDialogStep) {
BackUpDialogStep.SetPassword -> 0f
is BackUpDialogStep.CreatingBackup -> step.progress
BackUpDialogStep.Failure -> 1f
is BackUpDialogStep.Finished -> 1f
}

fun toCreateBackup() {
currentBackupDialogStep = BackUpDialogStep.CreatingBackup
val backupFileName: String
get() = (currentBackupDialogStep as? BackUpDialogStep.Finished)?.fileName ?: ""

fun toCreatingBackup(progress: Float = 0f) {
currentBackupDialogStep = BackUpDialogStep.CreatingBackup(progress)
}

fun toBackupFailure() {
currentBackupDialogStep = BackUpDialogStep.Failure
}

fun toFinished() {
isBackupFinished = true
backupProgress = 1.0f
fun toFinished(fileName: String) {
currentBackupDialogStep = BackUpDialogStep.Finished(fileName)
}
}

Expand All @@ -61,6 +69,7 @@ fun rememberBackUpDialogState(): CreateBackupDialogStateHolder {

sealed interface BackUpDialogStep {
object SetPassword : BackUpDialogStep
object CreatingBackup : BackUpDialogStep
data class CreatingBackup(val progress: Float) : BackUpDialogStep
data class Finished(val fileName: String) : BackUpDialogStep
object Failure : BackUpDialogStep
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

package com.wire.android.ui.home.settings.backup.dialog.create

import android.net.Uri
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -46,6 +47,7 @@ import com.wire.android.ui.common.spacers.VerticalSpace
import com.wire.android.ui.common.textfield.WirePasswordTextField
import com.wire.android.ui.common.textfield.WireTextFieldState
import com.wire.android.ui.theme.wireTypography
import com.wire.android.util.permission.rememberCreateFileFlow
import java.util.Locale
import kotlin.math.roundToInt

Expand Down Expand Up @@ -91,19 +93,36 @@ fun SetBackupPasswordDialog(
fun CreateBackupDialog(
isBackupCreationCompleted: Boolean,
createBackupProgress: Float,
onSaveBackup: () -> Unit,
backupFileName: String,
onSaveBackup: (Uri) -> Unit,
onShareBackup: () -> Unit,
onDismissDialog: () -> Unit
) {
val progress by animateFloatAsState(targetValue = createBackupProgress)
val fileStep = rememberCreateFileFlow(
onFileCreated = {
onSaveBackup(it)
onDismissDialog()
},
onPermissionDenied = { /* do nothing */ },
fileName = backupFileName
)
WireDialog(
title = stringResource(R.string.backup_dialog_create_backup_title),
onDismiss = onDismissDialog,
buttonsHorizontalAlignment = false,
optionButton1Properties = WireDialogButtonProperties(
onClick = fileStep::launch,
text = stringResource(R.string.backup_dialog_create_backup_save),
type = WireDialogButtonType.Primary,
state = if (isBackupCreationCompleted) WireButtonState.Default else WireButtonState.Disabled
),
optionButton2Properties = WireDialogButtonProperties(
onClick = {
onSaveBackup()
onShareBackup()
onDismissDialog()
},
text = stringResource(R.string.backup_dialog_create_backup_save),
text = stringResource(R.string.backup_dialog_create_backup_share),
type = WireDialogButtonType.Primary,
state = if (isBackupCreationCompleted) WireButtonState.Default else WireButtonState.Disabled
),
Expand Down
41 changes: 25 additions & 16 deletions app/src/main/kotlin/com/wire/android/util/FileManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C
return@withContext size
}

suspend fun copyToUri(
sourcePath: Path,
destinationUri: Uri,
dispatcher: DispatcherProvider = DefaultDispatcherProvider(),
) =
withContext(dispatcher.io()) {
context.contentResolver.copyFile(destinationUri, sourcePath)
}

suspend fun getTempWritableVideoUri(
tempCachePath: Path,
dispatcher: DispatcherProvider = DefaultDispatcherProvider(),
Expand All @@ -102,23 +111,23 @@ class FileManager @Inject constructor(@ApplicationContext private val context: C
tempCachePath: Path,
dispatcher: DispatcherProvider = DefaultDispatcherProvider(),
): AssetBundle? = withContext(dispatcher.io()) {
try {
val assetFileName = context.getFileName(attachmentUri) ?: throw IOException("The selected asset has an invalid name")
val fullTempAssetPath = "$tempCachePath/${UUID.randomUUID()}".toPath()
val mimeType = attachmentUri.getMimeType(context).orDefault(DEFAULT_FILE_MIME_TYPE)
val attachmentType = AttachmentType.fromMimeTypeString(mimeType)
val assetSize = if (attachmentType == AttachmentType.IMAGE) {
attachmentUri.resampleImageAndCopyToTempPath(context, fullTempAssetPath)
} else {
// TODO: We should add also a video resampling logic soon, that way we could drastically reduce as well the number
// of video assets hitting the max limit.
copyToPath(attachmentUri, fullTempAssetPath)
}
AssetBundle(mimeType, fullTempAssetPath, assetSize, assetFileName, attachmentType)
} catch (e: IOException) {
appLogger.e("There was an error while obtaining the file from disk", e)
null
try {
val assetFileName = context.getFileName(attachmentUri) ?: throw IOException("The selected asset has an invalid name")
val fullTempAssetPath = "$tempCachePath/${UUID.randomUUID()}".toPath()
val mimeType = attachmentUri.getMimeType(context).orDefault(DEFAULT_FILE_MIME_TYPE)
val attachmentType = AttachmentType.fromMimeTypeString(mimeType)
val assetSize = if (attachmentType == AttachmentType.IMAGE) {
attachmentUri.resampleImageAndCopyToTempPath(context, fullTempAssetPath)
} else {
// TODO: We should add also a video resampling logic soon, that way we could drastically reduce as well the number
// of video assets hitting the max limit.
copyToPath(attachmentUri, fullTempAssetPath)
}
AssetBundle(mimeType, fullTempAssetPath, assetSize, assetFileName, attachmentType)
} catch (e: IOException) {
appLogger.e("There was an error while obtaining the file from disk", e)
null
}
}

companion object {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/kotlin/com/wire/android/util/FileUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ private fun Context.saveFileDataToDownloadsFolder(assetName: String, downloadedD
}?.also { downloadedUri -> resolver.copyFile(downloadedUri, downloadedDataPath) }
}

private fun ContentResolver.copyFile(destinationUri: Uri, sourcePath: Path) {
fun ContentResolver.copyFile(destinationUri: Uri, sourcePath: Path) {
openOutputStream(destinationUri).use { outputStream ->
val brr = ByteArray(DATA_COPY_BUFFER_SIZE)
var len: Int
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Wire
* Copyright (C) 2023 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.util.permission

import android.net.Uri
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext

/**
* Flow that will launch file browser to select a path where new file has to be created.
* This will handle the permissions request in case there is no permission granted for the storage.
*
* @param onFileCreated action that will be executed when creating a file succeeded
* @param onPermissionDenied action to be executed when the permissions is denied
*/
@Composable
fun rememberCreateFileFlow(
onFileCreated: (Uri) -> Unit,
onPermissionDenied: () -> Unit,
fileName: String,
fileMimeType: String = "*/*"
): WriteStorageRequestFlow {
val context = LocalContext.current
val createFileLauncher: ManagedActivityResultLauncher<String, Uri?> = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(fileMimeType)
) { onFileCreatedUri ->
onFileCreatedUri?.let { onFileCreated(it) }
}

val actionIfGranted: () -> Unit = remember(createFileLauncher, fileName) { { createFileLauncher.launch(fileName) } }

val requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean> =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
actionIfGranted()
} else {
onPermissionDenied()
}
}

return remember(fileName, fileMimeType) {
WriteStorageRequestFlow(context, actionIfGranted, requestPermissionLauncher)
}
}
saleniuk marked this conversation as resolved.
Show resolved Hide resolved
Loading