diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManager.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManager.kt new file mode 100644 index 000000000..98cd73eba --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManager.kt @@ -0,0 +1,14 @@ +package com.bitwarden.authenticator.data.authenticator.manager + +import android.net.Uri + +/** + * Manages reading and writing files. + */ +interface FileManager { + + /** + * Writes the given [dataString] to disk at the provided [fileUri] + */ + suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManagerImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManagerImpl.kt new file mode 100644 index 000000000..2d0d9c1ac --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManagerImpl.kt @@ -0,0 +1,29 @@ +package com.bitwarden.authenticator.data.authenticator.manager + +import android.content.Context +import android.net.Uri +import com.bitwarden.authenticator.data.platform.manager.DispatcherManager +import kotlinx.coroutines.withContext + +class FileManagerImpl( + private val context: Context, + private val dispatcherManager: DispatcherManager, +) : FileManager { + + override suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean { + @Suppress("TooGenericExceptionCaught") + return try { + withContext(dispatcherManager.io) { + context + .contentResolver + .openOutputStream(fileUri) + ?.use { outputStream -> + outputStream.write(dataString.toByteArray()) + } + } + true + } catch (exception: RuntimeException) { + false + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/di/AuthenticatorManagerModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/di/AuthenticatorManagerModule.kt index 2edbad54a..b9d7af15c 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/di/AuthenticatorManagerModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/di/AuthenticatorManagerModule.kt @@ -1,12 +1,16 @@ package com.bitwarden.authenticator.data.authenticator.manager.di +import android.content.Context import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource +import com.bitwarden.authenticator.data.authenticator.manager.FileManager +import com.bitwarden.authenticator.data.authenticator.manager.FileManagerImpl import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManagerImpl import com.bitwarden.authenticator.data.platform.manager.DispatcherManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import java.time.Clock import javax.inject.Singleton @@ -18,6 +22,16 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object AuthenticatorManagerModule { + @Provides + @Singleton + fun providerFileManager( + @ApplicationContext context: Context, + dispatcherManager: DispatcherManager, + ) : FileManager = FileManagerImpl( + context = context, + dispatcherManager = dispatcherManager, + ) + @Provides @Singleton fun provideTotpCodeManager( diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/ExportJsonData.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/ExportJsonData.kt new file mode 100644 index 000000000..d7d84cd07 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/ExportJsonData.kt @@ -0,0 +1,51 @@ +package com.bitwarden.authenticator.data.authenticator.manager.model + +import kotlinx.serialization.Serializable + +/** + * Models exported authenticator data in JSON format. + * + * This model is loosely based off of Bitwarden's exported unencrypted vault data. + */ +@Serializable +data class ExportJsonData( + val encrypted: Boolean, + val items: List, +) { + + /** + * Represents a single exported authenticator item. + * + * This model is loosely based off of Bitwarden's exported Cipher JSON. + */ + @Serializable + data class ExportItem( + val id: String, + val name: String, + val folderId: String?, + val organizationId: String?, + val collectionIds: List?, + val notes: String?, + val type: Int, + val login: ItemLoginData, + val favorite: Boolean, + ) { + /** + * Represents the login specific data of an exported item. + * + * This model is loosely based off of Bitwarden's Cipher.Login JSON. + * + * @property totp OTP secret used to generate a verification code. + * @property issuer Optional issuer of the 2fa code. + * @property period Optional refresh period in seconds. Default is 30. + * @property digits Optional number of digits in the verification code. Default is 6 + */ + @Serializable + data class ItemLoginData( + val totp: String, + val issuer: String?, + val period: Int, + val digits: Int, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt index 34b5726c6..06b0bbeea 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepository.kt @@ -1,14 +1,17 @@ package com.bitwarden.authenticator.data.authenticator.repository +import android.net.Uri import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorData import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemRequest import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemResult import com.bitwarden.authenticator.data.platform.repository.model.DataState +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportFormat import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -78,4 +81,9 @@ interface AuthenticatorRepository { itemId: String, updateItemRequest: UpdateItemRequest, ): UpdateItemResult + + /** + * Attempt to get the user's data for export. + */ + suspend fun exportVaultData(format: ExportFormat, fileUri: Uri): ExportDataResult } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt index c14fb04ae..1d653ee19 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/AuthenticatorRepositoryImpl.kt @@ -1,12 +1,16 @@ package com.bitwarden.authenticator.data.authenticator.repository +import android.net.Uri import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.manager.FileManager import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager +import com.bitwarden.authenticator.data.authenticator.manager.model.ExportJsonData import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorData import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult import com.bitwarden.authenticator.data.authenticator.repository.model.DeleteItemResult +import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult import com.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemRequest import com.bitwarden.authenticator.data.authenticator.repository.model.UpdateItemResult @@ -15,6 +19,7 @@ import com.bitwarden.authenticator.data.platform.repository.model.DataState import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow import com.bitwarden.authenticator.data.platform.repository.util.combineDataStates import com.bitwarden.authenticator.data.platform.repository.util.map +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportFormat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -23,6 +28,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn @@ -30,6 +36,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import javax.inject.Inject /** @@ -41,6 +49,7 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 5_000L class AuthenticatorRepositoryImpl @Inject constructor( private val authenticatorDiskSource: AuthenticatorDiskSource, private val totpCodeManager: TotpCodeManager, + private val fileManager: FileManager, dispatcherManager: DispatcherManager, ) : AuthenticatorRepository { @@ -214,4 +223,73 @@ class AuthenticatorRepositoryImpl @Inject constructor( UpdateItemResult.Error(e.message) } } + + override suspend fun exportVaultData(format: ExportFormat, fileUri: Uri): ExportDataResult { + return when (format) { + ExportFormat.JSON -> encodeVaultDataToJson(fileUri) + ExportFormat.CSV -> encodeVaultDataToCsv(fileUri) + } + } + + private suspend fun encodeVaultDataToCsv(fileUri: Uri): ExportDataResult { + val headerLine = + "folder,favorite,type,name,login_uri,login_totp,issuer,period,digits" + val dataLines = authenticatorDiskSource + .getItems() + .firstOrNull() + .orEmpty() + .joinToString("\n") { it.toCsvFormat() } + + val csvString = "$headerLine\n$dataLines" + + return if (fileManager.stringToUri(fileUri = fileUri, dataString = csvString)) { + ExportDataResult.Success + } else { + ExportDataResult.Error + } + } + + private fun AuthenticatorItemEntity.toCsvFormat() = + ",,1,$accountName,,$key,$issuer,$period,$digits" + + private suspend fun encodeVaultDataToJson(fileUri: Uri): ExportDataResult { + val dataString: String = Json.encodeToString( + ExportJsonData( + encrypted = false, + items = authenticatorDiskSource + .getItems() + .firstOrNull() + .orEmpty() + .map { it.toExportJsonItem() }, + ) + ) + + return if ( + fileManager.stringToUri( + fileUri = fileUri, + dataString = dataString, + ) + ) { + ExportDataResult.Success + } else { + ExportDataResult.Error + } + } + + private fun AuthenticatorItemEntity.toExportJsonItem() = ExportJsonData.ExportItem( + id = id, + folderId = null, + organizationId = null, + collectionIds = null, + name = accountName, + notes = null, + type = 1, + login = ExportJsonData.ExportItem.ItemLoginData( + totp = key, + issuer = issuer, + period = period, + digits = digits, + ), + favorite = false, + ) } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt index 1233da9a2..35b9ae733 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/di/AuthenticatorRepositoryModule.kt @@ -1,6 +1,7 @@ package com.bitwarden.authenticator.data.authenticator.repository.di import com.bitwarden.authenticator.data.authenticator.datasource.disk.AuthenticatorDiskSource +import com.bitwarden.authenticator.data.authenticator.manager.FileManager import com.bitwarden.authenticator.data.authenticator.manager.TotpCodeManager import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepositoryImpl @@ -24,9 +25,11 @@ object AuthenticatorRepositoryModule { authenticatorDiskSource: AuthenticatorDiskSource, dispatcherManager: DispatcherManager, totpCodeManager: TotpCodeManager, + fileManager: FileManager, ): AuthenticatorRepository = AuthenticatorRepositoryImpl( authenticatorDiskSource, totpCodeManager, + fileManager, dispatcherManager, ) diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/ExportDataResult.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/ExportDataResult.kt new file mode 100644 index 000000000..52f7627ec --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/ExportDataResult.kt @@ -0,0 +1,12 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Represents the result of a data export operation. + */ +sealed class ExportDataResult { + + data object Success : ExportDataResult() + + data object Error : ExportDataResult() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt index 1bcc738d2..a5c0dd860 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt @@ -11,6 +11,7 @@ import com.bitwarden.authenticator.ui.authenticator.feature.navbar.AUTHENTICATOR import com.bitwarden.authenticator.ui.authenticator.feature.navbar.authenticatorNavBarDestination import com.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.navigateToQrCodeScanScreen import com.bitwarden.authenticator.ui.authenticator.feature.search.navigateToSearch +import com.bitwarden.authenticator.ui.platform.feature.settings.export.navigateToExport import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToTutorial const val AUTHENTICATOR_GRAPH_ROUTE = "authenticator_graph" @@ -38,7 +39,7 @@ fun NavGraphBuilder.authenticatorGraph( onNavigateToManualKeyEntry = { navController.navigateToManualCodeEntryScreen() }, onNavigateToEditItem = { navController.navigateToEditItem(itemId = it) }, onNavigateToTutorial = { navController.navigateToTutorial() }, - onNavigateToExport = { /* TODO */ }, + onNavigateToExport = { navController.navigateToExport() }, ) itemListingGraph( navController = navController, @@ -55,7 +56,7 @@ fun NavGraphBuilder.authenticatorGraph( navController.navigateToEditItem(itemId = it) }, navigateToTutorial = { navController.navigateToTutorial() }, - navigateToExport = { /* TODO */ }, + navigateToExport = { navController.navigateToExport() }, ) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt index 6eff4a93a..962533e33 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt @@ -39,7 +39,9 @@ fun NavGraphBuilder.itemListingGraph( onNavigateToSyncWithBitwardenScreen = { /*navController.navigateToSyncWithBitwardenScreen()*/ }, - onNavigateToImportScreen = { /*navController.navigateToImportScreen()*/ } + onNavigateToImportScreen = { + /*navController.navigateToImportScreen()*/ + } ) editItemDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index 71943ac61..27e0c34f1 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -153,7 +153,7 @@ fun ItemListingScreen( ItemListingAction.ConfirmDeleteClick(itemId = itemId), ) } - } + }, ) BitwardenScaffold( diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt index c0022ef26..e35942f3d 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.navigation import com.bitwarden.authenticator.ui.platform.base.util.composableWithRootPushTransitions +import com.bitwarden.authenticator.ui.platform.feature.settings.export.exportDestination const val SETTINGS_GRAPH_ROUTE = "settings_graph" private const val SETTINGS_ROUTE = "settings" @@ -29,6 +30,9 @@ fun NavGraphBuilder.settingsGraph( onNavigateToExport = onNavigateToExport, ) } + exportDestination( + onNavigateBack = { navController.popBackStack() } + ) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 1735cb73e..c837b3b12 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -159,14 +159,6 @@ private fun AppearanceSettings( modifier = Modifier.padding(horizontal = 16.dp), label = stringResource(id = R.string.appearance) ) - LanguageSelectionRow( - currentSelection = state.appearance.language, - onLanguageSelection = onLanguageSelection, - modifier = Modifier - .semantics { testTag = "LanguageChooser" } - .fillMaxWidth(), - ) - ThemeSelectionRow( currentSelection = state.appearance.theme, onThemeSelection = onThemeSelection, @@ -174,6 +166,14 @@ private fun AppearanceSettings( .semantics { testTag = "ThemeChooser" } .fillMaxWidth(), ) + + LanguageSelectionRow( + currentSelection = state.appearance.language, + onLanguageSelection = onLanguageSelection, + modifier = Modifier + .semantics { testTag = "LanguageChooser" } + .fillMaxWidth(), + ) } @Composable diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportNavigation.kt new file mode 100644 index 000000000..6c5840ab2 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportNavigation.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +const val EXPORT_ROUTE = "export" + +fun NavGraphBuilder.exportDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions(EXPORT_ROUTE) { + ExportScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +fun NavController.navigateToExport(navOptions: NavOptions? = null) { + navigate(EXPORT_ROUTE, navOptions) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportScreen.kt new file mode 100644 index 000000000..6ea3b72a3 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportScreen.kt @@ -0,0 +1,209 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export + +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.dropdown.BitwardenMultiSelectButton +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportFormat +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager +import com.bitwarden.authenticator.ui.platform.util.displayLabel +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExportScreen( + viewModel: ExportViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val exportLocationReceive: (Uri) -> Unit = remember { + { + viewModel.trySendAction(ExportAction.ExportLocationReceive(it)) + } + } + val fileSaveLauncher = intentManager.getActivityResultLauncher { activityResult -> + intentManager.getFileDataFromActivityResult(activityResult)?.let { + exportLocationReceive.invoke(it.uri) + } + } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + ExportEvent.NavigateBack -> onNavigateBack() + is ExportEvent.ShowToast -> { + Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show() + } + + is ExportEvent.NavigateToSelectExportDestination -> { + fileSaveLauncher.launch( + intentManager.createDocumentIntent( + fileName = event.fileName, + ), + ) + } + } + } + + var shouldShowConfirmationPrompt by remember { mutableStateOf(false) } + val confirmExportClick = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.ConfirmExportClick) + } + } + if (shouldShowConfirmationPrompt) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.export_confirmation_title), + message = stringResource( + id = R.string.export_vault_warning, + ), + confirmButtonText = stringResource(id = R.string.export), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + shouldShowConfirmationPrompt = false + confirmExportClick() + }, + onDismissClick = { shouldShowConfirmationPrompt = false }, + onDismissRequest = { shouldShowConfirmationPrompt = false }, + ) + } + + when (val dialog = state.dialogState) { + is ExportState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title, + message = dialog.message, + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.DialogDismiss) + } + } + ) + } + + is ExportState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = dialog.message + ) + ) + } + + null -> Unit + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.export), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.CloseButtonClick) + } + }, + ) + }, + ) { paddingValues -> + ExportScreenContent( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = state, + onExportFormatOptionSelected = remember(viewModel) { + { + viewModel.trySendAction(ExportAction.ExportFormatOptionSelect(it)) + } + }, + onExportClick = { shouldShowConfirmationPrompt = true } + ) + } +} + +@Composable +private fun ExportScreenContent( + modifier: Modifier = Modifier, + state: ExportState, + onExportFormatOptionSelected: (ExportFormat) -> Unit, + onExportClick: () -> Unit, +) { + Column( + modifier = modifier + .imePadding() + .verticalScroll(rememberScrollState()) + ) { + val resources = LocalContext.current.resources + BitwardenMultiSelectButton( + label = stringResource(id = R.string.file_format), + options = ExportFormat.entries.map { it.displayLabel() }.toImmutableList(), + selectedOption = state.exportFormat.displayLabel(), + onOptionSelected = { selectedOptionLabel -> + val selectedOption = ExportFormat + .entries + .first { it.displayLabel(resources) == selectedOptionLabel } + onExportFormatOptionSelected(selectedOption) + }, + modifier = Modifier + .semantics { testTag = "FileFormatPicker" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + BitwardenFilledTonalButton( + label = stringResource(id = R.string.export), + onClick = onExportClick, + modifier = Modifier + .semantics { testTag = "ExportVaultButton" } + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportViewModel.kt new file mode 100644 index 000000000..f98d7b3d5 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportViewModel.kt @@ -0,0 +1,179 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export + +import android.net.Uri +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.bitwarden.authenticator.data.authenticator.repository.model.ExportDataResult +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportFormat +import com.bitwarden.authenticator.ui.platform.util.fileExtension +import com.bitwarden.authenticator.ui.platform.util.toFormattedPattern +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import java.time.Clock +import javax.inject.Inject + +@HiltViewModel +class ExportViewModel @Inject constructor( + private val authenticatorRepository: AuthenticatorRepository, + private val clock: Clock, +) : + BaseViewModel( + initialState = ExportState(dialogState = null, exportFormat = ExportFormat.JSON) + ) { + + override fun handleAction(action: ExportAction) { + when (action) { + is ExportAction.CloseButtonClick -> { + handleCloseButtonClick() + } + + is ExportAction.ExportFormatOptionSelect -> { + handleExportFormatOptionSelect(action) + } + + is ExportAction.ConfirmExportClick -> { + handleConfirmExportClick() + } + + is ExportAction.DialogDismiss -> { + handleDialogDismiss() + } + + is ExportAction.ExportLocationReceive -> { + handleExportLocationReceive(action) + } + + is ExportAction.Internal -> { + handleInternalAction(action) + } + } + } + + private fun handleCloseButtonClick() { + sendEvent(ExportEvent.NavigateBack) + } + + private fun handleExportFormatOptionSelect(action: ExportAction.ExportFormatOptionSelect) { + mutableStateFlow.update { + it.copy(exportFormat = action.option) + } + } + + private fun handleConfirmExportClick() { + + val date = clock.instant().toFormattedPattern( + pattern = "yyyyMMddHHmmss", + clock = clock, + ) + val extension = state.exportFormat.fileExtension + val fileName = "authenticator_export_$date.$extension" + + sendEvent( + ExportEvent.NavigateToSelectExportDestination(fileName), + ) + } + + private fun handleDialogDismiss() { + mutableStateFlow.update { + it.copy(dialogState = null) + } + } + + private fun handleExportLocationReceive(action: ExportAction.ExportLocationReceive) { + mutableStateFlow.update { + it.copy(dialogState = ExportState.DialogState.Loading()) + } + + viewModelScope.launch { + val result = authenticatorRepository.exportVaultData( + format = state.exportFormat, + fileUri = action.fileUri, + ) + + sendAction( + ExportAction.Internal.SaveExportDataToUriResultReceive( + result = result + ) + ) + } + } + + private fun handleInternalAction(action: ExportAction.Internal) { + when (action) { + is ExportAction.Internal.SaveExportDataToUriResultReceive -> { + handleExportDataToUriResult(action.result) + } + } + } + + private fun handleExportDataToUriResult(result: ExportDataResult) { + when (result) { + ExportDataResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ExportState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.export_vault_failure.asText(), + ), + ) + } + } + + is ExportDataResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent(ExportEvent.ShowToast(R.string.export_success.asText())) + } + } + } +} + +data class ExportState( + @IgnoredOnParcel + val exportData: String? = null, + val dialogState: DialogState? = null, + val exportFormat: ExportFormat, +) { + sealed class DialogState { + data class Loading( + val message: Text = R.string.loading.asText(), + ) : DialogState() + + data class Error( + val title: Text? = null, + val message: Text, + ) : DialogState() + } +} + +sealed class ExportEvent { + data object NavigateBack : ExportEvent() + + data class ShowToast(val message: Text) : ExportEvent() + + data class NavigateToSelectExportDestination(val fileName: String) : ExportEvent() +} + +sealed class ExportAction { + data object CloseButtonClick : ExportAction() + + data object ConfirmExportClick : ExportAction() + + data object DialogDismiss : ExportAction() + + data class ExportFormatOptionSelect(val option: ExportFormat) : ExportAction() + + data class ExportLocationReceive(val fileUri: Uri) : ExportAction() + + sealed class Internal : ExportAction() { + + data class SaveExportDataToUriResultReceive( + val result: ExportDataResult, + ) : Internal() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/model/ExportVaultFormat.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/model/ExportVaultFormat.kt new file mode 100644 index 000000000..74b9a5397 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/model/ExportVaultFormat.kt @@ -0,0 +1,9 @@ +package com.bitwarden.authenticator.ui.platform.feature.settings.export.model + +/** + * Represents the file formats a user can select to export the vault. + */ +enum class ExportFormat { + JSON, + CSV, +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManager.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManager.kt index a613c567e..46bf2786c 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManager.kt @@ -2,10 +2,12 @@ package com.bitwarden.authenticator.ui.platform.manager.intent import android.content.Intent import android.net.Uri +import android.os.Parcelable import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.result.ActivityResult import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize /** * A manager class for simplifying the handling of Android Intents within a given context. @@ -36,4 +38,24 @@ interface IntentManager { fun getActivityResultLauncher( onResult: (ActivityResult) -> Unit, ): ManagedActivityResultLauncher + + /** + * Processes the [activityResult] and attempts to get the relevant file data from it. + */ + fun getFileDataFromActivityResult(activityResult: ActivityResult): FileData? + + /** + * Creates an intent to use when selecting to save an item with [fileName] to disk. + */ + fun createDocumentIntent(fileName: String): Intent + + /** + * Represents file information. + */ + @Parcelize + data class FileData( + val fileName: String, + val uri: Uri, + val sizeBytes: Long, + ) : Parcelable } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManagerImpl.kt index cf29f008a..117e55116 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/intent/IntentManagerImpl.kt @@ -1,10 +1,13 @@ package com.bitwarden.authenticator.ui.platform.manager.intent +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri +import android.provider.MediaStore import android.provider.Settings +import android.webkit.MimeTypeMap import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult @@ -35,6 +38,27 @@ class IntentManagerImpl( onResult = onResult, ) + override fun getFileDataFromActivityResult( + activityResult: ActivityResult, + ): IntentManager.FileData? { + if (activityResult.resultCode != Activity.RESULT_OK) return null + val uri = activityResult.data?.data ?: return null + return getLocalFileData(uri) + } + + override fun createDocumentIntent(fileName: String): Intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + // Attempt to get the MIME type from the file extension + val extension = MimeTypeMap.getFileExtensionFromUrl(fileName) + type = extension?.let { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(it) + } + ?: "*/*" + + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, fileName) + } + override fun startApplicationDetailsSettingsActivity() { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.parse("package:" + context.packageName) @@ -49,4 +73,35 @@ class IntentManagerImpl( } startActivity(Intent(Intent.ACTION_VIEW, newUri)) } + + private fun getLocalFileData(uri: Uri): IntentManager.FileData? = + context + .contentResolver + .query( + uri, + arrayOf( + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + ), + null, + null, + null, + ) + ?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val fileName = cursor + .getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + .takeIf { it >= 0 } + ?.let { cursor.getString(it) } + val fileSize = cursor + .getColumnIndex(MediaStore.MediaColumns.SIZE) + .takeIf { it >= 0 } + ?.let { cursor.getLong(it) } + if (fileName == null || fileSize == null) return@use null + IntentManager.FileData( + fileName = fileName, + uri = uri, + sizeBytes = fileSize, + ) + } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ExportFormatExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ExportFormatExtensions.kt new file mode 100644 index 000000000..7ad3c4e72 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ExportFormatExtensions.kt @@ -0,0 +1,25 @@ +package com.bitwarden.authenticator.ui.platform.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.export.model.ExportFormat + +/** + * Provides a human-readable label for the export format. + */ +val ExportFormat.displayLabel: Text + get() = when (this) { + ExportFormat.JSON -> R.string.json_extension.asText() + ExportFormat.CSV -> R.string.csv_extension.asText() + + } + +/** + * Provides the file extension associated with the export format. + */ +val ExportFormat.fileExtension: String + get() = when (this) { + ExportFormat.JSON -> "json" + ExportFormat.CSV -> "csv" + } \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/TemporalAccessExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/TemporalAccessExtensions.kt new file mode 100644 index 000000000..5d0ca42bb --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/TemporalAccessExtensions.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.platform.util + +import java.time.Clock +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAccessor + +/** + * Converts the [TemporalAccessor] to a formatted string based on the provided pattern and timezone. + */ +fun TemporalAccessor.toFormattedPattern( + pattern: String, + zone: ZoneId, +): String = DateTimeFormatter.ofPattern(pattern).withZone(zone).format(this) + +/** + * Converts the [TemporalAccessor] to a formatted string based on the provided pattern and timezone. + */ +fun TemporalAccessor.toFormattedPattern( + pattern: String, + clock: Clock = Clock.systemDefaultZone(), +): String = toFormattedPattern(pattern = pattern, zone = clock.zone) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a76fec9a5..d773c10e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,4 +87,10 @@ Do you really want to permanently delete? This cannot be undone. Vault Export + Loading + Confirm export + This export contains your data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it. + File format + There was a problem exporting your vault. If the problem persists, you\\\'ll need to export from the web vault. + Data exported successfully diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml new file mode 100644 index 000000000..35b005b53 --- /dev/null +++ b/app/src/main/res/values/strings_non_localized.xml @@ -0,0 +1,5 @@ + + + .json + .csv + \ No newline at end of file