diff --git a/app/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/1.json b/app/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/1.json index 4cc020353..0e0b7bf8e 100644 --- a/app/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/1.json +++ b/app/schemas/com.bitwarden.authenticator.data.authenticator.datasource.disk.database.AuthenticatorDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "8724b95439edde85bd15e0bd2e02195e", + "identityHash": "480a4540e7704429515a28eb9c38ab14", "entities": [ { "tableName": "items", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `key` TEXT NOT NULL, `accountName` TEXT NOT NULL, `type` TEXT NOT NULL, `algorithm` TEXT NOT NULL, `period` INTEGER NOT NULL, `digits` INTEGER NOT NULL, `issuer` TEXT, `userId` TEXT, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `key` TEXT NOT NULL, `type` TEXT NOT NULL, `algorithm` TEXT NOT NULL, `period` INTEGER NOT NULL, `digits` INTEGER NOT NULL, `issuer` TEXT NOT NULL, `userId` TEXT, `accountName` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -20,12 +20,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "accountName", - "columnName": "accountName", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "type", "columnName": "type", @@ -54,13 +48,19 @@ "fieldPath": "issuer", "columnName": "issuer", "affinity": "TEXT", - "notNull": false + "notNull": true }, { "fieldPath": "userId", "columnName": "userId", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -76,7 +76,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8724b95439edde85bd15e0bd2e02195e')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '480a4540e7704429515a28eb9c38ab14')" ] } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemEntity.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemEntity.kt index b250b2a90..afebf2b9f 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemEntity.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/datasource/disk/entity/AuthenticatorItemEntity.kt @@ -1,5 +1,7 @@ package com.bitwarden.authenticator.data.authenticator.datasource.disk.entity +import android.net.Uri +import androidx.core.text.htmlEncode import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -16,9 +18,6 @@ data class AuthenticatorItemEntity( @ColumnInfo(name = "key") val key: String, - @ColumnInfo(name = "accountName") - val accountName: String, - @ColumnInfo(name = "type") val type: AuthenticatorItemType = AuthenticatorItemType.TOTP, @@ -32,8 +31,42 @@ data class AuthenticatorItemEntity( val digits: Int = 6, @ColumnInfo(name = "issuer") - val issuer: String? = null, + val issuer: String, @ColumnInfo(name = "userId") val userId: String? = null, -) + + @ColumnInfo(name = "accountName") + val accountName: String? = null, +) { + fun toOtpAuthUriString(): String { + return when (type) { + AuthenticatorItemType.TOTP -> { + val label = if (accountName.isNullOrBlank()) { + issuer + } else { + "$issuer:$accountName" + } + Uri.Builder() + .scheme("otpauth") + .authority("totp") + .appendPath(label.htmlEncode()) + .appendQueryParameter("secret", key) + .appendQueryParameter("algorithm", algorithm.name) + .appendQueryParameter("digits", digits.toString()) + .appendQueryParameter("period", period.toString()) + .appendQueryParameter("issuer", issuer) + .build() + .toString() + } + + AuthenticatorItemType.STEAM -> { + if (key.startsWith("steam://")) { + key + } else { + "steam://$key" + } + } + } + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt index ce0e0e167..3f7278f0d 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/TotpCodeManagerImpl.kt @@ -75,7 +75,7 @@ class TotpCodeManagerImpl @Inject constructor( return mutableVerificationCodeStateFlowMap.getOrPut(itemEntity) { flow> { - val totpCode = itemEntity.key + val totpCode = itemEntity.toOtpAuthUriString() var item: VerificationCodeItem? = null while (currentCoroutineContext().isActive) { 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 index 3c4cf2a66..84fcade3b 100644 --- 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 @@ -36,16 +36,10 @@ data class ExportJsonData( * 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/manager/model/VerificationCodeItem.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt index 9e61f67af..517e59278 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/manager/model/VerificationCodeItem.kt @@ -22,7 +22,7 @@ data class VerificationCodeItem( val issuer: String?, ) { /** - * The composite label of the authenticator item. + * The composite label of the authenticator item. Used for constructing an OTPAuth URI. * ``` * label = issuer (“:” / “%3A”) *”%20” username * ``` 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 1d653ee19..7a3e696ea 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 @@ -233,7 +233,7 @@ class AuthenticatorRepositoryImpl @Inject constructor( private suspend fun encodeVaultDataToCsv(fileUri: Uri): ExportDataResult { val headerLine = - "folder,favorite,type,name,login_uri,login_totp,issuer,period,digits" + "folder,favorite,type,name,login_uri,login_totp" val dataLines = authenticatorDiskSource .getItems() .firstOrNull() @@ -250,7 +250,7 @@ class AuthenticatorRepositoryImpl @Inject constructor( } private fun AuthenticatorItemEntity.toCsvFormat() = - ",,1,$accountName,,$key,$issuer,$period,$digits" + ",,1,$issuer,,${toOtpAuthUriString()},$issuer,$period,$digits" private suspend fun encodeVaultDataToJson(fileUri: Uri): ExportDataResult { val dataString: String = Json.encodeToString( @@ -281,14 +281,11 @@ class AuthenticatorRepositoryImpl @Inject constructor( folderId = null, organizationId = null, collectionIds = null, - name = accountName, + name = issuer, notes = null, type = 1, login = ExportJsonData.ExportItem.ItemLoginData( - totp = key, - issuer = issuer, - period = period, - digits = digits, + totp = toOtpAuthUriString(), ), favorite = false, ) diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemRequest.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemRequest.kt index 70b6b7271..b4c0c4185 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemRequest.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UpdateItemRequest.kt @@ -16,12 +16,12 @@ import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.Aut */ data class UpdateItemRequest( val key: String, - val accountName: String, + val accountName: String?, val type: AuthenticatorItemType, val algorithm: AuthenticatorItemAlgorithm, val period: Int, val digits: Int, - val issuer: String?, + val issuer: String, ) { /** * The composite label of the authenticator item. Derived from combining [issuer] and [accountName] @@ -29,9 +29,9 @@ data class UpdateItemRequest( * label = accountName /issuer (“:” / “%3A”) *”%20” accountName * ``` */ - val label = if (issuer != null) { - "$issuer:$accountName" + val label = if (accountName.isNullOrBlank()) { + issuer } else { - accountName + "$issuer:$accountName" } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt new file mode 100644 index 000000000..dceddeb0f --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt @@ -0,0 +1,28 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +const val UNLOCK_ROUTE: String = "unlock" + +/** + * Navigate to the unlock screen. + */ +fun NavController.navigateToUnlock( + navOptions: NavOptions? = null, +) { + navigate(route = UNLOCK_ROUTE, navOptions = navOptions) +} + +/** + * Add the unlock screen to the nav graph. + */ +fun NavGraphBuilder.unlockDestination( + onUnlocked: () -> Unit, +) { + composable(route = UNLOCK_ROUTE) { + UnlockScreen(onUnlocked = onUnlocked) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt new file mode 100644 index 000000000..366d59d99 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt @@ -0,0 +1,146 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.material3.ExperimentalMaterial3Api +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenOutlinedButton +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.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UnlockScreen( + viewModel: UnlockViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + onUnlocked: () -> Unit, +) { + + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val resources = context.resources + var showBiometricsPrompt by remember { mutableStateOf(true) } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is UnlockEvent.ShowToast -> { + Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() + } + + UnlockEvent.NavigateToItemListing -> onUnlocked() + } + } + + when (val dialog = state.dialog) { + is UnlockState.Dialog.Error -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialog.message + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(UnlockAction.DismissDialog) + } + }, + ) + + UnlockState.Dialog.Loading -> BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(R.string.loading.asText()) + ) + + null -> Unit + } + + val onBiometricsUnlock: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsUnlock) } + } + val onBiometricsLockOut: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsLockout) } + } + + if (showBiometricsPrompt) { + biometricsManager.promptBiometrics( + onSuccess = { + showBiometricsPrompt = false + onBiometricsUnlock() + }, + onCancel = { + showBiometricsPrompt = false + }, + onError = { + showBiometricsPrompt = false + }, + onLockOut = { + showBiometricsPrompt = false + onBiometricsLockOut() + }, + ) + } + + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + ) { innerPadding -> + Box { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_horizontal), + contentDescription = stringResource(R.string.bitwarden_authenticator) + ) + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = R.string.use_biometrics_to_unlock), + onClick = { + biometricsManager.promptBiometrics( + onSuccess = onBiometricsUnlock, + onCancel = { + // no-op + }, + onError = { + // no-op + }, + onLockOut = onBiometricsLockOut, + ) + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt new file mode 100644 index 000000000..433aeaab8 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt @@ -0,0 +1,150 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the Unlock screen. + */ +@HiltViewModel +class UnlockViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val settingsRepository: SettingsRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: run { + UnlockState( + isBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(), + dialog = null, + ) + } +) { + + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + override fun handleAction(action: UnlockAction) { + when (action) { + UnlockAction.BiometricsUnlock -> { + handleBiometricsUnlock() + } + + UnlockAction.DismissDialog -> { + handleDismissDialog() + } + + UnlockAction.BiometricsLockout -> { + handleBiometricsLockout() + } + } + } + + private fun handleBiometricsUnlock() { + if (state.isBiometricsEnabled && !state.isBiometricsValid) { + biometricsEncryptionManager.setupBiometrics() + } + sendEvent(UnlockEvent.NavigateToItemListing) + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleBiometricsLockout() { + mutableStateFlow.update { + it.copy( + dialog = UnlockState.Dialog.Error( + message = R.string.too_many_failed_biometric_attempts.asText(), + ) + ) + } + } +} + +/** + * Represents state for the Unlock screen + */ +@Parcelize +data class UnlockState( + val isBiometricsEnabled: Boolean, + val isBiometricsValid: Boolean, + val dialog: Dialog?, +) : Parcelable { + + /** + * Represents the various dialogs the Unlock screen can display. + */ + @Parcelize + sealed class Dialog : Parcelable { + /** + * Displays a generic error dialog to the user. + */ + data class Error( + val message: Text, + ) : Dialog() + + /** + * Displays the loading dialog to the user. + */ + data object Loading : Dialog() + } +} + +/** + * Models events for the Unlock screen. + */ +sealed class UnlockEvent { + + /** + * Navigates to the item listing screen. + */ + data object NavigateToItemListing : UnlockEvent() + + /** + * Displays a toast to the user. + */ + data class ShowToast( + val message: Text, + ) : UnlockEvent() + +} + +/** + * Models actions for the Unlock screen. + */ +sealed class UnlockAction { + + /** + * The user dismissed the dialog. + */ + data object DismissDialog : UnlockAction() + + /** + * The user has failed biometric unlock too many times. + */ + data object BiometricsLockout : UnlockAction() + + /** + * The user has successfully unlocked the app with biometrics. + */ + data object BiometricsUnlock : UnlockAction() +} 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 9c42c789d..d3cd056a0 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 @@ -12,6 +12,7 @@ import com.bitwarden.authenticator.ui.authenticator.feature.navbar.authenticator 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.navigateToSettingsTutorial const val AUTHENTICATOR_GRAPH_ROUTE = "authenticator_graph" @@ -40,6 +41,7 @@ fun NavGraphBuilder.authenticatorGraph( onNavigateToManualKeyEntry = { navController.navigateToManualCodeEntryScreen() }, onNavigateToEditItem = { navController.navigateToEditItem(itemId = it) }, onNavigateToExport = { navController.navigateToExport() }, + onNavigateToTutorial = { navController.navigateToSettingsTutorial() } ) itemListingGraph( navController = navController, @@ -57,6 +59,7 @@ fun NavGraphBuilder.authenticatorGraph( navController.navigateToEditItem(itemId = it) }, navigateToExport = { navController.navigateToExport() }, + navigateToTutorial = { navController.navigateToSettingsTutorial() } ) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt index 9a5e5090a..890cd5344 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemScreen.kt @@ -139,17 +139,17 @@ fun EditItemScreen( .imePadding() .padding(innerPadding), viewState = viewState, - onAccountNameTextChange = remember(viewModel) { + onIssuerNameTextChange = remember(viewModel) { { viewModel.trySendAction( - EditItemAction.AccountNameTextChange(it) + EditItemAction.IssuerNameTextChange(it) ) } }, - onIssuerTextChange = remember(viewModel) { + onUsernameTextChange = remember(viewModel) { { viewModel.trySendAction( - EditItemAction.IssuerTextChange(it) + EditItemAction.UsernameTextChange(it) ) } }, @@ -211,8 +211,8 @@ fun EditItemScreen( fun EditItemContent( modifier: Modifier = Modifier, viewState: EditItemState.ViewState.Content, - onAccountNameTextChange: (String) -> Unit = {}, - onIssuerTextChange: (String) -> Unit = {}, + onIssuerNameTextChange: (String) -> Unit = {}, + onUsernameTextChange: (String) -> Unit = {}, onTypeOptionClicked: (AuthenticatorItemType) -> Unit = {}, onTotpCodeTextChange: (String) -> Unit = {}, onAlgorithmOptionClicked: (AuthenticatorItemAlgorithm) -> Unit = {}, @@ -237,8 +237,8 @@ fun EditItemContent( .fillMaxSize() .padding(horizontal = 16.dp), label = stringResource(id = R.string.name), - value = viewState.itemData.accountName, - onValueChange = onAccountNameTextChange, + value = viewState.itemData.issuer, + onValueChange = onIssuerNameTextChange, singleLine = true, ) } @@ -256,20 +256,18 @@ fun EditItemContent( ) } - viewState.itemData.issuer?.let { issuer -> item { Spacer(modifier = Modifier.height(8.dp)) BitwardenTextField( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - label = stringResource(id = R.string.account_info), - value = issuer, - onValueChange = onIssuerTextChange, + label = stringResource(id = R.string.username), + value = viewState.itemData.username.orEmpty(), + onValueChange = onUsernameTextChange, singleLine = true, ) } - } } Spacer(modifier = Modifier.height(8.dp)) @@ -483,7 +481,7 @@ private fun EditItemContentExpandedOptionsPreview() { refreshPeriod = AuthenticatorRefreshPeriodOption.THIRTY, totpCode = "123456", type = AuthenticatorItemType.TOTP, - accountName = "account name", + username = "account name", issuer = "issuer", algorithm = AuthenticatorItemAlgorithm.SHA1, digits = VerificationCodeDigitsOption.SIX @@ -502,7 +500,7 @@ private fun EditItemContentCollapsedOptionsPreview() { refreshPeriod = AuthenticatorRefreshPeriodOption.THIRTY, totpCode = "123456", type = AuthenticatorItemType.TOTP, - accountName = "account name", + username = "account name", issuer = "issuer", algorithm = AuthenticatorItemAlgorithm.SHA1, digits = VerificationCodeDigitsOption.SIX diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt index c33513439..9db68f137 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/EditItemViewModel.kt @@ -57,8 +57,8 @@ class EditItemViewModel @Inject constructor( is EditItemAction.AlgorithmOptionClick -> handleAlgorithmOptionClick(action) is EditItemAction.CancelClick -> handleCancelClick() is EditItemAction.TypeOptionClick -> handleTypeOptionClick(action) - is EditItemAction.AccountNameTextChange -> handleAccountNameTextChange(action) - is EditItemAction.IssuerTextChange -> handleIssuerTextChange(action) + is EditItemAction.IssuerNameTextChange -> handleIssuerNameTextChange(action) + is EditItemAction.UsernameTextChange -> handleIssuerTextChange(action) is EditItemAction.RefreshPeriodOptionClick -> handlePeriodTextChange(action) is EditItemAction.TotpCodeTextChange -> handleTotpCodeTextChange(action) is EditItemAction.NumberOfDigitsOptionClick -> handleNumberOfDigitsOptionChange(action) @@ -76,7 +76,7 @@ class EditItemViewModel @Inject constructor( } private fun handleSaveClick() = onContent { content -> - if (content.itemData.accountName.isBlank()) { + if (content.itemData.issuer.isBlank()) { mutableStateFlow.update { it.copy( dialog = EditItemState.DialogState.Generic( @@ -110,12 +110,12 @@ class EditItemViewModel @Inject constructor( AuthenticatorItemEntity( id = state.itemId, key = content.itemData.totpCode.trim(), - accountName = content.itemData.accountName.trim(), + accountName = content.itemData.username?.trim(), type = content.itemData.type, algorithm = content.itemData.algorithm, period = content.itemData.refreshPeriod.seconds, digits = content.itemData.digits.length, - issuer = content.itemData.issuer?.trim(), + issuer = content.itemData.issuer.trim(), ) ) trySendAction(EditItemAction.Internal.UpdateItemResult(result)) @@ -130,18 +130,18 @@ class EditItemViewModel @Inject constructor( } } - private fun handleAccountNameTextChange(action: EditItemAction.AccountNameTextChange) { + private fun handleIssuerNameTextChange(action: EditItemAction.IssuerNameTextChange) { updateItemData { currentItemData -> currentItemData.copy( - accountName = action.accountName + issuer = action.issuerName ) } } - private fun handleIssuerTextChange(action: EditItemAction.IssuerTextChange) { + private fun handleIssuerTextChange(action: EditItemAction.UsernameTextChange) { updateItemData { currentItemData -> currentItemData.copy( - issuer = action.issue + username = action.username ) } } @@ -318,7 +318,7 @@ class EditItemViewModel @Inject constructor( ?: AuthenticatorRefreshPeriodOption.THIRTY, totpCode = key, type = type, - accountName = accountName, + username = accountName, issuer = issuer, algorithm = algorithm, digits = VerificationCodeDigitsOption.fromIntOrNull(digits) @@ -435,12 +435,12 @@ sealed class EditItemAction { /** * The user has changed the account name text. */ - data class AccountNameTextChange(val accountName: String) : EditItemAction() + data class IssuerNameTextChange(val issuerName: String) : EditItemAction() /** * The user has changed the issue text. */ - data class IssuerTextChange(val issue: String) : EditItemAction() + data class UsernameTextChange(val username: String) : EditItemAction() /** * The user has selected an Item Type option. diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/model/EditItemData.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/model/EditItemData.kt index d47911b4a..16c7928c5 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/model/EditItemData.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/edititem/model/EditItemData.kt @@ -12,7 +12,7 @@ import kotlinx.parcelize.Parcelize * * @property refreshPeriod The period for the verification code. * @property totpCode The totp code for the item. - * @property accountName Account or username for this item. + * @property username Account or username for this item. * @property issuer Name of the item provider. * @property algorithm Hashing algorithm used with the item. * @property digits Number of digits in the verification code. @@ -22,8 +22,8 @@ data class EditItemData( val refreshPeriod: AuthenticatorRefreshPeriodOption, val totpCode: String, val type: AuthenticatorItemType, - val accountName: String, - val issuer: String?, + val username: String?, + val issuer: String, val algorithm: AuthenticatorItemAlgorithm, val digits: VerificationCodeDigitsOption, ) : Parcelable 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 420d83e6b..81cb8886a 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 @@ -25,6 +25,7 @@ fun NavGraphBuilder.itemListingGraph( navigateToManualKeyEntry: () -> Unit, navigateToEditItem: (String) -> Unit, navigateToExport: () -> Unit, + navigateToTutorial: () -> Unit, ) { navigation( route = ITEM_LISTING_GRAPH_ROUTE, @@ -60,6 +61,7 @@ fun NavGraphBuilder.itemListingGraph( settingsGraph( navController = navController, onNavigateToExport = navigateToExport, + onNavigateToTutorial = navigateToTutorial ) } } 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 0662b63c1..f820dd4ca 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 @@ -6,7 +6,6 @@ import android.net.Uri import android.provider.Settings import android.widget.Toast import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -18,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -47,7 +46,9 @@ import com.bitwarden.authenticator.R import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.ItemListingExpandableFabAction import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.authenticator.ui.platform.components.appbar.action.BitwardenSearchActionItem 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 @@ -56,10 +57,9 @@ import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoBut import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabIcon import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFloatingActionButton -import com.bitwarden.authenticator.ui.platform.components.icon.BitwardenIcon -import com.bitwarden.authenticator.ui.platform.components.model.IconData import com.bitwarden.authenticator.ui.platform.components.model.IconResource import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManager import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager @@ -153,25 +153,153 @@ fun ItemListingScreen( }, ) + when (val currentState = state.viewState) { + is ItemListingState.ViewState.Content -> { + ItemListingContent( + currentState, + scrollBehavior, + onNavigateToSearch = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.SearchClick + ) + } + }, + onScanQrCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onEnterSetupKeyClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) + } + }, + onItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.ItemClick(it) + ) + } + }, + onEditItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.EditItemClick(it) + ) + } + }, + onDeleteItemClick = remember(viewModel) { + { + viewModel.trySendAction( + ItemListingAction.DeleteItemClick(it) + ) + } + } + ) + } + + is ItemListingState.ViewState.Error -> { + Text( + text = "Error! ${currentState.message}", + modifier = Modifier.fillMaxSize(), + ) + } + + ItemListingState.ViewState.Loading, + ItemListingState.ViewState.NoItems, + -> { + EmptyItemListingContent( + appTheme = state.appTheme, + scrollBehavior = scrollBehavior, + onAddCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onScanQuCodeClick = remember(viewModel) { + { + launcher.launch(Manifest.permission.CAMERA) + } + }, + onEnterSetupKeyClick = remember(viewModel) { + { + viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) + } + } + ) + } + } +} + +@Composable +private fun ItemListingDialogs( + dialog: ItemListingState.DialogState?, + onDismissRequest: () -> Unit, + onConfirmDeleteClick: (itemId: String) -> Unit, +) { + when (dialog) { + ItemListingState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = R.string.syncing.asText(), + ), + ) + } + + is ItemListingState.DialogState.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialog.title, + message = dialog.message, + ), + onDismissRequest = onDismissRequest, + ) + } + + is ItemListingState.DialogState.DeleteConfirmationPrompt -> { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.delete), + message = dialog.message(), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + onConfirmDeleteClick(dialog.itemId) + }, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest + ) + } + + null -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ItemListingContent( + state: ItemListingState.ViewState.Content, + scrollBehavior: TopAppBarScrollBehavior, + onNavigateToSearch: () -> Unit, + onScanQrCodeClick: () -> Unit, + onEnterSetupKeyClick: () -> Unit, + onItemClick: (String) -> Unit, + onEditItemClick: (String) -> Unit, + onDeleteItemClick: (String) -> Unit, +) { BitwardenScaffold( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - BitwardenTopAppBar( + BitwardenMediumTopAppBar( title = stringResource(id = R.string.verification_codes), scrollBehavior = scrollBehavior, - navigationIcon = null, actions = { - if (state.viewState !is ItemListingState.ViewState.NoItems) { - BitwardenIcon( - modifier = Modifier.clickable { - viewModel.trySendAction(ItemListingAction.SearchClick) - }, - iconData = IconData.Local(R.drawable.ic_search_24px), - tint = MaterialTheme.colorScheme.surfaceTint - ) - } + BitwardenSearchActionItem( + contentDescription = stringResource(id = R.string.search_codes), + onClick = onNavigateToSearch, + ) } ) }, @@ -189,9 +317,7 @@ fun ItemListingScreen( contentDescription = stringResource(id = R.string.scan_a_qr_code), testTag = "ScanQRCodeButton", ), - onScanQrCodeClick = { - launcher.launch(Manifest.permission.CAMERA) - } + onScanQrCodeClick = onScanQrCodeClick, ), ItemListingExpandableFabAction.EnterSetupKey( label = R.string.enter_a_setup_key.asText(), @@ -200,9 +326,7 @@ fun ItemListingScreen( contentDescription = stringResource(id = R.string.enter_a_setup_key), testTag = "EnterSetupKeyButton", ), - onEnterSetupKeyClick = { - viewModel.trySendAction(ItemListingAction.EnterSetupKeyClick) - } + onEnterSetupKeyClick = onEnterSetupKeyClick ) ), expandableFabIcon = ExpandableFabIcon( @@ -222,63 +346,22 @@ fun ItemListingScreen( .fillMaxSize() .padding(paddingValues), ) { - when (val currentState = state.viewState) { - is ItemListingState.ViewState.Content -> { - LazyColumn { - items(currentState.itemList) { - VaultVerificationCodeItem( - authCode = it.authCode, - issuer = it.issuer, - periodSeconds = it.periodSeconds, - timeLeftSeconds = it.timeLeftSeconds, - alertThresholdSeconds = it.alertThresholdSeconds, - startIcon = it.startIcon, - onItemClick = remember(viewModel) { - { - viewModel.trySendAction( - ItemListingAction.ItemClick(it.authCode) - ) - } - }, - onEditItemClick = remember(viewModel) { - { - viewModel.trySendAction( - ItemListingAction.EditItemClick(it.id) - ) - } - }, - onDeleteItemClick = remember(viewModel) { - { - viewModel.trySendAction( - ItemListingAction.DeleteItemClick(it.id) - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - supportingLabel = it.supportingLabel, - ) - } - } - } - - is ItemListingState.ViewState.Error -> { - Text( - text = "Error! ${currentState.message}", - modifier = Modifier.fillMaxSize(), - ) - } - - ItemListingState.ViewState.NoItems, - ItemListingState.ViewState.Loading, - -> { - EmptyItemListingContent( - onAddCodeClick = remember(viewModel) { - { - launcher.launch(Manifest.permission.CAMERA) - } - }, + LazyColumn { + items(state.itemList) { + VaultVerificationCodeItem( + authCode = it.authCode, + name = it.issuer, + username = it.username, + periodSeconds = it.periodSeconds, + timeLeftSeconds = it.timeLeftSeconds, + alertThresholdSeconds = it.alertThresholdSeconds, + startIcon = it.startIcon, + onItemClick = { onItemClick(it.authCode) }, + onEditItemClick = { onEditItemClick(it.id) }, + onDeleteItemClick = { onDeleteItemClick(it.id) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) } } @@ -286,98 +369,124 @@ fun ItemListingScreen( } } -@Composable -private fun ItemListingDialogs( - dialog: ItemListingState.DialogState?, - onDismissRequest: () -> Unit, - onConfirmDeleteClick: (itemId: String) -> Unit, -) { - when (dialog) { - ItemListingState.DialogState.Loading -> { - BitwardenLoadingDialog( - visibilityState = LoadingDialogState.Shown( - text = R.string.syncing.asText(), - ), - ) - } - - is ItemListingState.DialogState.Error -> { - BitwardenBasicDialog( - visibilityState = BasicDialogState.Shown( - title = dialog.title, - message = dialog.message, - ), - onDismissRequest = onDismissRequest, - ) - } - - is ItemListingState.DialogState.DeleteConfirmationPrompt -> { - BitwardenTwoButtonDialog( - title = stringResource(id = R.string.delete), - message = dialog.message(), - confirmButtonText = stringResource(id = R.string.ok), - dismissButtonText = stringResource(id = R.string.cancel), - onConfirmClick = { - onConfirmDeleteClick(dialog.itemId) - }, - onDismissClick = onDismissRequest, - onDismissRequest = onDismissRequest - ) - } - - null -> Unit - } -} - /** * Displays the item listing screen with no existing items. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun EmptyItemListingContent( modifier: Modifier = Modifier, - onAddCodeClick: () -> Unit = {}, + appTheme: AppTheme, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + rememberTopAppBarState() + ), + onAddCodeClick: () -> Unit, + onScanQuCodeClick: () -> Unit, + onEnterSetupKeyClick: () -> Unit, ) { - Column( - modifier = modifier + BitwardenScaffold( + modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.verification_codes), + scrollBehavior = scrollBehavior, + navigationIcon = null, + actions = { } + ) + }, + floatingActionButton = { + ExpandableFloatingActionButton( + modifier = Modifier + .semantics { testTag = "AddItemButton" } + .padding(bottom = 16.dp), + label = R.string.add_item.asText(), + items = listOf( + ItemListingExpandableFabAction.ScanQrCode( + label = R.string.scan_a_qr_code.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_camera), + contentDescription = stringResource(id = R.string.scan_a_qr_code), + testTag = "ScanQRCodeButton", + ), + onScanQrCodeClick = onScanQuCodeClick + ), + ItemListingExpandableFabAction.EnterSetupKey( + label = R.string.enter_a_setup_key.asText(), + icon = IconResource( + iconPainter = painterResource(id = R.drawable.ic_keyboard_24px), + contentDescription = stringResource(id = R.string.enter_a_setup_key), + testTag = "EnterSetupKeyButton", + ), + onEnterSetupKeyClick = onEnterSetupKeyClick, + ) + ), + expandableFabIcon = ExpandableFabIcon( + iconData = IconResource( + iconPainter = painterResource(id = R.drawable.ic_plus), + contentDescription = stringResource(id = R.string.add_item), + testTag = "AddItemButton", + ), + iconRotation = 45f, + ), + ) + }, + floatingActionButtonPosition = FabPosition.EndOverlay, ) { - Image( - modifier = Modifier.fillMaxWidth(), - painter = painterResource(id = R.drawable.ic_empty_vault), - contentDescription = stringResource( - id = R.string.empty_item_list, - ), - contentScale = ContentScale.Fit, - ) + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = Modifier.fillMaxWidth(), + painter = painterResource( + id = when (appTheme) { + AppTheme.DARK -> R.drawable.ic_empty_vault_dark + AppTheme.LIGHT -> R.drawable.ic_empty_vault_light + AppTheme.DEFAULT -> R.drawable.ic_empty_vault + } + ), + contentDescription = stringResource( + id = R.string.empty_item_list, + ), + contentScale = ContentScale.Fit, + ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.you_dont_have_items_to_display), - style = Typography.titleMedium, - ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.you_dont_have_items_to_display), + style = Typography.titleMedium, + ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - textAlign = TextAlign.Center, - text = stringResource(id = R.string.empty_item_list_instruction), - ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + textAlign = TextAlign.Center, + text = stringResource(id = R.string.empty_item_list_instruction), + ) - Spacer(modifier = Modifier.height(16.dp)) - BitwardenFilledTonalButton( - modifier = Modifier.fillMaxWidth(), - label = stringResource(R.string.add_code), - onClick = onAddCodeClick, - ) + Spacer(modifier = Modifier.height(16.dp)) + BitwardenFilledTonalButton( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.add_code), + onClick = onAddCodeClick, + ) + } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable @Preview(showBackground = true) fun EmptyListingContentPreview() { EmptyItemListingContent( modifier = Modifier.padding(horizontal = 16.dp), + appTheme = AppTheme.DEFAULT, + onAddCodeClick = { }, + onScanQuCodeClick = { }, + onEnterSetupKeyClick = { }, ) } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt index 27e80ed3c..4d7f56ee4 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -21,6 +21,7 @@ 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.base.util.concat +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -41,6 +42,7 @@ class ItemListingViewModel @Inject constructor( settingsRepository: SettingsRepository, ) : BaseViewModel( initialState = ItemListingState( + settingsRepository.appTheme, settingsRepository.authenticatorAlertThresholdSeconds, viewState = ItemListingState.ViewState.Loading, dialog = null @@ -55,6 +57,12 @@ class ItemListingViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) + settingsRepository + .appThemeStateFlow + .map { ItemListingAction.Internal.AppThemeChangeReceive(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + authenticatorRepository .getAuthCodesFlow() .map { ItemListingAction.Internal.AuthCodesUpdated(it) } @@ -181,6 +189,16 @@ class ItemListingViewModel @Inject constructor( is ItemListingAction.Internal.DeleteItemReceive -> { handleDeleteItemReceive(internalAction.result) } + + is ItemListingAction.Internal.AppThemeChangeReceive -> { + handleAppThemeChangeReceive(internalAction.appTheme) + } + } + } + + private fun handleAppThemeChangeReceive(appTheme: AppTheme) { + mutableStateFlow.update { + it.copy(appTheme = appTheme) } } @@ -230,7 +248,7 @@ class ItemListingViewModel @Inject constructor( CreateItemResult.Success -> { sendEvent( event = ItemListingEvent.ShowToast( - message = R.string.authenticator_key_added.asText(), + message = R.string.verification_code_added.asText(), ), ) } @@ -391,6 +409,13 @@ class ItemListingViewModel @Inject constructor( ?: return null val label = uri.pathSegments.firstOrNull() ?: return null + val accountName = if (label.contains(":")) { + label + .split(":") + .last() + } else { + label + } val key = uri.getQueryParameter(SECRET) ?: return null @@ -401,14 +426,14 @@ class ItemListingViewModel @Inject constructor( val digits = uri.getQueryParameter(DIGITS)?.toInt() ?: 6 - val issuer = uri.getQueryParameter(ISSUER) + val issuer = uri.getQueryParameter(ISSUER) ?: label val period = uri.getQueryParameter(PERIOD)?.toInt() ?: 30 return AuthenticatorItemEntity( id = UUID.randomUUID().toString(), key = key, - accountName = label, + accountName = accountName, type = type, algorithm = algorithm, period = period, @@ -437,6 +462,7 @@ private const val ISSUER = "issuer" */ @Parcelize data class ItemListingState( + val appTheme: AppTheme, val alertThresholdSeconds: Int, val viewState: ViewState, val dialog: DialogState?, @@ -633,6 +659,11 @@ sealed class ItemListingAction { * Indicates a result for deleting an item has been received. */ data class DeleteItemReceive(val result: DeleteItemResult) : Internal() + + /** + * Indicates app theme change has been received. + */ + data class AppThemeChangeReceive(val appTheme: AppTheme) : Internal() } /** diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt index b8fc7e3aa..43e4b69d3 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt @@ -39,20 +39,21 @@ import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme * The verification code item displayed to the user. * * @param authCode The code for the item. - * @param issuer The label for the item. + * @param name The label for the item. Represents the OTP issuer. + * @param username The supporting label for the item. Represents the OTP account name. * @param periodSeconds The times span where the code is valid. * @param timeLeftSeconds The seconds remaining until a new code is needed. * @param startIcon The leading icon for the item. * @param onItemClick The lambda function to be invoked when the item is clicked. * @param modifier The modifier for the item. - * @param supportingLabel The supporting label for the item. */ @OptIn(ExperimentalFoundationApi::class) @Suppress("LongMethod", "MagicNumber") @Composable fun VaultVerificationCodeItem( authCode: String, - issuer: String?, + name: String?, + username: String?, periodSeconds: Int, timeLeftSeconds: Int, alertThresholdSeconds: Int, @@ -61,7 +62,6 @@ fun VaultVerificationCodeItem( onEditItemClick: () -> Unit, onDeleteItemClick: () -> Unit, modifier: Modifier = Modifier, - supportingLabel: String? = null, ) { var shouldShowDropdownMenu by remember { mutableStateOf(value = false) } Box(modifier = modifier) { @@ -93,9 +93,9 @@ fun VaultVerificationCodeItem( verticalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.weight(1f), ) { - issuer?.let { + if (!name.isNullOrEmpty()) { Text( - text = it, + text = name, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -103,9 +103,9 @@ fun VaultVerificationCodeItem( ) } - supportingLabel?.let { + if (!username.isNullOrEmpty()) { Text( - text = it, + text = username, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -173,7 +173,8 @@ private fun VerificationCodeItem_preview() { AuthenticatorTheme { VaultVerificationCodeItem( authCode = "1234567890".chunked(3).joinToString(" "), - issuer = "Sample Label", + name = "Issuer, AKA Name", + username = "username@bitwarden.com", periodSeconds = 30, timeLeftSeconds = 15, alertThresholdSeconds = 7, @@ -182,7 +183,6 @@ private fun VerificationCodeItem_preview() { onEditItemClick = {}, onDeleteItemClick = {}, modifier = Modifier.padding(horizontal = 16.dp), - supportingLabel = "Supporting Label", ) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt index 66dfd34e5..f0eda1377 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt @@ -11,9 +11,8 @@ import kotlinx.parcelize.Parcelize @Parcelize data class VerificationCodeDisplayItem( val id: String, - val label: String, val issuer: String?, - val supportingLabel: String?, + val username: String?, val timeLeftSeconds: Int, val periodSeconds: Int, val alertThresholdSeconds: Int, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt index 00ccd47f4..aa6c89c17 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt @@ -18,9 +18,8 @@ fun List.toViewState( fun VerificationCodeItem.toDisplayItem(alertThresholdSeconds: Int) = VerificationCodeDisplayItem( id = id, - label = label, issuer = issuer, - supportingLabel = username, + username = username, timeLeftSeconds = timeLeftSeconds, periodSeconds = periodSeconds, alertThresholdSeconds = alertThresholdSeconds, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt index d45afc742..855266701 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt @@ -133,7 +133,7 @@ fun ManualCodeEntryScreen( Spacer(modifier = Modifier.height(8.dp)) BitwardenTextField( label = stringResource(id = R.string.name), - value = state.accountName, + value = state.issuer, onValueChange = remember(viewModel) { { viewModel.trySendAction( diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt index 796e51ff8..ee9844f26 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.R import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemEntity +import com.bitwarden.authenticator.data.authenticator.datasource.disk.entity.AuthenticatorItemType import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository import com.bitwarden.authenticator.data.authenticator.repository.model.CreateItemResult import com.bitwarden.authenticator.ui.platform.base.BaseViewModel @@ -29,7 +30,7 @@ class ManualCodeEntryViewModel @Inject constructor( private val authenticatorRepository: AuthenticatorRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] - ?: ManualCodeEntryState(code = "", accountName = "", dialog = null), + ?: ManualCodeEntryState(code = "", issuer = "", dialog = null), ) { override fun handleAction(action: ManualCodeEntryAction) { when (action) { @@ -47,7 +48,7 @@ class ManualCodeEntryViewModel @Inject constructor( private fun handleIssuerTextChange(action: ManualCodeEntryAction.IssuerTextChange) { mutableStateFlow.update { - it.copy(accountName = action.accountName) + it.copy(issuer = action.issuer) } } @@ -67,8 +68,14 @@ class ManualCodeEntryViewModel @Inject constructor( AuthenticatorItemEntity( id = UUID.randomUUID().toString(), key = state.code, - accountName = state.accountName, + issuer = state.issuer, + accountName = "", userId = null, + type = if (state.code.startsWith("steam://")) { + AuthenticatorItemType.STEAM + } else { + AuthenticatorItemType.TOTP + } ) ) sendAction(ManualCodeEntryAction.Internal.CreateItemResultReceive(result)) @@ -100,7 +107,9 @@ class ManualCodeEntryViewModel @Inject constructor( CreateItemResult.Success -> { sendEvent( - event = ManualCodeEntryEvent.ShowToast(R.string.item_added.asText()), + event = ManualCodeEntryEvent.ShowToast( + message = R.string.verification_code_added.asText(), + ), ) sendEvent( event = ManualCodeEntryEvent.NavigateBack, @@ -116,7 +125,7 @@ class ManualCodeEntryViewModel @Inject constructor( @Parcelize data class ManualCodeEntryState( val code: String, - val accountName: String, + val issuer: String, val dialog: DialogState?, ) : Parcelable { @@ -185,7 +194,7 @@ sealed class ManualCodeEntryAction { /** * The use has changed the issuer text. */ - data class IssuerTextChange(val accountName: String) : ManualCodeEntryAction() + data class IssuerTextChange(val issuer: String) : ManualCodeEntryAction() /** * Models actions that the [ManualCodeEntryViewModel] itself might send. 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 7c687ad2e..bcf5850e9 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 @@ -15,6 +15,7 @@ fun NavGraphBuilder.authenticatorNavBarDestination( onNavigateToManualKeyEntry: () -> Unit, onNavigateToEditItem: (itemId: String) -> Unit, onNavigateToExport: () -> Unit, + onNavigateToTutorial: () -> Unit, ) { composableWithStayTransitions( route = AUTHENTICATOR_NAV_BAR_ROUTE, @@ -26,6 +27,7 @@ fun NavGraphBuilder.authenticatorNavBarDestination( onNavigateToManualKeyEntry = onNavigateToManualKeyEntry, onNavigateToEditItem = onNavigateToEditItem, onNavigateToExport = onNavigateToExport, + onNavigateToTutorial = onNavigateToTutorial ) } } 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 be24b00e7..968e0506a 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, onNavigateToExport: () -> Unit, + onNavigateToTutorial: () -> Unit, ) { EventsEffect(viewModel = viewModel) { event -> navController.apply { @@ -111,6 +112,7 @@ fun AuthenticatorNavBarScreen( navigateToManualKeyEntry = onNavigateToManualKeyEntry, navigateToEditItem = onNavigateToEditItem, navigateToExport = onNavigateToExport, + navigateToTutorial = onNavigateToTutorial, ) } @@ -126,6 +128,7 @@ private fun AuthenticatorNavBarScaffold( navigateToManualKeyEntry: () -> Unit, navigateToEditItem: (itemId: String) -> Unit, navigateToExport: () -> Unit, + navigateToTutorial: () -> Unit, ) { BitwardenScaffold( contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.statusBars), @@ -173,6 +176,7 @@ private fun AuthenticatorNavBarScaffold( navigateToManualKeyEntry = navigateToManualKeyEntry, navigateToEditItem = navigateToEditItem, navigateToExport = navigateToExport, + navigateToTutorial = navigateToTutorial, ) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt new file mode 100644 index 000000000..dc5cba902 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/appbar/action/BitwardenSearchActionItem.kt @@ -0,0 +1,45 @@ +package com.bitwarden.authenticator.ui.platform.components.appbar.action + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter + +/** + * Represents the Bitwarden search action item. + * + * This is an [Icon] composable tailored specifically for the search functionality + * in the Bitwarden app. + * It presents the search icon and offers an `onClick` callback for when the icon is tapped. + * + * @param contentDescription A description of the UI element, used for accessibility purposes. + * @param onClick A callback to be invoked when this action item is clicked. + */ +@Composable +fun BitwardenSearchActionItem( + contentDescription: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier.testTag("SearchButton"), + ) { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_search_24px), + contentDescription = contentDescription, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenSearchActionItem_preview() { + BitwardenSearchActionItem( + contentDescription = "Search", + onClick = {}, + ) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt new file mode 100644 index 000000000..d64d2942b --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt @@ -0,0 +1,65 @@ +package com.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Represents a Bitwarden-styled filled [OutlinedButton]. + * + * @param label The label for the button. + * @param onClick The callback when the button is clicked. + * @param modifier The [Modifier] to be applied to the button. + * @param isEnabled Whether or not the button is enabled. + */ +@Composable +fun BitwardenOutlinedButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier + .semantics(mergeDescendants = true) { }, + enabled = isEnabled, + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 24.dp, + ), + colors = ButtonDefaults.outlinedButtonColors(), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview +@Composable +private fun BitwardenOutlinedButton_preview_isEnabled() { + BitwardenOutlinedButton( + label = "Label", + onClick = {}, + isEnabled = true, + ) +} + +@Preview +@Composable +private fun BitwardenOutlinedButton_preview_isNotEnabled() { + BitwardenOutlinedButton( + label = "Label", + onClick = {}, + isEnabled = false, + ) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenExternalLinkRow.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenExternalLinkRow.kt new file mode 100644 index 000000000..82e07c3fe --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenExternalLinkRow.kt @@ -0,0 +1,88 @@ +package com.bitwarden.authenticator.ui.platform.components.row + +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.mirrorIfRtl +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * Represents a row of text that can be clicked on and contains an external link. + * A confirmation dialog will always be displayed before [onConfirmClick] is invoked. + * + * @param text The label for the row as a [String]. + * @param onConfirmClick The callback when the confirm button of the dialog is clicked. + * @param modifier The modifier to be applied to the layout. + * @param withDivider Indicates if a divider should be drawn on the bottom of the row, defaults + * to `true`. + * @param dialogTitle The title of the dialog displayed when the user clicks this item. + * @param dialogMessage The message of the dialog displayed when the user clicks this item. + * @param dialogConfirmButtonText The text on the confirm button of the dialog displayed when the + * user clicks this item. + * @param dialogDismissButtonText The text on the dismiss button of the dialog displayed when the + * user clicks this item. + */ +@Composable +fun BitwardenExternalLinkRow( + text: String, + onConfirmClick: () -> Unit, + modifier: Modifier = Modifier, + withDivider: Boolean = true, + dialogTitle: String, + dialogMessage: String, + dialogConfirmButtonText: String = stringResource(id = R.string.continue_text), + dialogDismissButtonText: String = stringResource(id = R.string.cancel), +) { + var shouldShowDialog by rememberSaveable { mutableStateOf(false) } + BitwardenTextRow( + text = text, + onClick = { shouldShowDialog = true }, + modifier = modifier, + withDivider = withDivider, + ) { + Icon( + modifier = Modifier.mirrorIfRtl(), + painter = rememberVectorPainter(id = R.drawable.ic_external_link), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + if (shouldShowDialog) { + BitwardenTwoButtonDialog( + title = dialogTitle, + message = dialogMessage, + confirmButtonText = dialogConfirmButtonText, + dismissButtonText = dialogDismissButtonText, + onConfirmClick = { + shouldShowDialog = false + onConfirmClick() + }, + onDismissClick = { shouldShowDialog = false }, + onDismissRequest = { shouldShowDialog = false }, + ) + } +} + +@Preview +@Composable +private fun BitwardenExternalLinkRow_preview() { + AuthenticatorTheme { + BitwardenExternalLinkRow( + text = "Linked Text", + onConfirmClick = { }, + dialogTitle = "", + dialogMessage = "", + ) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt new file mode 100644 index 000000000..1f6345947 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/util/RememberVectorPainter.kt @@ -0,0 +1,19 @@ +package com.bitwarden.authenticator.ui.platform.components.util + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource + +/** + * Returns a [VectorPainter] built from the given [id] to circumvent issues with painter resources + * recomposing unnecessarily. + */ +@Composable +fun rememberVectorPainter( + @DrawableRes id: Int, +): VectorPainter = rememberVectorPainter( + image = ImageVector.vectorResource(id), +) diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index c6b5ffcf4..fae440a27 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -13,6 +13,9 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.bitwarden.authenticator.ui.auth.unlock.UNLOCK_ROUTE +import com.bitwarden.authenticator.ui.auth.unlock.navigateToUnlock +import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AUTHENTICATOR_GRAPH_ROUTE import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph @@ -65,17 +68,17 @@ fun RootNavScreen( popEnterTransition = { toEnterTransition()(this) }, popExitTransition = { toExitTransition()(this) }, ) { - splashDestination( - onSplashScreenDismissed = { - viewModel.trySendAction(RootNavAction.Internal.SplashScreenDismissed) - }, - onExitApplication = onExitApplication, - ) + splashDestination() tutorialDestination( onTutorialFinished = { viewModel.trySendAction(RootNavAction.Internal.TutorialFinished) }, ) + unlockDestination( + onUnlocked = { + viewModel.trySendAction(RootNavAction.Internal.AppUnlocked) + } + ) authenticatorGraph( navController = navController, onNavigateBack = onExitApplication, @@ -83,9 +86,10 @@ fun RootNavScreen( } val targetRoute = when (state.navState) { - RootNavState.NavState.ItemListing -> AUTHENTICATOR_GRAPH_ROUTE RootNavState.NavState.Splash -> SPLASH_ROUTE + RootNavState.NavState.Locked -> UNLOCK_ROUTE RootNavState.NavState.Tutorial -> TUTORIAL_ROUTE + RootNavState.NavState.Unlocked -> AUTHENTICATOR_GRAPH_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -120,7 +124,11 @@ fun RootNavScreen( navController.navigateToTutorial(rootNavOptions) } - RootNavState.NavState.ItemListing -> { + RootNavState.NavState.Locked -> { + navController.navigateToUnlock(rootNavOptions) + } + + RootNavState.NavState.Unlocked -> { navController.navigateToAuthenticatorGraph(rootNavOptions) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index ba106b2fc..687ca0102 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -1,11 +1,17 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav import android.os.Parcelable +import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.data.auth.repository.AuthRepository +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -13,6 +19,7 @@ import javax.inject.Inject class RootNavViewModel @Inject constructor( private val authRepository: AuthRepository, private val settingsRepository: SettingsRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, ) : BaseViewModel( initialState = RootNavState( hasSeenWelcomeGuide = settingsRepository.hasSeenWelcomeTutorial, @@ -20,6 +27,15 @@ class RootNavViewModel @Inject constructor( ) ) { + init { + viewModelScope.launch { + settingsRepository.hasSeenWelcomeTutorialFlow + .map { RootNavAction.Internal.HasSeenWelcomeTutorialChange(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + } + override fun handleAction(action: RootNavAction) { when (action) { RootNavAction.BackStackUpdate -> { @@ -37,6 +53,10 @@ class RootNavViewModel @Inject constructor( RootNavAction.Internal.SplashScreenDismissed -> { handleSplashScreenDismissed() } + + RootNavAction.Internal.AppUnlocked -> { + handleAppUnlocked() + } } } @@ -45,8 +65,14 @@ class RootNavViewModel @Inject constructor( } private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { + settingsRepository.hasSeenWelcomeTutorial = hasSeenWelcomeGuide if (hasSeenWelcomeGuide) { - mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } + if (settingsRepository.isUnlockWithBiometricsEnabled + && biometricsEncryptionManager.isBiometricIntegrityValid()) { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) } + } else { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } + } } else { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } } @@ -54,16 +80,22 @@ class RootNavViewModel @Inject constructor( private fun handleTutorialFinished() { settingsRepository.hasSeenWelcomeTutorial = true - mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } } private fun handleSplashScreenDismissed() { if (settingsRepository.hasSeenWelcomeTutorial) { - mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } } else { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } } } + + private fun handleAppUnlocked() { + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + } } /** @@ -83,6 +115,12 @@ data class RootNavState( @Parcelize data object Splash : NavState() + /** + * App should display the Unlock screen. + */ + @Parcelize + data object Locked : NavState() + /** * App should display the Tutorial nav graph. */ @@ -93,7 +131,7 @@ data class RootNavState( * App should display the Account List nav graph. */ @Parcelize - data object ItemListing : NavState() + data object Unlocked : NavState() } } @@ -111,10 +149,21 @@ sealed class RootNavAction { */ sealed class Internal : RootNavAction() { + /** + * Splash screen has been dismissed. + */ data object SplashScreenDismissed : Internal() + /** + * Indicates the user finished or skipped opening tutorial slides. + */ data object TutorialFinished : Internal() + /** + * Indicates the application has been unlocked. + */ + data object AppUnlocked : Internal() + /** * Indicates an update in the welcome guide being seen has been received. */ 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 c131d1396..6c2a88a23 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 @@ -6,7 +6,6 @@ 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 -import com.bitwarden.authenticator.ui.platform.feature.tutorial.navigateToSettingsTutorial import com.bitwarden.authenticator.ui.platform.feature.tutorial.tutorialSettingsDestination const val SETTINGS_GRAPH_ROUTE = "settings_graph" @@ -18,6 +17,7 @@ private const val SETTINGS_ROUTE = "settings" fun NavGraphBuilder.settingsGraph( navController: NavController, onNavigateToExport: () -> Unit, + onNavigateToTutorial: () -> Unit, ) { navigation( startDestination = SETTINGS_ROUTE, @@ -27,7 +27,7 @@ fun NavGraphBuilder.settingsGraph( route = SETTINGS_ROUTE ) { SettingsScreen( - onNavigateToTutorial = { navController.navigateToSettingsTutorial() }, + onNavigateToTutorial = onNavigateToTutorial, onNavigateToExport = onNavigateToExport, ) } 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 b15778535..ecc2d8f71 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,7 +1,13 @@ package com.bitwarden.authenticator.ui.platform.feature.settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -9,9 +15,12 @@ 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.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider 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 @@ -20,18 +29,24 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri 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.base.util.Text 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 @@ -40,13 +55,18 @@ import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicD import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionDialog import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelectionRow import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText +import com.bitwarden.authenticator.ui.platform.components.row.BitwardenExternalLinkRow 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.components.util.rememberVectorPainter 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.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalIntentManager import com.bitwarden.authenticator.ui.platform.util.displayLabel /** @@ -57,6 +77,7 @@ import com.bitwarden.authenticator.ui.platform.util.displayLabel fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), biometricsManager: BiometricsManager = LocalBiometricsManager.current, + intentManager: IntentManager = LocalIntentManager.current, onNavigateToTutorial: () -> Unit, onNavigateToExport: () -> Unit, ) { @@ -68,17 +89,26 @@ fun SettingsScreen( when (event) { SettingsEvent.NavigateToTutorial -> onNavigateToTutorial() SettingsEvent.NavigateToExport -> onNavigateToExport() + SettingsEvent.NavigateToHelpCenter -> { + intentManager.launchUri("https://bitwarden.com/help".toUri()) + } + + SettingsEvent.NavigateToPrivacyPolicy -> { + intentManager.launchUri("https://bitwarden.com/privacy".toUri()) + } } } BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { BitwardenMediumTopAppBar( title = stringResource(id = R.string.settings), scrollBehavior = scrollBehavior ) }, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { innerPadding -> Column( modifier = Modifier @@ -119,14 +149,43 @@ fun SettingsScreen( } }, ) - + Spacer(Modifier.height(16.dp)) HelpSettings( onTutorialClick = remember(viewModel) { { viewModel.trySendAction(SettingsAction.HelpClick.ShowTutorialClick) } + }, + onHelpCenterClick = remember(viewModel) { + { + viewModel.trySendAction(SettingsAction.HelpClick.HelpCenterClick) + } } ) + Spacer(modifier = Modifier.height(16.dp)) + AboutSettings( + state = state, + onPrivacyPolicyClick = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.AboutClick.PrivacyPolicyClick) } + }, + onVersionClick = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.AboutClick.VersionClick) } + } + ) + Box( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(end = 16.dp), + text = state.copyrightInfo.invoke(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } } } } @@ -365,6 +424,7 @@ private fun ThemeSelectionRow( private fun HelpSettings( modifier: Modifier = Modifier, onTutorialClick: () -> Unit, + onHelpCenterClick: () -> Unit, ) { BitwardenListHeaderText( modifier = Modifier.padding(horizontal = 16.dp), @@ -376,6 +436,102 @@ private fun HelpSettings( modifier = modifier, withDivider = true, ) + Spacer(modifier = Modifier.height(8.dp)) + BitwardenExternalLinkRow( + text = stringResource(id = R.string.bitwarden_help_center), + onConfirmClick = onHelpCenterClick, + dialogTitle = stringResource(id = R.string.continue_to_help_center), + dialogMessage = stringResource( + id = R.string.learn_more_about_how_to_use_bitwarden_on_the_help_center, + ), + ) } //endregion Help settings + +//region About settings +@Composable +private fun AboutSettings( + state: SettingsState, + onPrivacyPolicyClick: () -> Unit, + onVersionClick: () -> Unit, +) { + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.about) + ) + BitwardenExternalLinkRow( + text = stringResource(id = R.string.privacy_policy), + onConfirmClick = onPrivacyPolicyClick, + dialogTitle = stringResource(id = R.string.continue_to_privacy_policy), + dialogMessage = stringResource( + id = R.string.privacy_policy_description_long, + ), + ) + CopyRow( + text = state.version, + onClick = onVersionClick, + ) +} + +@Composable +private fun CopyRow( + text: Text, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val resources = LocalContext.current.resources + Box( + contentAlignment = Alignment.BottomCenter, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .semantics(mergeDescendants = true) { + contentDescription = text.toString(resources) + }, + ) { + Row( + modifier = Modifier + .defaultMinSize(minHeight = 56.dp) + .padding(start = 16.dp, end = 24.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(end = 16.dp) + .weight(1f), + text = text(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_copy), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} + +//endregion About settings + +@Preview +@Composable +private fun CopyRow_preview() { + AuthenticatorTheme { + CopyRow( + text = "Copyable Text".asText(), + onClick = { }, + ) + } +} 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 0b00826fb..57d1c4ad1 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 @@ -5,18 +5,23 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.BuildConfig import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult 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.base.util.concat import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import java.time.Clock +import java.time.Year import javax.inject.Inject private const val KEY_STATE = "state" @@ -27,17 +32,17 @@ private const val KEY_STATE = "state" @HiltViewModel class SettingsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - val settingsRepository: SettingsRepository, + clock: Clock, + private val settingsRepository: SettingsRepository, + private val clipboardManager: BitwardenClipboardManager, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] - ?: SettingsState( - appearance = SettingsState.Appearance( - language = settingsRepository.appLanguage, - theme = settingsRepository.appTheme, - ), - isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, - dialog = null, - ), + ?: createInitialState( + clock, + settingsRepository.appLanguage, + settingsRepository.appTheme, + settingsRepository.isUnlockWithBiometricsEnabled + ) ) { override fun handleAction(action: SettingsAction) { when (action) { @@ -57,6 +62,10 @@ class SettingsViewModel @Inject constructor( handleHelpClick(action) } + is SettingsAction.AboutClick -> { + handleAboutClick(action) + } + is SettingsAction.Internal.BiometricsKeyResultReceive -> { handleBiometricsKeyResultReceive(action) } @@ -162,12 +171,63 @@ class SettingsViewModel @Inject constructor( private fun handleHelpClick(action: SettingsAction.HelpClick) { when (action) { SettingsAction.HelpClick.ShowTutorialClick -> handleShowTutorialCLick() + SettingsAction.HelpClick.HelpCenterClick -> handleHelpCenterClick() } } private fun handleShowTutorialCLick() { sendEvent(SettingsEvent.NavigateToTutorial) } + + private fun handleHelpCenterClick() { + sendEvent(SettingsEvent.NavigateToHelpCenter) + } + + private fun handleAboutClick(action: SettingsAction.AboutClick) { + when (action) { + SettingsAction.AboutClick.PrivacyPolicyClick -> { + handlePrivacyPolicyClick() + } + + SettingsAction.AboutClick.VersionClick -> { + handleVersionClick() + } + } + } + + private fun handlePrivacyPolicyClick() { + sendEvent(SettingsEvent.NavigateToPrivacyPolicy) + } + + private fun handleVersionClick() { + clipboardManager.setText( + text = state.copyrightInfo.concat("\n\n".asText()).concat(state.version), + ) + } + + companion object { + fun createInitialState( + clock: Clock, + appLanguage: AppLanguage, + appTheme: AppTheme, + unlockWithBiometricsEnabled: Boolean, + ): SettingsState { + val currentYear = Year.now(clock) + val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText() + return SettingsState( + appearance = SettingsState.Appearance( + language = appLanguage, + theme = appTheme, + ), + isUnlockWithBiometricsEnabled = unlockWithBiometricsEnabled, + version = R.string.version + .asText() + .concat(": ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})".asText()), + copyrightInfo = copyrightInfo, + dialog = null, + ) + } + } } /** @@ -178,6 +238,8 @@ data class SettingsState( val appearance: Appearance, val isUnlockWithBiometricsEnabled: Boolean, val dialog: Dialog?, + val version: Text, + val copyrightInfo: Text, ) : Parcelable { @Parcelize @@ -202,9 +264,26 @@ data class SettingsState( * Models events for the settings screen. */ sealed class SettingsEvent { + + /** + * Navigate to the Tutorial screen. + */ data object NavigateToTutorial : SettingsEvent() + /** + * Navigate to the Export screen. + */ data object NavigateToExport : SettingsEvent() + + /** + * Navigate to the Help Center web page. + */ + data object NavigateToHelpCenter : SettingsEvent() + + /** + * Navigate to the privacy policy web page. + */ + data object NavigateToPrivacyPolicy : SettingsEvent() } /** @@ -214,7 +293,14 @@ sealed class SettingsAction( val dialog: Dialog? = null, ) { + /** + * Represents dialogs that may be displayed by the Settings screen. + */ sealed class Dialog { + + /** + * + */ data class Loading( val message: Text, ) : Dialog() @@ -244,6 +330,11 @@ sealed class SettingsAction( * Indicates the user clicked launch tutorial. */ data object ShowTutorialClick : HelpClick() + + /** + * Indicates teh user clicked About. + */ + data object HelpCenterClick : HelpClick() } /** @@ -265,10 +356,30 @@ sealed class SettingsAction( ) : AppearanceChange() } - sealed class Internal { - class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : SettingsAction() { + /** + * Models actions for the About section of settings. + */ + sealed class AboutClick : SettingsAction() { - } + /** + * Indicates the user clicked privacy policy. + */ + data object PrivacyPolicyClick : AboutClick() + + /** + * Indicates the user clicked version. + */ + data object VersionClick : AboutClick() + } + /** + * Models actions that the Settings screen itself may send. + */ + sealed class Internal { + + /** + * Indicates the biometrics key validation results has been received. + */ + data class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : SettingsAction() } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt index d981c0b26..bbca8c418 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt @@ -10,16 +10,8 @@ const val SPLASH_ROUTE: String = "splash" /** * Add splash destinations to the nav graph. */ -fun NavGraphBuilder.splashDestination( - onSplashScreenDismissed: () -> Unit, - onExitApplication: () -> Unit, -) { - composable(SPLASH_ROUTE) { - SplashScreen( - onNavigateToAuthenticator = onSplashScreenDismissed, - onExitApplication = onExitApplication, - ) - } +fun NavGraphBuilder.splashDestination() { + composable(SPLASH_ROUTE) { SplashScreen() } } /** diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt index 3d45dd9b6..ee3550542 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt @@ -1,119 +1,14 @@ package com.bitwarden.authenticator.ui.platform.feature.splash -import android.widget.Toast import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -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.base.util.asText -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.LoadingDialogState -import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager -import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager /** * Splash screen with empty composable content so that the Activity window background is shown. */ @Composable -fun SplashScreen( - viewModel: SplashViewModel = hiltViewModel(), - biometricsManager: BiometricsManager = LocalBiometricsManager.current, - onNavigateToAuthenticator: () -> Unit, - onExitApplication: () -> Unit, -) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val context = LocalContext.current - val resources = context.resources - - EventsEffect(viewModel) { event -> - when (event) { - is SplashEvent.NavigateToAuthenticator -> onNavigateToAuthenticator() - - is SplashEvent.ShowToast -> { - Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() - } - - is SplashEvent.ExitApplication -> onExitApplication() - } - } - - when (val dialog = state.dialog) { - is SplashState.Dialog.Error -> { - BitwardenBasicDialog( - visibilityState = BasicDialogState.Shown( - title = R.string.an_error_has_occurred.asText(), - message = dialog.message - ), - onDismissRequest = remember(viewModel) { - { - viewModel.trySendAction(SplashAction.DismissDialog) - } - }, - ) - } - - SplashState.Dialog.Loading -> { - BitwardenLoadingDialog( - visibilityState = LoadingDialogState.Shown( - text = R.string.loading.asText() - ), - ) - } - - null -> Unit - } - +fun SplashScreen() { Box(modifier = Modifier.fillMaxSize()) - - when (val viewState = state.viewState) { - is SplashState.ViewState.Locked -> { - if (viewState.showBiometricsPrompt) { - biometricsManager.promptBiometrics( - onSuccess = remember(viewModel) { - { - viewModel.trySendAction( - SplashAction.Internal.UnlockResultReceived(UnlockResult.Success) - ) - } - }, - onCancel = remember(viewModel) { - { - viewModel.trySendAction( - SplashAction.Internal.UnlockResultReceived(UnlockResult.Cancel) - ) - } - }, - onError = remember(viewModel) { - { - viewModel.trySendAction( - SplashAction.Internal.UnlockResultReceived(UnlockResult.Error), - ) - } - }, - onLockOut = remember(viewModel) { - { - viewModel.trySendAction( - SplashAction.Internal.UnlockResultReceived(UnlockResult.LockOut), - ) - } - }, - ) - } else { - viewModel.trySendAction( - SplashAction.Internal.UnlockResultReceived(UnlockResult.Success) - ) - } - } - - SplashState.ViewState.Unlocked -> Unit - } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashViewModel.kt deleted file mode 100644 index f27a25208..000000000 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashViewModel.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.bitwarden.authenticator.ui.platform.feature.splash - -import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle -import com.bitwarden.authenticator.R -import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager -import com.bitwarden.authenticator.data.platform.repository.SettingsRepository -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 dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.update -import kotlinx.parcelize.Parcelize -import javax.inject.Inject - -private const val KEY_STATE = "state" - -@HiltViewModel -class SplashViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - settingsRepository: SettingsRepository, - private val biometricsEncryptionManager: BiometricsEncryptionManager, -) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] ?: run { - val showBiometricsPrompt = settingsRepository.isUnlockWithBiometricsEnabled - && biometricsEncryptionManager.isBiometricIntegrityValid() - SplashState( - isBiometricEnabled = settingsRepository.isUnlockWithBiometricsEnabled, - isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(), - viewState = SplashState.ViewState.Locked(showBiometricsPrompt = showBiometricsPrompt), - dialog = null, - ) - } -) { - - override fun handleAction(action: SplashAction) { - when (action) { - SplashAction.DismissDialog -> handleDismissDialog() - is SplashAction.Internal -> handleInternalAction(action) - } - } - - private fun handleDismissDialog() { - mutableStateFlow.update { - it.copy(dialog = null) - } - } - - private fun handleInternalAction(action: SplashAction.Internal) { - when (action) { - is SplashAction.Internal.UnlockResultReceived -> handleUnlockResultReceive(action) - } - } - - private fun handleUnlockResultReceive(action: SplashAction.Internal.UnlockResultReceived) { - when (action.unlockResult) { - UnlockResult.Error -> { - mutableStateFlow.update { - it.copy( - viewState = SplashState.ViewState.Locked(showBiometricsPrompt = false), - dialog = SplashState.Dialog.Error(R.string.generic_error_message.asText()) - ) - } - sendEvent(SplashEvent.ExitApplication) - } - - UnlockResult.LockOut, - UnlockResult.Cancel, - -> { - mutableStateFlow.update { - it.copy( - viewState = SplashState.ViewState.Locked( - showBiometricsPrompt = false - ) - ) - } - sendEvent(SplashEvent.ExitApplication) - } - - UnlockResult.Success -> { - mutableStateFlow.update { - it.copy( - viewState = SplashState.ViewState.Unlocked, - dialog = null, - ) - } - if (state.isBiometricEnabled && !state.isBiometricsValid) { - biometricsEncryptionManager.setupBiometrics() - } - sendEvent(SplashEvent.NavigateToAuthenticator) - } - } - } -} - -data class SplashState( - val isBiometricEnabled: Boolean, - val isBiometricsValid: Boolean, - val viewState: ViewState, - val dialog: Dialog?, -) { - sealed class ViewState { - data object Unlocked : ViewState() - - data class Locked( - val showBiometricsPrompt: Boolean, - ) : ViewState() - } - - sealed class Dialog : Parcelable { - - @Parcelize - data class Error( - val message: Text, - ) : Dialog() - - @Parcelize - data object Loading : Dialog() - } -} - -sealed class SplashEvent { - data object NavigateToAuthenticator : SplashEvent() - - data class ShowToast(val message: Text) : SplashEvent() - - data object ExitApplication : SplashEvent() -} - -sealed class SplashAction { - data object DismissDialog : SplashAction() - - sealed class Internal : SplashAction() { - data class UnlockResultReceived( - val unlockResult: UnlockResult, - ) : Internal() - } -} - -sealed class UnlockResult { - data object Success : UnlockResult() - - data object Cancel : UnlockResult() - - data object LockOut : UnlockResult() - - data object Error : UnlockResult() -} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt index 850971ddf..4c62b29a3 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -177,11 +178,13 @@ private fun VerificationCodesContent() { id = R.string.secure_your_accounts_with_bitwarden_authenticator, ), ) + Spacer(Modifier.height(24.dp)) Text( style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, text = stringResource(R.string.secure_your_accounts_with_bitwarden_authenticator), ) + Spacer(Modifier.height(8.dp)) Text( style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, @@ -195,6 +198,7 @@ private fun TutorialQrScannerScreen() { painter = painterResource(id = R.drawable.ic_tutorial_qr_scanner), contentDescription = stringResource(id = R.string.scan_qr_code), ) + Spacer(Modifier.height(24.dp)) Text( style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, @@ -202,6 +206,7 @@ private fun TutorialQrScannerScreen() { R.string.use_your_device_camera_to_scan_codes ), ) + Spacer(Modifier.height(8.dp)) Text( style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, @@ -217,11 +222,13 @@ private fun UniqueCodesContent() { painter = painterResource(id = R.drawable.ic_tutorial_2fa), contentDescription = stringResource(id = R.string.unique_codes) ) + Spacer(Modifier.height(24.dp)) Text( style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, text = stringResource(R.string.sign_in_using_unique_codes), ) + Spacer(Modifier.height(8.dp)) Text( style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt index 16a53b7e3..e152c136a 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/manager/biometrics/BiometricsManagerImpl.kt @@ -78,8 +78,9 @@ class BiometricsManagerImpl( val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(activity.getString(R.string.bitwarden_authenticator)) .setDescription(activity.getString(R.string.biometrics_direction)) - .setNegativeButtonText(activity.getString(R.string.cancel)) - .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG) + .setAllowedAuthenticators( + Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL + ) .build() biometricPrompt.authenticate(promptInfo) diff --git a/app/src/main/res/drawable-night/ic_empty_vault.xml b/app/src/main/res/drawable-night/ic_empty_vault.xml new file mode 100644 index 000000000..784404200 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_empty_vault.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_empty_vault_dark.xml b/app/src/main/res/drawable/ic_empty_vault_dark.xml new file mode 100644 index 000000000..784404200 --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_vault_dark.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_empty_vault_light.xml b/app/src/main/res/drawable/ic_empty_vault_light.xml new file mode 100644 index 000000000..b14c68cd7 --- /dev/null +++ b/app/src/main/res/drawable/ic_empty_vault_light.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_external_link.xml b/app/src/main/res/drawable/ic_external_link.xml new file mode 100644 index 000000000..63c65dfe0 --- /dev/null +++ b/app/src/main/res/drawable/ic_external_link.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_logo_horizontal.xml b/app/src/main/res/drawable/ic_logo_horizontal.xml new file mode 100644 index 000000000..463bd0b9d --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_horizontal.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 046409a98..a0cbd9908 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,12 +30,11 @@ Enable camera permission to use the scanner Empty Item Listing You don\'t have any items to display. - Add a new code, sync an existing account, or import codes to secure your accounts. + Add a new code to secure your accounts. Add code Cannot read authenticator key. - Authenticator key added. - Item added - Account info + Verification code added + Username Refresh period Algorithm Hide @@ -93,4 +92,15 @@ Unlock with %1$s Biometrics Security + Use biometrics to unlock + Too many failed biometrics attempts. + About + Version + Continue + Bitwarden Help Center + Continue to Help Center? + Learn more about how to use Bitwarden Authenticator on the Help Center. + Privacy policy + Continue to privacy policy? + Check out our privacy policy on bitwarden.com