Skip to content

Commit 9d6341b

Browse files
committed
implement authentication screen on Android and iOS
1 parent 83ee538 commit 9d6341b

File tree

25 files changed

+644
-75
lines changed

25 files changed

+644
-75
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
This version brings new features to app:
2-
- manually reorder items with drag & drop gesture when sorting is disabled
2+
- require user authentication if accessible

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ android-minSdk = "26"
88
android-targetSdk = "34"
99
androidx-activity-compose = "1.8.0-rc01"
1010
androidx-appcompat-appcompat = "1.6.1"
11+
androidx-biometric = "1.2.0-alpha05"
1112
androidx-core-ktx = "1.12.0"
1213
# @pin
1314
androidx-crypto = "1.1.0-alpha05"
@@ -46,6 +47,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss
4647
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
4748
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
4849
androidx-appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat-appcompat" }
50+
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidx-biometric" }
4951
androidx-camera = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" }
5052
androidx-cameraLifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" }
5153
androidx-cameraPreview = { module = "androidx.camera:camera-view", version.ref = "cameraX" }

iosApp/iosApp/ContentView.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ struct ContentView: View {
2929
backgroundColor
3030
.edgesIgnoringSafeArea(.all)
3131

32-
3332
ComposeView(component: component)
3433
.ignoresSafeArea(.all)
3534
}.ignoresSafeArea(.all)

iosApp/iosApp/Info.plist

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@
4848
</array>
4949
<key>CFBundleLocalizations</key>
5050
<array>
51-
<string>en</string>
52-
<string>pl</string>
53-
</array>
54-
<key>NSCameraUsageDescription</key>
55-
<string></string>
51+
<string>en</string>
52+
<string>pl</string>
53+
</array>
54+
<key>NSCameraUsageDescription</key>
55+
<string></string>
56+
<key>NSFaceIDUsageDescription</key>
57+
<string></string>
5658
</dict>
5759
</plist>

shared/build.gradle.kts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,6 @@ kotlin {
8686

8787
api(libs.moko.resoures)
8888
api(libs.moko.resoures.compose)
89-
90-
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.2")
9189
}
9290
}
9391
val commonTest by getting {
@@ -106,6 +104,7 @@ kotlin {
106104
implementation(libs.androidx.camera)
107105
implementation(libs.androidx.cameraLifecycle)
108106
implementation(libs.androidx.cameraPreview)
107+
implementation(libs.androidx.biometric)
109108

110109
implementation(libs.mlkit.barcodeScanning)
111110
implementation(libs.androidx.security.crypto)

shared/src/androidMain/kotlin/main.android.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import androidx.activity.ComponentActivity
22
import androidx.activity.compose.setContent
3-
import androidx.compose.material3.SnackbarHostState
43
import com.arkivanov.decompose.defaultComponentContext
54
import ml.dev.kotlin.openotp.OpenOtpApp
65
import ml.dev.kotlin.openotp.component.OpenOtpAppComponentContext
76
import ml.dev.kotlin.openotp.component.OpenOtpAppComponentImpl
87
import ml.dev.kotlin.openotp.initOpenOtpKoin
8+
import ml.dev.kotlin.openotp.util.BiometryAuthenticator
99
import org.koin.compose.KoinContext
1010
import org.koin.dsl.module
1111

1212
fun ComponentActivity.androidOpenOtpApp() {
13+
val activity = this@androidOpenOtpApp
1314
initOpenOtpKoin {
1415
modules(
1516
module {
16-
single { OpenOtpAppComponentContext(this@androidOpenOtpApp) }
17+
single { OpenOtpAppComponentContext(activity) }
18+
single { BiometryAuthenticator(activity.applicationContext) }
1719
}
1820
)
1921
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package ml.dev.kotlin.openotp.util
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import androidx.biometric.BiometricManager
6+
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
7+
import androidx.biometric.BiometricPrompt
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.LaunchedEffect
10+
import androidx.compose.ui.platform.LocalContext
11+
import androidx.compose.ui.platform.LocalLifecycleOwner
12+
import androidx.core.content.ContextCompat
13+
import androidx.fragment.app.Fragment
14+
import androidx.fragment.app.FragmentActivity
15+
import androidx.fragment.app.FragmentManager
16+
import androidx.lifecycle.Lifecycle
17+
import androidx.lifecycle.LifecycleObserver
18+
import androidx.lifecycle.LifecycleOwner
19+
import androidx.lifecycle.OnLifecycleEvent
20+
import java.util.concurrent.Executor
21+
import kotlin.coroutines.suspendCoroutine
22+
23+
actual class BiometryAuthenticator(
24+
private val applicationContext: Context,
25+
) {
26+
private var fragmentManager: FragmentManager? = null
27+
28+
fun bind(lifecycle: Lifecycle, fragmentManager: FragmentManager) {
29+
this.fragmentManager = fragmentManager
30+
31+
val observer = object : LifecycleObserver {
32+
33+
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
34+
fun onDestroyed(source: LifecycleOwner) {
35+
this@BiometryAuthenticator.fragmentManager = null
36+
source.lifecycle.removeObserver(this)
37+
}
38+
}
39+
lifecycle.addObserver(observer)
40+
}
41+
42+
actual suspend fun checkBiometryAuthentication(
43+
requestTitle: String,
44+
requestReason: String,
45+
): Boolean {
46+
val resolverFragment: ResolverFragment = getResolverFragment()
47+
48+
return suspendCoroutine { continuation ->
49+
var resumed = false
50+
resolverFragment.showBiometricPrompt(
51+
requestTitle = requestTitle,
52+
requestReason = requestReason,
53+
) {
54+
if (!resumed) {
55+
continuation.resumeWith(it)
56+
resumed = true
57+
}
58+
}
59+
}
60+
}
61+
62+
actual fun isBiometricAvailable(): Boolean {
63+
val manager: BiometricManager = BiometricManager.from(applicationContext)
64+
return manager.canAuthenticate(BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
65+
}
66+
67+
private fun getResolverFragment(): ResolverFragment {
68+
val fragmentManager = fragmentManager
69+
?: error("can't check biometry without active window")
70+
71+
val currentFragment = fragmentManager
72+
.findFragmentByTag(BIOMETRY_RESOLVER_FRAGMENT_TAG)
73+
74+
return if (currentFragment != null) {
75+
currentFragment as ResolverFragment
76+
} else {
77+
ResolverFragment().apply {
78+
fragmentManager
79+
.beginTransaction()
80+
.add(this, BIOMETRY_RESOLVER_FRAGMENT_TAG)
81+
.commitNow()
82+
}
83+
}
84+
}
85+
86+
class ResolverFragment : Fragment() {
87+
private lateinit var executor: Executor
88+
private lateinit var biometricPrompt: BiometricPrompt
89+
private lateinit var promptInfo: BiometricPrompt.PromptInfo
90+
91+
init {
92+
retainInstance = true
93+
}
94+
95+
fun showBiometricPrompt(
96+
requestTitle: String,
97+
requestReason: String,
98+
callback: (Result<Boolean>) -> Unit,
99+
) {
100+
val context = requireContext()
101+
102+
executor = ContextCompat.getMainExecutor(context)
103+
104+
biometricPrompt = BiometricPrompt(this, executor,
105+
object : BiometricPrompt.AuthenticationCallback() {
106+
@SuppressLint("RestrictedApi")
107+
override fun onAuthenticationError(
108+
errorCode: Int,
109+
errString: CharSequence,
110+
) {
111+
super.onAuthenticationError(errorCode, errString)
112+
if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
113+
errorCode == BiometricPrompt.ERROR_USER_CANCELED
114+
) {
115+
callback.invoke(Result.success(false))
116+
} else {
117+
callback.invoke(Result.failure(Exception(errString.toString())))
118+
}
119+
}
120+
121+
override fun onAuthenticationSucceeded(
122+
result: BiometricPrompt.AuthenticationResult,
123+
) {
124+
super.onAuthenticationSucceeded(result)
125+
callback.invoke(Result.success(true))
126+
}
127+
}
128+
)
129+
130+
promptInfo = BiometricPrompt.PromptInfo.Builder()
131+
.setTitle(requestTitle)
132+
.setSubtitle(requestReason)
133+
.setDeviceCredentialAllowed(true)
134+
.build()
135+
136+
biometricPrompt.authenticate(promptInfo)
137+
}
138+
}
139+
140+
companion object {
141+
private const val BIOMETRY_RESOLVER_FRAGMENT_TAG = "BiometryControllerResolver"
142+
}
143+
}
144+
145+
@Composable
146+
actual fun BindBiometryAuthenticatorEffect(biometryAuthenticator: BiometryAuthenticator) {
147+
val lifecycleOwner = LocalLifecycleOwner.current
148+
val context = LocalContext.current
149+
150+
LaunchedEffect(biometryAuthenticator, lifecycleOwner, context) {
151+
val fragmentManager = (context as FragmentActivity).supportFragmentManager
152+
biometryAuthenticator.bind(lifecycleOwner.lifecycle, fragmentManager)
153+
}
154+
}
155+

shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/OpenOtpApp.kt

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@ import androidx.compose.material3.SnackbarHost
66
import androidx.compose.material3.SnackbarHostState
77
import androidx.compose.material3.Surface
88
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.getValue
910
import androidx.compose.ui.Modifier
1011
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children
1112
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide
1213
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation
14+
import com.arkivanov.decompose.extensions.compose.jetbrains.subscribeAsState
1315
import kotlinx.serialization.builtins.ListSerializer
1416
import ml.dev.kotlin.openotp.component.OpenOtpAppComponent
1517
import ml.dev.kotlin.openotp.component.OpenOtpAppComponent.Child
1618
import ml.dev.kotlin.openotp.component.UserPreferencesModel
1719
import ml.dev.kotlin.openotp.otp.OtpData
18-
import ml.dev.kotlin.openotp.ui.screen.AddProviderScreen
19-
import ml.dev.kotlin.openotp.ui.screen.MainScreen
20-
import ml.dev.kotlin.openotp.ui.screen.ScanQRCodeScreen
21-
import ml.dev.kotlin.openotp.ui.screen.SettingsScreen
20+
import ml.dev.kotlin.openotp.ui.screen.*
2221
import ml.dev.kotlin.openotp.ui.theme.OpenOtpTheme
22+
import ml.dev.kotlin.openotp.util.BindBiometryAuthenticatorEffect
23+
import ml.dev.kotlin.openotp.util.BiometryAuthenticator
24+
import ml.dev.kotlin.openotp.util.OnceLaunchedEffect
2325
import ml.dev.kotlin.openotp.util.StateFlowSettings
2426
import org.koin.compose.koinInject
2527
import org.koin.core.context.startKoin
@@ -35,21 +37,30 @@ internal fun OpenOtpApp(component: OpenOtpAppComponent) {
3537
Surface(
3638
modifier = Modifier.fillMaxSize(),
3739
) {
38-
Children(
39-
stack = component.stack,
40-
modifier = Modifier.fillMaxSize(),
41-
animation = stackAnimation(slide())
42-
) { child ->
43-
val snackbarHostState = koinInject<SnackbarHostState>()
44-
Scaffold(
45-
snackbarHost = { SnackbarHost(snackbarHostState) },
46-
modifier = Modifier.fillMaxSize()
47-
) {
48-
when (val instance = child.instance) {
49-
is Child.Main -> MainScreen(instance.component)
50-
is Child.ScanQRCode -> ScanQRCodeScreen(instance.component)
51-
is Child.AddProvider -> AddProviderScreen(instance.totpComponent, instance.hotpComponent)
52-
is Child.Settings -> SettingsScreen(instance.component)
40+
BindBiometryAuthenticatorEffect(koinInject<BiometryAuthenticator>())
41+
OnceLaunchedEffect { component.onAuthenticate() }
42+
43+
val authenticated by component.authenticated.subscribeAsState()
44+
AuthenticationScreen(
45+
authenticated = authenticated,
46+
onAuthenticate = component::onAuthenticate,
47+
) {
48+
Children(
49+
stack = component.stack,
50+
modifier = Modifier.fillMaxSize(),
51+
animation = stackAnimation(slide())
52+
) { child ->
53+
val snackbarHostState = koinInject<SnackbarHostState>()
54+
Scaffold(
55+
snackbarHost = { SnackbarHost(snackbarHostState) },
56+
modifier = Modifier.fillMaxSize()
57+
) {
58+
when (val instance = child.instance) {
59+
is Child.Main -> MainScreen(instance.component)
60+
is Child.ScanQRCode -> ScanQRCodeScreen(instance.component)
61+
is Child.AddProvider -> AddProviderScreen(instance.totpComponent, instance.hotpComponent)
62+
is Child.Settings -> SettingsScreen(instance.component)
63+
}
5364
}
5465
}
5566
}

shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/OpenOtpAppComponent.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,24 @@ package ml.dev.kotlin.openotp.component
33
import com.arkivanov.decompose.ComponentContext
44
import com.arkivanov.decompose.router.stack.*
55
import com.arkivanov.decompose.value.Value
6+
import kotlinx.coroutines.flow.MutableStateFlow
7+
import kotlinx.coroutines.launch
68
import kotlinx.serialization.Serializable
79
import ml.dev.kotlin.openotp.USER_PREFERENCES_MODULE_QUALIFIER
810
import ml.dev.kotlin.openotp.component.OpenOtpAppComponent.Child
11+
import ml.dev.kotlin.openotp.shared.OpenOtpResources
12+
import ml.dev.kotlin.openotp.util.BiometryAuthenticator
913
import ml.dev.kotlin.openotp.util.StateFlowSettings
1014
import org.koin.core.component.get
1115

1216
interface OpenOtpAppComponent {
1317

1418
val theme: Value<OpenOtpAppTheme>
1519
val stack: Value<ChildStack<*, Child>>
20+
val requireAuthentication: Value<Boolean>
21+
val authenticated: Value<Boolean>
22+
23+
fun onAuthenticate()
1624

1725
fun onBackClicked(toIndex: Int)
1826

@@ -44,8 +52,22 @@ class OpenOtpAppComponentImpl(
4452

4553
private val userPreferences: StateFlowSettings<UserPreferencesModel> = get(USER_PREFERENCES_MODULE_QUALIFIER)
4654

55+
private val authenticator: BiometryAuthenticator = get()
56+
4757
override val theme: Value<OpenOtpAppTheme> = userPreferences.stateFlow.map { it.theme }.asValue()
4858

59+
override val requireAuthentication: Value<Boolean> =
60+
userPreferences.stateFlow.map { it.requireAuthentication }.asValue()
61+
62+
private val _authenticated: MutableStateFlow<Boolean> = MutableStateFlow(!requireAuthentication.value)
63+
64+
override val authenticated: Value<Boolean> = combine(
65+
_authenticated,
66+
userPreferences.stateFlow.map { it.requireAuthentication },
67+
) { authenticated, require ->
68+
!require || authenticated
69+
}.asValue()
70+
4971
private fun child(config: Config, childComponentContext: ComponentContext): Child = when (config) {
5072
is Config.Main -> Child.Main(
5173
MainComponentImpl(
@@ -86,6 +108,17 @@ class OpenOtpAppComponentImpl(
86108
)
87109
}
88110

111+
override fun onAuthenticate() {
112+
if (!requireAuthentication.value) return
113+
114+
scope.launch {
115+
_authenticated.value = authenticator.checkBiometryAuthentication(
116+
requestTitle = stringResource(OpenOtpResources.strings.authenticate_request_title),
117+
requestReason = stringResource(OpenOtpResources.strings.authenticate_request_description)
118+
)
119+
}
120+
}
121+
89122
override fun onBackClicked(toIndex: Int) {
90123
navigation.popTo(index = toIndex)
91124
}

0 commit comments

Comments
 (0)