From 66d834c7e91b7a24c22cf2649074156c0b390c7c Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 22 Apr 2024 10:30:04 -0400 Subject: [PATCH] Users can export unencrypted data to JSON or CSV (#41) --- .../data/authenticator/manager/FileManager.kt | 14 ++ .../authenticator/manager/FileManagerImpl.kt | 29 +++ .../manager/di/AuthenticatorManagerModule.kt | 14 ++ .../manager/model/ExportJsonData.kt | 51 +++++ .../repository/AuthenticatorRepository.kt | 8 + .../repository/AuthenticatorRepositoryImpl.kt | 78 +++++++ .../di/AuthenticatorRepositoryModule.kt | 3 + .../repository/model/ExportDataResult.kt | 12 + .../datasource/disk/SettingsDiskSource.kt | 10 - .../datasource/disk/SettingsDiskSourceImpl.kt | 12 - .../platform/repository/SettingsRepository.kt | 11 - .../repository/SettingsRepositoryImpl.kt | 17 -- .../authenticator/AuthenticatorNavigation.kt | 3 + .../itemlisting/ItemListingGraphNavigation.kt | 4 +- .../feature/itemlisting/ItemListingScreen.kt | 2 +- .../navbar/AuthenticatorNavBarNavigation.kt | 2 + .../navbar/AuthenticatorNavBarScreen.kt | 4 + .../feature/settings/SettingsNavigation.kt | 6 + .../feature/settings/SettingsScreen.kt | 110 ++++++--- .../feature/settings/SettingsViewModel.kt | 57 +++-- .../settings/export/ExportNavigation.kt | 22 ++ .../feature/settings/export/ExportScreen.kt | 209 ++++++++++++++++++ .../settings/export/ExportViewModel.kt | 179 +++++++++++++++ .../export/model/ExportVaultFormat.kt | 9 + .../platform/manager/intent/IntentManager.kt | 22 ++ .../manager/intent/IntentManagerImpl.kt | 55 +++++ .../platform/util/ExportFormatExtensions.kt | 25 +++ .../platform/util/TemporalAccessExtensions.kt | 22 ++ .../res/drawable/ic_tutorial_unique_codes.xml | 29 --- app/src/main/res/values/strings.xml | 15 +- .../main/res/values/strings_non_localized.xml | 5 + 31 files changed, 894 insertions(+), 145 deletions(-) create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManager.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/FileManagerImpl.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/ExportJsonData.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/ExportDataResult.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportNavigation.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportScreen.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/ExportViewModel.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/export/model/ExportVaultFormat.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ExportFormatExtensions.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/TemporalAccessExtensions.kt delete mode 100644 app/src/main/res/drawable/ic_tutorial_unique_codes.xml create mode 100644 app/src/main/res/values/strings_non_localized.xml 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..3a8eb5a36 --- /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 +} 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..d104f89b2 --- /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 + } + } +} 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..3c4cf2a66 --- /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, + ) + } +} 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..b3149c2de --- /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() + +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt index 94010f614..8a9d1d13d 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSource.kt @@ -24,16 +24,6 @@ interface SettingsDiskSource { */ val appThemeFlow: Flow - /** - * The currently persisted setting for getting login item icons (or `null` if not set). - */ - var isIconLoadingDisabled: Boolean? - - /** - * Emits updates that track [isIconLoadingDisabled]. - */ - val isIconLoadingDisabledFlow: Flow - /** * Tracks whether user has seen the Welcome tutorial. */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 01f7fc65e..2e8a1687f 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -14,7 +14,6 @@ private const val APP_LANGUAGE_KEY = "$BASE_KEY:appLocale" private const val SCREEN_CAPTURE_ALLOW_KEY = "$BASE_KEY:screenCaptureAllowed" private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "$BASE_KEY:accountBiometricIntegrityValid" private const val ALERT_THRESHOLD_SECONDS_KEY = "$BASE_KEY:alertThresholdSeconds" -private const val DISABLE_ICON_LOADING_KEY = "$BASE_KEY:disableFavicon" private const val FIRST_LAUNCH_KEY = "$BASE_KEY:hasSeenWelcomeTutorial" /** @@ -69,17 +68,6 @@ class SettingsDiskSourceImpl( get() = mutableAppThemeFlow .onSubscription { emit(appTheme) } - override var isIconLoadingDisabled: Boolean? - get() = getBoolean(key = DISABLE_ICON_LOADING_KEY) - set(value) { - putBoolean(key = DISABLE_ICON_LOADING_KEY, value = value) - mutableIsIconLoadingDisabledFlow.tryEmit(value) - } - - override val isIconLoadingDisabledFlow: Flow - get() = mutableIsIconLoadingDisabledFlow - .onSubscription { emit(getBoolean(DISABLE_ICON_LOADING_KEY)) } - override var hasSeenWelcomeTutorial: Boolean get() = getBoolean(key = FIRST_LAUNCH_KEY) ?: false set(value) { diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt index 9cd1f89a9..52605118c 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepository.kt @@ -2,7 +2,6 @@ package com.bitwarden.authenticator.data.platform.repository import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -35,16 +34,6 @@ interface SettingsRepository { */ val authenticatorAlertThresholdSecondsFlow: StateFlow - /** - * The current setting for getting login item icons. - */ - var isIconLoadingDisabled: Boolean - - /** - * Emits updates that track the [isIconLoadingDisabled] value. - */ - val isIconLoadingDisabledFlow: Flow - /** * Whether the user has seen the Welcome tutorial. */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt index 3ddb428a6..52e266aac 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/SettingsRepositoryImpl.kt @@ -5,7 +5,6 @@ import com.bitwarden.authenticator.data.platform.manager.DispatcherManager import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map @@ -49,23 +48,7 @@ class SettingsRepositoryImpl( started = SharingStarted.Eagerly, initialValue = settingsDiskSource.getAlertThresholdSeconds(), ) - override var isIconLoadingDisabled: Boolean - get() = settingsDiskSource.isIconLoadingDisabled ?: false - set(value) { - settingsDiskSource.isIconLoadingDisabled = value - } - override val isIconLoadingDisabledFlow: StateFlow - get() = settingsDiskSource - .isIconLoadingDisabledFlow - .map { it ?: false } - .stateIn( - scope = unconfinedScope, - started = SharingStarted.Eagerly, - initialValue = settingsDiskSource - .isIconLoadingDisabled - ?: false, - ) override var hasSeenWelcomeTutorial: Boolean get() = settingsDiskSource.hasSeenWelcomeTutorial set(value) { 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 73fc9810c..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,6 +39,7 @@ fun NavGraphBuilder.authenticatorGraph( onNavigateToManualKeyEntry = { navController.navigateToManualCodeEntryScreen() }, onNavigateToEditItem = { navController.navigateToEditItem(itemId = it) }, onNavigateToTutorial = { navController.navigateToTutorial() }, + onNavigateToExport = { navController.navigateToExport() }, ) itemListingGraph( navController = navController, @@ -54,6 +56,7 @@ fun NavGraphBuilder.authenticatorGraph( navController.navigateToEditItem(itemId = it) }, navigateToTutorial = { navController.navigateToTutorial() }, + 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 9907433cc..a5009137e 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 @@ -24,6 +24,7 @@ fun NavGraphBuilder.itemListingGraph( navigateToManualKeyEntry: () -> Unit, navigateToEditItem: (String) -> Unit, navigateToTutorial: () -> Unit, + navigateToExport: () -> Unit, ) { navigation( route = ITEM_LISTING_GRAPH_ROUTE, @@ -58,7 +59,8 @@ fun NavGraphBuilder.itemListingGraph( ) settingsGraph( navController = navController, - onNavigateToTutorial = navigateToTutorial + onNavigateToTutorial = navigateToTutorial, + onNavigateToExport = navigateToExport, ) } } 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 9630a2741..2b057aee6 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 @@ -151,7 +151,7 @@ fun ItemListingScreen( ItemListingAction.ConfirmDeleteClick(itemId = itemId), ) } - } + }, ) BitwardenScaffold( diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt index 241ac7629..36cbb06f0 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt @@ -14,6 +14,7 @@ fun NavGraphBuilder.authenticatorNavBarDestination( onNavigateToManualKeyEntry: () -> Unit, onNavigateToEditItem: (itemId: String) -> Unit, onNavigateToTutorial: () -> Unit, + onNavigateToExport: () -> Unit, ) { composableWithStayTransitions( route = AUTHENTICATOR_NAV_BAR_ROUTE, @@ -24,6 +25,7 @@ fun NavGraphBuilder.authenticatorNavBarDestination( onNavigateToEditItem = onNavigateToEditItem, onNavigateToSearch = onNavigateToSearch, onNavigateToTutorial = onNavigateToTutorial, + onNavigateToExport = onNavigateToExport, ) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt index a7131180c..35d0cf8fd 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt @@ -72,6 +72,7 @@ fun AuthenticatorNavBarScreen( onNavigateToManualKeyEntry: () -> Unit, onNavigateToEditItem: (itemId: String) -> Unit, onNavigateToTutorial: () -> Unit, + onNavigateToExport: () -> Unit, ) { EventsEffect(viewModel = viewModel) { event -> navController.apply { @@ -110,6 +111,7 @@ fun AuthenticatorNavBarScreen( navigateToManualKeyEntry = onNavigateToManualKeyEntry, navigateToEditItem = onNavigateToEditItem, navigateToTutorial = onNavigateToTutorial, + navigateToExport = onNavigateToExport, ) } @@ -124,6 +126,7 @@ private fun AuthenticatorNavBarScaffold( navigateToManualKeyEntry: () -> Unit, navigateToEditItem: (itemId: String) -> Unit, navigateToTutorial: () -> Unit, + navigateToExport: () -> Unit, ) { BitwardenScaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.statusBars), @@ -170,6 +173,7 @@ private fun AuthenticatorNavBarScaffold( navigateToManualKeyEntry = navigateToManualKeyEntry, navigateToEditItem = navigateToEditItem, navigateToTutorial = navigateToTutorial, + navigateToExport = navigateToExport, ) } } 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 5125e86ac..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" @@ -15,6 +16,7 @@ private const val SETTINGS_ROUTE = "settings" fun NavGraphBuilder.settingsGraph( navController: NavController, onNavigateToTutorial: () -> Unit, + onNavigateToExport: () -> Unit, ) { navigation( startDestination = SETTINGS_ROUTE, @@ -25,8 +27,12 @@ fun NavGraphBuilder.settingsGraph( ) { SettingsScreen( onNavigateToTutorial = onNavigateToTutorial, + 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 ea3059443..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 @@ -1,14 +1,17 @@ package com.bitwarden.authenticator.ui.platform.feature.settings 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.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -19,6 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag @@ -28,6 +32,7 @@ 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.base.util.asText +import com.bitwarden.authenticator.ui.platform.base.util.mirrorIfRtl import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog @@ -36,7 +41,6 @@ import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelect import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.authenticator.ui.platform.components.row.BitwardenTextRow import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold -import com.bitwarden.authenticator.ui.platform.components.toggle.BitwardenWideSwitch import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme import com.bitwarden.authenticator.ui.platform.util.displayLabel @@ -49,6 +53,7 @@ import com.bitwarden.authenticator.ui.platform.util.displayLabel fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), onNavigateToTutorial: () -> Unit, + onNavigateToExport: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val scrollBehavior = @@ -57,6 +62,7 @@ fun SettingsScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { SettingsEvent.NavigateToTutorial -> onNavigateToTutorial() + SettingsEvent.NavigateToExport -> onNavigateToExport() } } @@ -75,6 +81,14 @@ fun SettingsScreen( .fillMaxSize() .verticalScroll(state = rememberScrollState()) ) { + VaultSettings( + onExportClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.VaultClick.ExportClick) + } + } + ) + Spacer(modifier = Modifier.height(16.dp)) AppearanceSettings( state = state, onLanguageSelection = remember(viewModel) { @@ -87,13 +101,6 @@ fun SettingsScreen( viewModel.trySendAction(SettingsAction.AppearanceChange.ThemeChange(it)) } }, - onShowWebsiteIconsChange = remember(viewModel) { - { - viewModel.trySendAction( - SettingsAction.AppearanceChange.ShowWebsiteIconsChange(it) - ) - } - }, ) HelpSettings( @@ -107,6 +114,39 @@ fun SettingsScreen( } } +//region Vault settings + +@Composable +fun VaultSettings( + modifier: Modifier = Modifier, + + onExportClick: () -> Unit, +) { + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.vault) + ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextRow( + text = stringResource(id = R.string.export), + onClick = onExportClick, + modifier = modifier, + withDivider = true, + content = { + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource(id = R.drawable.ic_navigate_next), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + ) +} + +//endregion Vault settings + //region Appearance settings @Composable @@ -114,20 +154,11 @@ private fun AppearanceSettings( state: SettingsState, onLanguageSelection: (language: AppLanguage) -> Unit, onThemeSelection: (theme: AppTheme) -> Unit, - onShowWebsiteIconsChange: (showWebsiteIcons: Boolean) -> Unit, ) { BitwardenListHeaderText( 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, @@ -136,15 +167,12 @@ private fun AppearanceSettings( .fillMaxWidth(), ) - BitwardenWideSwitch( - label = stringResource(id = R.string.show_website_icons), - description = stringResource(id = R.string.show_website_icons_description), - isChecked = state.appearance.showWebsiteIcons, - onCheckedChange = onShowWebsiteIconsChange, + LanguageSelectionRow( + currentSelection = state.appearance.language, + onLanguageSelection = onLanguageSelection, modifier = Modifier - .semantics { testTag = "ShowWebsiteIconsSwitch" } - .fillMaxWidth() - .padding(horizontal = 16.dp), + .semantics { testTag = "LanguageChooser" } + .fillMaxWidth(), ) } @@ -168,11 +196,15 @@ private fun LanguageSelectionRow( text = stringResource(id = R.string.language), onClick = { shouldShowLanguageSelectionDialog = true }, modifier = modifier, + withDivider = true, ) { - Text( - text = currentSelection.text(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource(id = R.drawable.ic_navigate_next), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, ) } @@ -209,14 +241,19 @@ private fun ThemeSelectionRow( BitwardenTextRow( text = stringResource(id = R.string.theme), - description = stringResource(id = R.string.theme_description), onClick = { shouldShowThemeSelectionDialog = true }, modifier = modifier, + withDivider = true, ) { - Text( - text = currentSelection.displayLabel(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Icon( + modifier = Modifier + .mirrorIfRtl() + .size(24.dp), + painter = painterResource( + id = R.drawable.ic_navigate_next + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, ) } @@ -258,7 +295,8 @@ private fun HelpSettings( text = stringResource(id = R.string.launch_tutorial), onClick = onTutorialClick, modifier = modifier, + withDivider = true, ) } -//region Help settings +//endregion Help settings diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index b6e9c0c3f..c96c33ee6 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -28,27 +28,33 @@ class SettingsViewModel @Inject constructor( appearance = SettingsState.Appearance( language = settingsRepository.appLanguage, theme = settingsRepository.appTheme, - showWebsiteIcons = !settingsRepository.isIconLoadingDisabled, ), ), ) { override fun handleAction(action: SettingsAction) { when (action) { + is SettingsAction.VaultClick -> handleVaultClick(action) is SettingsAction.AppearanceChange -> handleAppearanceChange(action) is SettingsAction.HelpClick -> handleHelpClick(action) } } + private fun handleVaultClick(action: SettingsAction.VaultClick) { + when(action) { + SettingsAction.VaultClick.ExportClick -> handleExportClick() + } + } + + private fun handleExportClick() { + sendEvent(SettingsEvent.NavigateToExport) + } + private fun handleAppearanceChange(action: SettingsAction.AppearanceChange) { when (action) { is SettingsAction.AppearanceChange.LanguageChange -> { handleLanguageChange(action.language) } - is SettingsAction.AppearanceChange.ShowWebsiteIconsChange -> { - handleShowWebsiteIconsChange(action.showWebsiteIcons) - } - is SettingsAction.AppearanceChange.ThemeChange -> { handleThemeChange(action.appTheme) } @@ -68,16 +74,6 @@ class SettingsViewModel @Inject constructor( AppCompatDelegate.setApplicationLocales(appLocale) } - private fun handleShowWebsiteIconsChange(showWebsiteIcons: Boolean) { - mutableStateFlow.update { - it.copy( - appearance = it.appearance.copy(showWebsiteIcons = showWebsiteIcons) - ) - } - // Negate the boolean to properly update the settings repository - settingsRepository.isIconLoadingDisabled = !showWebsiteIcons - } - private fun handleThemeChange(theme: AppTheme) { mutableStateFlow.update { it.copy( @@ -112,7 +108,6 @@ data class SettingsState( data class Appearance( val language: AppLanguage, val theme: AppTheme, - val showWebsiteIcons: Boolean, ) : Parcelable } @@ -121,6 +116,8 @@ data class SettingsState( */ sealed class SettingsEvent { data object NavigateToTutorial : SettingsEvent() + + data object NavigateToExport : SettingsEvent() } /** @@ -128,10 +125,31 @@ sealed class SettingsEvent { */ sealed class SettingsAction { + /** + * Models actions for the Vault section of settings. + */ + sealed class VaultClick : SettingsAction() { + + /** + * Indicates the user clicked export. + */ + data object ExportClick : VaultClick() + } + + /** + * Models actions for the Help section of settings. + */ sealed class HelpClick : SettingsAction() { + + /** + * Indicates the user clicked launch tutorial. + */ data object ShowTutorialClick : HelpClick() } + /** + * Models actions for the Appearance section of settings. + */ sealed class AppearanceChange : SettingsAction() { /** * Indicates the user changed the language. @@ -140,13 +158,6 @@ sealed class SettingsAction { val language: AppLanguage, ) : AppearanceChange() - /** - * Indicates the user toggled the Show Website Icons switch to [showWebsiteIcons]. - */ - data class ShowWebsiteIconsChange( - val showWebsiteIcons: Boolean, - ) : AppearanceChange() - /** * Indicates the user selected a new theme. */ 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..64254d4fb --- /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) +} 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..f5d6c6e7d --- /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, +} 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..b69e31726 --- /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" + } 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..936527cf5 --- /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) diff --git a/app/src/main/res/drawable/ic_tutorial_unique_codes.xml b/app/src/main/res/drawable/ic_tutorial_unique_codes.xml deleted file mode 100644 index 073fffbe7..000000000 --- a/app/src/main/res/drawable/ic_tutorial_unique_codes.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85e91cb9e..83a978447 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,14 +62,11 @@ Appearance Default (System) Theme - Change the application\'s color theme. Dark Light Language The language has been changed to %1$s. Please restart the app to see the change - Show website icons - Show a recognizable image next to each login. - Secure your accounts with Bitwarden Authneticator + Secure your accounts with Bitwarden Authenticator Get verification codes for all your accounts that support 2-step verification. Use your device camera to scan codes Scan the QR code in your 2-step verification settings for any account. @@ -78,7 +75,7 @@ Continue Skip Get started - Uniqe codes + Unique codes Help Launch tutorial %1$s copied @@ -86,4 +83,12 @@ Item deleted Delete 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..7ac98dd54 --- /dev/null +++ b/app/src/main/res/values/strings_non_localized.xml @@ -0,0 +1,5 @@ + + + .json + .csv +