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