From a92ed17d65bf18185f80ad8ad42a868ff1062942 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 2 Apr 2024 17:45:33 -0400 Subject: [PATCH] QR Scanner and Manual Key Entry screens (#7) --- .../itemlisting/ItemListingGraphNavigation.kt | 22 +- .../ManualCodeEntryNavigation.kt | 34 ++ .../manualcodeentry/ManualCodeEntryScreen.kt | 202 ++++++++ .../ManualCodeEntryViewModel.kt | 125 +++++ .../qrcodescan/QrCodeScanNavigation.kt | 34 ++ .../feature/qrcodescan/QrCodeScanScreen.kt | 468 ++++++++++++++++++ .../feature/qrcodescan/QrCodeScanViewModel.kt | 170 +++++++ .../feature/qrcodescan/util/QrCodeAnalyzer.kt | 16 + .../qrcodescan/util/QrCodeAnalyzerImpl.kt | 70 +++ .../base/util/NavGraphBuilderExtensions.kt | 21 + .../ui/platform/base/util/StringExtensions.kt | 6 + .../button/BitwardenFilledTonalButton.kt | 61 +++ .../dialog/BitwardenTwoButtonDialog.kt | 68 +++ .../ui/platform/theme/SpanStyles.kt | 21 + app/src/main/res/values/strings.xml | 11 + 15 files changed, 1327 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzer.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/button/BitwardenFilledTonalButton.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/theme/SpanStyles.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt index c9d7309ef..4f6f13db1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt @@ -6,6 +6,10 @@ import androidx.navigation.NavOptions import androidx.navigation.navigation import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item.itemDestination import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.item.navigateToItem +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry.manualCodeEntryDestination +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry.navigateToManualCodeEntryScreen +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.navigateToQrCodeScanScreen +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.qrCodeScanDestination const val ITEM_LISTING_GRAPH_ROUTE = "item_listing_graph" @@ -21,15 +25,29 @@ fun NavGraphBuilder.itemListingGraph( ) { itemListingDestination( onNavigateBack = { navController.popBackStack() }, - onNavigateToQrCodeScanner = { /*navController.navigateToQrCodeScanner()*/ }, + onNavigateToQrCodeScanner = { navController.navigateToQrCodeScanScreen() }, onNavigateToItemScreen = { navController.navigateToItem(itemId = it) }, onNavigateToEditItemScreen = { /*navController.navigateToEditItem(itemId = it)*/ }, - onNavigateToManualKeyEntry = { /*navController.navigateToManualKeySetup()*/ }, + onNavigateToManualKeyEntry = { navController.navigateToManualCodeEntryScreen() }, ) itemDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToEditItem = { /*navController.navigateToEditItem(itemId = it)*/ } ) + qrCodeScanDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToManualCodeEntryScreen = { + navController.popBackStack() + navController.navigateToManualCodeEntryScreen() + }, + ) + manualCodeEntryDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToQrCodeScreen = { + navController.popBackStack() + navController.navigateToQrCodeScanScreen() + } + ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryNavigation.kt new file mode 100644 index 000000000..e200740e7 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryNavigation.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +private const val MANUAL_CODE_ENTRY_ROUTE: String = "manual_code_entry" + +/** + * Add the manual code entry screen to the nav graph. + */ +fun NavGraphBuilder.manualCodeEntryDestination( + onNavigateBack: () -> Unit, + onNavigateToQrCodeScreen: () -> Unit, +) { + composableWithSlideTransitions( + route = MANUAL_CODE_ENTRY_ROUTE, + ) { + ManualCodeEntryScreen( + onNavigateBack = onNavigateBack, + onNavigateToQrCodeScreen = onNavigateToQrCodeScreen, + ) + } +} + +/** + * Navigate to the manual code entry screen. + */ +fun NavController.navigateToManualCodeEntryScreen( + navOptions: NavOptions? = null, +) { + this.navigate(MANUAL_CODE_ENTRY_ROUTE, navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt new file mode 100644 index 000000000..18e64e65c --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryScreen.kt @@ -0,0 +1,202 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.ExperimentalMaterial3Api +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 +import androidx.compose.runtime.getValue +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.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.x8bit.bitwarden.authenticator.R +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.toAnnotatedString +import com.x8bit.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoButtonDialog +import com.x8bit.bitwarden.authenticator.ui.platform.components.field.BitwardenTextField +import com.x8bit.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManager +import com.x8bit.bitwarden.authenticator.ui.platform.theme.LocalIntentManager +import com.x8bit.bitwarden.authenticator.ui.platform.theme.LocalPermissionsManager + +/** + * The screen to manually add a totp code. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManualCodeEntryScreen( + onNavigateBack: () -> Unit, + onNavigateToQrCodeScreen: () -> Unit, + viewModel: ManualCodeEntryViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, + permissionsManager: PermissionsManager = LocalPermissionsManager.current, +) { + var shouldShowPermissionDialog by rememberSaveable { mutableStateOf(false) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val launcher = permissionsManager.getLauncher { isGranted -> + if (isGranted) { + viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick) + } else { + shouldShowPermissionDialog = true + } + } + + val context = LocalContext.current + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is ManualCodeEntryEvent.NavigateToAppSettings -> { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:" + context.packageName) + + intentManager.startActivity(intent = intent) + } + + is ManualCodeEntryEvent.ShowToast -> { + Toast + .makeText(context, event.message.invoke(context.resources), Toast.LENGTH_SHORT) + .show() + } + + is ManualCodeEntryEvent.NavigateToQrCodeScreen -> { + onNavigateToQrCodeScreen.invoke() + } + + is ManualCodeEntryEvent.NavigateBack -> { + onNavigateBack.invoke() + } + } + } + + if (shouldShowPermissionDialog) { + BitwardenTwoButtonDialog( + message = stringResource(id = R.string.enable_camer_permission_to_use_the_scanner), + confirmButtonText = stringResource(id = R.string.settings), + dismissButtonText = stringResource(id = R.string.no_thanks), + onConfirmClick = remember(viewModel) { + { viewModel.trySendAction(ManualCodeEntryAction.SettingsClick) } + }, + onDismissClick = { shouldShowPermissionDialog = false }, + onDismissRequest = { shouldShowPermissionDialog = false }, + title = null, + ) + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.authenticator_key_scanner), + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(ManualCodeEntryAction.CloseClick) } + }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + + Text( + text = stringResource(id = R.string.enter_key_manually), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + singleLine = false, + label = stringResource(id = R.string.authenticator_key_scanner), + value = state.code, + onValueChange = remember(viewModel) { + { + viewModel.trySendAction( + ManualCodeEntryAction.CodeTextChange(it), + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + BitwardenFilledTonalButton( + label = stringResource(id = R.string.add_totp), + onClick = remember(viewModel) { + { viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Text( + text = stringResource(id = R.string.once_the_key_is_successfully_entered), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 16.dp, + horizontal = 16.dp, + ), + ) + + Text( + text = stringResource(id = R.string.cannot_add_authenticator_key), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding( + vertical = 8.dp, + horizontal = 16.dp, + ), + ) + + ClickableText( + text = stringResource(id = R.string.scan_qr_code).toAnnotatedString(), + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier + .padding(horizontal = 16.dp), + onClick = remember(viewModel) { + { + if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) { + viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick) + } else { + launcher.launch(Manifest.permission.CAMERA) + } + } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt new file mode 100644 index 000000000..8c937e4d6 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/manualcodeentry/ManualCodeEntryViewModel.kt @@ -0,0 +1,125 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.manualcodeentry + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text +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" + +/** + * The ViewModel for handling user interactions in the manual code entry screen. + * + */ +@HiltViewModel +class ManualCodeEntryViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authenticatorRepository: AuthenticatorRepository, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] + ?: ManualCodeEntryState(code = ""), +) { + override fun handleAction(action: ManualCodeEntryAction) { + when (action) { + is ManualCodeEntryAction.CloseClick -> handleCloseClick() + is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action) + is ManualCodeEntryAction.CodeSubmit -> handleCodeSubmit() + is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick() + is ManualCodeEntryAction.SettingsClick -> handleSettingsClick() + } + } + + private fun handleCloseClick() { + sendEvent(ManualCodeEntryEvent.NavigateBack) + } + + private fun handleCodeTextChange(action: ManualCodeEntryAction.CodeTextChange) { + mutableStateFlow.update { + it.copy(code = action.code) + } + } + + private fun handleCodeSubmit() { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.Success(state.code)) + sendEvent(ManualCodeEntryEvent.NavigateBack) + } + + private fun handleScanQrCodeTextClick() { + sendEvent(ManualCodeEntryEvent.NavigateToQrCodeScreen) + } + + private fun handleSettingsClick() { + sendEvent(ManualCodeEntryEvent.NavigateToAppSettings) + } +} + +/** + * Models state of the manual entry screen. + */ +@Parcelize +data class ManualCodeEntryState( + val code: String, +) : Parcelable + +/** + * Models events for the [ManualCodeEntryScreen]. + */ +sealed class ManualCodeEntryEvent { + + /** + * Navigate back. + */ + data object NavigateBack : ManualCodeEntryEvent() + + /** + * Navigate to the Qr code screen. + */ + data object NavigateToQrCodeScreen : ManualCodeEntryEvent() + + /** + * Navigate to the app settings. + */ + data object NavigateToAppSettings : ManualCodeEntryEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: Text) : ManualCodeEntryEvent() +} + +/** + * Models actions for the [ManualCodeEntryScreen]. + */ +sealed class ManualCodeEntryAction { + + /** + * User clicked close. + */ + data object CloseClick : ManualCodeEntryAction() + + /** + * The user has submitted a code. + */ + data object CodeSubmit : ManualCodeEntryAction() + + /** + * The user has changed the code text. + */ + data class CodeTextChange(val code: String) : ManualCodeEntryAction() + + /** + * The text to switch to QR code scanning is clicked. + */ + data object ScanQrCodeTextClick : ManualCodeEntryAction() + + /** + * The action for the user clicking the settings button. + */ + data object SettingsClick : ManualCodeEntryAction() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanNavigation.kt new file mode 100644 index 000000000..109170b3e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanNavigation.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.composableWithSlideTransitions + +private const val QR_CODE_SCAN_ROUTE: String = "qr_code_scan" + +/** + * Add the QR code scan screen to the nav graph. + */ +fun NavGraphBuilder.qrCodeScanDestination( + onNavigateBack: () -> Unit, + onNavigateToManualCodeEntryScreen: () -> Unit, +) { + composableWithSlideTransitions( + route = QR_CODE_SCAN_ROUTE, + ) { + QrCodeScanScreen( + onNavigateToManualCodeEntryScreen = onNavigateToManualCodeEntryScreen, + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the QR code scan screen. + */ +fun NavController.navigateToQrCodeScanScreen( + navOptions: NavOptions? = null, +) { + this.navigate(QR_CODE_SCAN_ROUTE, navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt new file mode 100644 index 000000000..b11103d01 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanScreen.kt @@ -0,0 +1,468 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import android.content.res.Configuration +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.Toast +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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 +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import com.x8bit.bitwarden.authenticator.R +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzer +import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util.QrCodeAnalyzerImpl +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.authenticator.ui.platform.theme.LocalNonMaterialColors +import com.x8bit.bitwarden.authenticator.ui.platform.theme.clickableSpanStyle +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * The screen to scan QR codes for the application. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QrCodeScanScreen( + onNavigateBack: () -> Unit, + viewModel: QrCodeScanViewModel = hiltViewModel(), + qrCodeAnalyzer: QrCodeAnalyzer = QrCodeAnalyzerImpl(), + onNavigateToManualCodeEntryScreen: () -> Unit, +) { + qrCodeAnalyzer.onQrCodeScanned = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.QrCodeScanReceive(it)) } + } + + val orientation = LocalConfiguration.current.orientation + + val context = LocalContext.current + + val onEnterCodeManuallyClick = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.ManualEntryTextClick) } + } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is QrCodeScanEvent.ShowToast -> { + Toast + .makeText(context, event.message.invoke(context.resources), Toast.LENGTH_SHORT) + .show() + } + + is QrCodeScanEvent.NavigateBack -> { + onNavigateBack.invoke() + } + + is QrCodeScanEvent.NavigateToManualCodeEntry -> { + onNavigateToManualCodeEntryScreen.invoke() + } + } + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.scan_qr_code), + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.CloseClick) } + }, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + ) + }, + ) { innerPadding -> + CameraPreview( + cameraErrorReceive = remember(viewModel) { + { viewModel.trySendAction(QrCodeScanAction.CameraSetupErrorReceive) } + }, + qrCodeAnalyzer = qrCodeAnalyzer, + modifier = Modifier.padding(innerPadding), + ) + + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + LandscapeQRCodeContent( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + modifier = Modifier.padding(innerPadding), + ) + } + + else -> { + PortraitQRCodeContent( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + modifier = Modifier.padding(innerPadding), + ) + } + } + } +} + +@Composable +private fun PortraitQRCodeContent( + onEnterCodeManuallyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + QrCodeSquare( + squareOutlineSize = 250.dp, + modifier = Modifier.weight(2f), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background(color = Color.Black.copy(alpha = .4f)) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(id = R.string.point_your_camera_at_the_qr_code), + textAlign = TextAlign.Center, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + BottomClickableText( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + ) + Spacer(modifier = Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun LandscapeQRCodeContent( + onEnterCodeManuallyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + QrCodeSquare( + squareOutlineSize = 200.dp, + modifier = Modifier.weight(2f), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround, + modifier = Modifier + .weight(1f) + .fillMaxSize() + .background(color = Color.Black.copy(alpha = .4f)) + .padding(horizontal = 16.dp) + .navigationBarsPadding() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(id = R.string.point_your_camera_at_the_qr_code), + textAlign = TextAlign.Center, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + ) + + BottomClickableText( + onEnterCodeManuallyClick = onEnterCodeManuallyClick, + ) + } + } +} + +@Suppress("LongMethod", "TooGenericExceptionCaught") +@Composable +private fun CameraPreview( + cameraErrorReceive: () -> Unit, + qrCodeAnalyzer: QrCodeAnalyzer, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var cameraProvider: ProcessCameraProvider? by remember { mutableStateOf(null) } + + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + layoutParams = ViewGroup.LayoutParams( + MATCH_PARENT, + MATCH_PARENT, + ) + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + + val imageAnalyzer = remember(qrCodeAnalyzer) { + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .apply { + setAnalyzer( + Executors.newSingleThreadExecutor(), + qrCodeAnalyzer, + ) + } + } + + val preview = Preview.Builder() + .build() + .apply { setSurfaceProvider(previewView.surfaceProvider) } + + // Unbind from the camera provider when we leave the screen. + DisposableEffect(Unit) { + onDispose { + cameraProvider?.unbindAll() + } + } + + // Set up the camera provider on a background thread. This is necessary because + // ProcessCameraProvider.getInstance returns a ListenableFuture. For an example see + // https://github.com/JetBrains/compose-multiplatform/blob/1c7154b975b79901f40f28278895183e476ed36d/examples/imageviewer/shared/src/androidMain/kotlin/example/imageviewer/view/CameraView.android.kt#L85 + LaunchedEffect(imageAnalyzer) { + try { + cameraProvider = suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(context).also { future -> + future.addListener( + { continuation.resume(future.get()) }, + Executors.newSingleThreadExecutor(), + ) + } + } + + cameraProvider?.unbindAll() + cameraProvider?.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer, + ) + } catch (e: Exception) { + cameraErrorReceive() + } + } + + AndroidView( + factory = { previewView }, + modifier = modifier, + ) +} + +/** + * UI for the blue QR code square that is drawn onto the screen. + */ +@Suppress("MagicNumber", "LongMethod") +@Composable +private fun QrCodeSquare( + modifier: Modifier = Modifier, + squareOutlineSize: Dp, +) { + val color = MaterialTheme.colorScheme.primary + + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + Canvas( + modifier = Modifier + .size(squareOutlineSize) + .padding(8.dp), + ) { + val strokeWidth = 3.dp.toPx() + + val squareSize = size.width + val strokeOffset = strokeWidth / 2 + val sideLength = (1f / 6) * squareSize + + drawIntoCanvas { canvas -> + canvas.nativeCanvas.apply { + // Draw upper top left. + drawLine( + color = color, + start = Offset(0f, strokeOffset), + end = Offset(sideLength, strokeOffset), + strokeWidth = strokeWidth, + ) + + // Draw lower top left. + drawLine( + color = color, + start = Offset(strokeOffset, strokeOffset), + end = Offset(strokeOffset, sideLength), + strokeWidth = strokeWidth, + ) + + // Draw upper top right. + drawLine( + color = color, + start = Offset(squareSize - sideLength, strokeOffset), + end = Offset(squareSize - strokeOffset, strokeOffset), + strokeWidth = strokeWidth, + ) + + // Draw lower top right. + drawLine( + color = color, + start = Offset(squareSize - strokeOffset, 0f), + end = Offset(squareSize - strokeOffset, sideLength), + strokeWidth = strokeWidth, + ) + + // Draw upper bottom right. + drawLine( + color = color, + start = Offset(squareSize - strokeOffset, squareSize), + end = Offset(squareSize - strokeOffset, squareSize - sideLength), + strokeWidth = strokeWidth, + ) + + // Draw lower bottom right. + drawLine( + color = color, + start = Offset(squareSize - strokeOffset, squareSize - strokeOffset), + end = Offset(squareSize - sideLength, squareSize - strokeOffset), + strokeWidth = strokeWidth, + ) + + // Draw upper bottom left. + drawLine( + color = color, + start = Offset(strokeOffset, squareSize), + end = Offset(strokeOffset, squareSize - sideLength), + strokeWidth = strokeWidth, + ) + + // Draw lower bottom left. + drawLine( + color = color, + start = Offset(0f, squareSize - strokeOffset), + end = Offset(sideLength, squareSize - strokeOffset), + strokeWidth = strokeWidth, + ) + } + } + } + } +} + +@Composable +private fun BottomClickableText( + onEnterCodeManuallyClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val cannotScanText = stringResource(id = R.string.cannot_scan_qr_code) + val enterKeyText = stringResource(id = R.string.enter_key_manually) + val clickableStyle = clickableSpanStyle() + val manualTextColor = LocalNonMaterialColors.current.qrCodeClickableText + + val customTitleLineBreak = LineBreak( + strategy = LineBreak.Strategy.Balanced, + strictness = LineBreak.Strictness.Strict, + wordBreak = LineBreak.WordBreak.Phrase, + ) + + val annotatedString = remember { + buildAnnotatedString { + withStyle(style = clickableStyle.copy(color = Color.White)) { + pushStringAnnotation( + tag = cannotScanText, + annotation = cannotScanText, + ) + append(cannotScanText) + } + + append(" ") + + withStyle(style = clickableStyle.copy(color = manualTextColor)) { + pushStringAnnotation(tag = enterKeyText, annotation = enterKeyText) + append(enterKeyText) + } + } + } + + ClickableText( + text = annotatedString, + style = MaterialTheme.typography.bodyMedium.copy( + textAlign = TextAlign.Center, + lineBreak = customTitleLineBreak, + ), + onClick = { offset -> + annotatedString + .getStringAnnotations( + tag = enterKeyText, + start = offset, + end = offset, + ) + .firstOrNull() + ?.let { onEnterCodeManuallyClick.invoke() } + }, + modifier = modifier + .semantics { + CustomAccessibilityAction( + label = enterKeyText, + action = { + onEnterCodeManuallyClick.invoke() + true + }, + ) + }, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt new file mode 100644 index 000000000..8527c07b3 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/QrCodeScanViewModel.kt @@ -0,0 +1,170 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan + +import android.net.Uri +import com.x8bit.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRepository +import com.x8bit.bitwarden.authenticator.data.authenticator.repository.model.TotpCodeResult +import com.x8bit.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +private const val ALGORITHM = "algorithm" +private const val DIGITS = "digits" +private const val PERIOD = "period" +private const val SECRET = "secret" +private const val TOTP_CODE_PREFIX = "otpauth://totp" + +/** + * Handles [QrCodeScanAction], + * and launches [QrCodeScanEvent] for the [QrCodeScanScreen]. + */ +@HiltViewModel +class QrCodeScanViewModel @Inject constructor( + private val authenticatorRepository: AuthenticatorRepository, +) : BaseViewModel( + initialState = Unit, +) { + override fun handleAction(action: QrCodeScanAction) { + when (action) { + is QrCodeScanAction.CloseClick -> handleCloseClick() + is QrCodeScanAction.ManualEntryTextClick -> handleManualEntryTextClick() + is QrCodeScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive() + is QrCodeScanAction.QrCodeScanReceive -> handleQrCodeScanReceive(action) + } + } + + private fun handleCloseClick() { + sendEvent( + QrCodeScanEvent.NavigateBack, + ) + } + + private fun handleManualEntryTextClick() { + sendEvent( + QrCodeScanEvent.NavigateToManualCodeEntry, + ) + } + + // For more information: https://bitwarden.com/help/authenticator-keys/#support-for-more-parameters + private fun handleQrCodeScanReceive(action: QrCodeScanAction.QrCodeScanReceive) { + var result: TotpCodeResult = TotpCodeResult.Success(action.qrCode) + val scannedCode = action.qrCode + + if (scannedCode.isBlank() || !scannedCode.startsWith(TOTP_CODE_PREFIX)) { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + + val scannedCodeUri = Uri.parse(scannedCode) + val secretValue = scannedCodeUri.getQueryParameter(SECRET) + if (secretValue == null || !secretValue.isBase32()) { + authenticatorRepository.emitTotpCodeResult(TotpCodeResult.CodeScanningError) + sendEvent(QrCodeScanEvent.NavigateBack) + return + } + + val values = scannedCodeUri.queryParameterNames + if (!areParametersValid(scannedCode, values)) { + result = TotpCodeResult.CodeScanningError + } + + authenticatorRepository.emitTotpCodeResult(result) + sendEvent(QrCodeScanEvent.NavigateBack) + } + + private fun handleCameraErrorReceive() { + sendEvent( + QrCodeScanEvent.NavigateToManualCodeEntry, + ) + } + + @Suppress("NestedBlockDepth", "ReturnCount", "MagicNumber") + private fun areParametersValid(scannedCode: String, parameters: Set): Boolean { + parameters.forEach { parameter -> + Uri.parse(scannedCode).getQueryParameter(parameter)?.let { value -> + when (parameter) { + DIGITS -> { + val digit = value.toInt() + if (digit > 10 || digit < 1) { + return false + } + } + + PERIOD -> { + val period = value.toInt() + if (period < 1) { + return false + } + } + + ALGORITHM -> { + val lowercaseAlgo = value.lowercase() + if (lowercaseAlgo != "sha1" && + lowercaseAlgo != "sha256" && + lowercaseAlgo != "sha512" + ) { + return false + } + } + } + } + } + return true + } +} + +/** + * Models events for the [QrCodeScanScreen]. + */ +sealed class QrCodeScanEvent { + + /** + * Navigate back. + */ + data object NavigateBack : QrCodeScanEvent() + + /** + * Navigate to manual code entry screen. + */ + data object NavigateToManualCodeEntry : QrCodeScanEvent() + + /** + * Show a toast with the given [message]. + */ + data class ShowToast(val message: Text) : QrCodeScanEvent() +} + +/** + * Models actions for the [QrCodeScanScreen]. + */ +sealed class QrCodeScanAction { + + /** + * User clicked close. + */ + data object CloseClick : QrCodeScanAction() + + /** + * The user has scanned a QR code. + */ + data class QrCodeScanReceive(val qrCode: String) : QrCodeScanAction() + + /** + * The text to switch to manual entry is clicked. + */ + data object ManualEntryTextClick : QrCodeScanAction() + + /** + * The Camera is unable to be setup. + */ + data object CameraSetupErrorReceive : QrCodeScanAction() +} + +/** + * Checks if a string is using base32 digits. + */ +private fun String.isBase32(): Boolean { + val regex = ("^[A-Z2-7]+=*$").toRegex() + return regex.matches(this) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzer.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzer.kt new file mode 100644 index 000000000..d4f66668b --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzer.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util + +import androidx.camera.core.ImageAnalysis +import androidx.compose.runtime.Stable + +/** + * An interface that is used to help scan QR codes. + */ +@Stable +interface QrCodeAnalyzer : ImageAnalysis.Analyzer { + + /** + * The method that is called once the code is scanned. + */ + var onQrCodeScanned: (String) -> Unit +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt new file mode 100644 index 000000000..bf02e847d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/authenticator/feature/qrcodescan/util/QrCodeAnalyzerImpl.kt @@ -0,0 +1,70 @@ +package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.qrcodescan.util + +import androidx.camera.core.ImageProxy +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import java.nio.ByteBuffer + +/** + * A class setup to handle image analysis so that we can use the Zxing library + * to scan QR codes and convert them to a string. + */ +class QrCodeAnalyzerImpl : QrCodeAnalyzer { + + /** + * This will ensure the result is only sent once as multiple images with a valid + * QR code can be sent for analysis. + */ + private var qrCodeRead = false + + override lateinit var onQrCodeScanned: (String) -> Unit + + override fun analyze(image: ImageProxy) { + if (qrCodeRead) { + return + } + + val source = PlanarYUVLuminanceSource( + image.planes[0].buffer.toByteArray(), + image.width, + image.height, + 0, + 0, + image.width, + image.height, + false, + ) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + try { + val result = MultiFormatReader() + .apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to arrayListOf( + BarcodeFormat.QR_CODE, + ), + ), + ) + } + .decode(binaryBitmap) + + qrCodeRead = true + onQrCodeScanned(result.text) + } catch (e: NotFoundException) { + return + } finally { + image.close() + } + } +} + +/** + * This function helps us prepare the byte buffer to be read. + */ +private fun ByteBuffer.toByteArray(): ByteArray = + ByteArray(rewind().remaining()).also { get(it) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/NavGraphBuilderExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/NavGraphBuilderExtensions.kt index d51f27e99..c1eae658a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/NavGraphBuilderExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/NavGraphBuilderExtensions.kt @@ -9,6 +9,27 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.x8bit.bitwarden.authenticator.ui.platform.theme.TransitionProviders +/** + * A wrapper around [NavGraphBuilder.composable] that supplies slide up/down transitions. + */ +fun NavGraphBuilder.composableWithSlideTransitions( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + route = route, + arguments = arguments, + deepLinks = deepLinks, + enterTransition = TransitionProviders.Enter.slideUp, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, + popExitTransition = TransitionProviders.Exit.slideDown, + content = content, + ) +} + /** * A wrapper around [NavGraphBuilder.composable] that supplies push transitions. * diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/StringExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/StringExtensions.kt index 7a3efe7c6..fd57e4888 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/StringExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/base/util/StringExtensions.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.authenticator.ui.platform.base.util import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer import kotlin.math.floor @@ -35,3 +36,8 @@ fun String.withLineBreaksAtWidth( } } } + +/** + * Returns the [String] as an [AnnotatedString]. + */ +fun String.toAnnotatedString(): AnnotatedString = AnnotatedString(text = this) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/button/BitwardenFilledTonalButton.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/button/BitwardenFilledTonalButton.kt new file mode 100644 index 000000000..6affeb8d8 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/button/BitwardenFilledTonalButton.kt @@ -0,0 +1,61 @@ +package com.x8bit.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme + +/** + * A filled tonal button for the Bitwarden UI with a customized appearance. + * + * This button uses the `secondaryContainer` color from the current [MaterialTheme.colorScheme] + * for its background and the `onSecondaryContainer` color for its label text. + * + * @param label The text to be displayed on the button. + * @param onClick A lambda which will be invoked when the button is clicked. + * @param isEnabled Whether or not the button is enabled. + * @param modifier A [Modifier] for this composable, allowing for adjustments to its appearance + * or behavior. This can be used to apply padding, layout, and other Modifiers. + */ +@Composable +fun BitwardenFilledTonalButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + Button( + onClick = onClick, + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 24.dp, + ), + enabled = isEnabled, + colors = ButtonDefaults.filledTonalButtonColors(), + modifier = modifier, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun BitwardenFilledTonalButton_preview() { + AuthenticatorTheme { + BitwardenFilledTonalButton( + label = "Sample Text", + onClick = {}, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt new file mode 100644 index 000000000..0052c6e31 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt @@ -0,0 +1,68 @@ +package com.x8bit.bitwarden.authenticator.ui.platform.components.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.x8bit.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton + +/** + * Represents a Bitwarden-styled dialog with two buttons. + * + * @param title the optional title to show. + * @param message message to show. + * @param confirmButtonText text to show on confirm button. + * @param dismissButtonText text to show on dismiss button. + * @param onConfirmClick called when the confirm button is clicked. + * @param onDismissClick called when the dismiss button is clicked. + * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by + * tapping outside of it). + * @param confirmTextColor The color of the confirm text. + * @param dismissTextColor The color of the dismiss text. + */ +@Composable +fun BitwardenTwoButtonDialog( + title: String?, + message: String, + confirmButtonText: String, + dismissButtonText: String, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + confirmTextColor: Color? = null, + dismissTextColor: Color? = null, +) { + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + BitwardenTextButton( + label = dismissButtonText, + labelTextColor = dismissTextColor, + onClick = onDismissClick, + ) + }, + confirmButton = { + BitwardenTextButton( + label = confirmButtonText, + labelTextColor = confirmTextColor, + onClick = onConfirmClick, + ) + }, + title = title?.let { + { + Text( + text = it, + style = MaterialTheme.typography.headlineSmall, + ) + } + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/theme/SpanStyles.kt b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/theme/SpanStyles.kt new file mode 100644 index 000000000..b3b673373 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/authenticator/ui/platform/theme/SpanStyles.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.authenticator.ui.platform.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle + +/** + * Defines a span style for clickable span texts. Useful because spans require a + * [SpanStyle] instead of the typical [TextStyle]. + */ +@Composable +@ReadOnlyComposable +fun clickableSpanStyle(): SpanStyle = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, + fontStyle = MaterialTheme.typography.bodyMedium.fontStyle, + fontFamily = MaterialTheme.typography.bodyMedium.fontFamily, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d273b5f11..92f7e2b0e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,4 +21,15 @@ Add Item Rotation Scan a QR code Enter a setup key + Scan QR code + Point your camera at the QR code. + Cannot scan QR code. + Enter key manually. + Cannot add authenticator key? + Once the key is successfully entered,\nselect Add TOTP to store the key safely + Add TOTP + Authenticator key + No thanks + Settings + Enable camera permission to use the scanner