QR Scanner and Manual Key Entry screens (#7)

This commit is contained in:
Patrick Honkonen
2024-04-02 17:45:33 -04:00
committed by GitHub
parent 580a0eecdd
commit a92ed17d65
15 changed files with 1327 additions and 2 deletions

View File

@@ -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()
}
)
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
},
)
}
}
}

View File

@@ -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<ManualCodeEntryState, ManualCodeEntryEvent, ManualCodeEntryAction>(
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()
}

View File

@@ -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)
}

View File

@@ -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
},
)
},
)
}

View File

@@ -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<Unit, QrCodeScanEvent, QrCodeScanAction>(
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<String>): 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)
}

View File

@@ -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
}

View File

@@ -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) }

View File

@@ -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<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = 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.
*

View File

@@ -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)

View File

@@ -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),
)
}
}

View File

@@ -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,
)
}

View File

@@ -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,
)

View File

@@ -21,4 +21,15 @@
<string name="add_item_rotation">Add Item Rotation</string>
<string name="scan_a_qr_code">Scan a QR code</string>
<string name="enter_a_setup_key">Enter a setup key</string>
<string name="scan_qr_code">Scan QR code</string>
<string name="point_your_camera_at_the_qr_code">Point your camera at the QR code.</string>
<string name="cannot_scan_qr_code">Cannot scan QR code.</string>
<string name="enter_key_manually">Enter key manually.</string>
<string name="cannot_add_authenticator_key">Cannot add authenticator key?</string>
<string name="once_the_key_is_successfully_entered">Once the key is successfully entered,\nselect Add TOTP to store the key safely</string>
<string name="add_totp">Add TOTP</string>
<string name="authenticator_key_scanner">Authenticator key</string>
<string name="no_thanks">No thanks</string>
<string name="settings">Settings</string>
<string name="enable_camer_permission_to_use_the_scanner">Enable camera permission to use the scanner</string>
</resources>