mirror of
https://github.com/bitwarden/android.git
synced 2026-03-11 20:54:58 -05:00
QR Scanner and Manual Key Entry screens (#7)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user