Skip to content

Commit

Permalink
implement authentication screen on Android and iOS
Browse files Browse the repository at this point in the history
  • Loading branch information
avan1235 committed Oct 8, 2023
1 parent 83ee538 commit 9d6341b
Show file tree
Hide file tree
Showing 25 changed files with 644 additions and 75 deletions.
2 changes: 1 addition & 1 deletion androidApp/distribution/whatsnew-en-GB
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand Down
1 change: 0 additions & 1 deletion iosApp/iosApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ struct ContentView: View {
backgroundColor
.edgesIgnoringSafeArea(.all)


ComposeView(component: component)
.ignoresSafeArea(.all)
}.ignoresSafeArea(.all)
Expand Down
12 changes: 7 additions & 5 deletions iosApp/iosApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@
</array>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>pl</string>
</array>
<key>NSCameraUsageDescription</key>
<string></string>
<string>en</string>
<string>pl</string>
</array>
<key>NSCameraUsageDescription</key>
<string></string>
<key>NSFaceIDUsageDescription</key>
<string></string>
</dict>
</plist>
3 changes: 1 addition & 2 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions shared/src/androidMain/kotlin/main.android.kt
Original file line number Diff line number Diff line change
@@ -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) }
}
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean>) -> 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)
}
}

49 changes: 30 additions & 19 deletions shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/OpenOtpApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<SnackbarHostState>()
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<BiometryAuthenticator>())
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<SnackbarHostState>()
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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,24 @@ 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

interface OpenOtpAppComponent {

val theme: Value<OpenOtpAppTheme>
val stack: Value<ChildStack<*, Child>>
val requireAuthentication: Value<Boolean>
val authenticated: Value<Boolean>

fun onAuthenticate()

fun onBackClicked(toIndex: Int)

Expand Down Expand Up @@ -44,8 +52,22 @@ class OpenOtpAppComponentImpl(

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

private val authenticator: BiometryAuthenticator = get()

override val theme: Value<OpenOtpAppTheme> = userPreferences.stateFlow.map { it.theme }.asValue()

override val requireAuthentication: Value<Boolean> =
userPreferences.stateFlow.map { it.requireAuthentication }.asValue()

private val _authenticated: MutableStateFlow<Boolean> = MutableStateFlow(!requireAuthentication.value)

override val authenticated: Value<Boolean> = 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(
Expand Down Expand Up @@ -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)
}
Expand Down
Loading

0 comments on commit 9d6341b

Please sign in to comment.