From 9d6341b74f7a6f0ad978978cb6dc80b17bef3ade Mon Sep 17 00:00:00 2001 From: Maciej Procyk Date: Sun, 8 Oct 2023 18:07:25 +0200 Subject: [PATCH] implement authentication screen on Android and iOS --- androidApp/distribution/whatsnew-en-GB | 2 +- gradle/libs.versions.toml | 2 + iosApp/iosApp/ContentView.swift | 1 - iosApp/iosApp/Info.plist | 12 +- shared/build.gradle.kts | 3 +- shared/src/androidMain/kotlin/main.android.kt | 6 +- .../dev/kotlin/openotp/util/BiometryUtil.kt | 155 ++++++++++++++++++ .../ml/dev/kotlin/openotp/OpenOtpApp.kt | 49 +++--- .../openotp/component/OpenOtpAppComponent.kt | 33 ++++ .../openotp/component/SettingsComponent.kt | 17 ++ .../openotp/component/UserPreferencesModel.kt | 1 + .../ui/component/LoadingAnimatedVisibility.kt | 59 +++++++ .../openotp/ui/component/NamedSwitch.kt | 6 +- .../ml/dev/kotlin/openotp/ui/icons/OpenOtp.kt | 85 ++++++++++ .../openotp/ui/screen/AbstractScreen.kt | 5 +- .../openotp/ui/screen/AuthenticationScreen.kt | 60 +++++++ .../openotp/ui/screen/ScanQRCodeScreen.kt | 50 ++---- .../openotp/ui/screen/SettingsScreen.kt | 20 ++- .../dev/kotlin/openotp/util/BiometryUtil.kt | 15 ++ .../commonMain/resources/MR/base/strings.xml | 11 +- .../commonMain/resources/MR/pl/strings.xml | 15 +- shared/src/desktopMain/kotlin/main.desktop.kt | 3 +- .../dev/kotlin/openotp/util/BiometryUtil.kt | 15 ++ shared/src/iosMain/kotlin/SwiftHelpers.kt | 4 +- .../dev/kotlin/openotp/util/BiometryUtil.kt | 90 ++++++++++ 25 files changed, 644 insertions(+), 75 deletions(-) create mode 100644 shared/src/androidMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt create mode 100644 shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/LoadingAnimatedVisibility.kt create mode 100644 shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OpenOtp.kt create mode 100644 shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AuthenticationScreen.kt create mode 100644 shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt create mode 100644 shared/src/desktopMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt create mode 100644 shared/src/iosMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt diff --git a/androidApp/distribution/whatsnew-en-GB b/androidApp/distribution/whatsnew-en-GB index 9025798..cb036fb 100644 --- a/androidApp/distribution/whatsnew-en-GB +++ b/androidApp/distribution/whatsnew-en-GB @@ -1,2 +1,2 @@ This version brings new features to app: -- manually reorder items with drag & drop gesture when sorting is disabled +- require user authentication if accessible diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31aaf5a..82fb0c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ android-minSdk = "26" android-targetSdk = "34" androidx-activity-compose = "1.8.0-rc01" androidx-appcompat-appcompat = "1.6.1" +androidx-biometric = "1.2.0-alpha05" androidx-core-ktx = "1.12.0" # @pin androidx-crypto = "1.1.0-alpha05" @@ -46,6 +47,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } androidx-appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat-appcompat" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidx-biometric" } androidx-camera = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } androidx-cameraLifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" } androidx-cameraPreview = { module = "androidx.camera:camera-view", version.ref = "cameraX" } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 682d674..769cb44 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -29,7 +29,6 @@ struct ContentView: View { backgroundColor .edgesIgnoringSafeArea(.all) - ComposeView(component: component) .ignoresSafeArea(.all) }.ignoresSafeArea(.all) diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 59dae1f..e10806f 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -48,10 +48,12 @@ CFBundleLocalizations - en - pl - - NSCameraUsageDescription - + en + pl + + NSCameraUsageDescription + + NSFaceIDUsageDescription + diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c3aa9ca..2e970e1 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -86,8 +86,6 @@ kotlin { api(libs.moko.resoures) api(libs.moko.resoures.compose) - - implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.2") } } val commonTest by getting { @@ -106,6 +104,7 @@ kotlin { implementation(libs.androidx.camera) implementation(libs.androidx.cameraLifecycle) implementation(libs.androidx.cameraPreview) + implementation(libs.androidx.biometric) implementation(libs.mlkit.barcodeScanning) implementation(libs.androidx.security.crypto) diff --git a/shared/src/androidMain/kotlin/main.android.kt b/shared/src/androidMain/kotlin/main.android.kt index 48ceafe..390b925 100644 --- a/shared/src/androidMain/kotlin/main.android.kt +++ b/shared/src/androidMain/kotlin/main.android.kt @@ -1,19 +1,21 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.material3.SnackbarHostState import com.arkivanov.decompose.defaultComponentContext import ml.dev.kotlin.openotp.OpenOtpApp import ml.dev.kotlin.openotp.component.OpenOtpAppComponentContext import ml.dev.kotlin.openotp.component.OpenOtpAppComponentImpl import ml.dev.kotlin.openotp.initOpenOtpKoin +import ml.dev.kotlin.openotp.util.BiometryAuthenticator import org.koin.compose.KoinContext import org.koin.dsl.module fun ComponentActivity.androidOpenOtpApp() { + val activity = this@androidOpenOtpApp initOpenOtpKoin { modules( module { - single { OpenOtpAppComponentContext(this@androidOpenOtpApp) } + single { OpenOtpAppComponentContext(activity) } + single { BiometryAuthenticator(activity.applicationContext) } } ) } diff --git a/shared/src/androidMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt b/shared/src/androidMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt new file mode 100644 index 0000000..d721708 --- /dev/null +++ b/shared/src/androidMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt @@ -0,0 +1,155 @@ +package ml.dev.kotlin.openotp.util + +import android.annotation.SuppressLint +import android.content.Context +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricPrompt +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.OnLifecycleEvent +import java.util.concurrent.Executor +import kotlin.coroutines.suspendCoroutine + +actual class BiometryAuthenticator( + private val applicationContext: Context, +) { + private var fragmentManager: FragmentManager? = null + + fun bind(lifecycle: Lifecycle, fragmentManager: FragmentManager) { + this.fragmentManager = fragmentManager + + val observer = object : LifecycleObserver { + + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun onDestroyed(source: LifecycleOwner) { + this@BiometryAuthenticator.fragmentManager = null + source.lifecycle.removeObserver(this) + } + } + lifecycle.addObserver(observer) + } + + actual suspend fun checkBiometryAuthentication( + requestTitle: String, + requestReason: String, + ): Boolean { + val resolverFragment: ResolverFragment = getResolverFragment() + + return suspendCoroutine { continuation -> + var resumed = false + resolverFragment.showBiometricPrompt( + requestTitle = requestTitle, + requestReason = requestReason, + ) { + if (!resumed) { + continuation.resumeWith(it) + resumed = true + } + } + } + } + + actual fun isBiometricAvailable(): Boolean { + val manager: BiometricManager = BiometricManager.from(applicationContext) + return manager.canAuthenticate(BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS + } + + private fun getResolverFragment(): ResolverFragment { + val fragmentManager = fragmentManager + ?: error("can't check biometry without active window") + + val currentFragment = fragmentManager + .findFragmentByTag(BIOMETRY_RESOLVER_FRAGMENT_TAG) + + return if (currentFragment != null) { + currentFragment as ResolverFragment + } else { + ResolverFragment().apply { + fragmentManager + .beginTransaction() + .add(this, BIOMETRY_RESOLVER_FRAGMENT_TAG) + .commitNow() + } + } + } + + class ResolverFragment : Fragment() { + private lateinit var executor: Executor + private lateinit var biometricPrompt: BiometricPrompt + private lateinit var promptInfo: BiometricPrompt.PromptInfo + + init { + retainInstance = true + } + + fun showBiometricPrompt( + requestTitle: String, + requestReason: String, + callback: (Result) -> Unit, + ) { + val context = requireContext() + + executor = ContextCompat.getMainExecutor(context) + + biometricPrompt = BiometricPrompt(this, executor, + object : BiometricPrompt.AuthenticationCallback() { + @SuppressLint("RestrictedApi") + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence, + ) { + super.onAuthenticationError(errorCode, errString) + if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + errorCode == BiometricPrompt.ERROR_USER_CANCELED + ) { + callback.invoke(Result.success(false)) + } else { + callback.invoke(Result.failure(Exception(errString.toString()))) + } + } + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult, + ) { + super.onAuthenticationSucceeded(result) + callback.invoke(Result.success(true)) + } + } + ) + + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(requestTitle) + .setSubtitle(requestReason) + .setDeviceCredentialAllowed(true) + .build() + + biometricPrompt.authenticate(promptInfo) + } + } + + companion object { + private const val BIOMETRY_RESOLVER_FRAGMENT_TAG = "BiometryControllerResolver" + } +} + +@Composable +actual fun BindBiometryAuthenticatorEffect(biometryAuthenticator: BiometryAuthenticator) { + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + + LaunchedEffect(biometryAuthenticator, lifecycleOwner, context) { + val fragmentManager = (context as FragmentActivity).supportFragmentManager + biometryAuthenticator.bind(lifecycleOwner.lifecycle, fragmentManager) + } +} + diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/OpenOtpApp.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/OpenOtpApp.kt index d0e4c40..6a797a3 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/OpenOtpApp.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/OpenOtpApp.kt @@ -6,20 +6,22 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState import kotlinx.serialization.builtins.ListSerializer import ml.dev.kotlin.openotp.component.OpenOtpAppComponent import ml.dev.kotlin.openotp.component.OpenOtpAppComponent.Child import ml.dev.kotlin.openotp.component.UserPreferencesModel import ml.dev.kotlin.openotp.otp.OtpData -import ml.dev.kotlin.openotp.ui.screen.AddProviderScreen -import ml.dev.kotlin.openotp.ui.screen.MainScreen -import ml.dev.kotlin.openotp.ui.screen.ScanQRCodeScreen -import ml.dev.kotlin.openotp.ui.screen.SettingsScreen +import ml.dev.kotlin.openotp.ui.screen.* import ml.dev.kotlin.openotp.ui.theme.OpenOtpTheme +import ml.dev.kotlin.openotp.util.BindBiometryAuthenticatorEffect +import ml.dev.kotlin.openotp.util.BiometryAuthenticator +import ml.dev.kotlin.openotp.util.OnceLaunchedEffect import ml.dev.kotlin.openotp.util.StateFlowSettings import org.koin.compose.koinInject import org.koin.core.context.startKoin @@ -35,21 +37,30 @@ internal fun OpenOtpApp(component: OpenOtpAppComponent) { Surface( modifier = Modifier.fillMaxSize(), ) { - Children( - stack = component.stack, - modifier = Modifier.fillMaxSize(), - animation = stackAnimation(slide()) - ) { child -> - val snackbarHostState = koinInject() - Scaffold( - snackbarHost = { SnackbarHost(snackbarHostState) }, - modifier = Modifier.fillMaxSize() - ) { - when (val instance = child.instance) { - is Child.Main -> MainScreen(instance.component) - is Child.ScanQRCode -> ScanQRCodeScreen(instance.component) - is Child.AddProvider -> AddProviderScreen(instance.totpComponent, instance.hotpComponent) - is Child.Settings -> SettingsScreen(instance.component) + BindBiometryAuthenticatorEffect(koinInject()) + OnceLaunchedEffect { component.onAuthenticate() } + + val authenticated by component.authenticated.subscribeAsState() + AuthenticationScreen( + authenticated = authenticated, + onAuthenticate = component::onAuthenticate, + ) { + Children( + stack = component.stack, + modifier = Modifier.fillMaxSize(), + animation = stackAnimation(slide()) + ) { child -> + val snackbarHostState = koinInject() + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = Modifier.fillMaxSize() + ) { + when (val instance = child.instance) { + is Child.Main -> MainScreen(instance.component) + is Child.ScanQRCode -> ScanQRCodeScreen(instance.component) + is Child.AddProvider -> AddProviderScreen(instance.totpComponent, instance.hotpComponent) + is Child.Settings -> SettingsScreen(instance.component) + } } } } diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/OpenOtpAppComponent.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/OpenOtpAppComponent.kt index af44aed..e80de15 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/OpenOtpAppComponent.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/OpenOtpAppComponent.kt @@ -3,9 +3,13 @@ package ml.dev.kotlin.openotp.component import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.router.stack.* import com.arkivanov.decompose.value.Value +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import ml.dev.kotlin.openotp.USER_PREFERENCES_MODULE_QUALIFIER import ml.dev.kotlin.openotp.component.OpenOtpAppComponent.Child +import ml.dev.kotlin.openotp.shared.OpenOtpResources +import ml.dev.kotlin.openotp.util.BiometryAuthenticator import ml.dev.kotlin.openotp.util.StateFlowSettings import org.koin.core.component.get @@ -13,6 +17,10 @@ interface OpenOtpAppComponent { val theme: Value val stack: Value> + val requireAuthentication: Value + val authenticated: Value + + fun onAuthenticate() fun onBackClicked(toIndex: Int) @@ -44,8 +52,22 @@ class OpenOtpAppComponentImpl( private val userPreferences: StateFlowSettings = get(USER_PREFERENCES_MODULE_QUALIFIER) + private val authenticator: BiometryAuthenticator = get() + override val theme: Value = userPreferences.stateFlow.map { it.theme }.asValue() + override val requireAuthentication: Value = + userPreferences.stateFlow.map { it.requireAuthentication }.asValue() + + private val _authenticated: MutableStateFlow = MutableStateFlow(!requireAuthentication.value) + + override val authenticated: Value = combine( + _authenticated, + userPreferences.stateFlow.map { it.requireAuthentication }, + ) { authenticated, require -> + !require || authenticated + }.asValue() + private fun child(config: Config, childComponentContext: ComponentContext): Child = when (config) { is Config.Main -> Child.Main( MainComponentImpl( @@ -86,6 +108,17 @@ class OpenOtpAppComponentImpl( ) } + override fun onAuthenticate() { + if (!requireAuthentication.value) return + + scope.launch { + _authenticated.value = authenticator.checkBiometryAuthentication( + requestTitle = stringResource(OpenOtpResources.strings.authenticate_request_title), + requestReason = stringResource(OpenOtpResources.strings.authenticate_request_description) + ) + } + } + override fun onBackClicked(toIndex: Int) { navigation.popTo(index = toIndex) } diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/SettingsComponent.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/SettingsComponent.kt index cb2109f..1d151e6 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/SettingsComponent.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/SettingsComponent.kt @@ -3,6 +3,7 @@ package ml.dev.kotlin.openotp.component import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.value.Value import ml.dev.kotlin.openotp.USER_PREFERENCES_MODULE_QUALIFIER +import ml.dev.kotlin.openotp.util.BiometryAuthenticator import ml.dev.kotlin.openotp.util.StateFlowSettings import org.koin.core.component.get @@ -14,6 +15,8 @@ interface SettingsComponent { val canReorderDataManually: Value val sortOtpDataNullsFirst: Value val sortOtpDataReversed: Value + val requireAuthentication: Value + val isAuthenticationAvailable: Boolean fun onSelectedTheme(theme: OpenOtpAppTheme) @@ -25,6 +28,8 @@ interface SettingsComponent { fun onSortReversedChange(reversed: Boolean) + fun onRequireAuthenticationChange(require: Boolean) + fun onExitSettings() } @@ -35,6 +40,8 @@ class SettingsComponentImpl( private val userPreferences: StateFlowSettings = get(USER_PREFERENCES_MODULE_QUALIFIER) + private val authenticator: BiometryAuthenticator = get() + override val theme: Value = userPreferences.stateFlow.map { it.theme }.asValue() @@ -53,6 +60,12 @@ class SettingsComponentImpl( override val sortOtpDataReversed: Value = userPreferences.stateFlow.map { it.sortOtpDataReversed }.asValue() + override val requireAuthentication: Value = + userPreferences.stateFlow.map { it.requireAuthentication }.asValue() + + override val isAuthenticationAvailable: Boolean + get() = authenticator.isBiometricAvailable() + override fun onSelectedTheme(theme: OpenOtpAppTheme) { userPreferences.updateInScope { it.copy(theme = theme) } } @@ -73,6 +86,10 @@ class SettingsComponentImpl( userPreferences.updateInScope { it.copy(sortOtpDataReversed = reversed) } } + override fun onRequireAuthenticationChange(require: Boolean) { + userPreferences.updateInScope { it.copy(requireAuthentication = require) } + } + override fun onExitSettings() { navigateOnExit() } diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserPreferencesModel.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserPreferencesModel.kt index 711f8ef..54cfcb4 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserPreferencesModel.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserPreferencesModel.kt @@ -59,6 +59,7 @@ data class UserPreferencesModel( val sortOtpDataNullsFirst: Boolean = false, val sortOtpDataReversed: Boolean = false, val confirmOtpDataDelete: Boolean = true, + val requireAuthentication: Boolean = false, ) private val LightColors: ColorScheme = lightColorScheme( diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/LoadingAnimatedVisibility.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/LoadingAnimatedVisibility.kt new file mode 100644 index 0000000..bcb32d4 --- /dev/null +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/LoadingAnimatedVisibility.kt @@ -0,0 +1,59 @@ +package ml.dev.kotlin.openotp.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +internal fun LoadingAnimatedVisibility( + visibleContent: Boolean, + loadingVerticalArrangement: Arrangement.Vertical = Arrangement.Center, + loadingHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + precedingContent: @Composable (ColumnScope.() -> Unit)? = null, + centerContent: @Composable (ColumnScope.() -> Unit)? = { + CircularProgressIndicator(Modifier.padding(12.dp)) + }, + followingContent: @Composable (ColumnScope.() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.fillMaxSize(), + content = content, + ) + AnimatedVisibility( + modifier = Modifier.fillMaxSize(), + visible = !visibleContent, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + horizontalAlignment = loadingHorizontalAlignment, + verticalArrangement = loadingVerticalArrangement, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + if (precedingContent != null) { + precedingContent() + } + if (centerContent != null) { + centerContent() + } + if (followingContent != null) { + followingContent() + } + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/NamedSwitch.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/NamedSwitch.kt index fb6f199..5c68da5 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/NamedSwitch.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/component/NamedSwitch.kt @@ -15,12 +15,14 @@ internal fun NamedSwitch( checked: Boolean, onCheckedChange: (Boolean) -> Unit, icon: ImageVector? = null, + nameModifier: Modifier = Modifier.wrapContentWidth(), + contentModifier: Modifier = Modifier.wrapContentWidth(), ) { NamedBox( name = name, icon = icon, - nameModifier = Modifier.wrapContentWidth(), - contentModifier = Modifier.wrapContentWidth(), + nameModifier = nameModifier, + contentModifier = contentModifier, ) { Box( modifier = Modifier.fillMaxWidth(), diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OpenOtp.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OpenOtp.kt new file mode 100644 index 0000000..b55c8f9 --- /dev/null +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OpenOtp.kt @@ -0,0 +1,85 @@ +package ml.dev.kotlin.openotp.ui.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ml.dev.kotlin.openotp.ui.OtpIcons + +val OtpIcons.OpenOtp: ImageVector + get() { + if (_openOtp != null) { + return _openOtp!! + } + _openOtp = Builder( + name = "Icon", defaultWidth = 108.0.dp, defaultHeight = 108.0.dp, + viewportWidth = 24.0f, viewportHeight = 24.0f + ).apply { + group { + path( + fill = SolidColor(Color(0xFF466800)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero + ) { + moveTo(18.0f, 11.0f) + curveToRelative(0.7f, 0.0f, 1.37f, 0.1f, 2.0f, 0.29f) + verticalLineTo(10.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + horizontalLineToRelative(-1.0f) + verticalLineTo(6.0f) + curveToRelative(0.0f, -2.76f, -2.24f, -5.0f, -5.0f, -5.0f) + reflectiveCurveTo(7.0f, 3.24f, 7.0f, 6.0f) + verticalLineToRelative(2.0f) + horizontalLineTo(6.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(10.0f) + curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) + horizontalLineToRelative(6.26f) + curveTo(11.47f, 20.87f, 11.0f, 19.49f, 11.0f, 18.0f) + curveTo(11.0f, 14.13f, 14.13f, 11.0f, 18.0f, 11.0f) + close() + moveTo(8.9f, 6.0f) + curveToRelative(0.0f, -1.71f, 1.39f, -3.1f, 3.1f, -3.1f) + reflectiveCurveToRelative(3.1f, 1.39f, 3.1f, 3.1f) + verticalLineToRelative(2.0f) + horizontalLineTo(8.9f) + verticalLineTo(6.0f) + close() + } + path( + fill = SolidColor(Color(0xFF466800)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero + ) { + moveTo(18.0f, 13.0f) + curveToRelative(-2.76f, 0.0f, -5.0f, 2.24f, -5.0f, 5.0f) + reflectiveCurveToRelative(2.24f, 5.0f, 5.0f, 5.0f) + reflectiveCurveToRelative(5.0f, -2.24f, 5.0f, -5.0f) + reflectiveCurveTo(20.76f, 13.0f, 18.0f, 13.0f) + close() + moveTo(18.0f, 15.0f) + curveToRelative(0.83f, 0.0f, 1.5f, 0.67f, 1.5f, 1.5f) + reflectiveCurveTo(18.83f, 18.0f, 18.0f, 18.0f) + reflectiveCurveToRelative(-1.5f, -0.67f, -1.5f, -1.5f) + reflectiveCurveTo(17.17f, 15.0f, 18.0f, 15.0f) + close() + moveTo(18.0f, 21.0f) + curveToRelative(-1.03f, 0.0f, -1.94f, -0.52f, -2.48f, -1.32f) + curveTo(16.25f, 19.26f, 17.09f, 19.0f, 18.0f, 19.0f) + reflectiveCurveToRelative(1.75f, 0.26f, 2.48f, 0.68f) + curveTo(19.94f, 20.48f, 19.03f, 21.0f, 18.0f, 21.0f) + close() + } + } + } + .build() + return _openOtp!! + } + +private var _openOtp: ImageVector? = null diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AbstractScreen.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AbstractScreen.kt index 820b4d6..02861b4 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AbstractScreen.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AbstractScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Density import ml.dev.kotlin.openotp.ui.theme.rememberPlatformColors @Composable @@ -38,7 +39,9 @@ internal fun SystemBarsScreen( Box( modifier = Modifier .fillMaxSize() - .windowInsetsPadding(WindowInsets.systemBars), + .windowInsetsPadding(object : WindowInsets by WindowInsets.systemBars { + override fun getBottom(density: Density): Int = 0 + }), contentAlignment = contentAlignment, propagateMinConstraints = propagateMinConstraints, content = content, diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AuthenticationScreen.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AuthenticationScreen.kt new file mode 100644 index 0000000..e2680ba --- /dev/null +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/AuthenticationScreen.kt @@ -0,0 +1,60 @@ +package ml.dev.kotlin.openotp.ui.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import ml.dev.kotlin.openotp.shared.OpenOtpResources +import ml.dev.kotlin.openotp.ui.OtpIcons +import ml.dev.kotlin.openotp.ui.component.LoadingAnimatedVisibility +import ml.dev.kotlin.openotp.ui.icons.OpenOtp +import org.jetbrains.compose.resources.ExperimentalResourceApi + +@OptIn(ExperimentalResourceApi::class) +@Composable +internal fun AuthenticationScreen( + authenticated: Boolean, + onAuthenticate: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + LoadingAnimatedVisibility( + visibleContent = authenticated, + loadingVerticalArrangement = Arrangement.SpaceEvenly, + precedingContent = { + Image( + imageVector = OtpIcons.OpenOtp, + contentDescription = stringResource(OpenOtpResources.strings.app_icon), + modifier = Modifier.size(128.dp) + ) + }, + centerContent = null, + followingContent = { + Button( + onClick = onAuthenticate, + contentPadding = PaddingValues( + horizontal = 20.dp, + vertical = 12.dp, + ) + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = stringResource(OpenOtpResources.strings.locked_icon_name) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(OpenOtpResources.strings.authenticate_request), + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + content = content, + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/ScanQRCodeScreen.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/ScanQRCodeScreen.kt index 4064e1f..b4fc86b 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/ScanQRCodeScreen.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/ScanQRCodeScreen.kt @@ -1,10 +1,6 @@ package ml.dev.kotlin.openotp.ui.screen -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.QrCodeScanner @@ -28,6 +24,7 @@ import ml.dev.kotlin.openotp.component.ScanQRCodeComponent import ml.dev.kotlin.openotp.qr.QRCodeScanner import ml.dev.kotlin.openotp.shared.OpenOtpResources import ml.dev.kotlin.openotp.ui.component.ClickableIconButton +import ml.dev.kotlin.openotp.ui.component.LoadingAnimatedVisibility import ml.dev.kotlin.openotp.ui.theme.Typography @Composable @@ -41,16 +38,19 @@ internal fun ScanQRCodeScreen( Scaffold( modifier = Modifier.fillMaxSize(), ) { - QRCodeScanner( - onResult = scanQRCodeComponent::onQRCodeScanned, - isLoading = isLoading, - ) - QRCodeCameraHole(holePercent) - ScanQRCodeScreenDescription( - holePercent = holePercent, - onCancel = scanQRCodeComponent::onCancelClick, - ) - CoverErrorsLoadingAnimation(isLoading.value) + LoadingAnimatedVisibility( + visibleContent = !isLoading.value + ) { + QRCodeScanner( + onResult = scanQRCodeComponent::onQRCodeScanned, + isLoading = isLoading, + ) + QRCodeCameraHole(holePercent) + ScanQRCodeScreenDescription( + holePercent = holePercent, + onCancel = scanQRCodeComponent::onCancelClick, + ) + } } } } @@ -78,7 +78,7 @@ private fun ScanQRCodeScreenDescription( onCancel: () -> Unit, buttons: @Composable BoxWithConstraintsScope.() -> Unit = { CancelScanQRCodeButton(onCancel) - } + }, ) { BoxWithConstraints( modifier = Modifier.fillMaxSize() @@ -98,26 +98,6 @@ private fun ScanQRCodeScreenDescription( } } -@Composable -private fun CoverErrorsLoadingAnimation(isLoading: Boolean) { - AnimatedVisibility( - visible = isLoading, - enter = fadeIn(), - exit = fadeOut() - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - - CircularProgressIndicator() - } - } -} - @Composable private fun QRCodeCameraHole( holePercent: Float, diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/SettingsScreen.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/SettingsScreen.kt index 9719a3c..020ae81 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/screen/SettingsScreen.kt @@ -77,11 +77,29 @@ internal fun SettingsScreen( Spacer(Modifier.height(4.dp)) LookAndFeelSettingsGroup(component) CodesManagementSettingsGroup(component) + SecuritySettingsGroup(component) } } } } +@Composable +private fun SecuritySettingsGroup(component: SettingsComponent) { + if (!component.isAuthenticationAvailable) return + + SettingsGroup( + name = stringResource(OpenOtpResources.strings.security_group_name), + ) { + val requireAuthentication by component.requireAuthentication.subscribeAsState() + NamedSwitch( + name = stringResource(OpenOtpResources.strings.require_authentication), + checked = requireAuthentication, + onCheckedChange = component::onRequireAuthenticationChange, + nameModifier = Modifier.fillMaxWidth(0.7f) + ) + } +} + @Composable private fun CodesManagementSettingsGroup(component: SettingsComponent) { SettingsGroup( @@ -150,7 +168,7 @@ private fun LookAndFeelSettingsGroup(component: SettingsComponent) { @Composable private fun SettingsGroup( name: String, - content: @Composable ColumnScope.() -> Unit + content: @Composable ColumnScope.() -> Unit, ) { Column( modifier = Modifier diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt new file mode 100644 index 0000000..616bb9e --- /dev/null +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt @@ -0,0 +1,15 @@ +package ml.dev.kotlin.openotp.util + +import androidx.compose.runtime.Composable + +expect class BiometryAuthenticator { + suspend fun checkBiometryAuthentication( + requestTitle: String, + requestReason: String, + ): Boolean + + fun isBiometricAvailable(): Boolean +} + +@Composable +expect fun BindBiometryAuthenticatorEffect(biometryAuthenticator: BiometryAuthenticator) diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index 7e86e8c..51ad78c 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -6,8 +6,8 @@ Missing camera permissions Scan QR Code Copied "%s" to clipboard - Camera is not available\non simulator. Please try\nto run on a real iOS - device. + + Camera is not available\non simulator. Please try\nto run on a real iOS device. Are you sure you want to delete this item? Are you sure you want to delete\n%s? @@ -23,6 +23,11 @@ Account name Disabled You can manually reorder items with drag & drop gesture. + Require authentication before start + Authenticate + User authentication + Authenticate to get access to application codes. + App icon Settings @@ -35,6 +40,7 @@ Delete Question Select from multiple options + Locked Camera image Settings @@ -47,6 +53,7 @@ Advanced Settings Codes Management Look & Feel + Security Issuer Account Name diff --git a/shared/src/commonMain/resources/MR/pl/strings.xml b/shared/src/commonMain/resources/MR/pl/strings.xml index 82d4b8c..f0467cf 100644 --- a/shared/src/commonMain/resources/MR/pl/strings.xml +++ b/shared/src/commonMain/resources/MR/pl/strings.xml @@ -6,7 +6,9 @@ Brak uprawnień do używania kamery Zeskanuj kod QR Skopiowano "%s" do schowka - Aparat nie jest dostępny\nna symulatorze. Proszę\nspróbowaź uruchomić na\nfizycznym urządzeniu z iOS. + + Aparat nie jest dostępny\nna symulatorze. Proszę\nspróbowaź uruchomić na\nfizycznym urządzeniu z iOS. + Czy jesteś pewien, że chcesz usunąć ten element? Czy jesteś pewien, że chcesz usunąć\n%s? Zatwierdzaj usunięcie kodu @@ -20,7 +22,14 @@ Wystawcy Nazwie konta Wyłączone - Możliwość ręcznej zmiany kolejności kodów dzięki gestom przytrzymania i przesuwania. + + Możliwość ręcznej zmiany kolejności kodów dzięki gestom przytrzymania i przesuwania. + + Wymagaj uwierzytelnienia przed uruchomieniem + Uwierzytelnij + Uwierzytelnienie użytkownika + Uwierzytelnij, aby uzyskać dostęp do kodów aplikacji. + Ikona aplikacji Ustawienia @@ -33,6 +42,7 @@ Usuń Pytanie Wybierz z wielu opcji + Zablokowane Widok kamery Ustawienia @@ -45,6 +55,7 @@ Zaawansowane ustawienia Zarządzanie kodami Wygląd aplikacji + Bezpieczeństwo Wystawca Nazwa konta diff --git a/shared/src/desktopMain/kotlin/main.desktop.kt b/shared/src/desktopMain/kotlin/main.desktop.kt index caf75cc..07f1df6 100644 --- a/shared/src/desktopMain/kotlin/main.desktop.kt +++ b/shared/src/desktopMain/kotlin/main.desktop.kt @@ -1,4 +1,3 @@ -import androidx.compose.material3.SnackbarHostState import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.loadImageBitmap import androidx.compose.ui.res.useResource @@ -14,6 +13,7 @@ import ml.dev.kotlin.openotp.OpenOtpApp import ml.dev.kotlin.openotp.component.OpenOtpAppComponentContext import ml.dev.kotlin.openotp.component.OpenOtpAppComponentImpl import ml.dev.kotlin.openotp.initOpenOtpKoin +import ml.dev.kotlin.openotp.util.BiometryAuthenticator import ml.dev.kotlin.openotp.util.runOnUiThread import org.koin.compose.KoinContext import org.koin.dsl.module @@ -22,6 +22,7 @@ fun desktopOpenOtpApp() { initOpenOtpKoin { modules(module { single { OpenOtpAppComponentContext() } + single { BiometryAuthenticator() } }) } val lifecycle = LifecycleRegistry() diff --git a/shared/src/desktopMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt b/shared/src/desktopMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt new file mode 100644 index 0000000..9a91d5f --- /dev/null +++ b/shared/src/desktopMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt @@ -0,0 +1,15 @@ +package ml.dev.kotlin.openotp.util + +import androidx.compose.runtime.Composable + +actual class BiometryAuthenticator { + actual suspend fun checkBiometryAuthentication( + requestTitle: String, + requestReason: String, + ): Boolean = true + + actual fun isBiometricAvailable(): Boolean = false +} + +@Composable +actual fun BindBiometryAuthenticatorEffect(biometryAuthenticator: BiometryAuthenticator) = Unit diff --git a/shared/src/iosMain/kotlin/SwiftHelpers.kt b/shared/src/iosMain/kotlin/SwiftHelpers.kt index 4ac6e9a..7a59204 100644 --- a/shared/src/iosMain/kotlin/SwiftHelpers.kt +++ b/shared/src/iosMain/kotlin/SwiftHelpers.kt @@ -1,7 +1,8 @@ import ml.dev.kotlin.openotp.component.OpenOtpAppComponentContext import ml.dev.kotlin.openotp.initOpenOtpKoin -import ml.dev.kotlin.openotp.ui.theme.md_theme_light_background import ml.dev.kotlin.openotp.ui.theme.md_theme_dark_background +import ml.dev.kotlin.openotp.ui.theme.md_theme_light_background +import ml.dev.kotlin.openotp.util.BiometryAuthenticator import org.koin.dsl.module import platform.UIKit.UIColor @@ -9,6 +10,7 @@ fun initIOSKoin() { initOpenOtpKoin { modules(module { single { OpenOtpAppComponentContext() } + single { BiometryAuthenticator() } }) } } diff --git a/shared/src/iosMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt b/shared/src/iosMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt new file mode 100644 index 0000000..03b384a --- /dev/null +++ b/shared/src/iosMain/kotlin/ml/dev/kotlin/openotp/util/BiometryUtil.kt @@ -0,0 +1,90 @@ +package ml.dev.kotlin.openotp.util + +import androidx.compose.runtime.Composable +import kotlinx.cinterop.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Runnable +import platform.Foundation.NSError +import platform.Foundation.NSRunLoop +import platform.Foundation.NSThread +import platform.Foundation.performBlock +import platform.LocalAuthentication.LAContext +import platform.LocalAuthentication.LAPolicy +import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthenticationWithBiometrics +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +actual class BiometryAuthenticator { + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + actual suspend fun checkBiometryAuthentication( + requestTitle: String, + requestReason: String, + ): Boolean { + val laContext = LAContext() + + val (canEvaluate, error) = memScoped { + val error = alloc>() + val canEvaluate = runCatchingOrNull { + laContext.canEvaluatePolicy(POLICY, error = error.ptr) + } + canEvaluate to error.value + } + + if (error != null) throw error.toException() + if (canEvaluate == null) return false + + return callbackToCoroutine { callback -> + laContext.evaluatePolicy( + policy = POLICY, + localizedReason = requestReason, + reply = mainContinuation { result: Boolean, error: NSError? -> + callback(result, error) + } + ) + } + } + + @OptIn(ExperimentalForeignApi::class) + actual fun isBiometricAvailable(): Boolean { + return LAContext().canEvaluatePolicy(POLICY, error = null) + } +} + +@Composable +actual fun BindBiometryAuthenticatorEffect(biometryAuthenticator: BiometryAuthenticator) = Unit + +private inline fun mainContinuation( + noinline block: (T1, T2) -> Unit, +): (T1, T2) -> Unit = { arg1, arg2 -> + if (NSThread.isMainThread()) { + block.invoke(arg1, arg2) + } else { + MainRunDispatcher.run { + block.invoke(arg1, arg2) + } + } +} + +private object MainRunDispatcher : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) = + NSRunLoop.mainRunLoop.performBlock { block.run() } +} + +private suspend fun callbackToCoroutine(callbackCall: ((T?, NSError?) -> Unit) -> Unit): T = + suspendCoroutine { continuation -> + callbackCall { data, error -> + when { + data != null -> continuation.resume(data) + else -> continuation.resumeWithException(error.toException()) + } + } + } + +private fun NSError?.toException(): Exception = when { + this == null -> NullPointerException("NSError is null") + else -> Exception(this.description()) +} + +private val POLICY: LAPolicy = LAPolicyDeviceOwnerAuthenticationWithBiometrics