diff --git a/sdk/src/main/java/com/oursky/authgear/AuthgearException.kt b/sdk/src/main/java/com/oursky/authgear/AuthgearException.kt index 1b8ea6ae..a94d6d26 100644 --- a/sdk/src/main/java/com/oursky/authgear/AuthgearException.kt +++ b/sdk/src/main/java/com/oursky/authgear/AuthgearException.kt @@ -19,7 +19,7 @@ internal fun wrapException(e: Exception): Exception { // CancelException if (e is BiometricPromptAuthenticationException) { - if (e.errorCode == BiometricPrompt.ERROR_CANCELED || e.errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || e.errorCode == BiometricPrompt.ERROR_USER_CANCELED) { + if (isBiometricCancelError(e.errorCode)) { return CancelException(e) } } diff --git a/sdk/src/main/java/com/oursky/authgear/Biometric.kt b/sdk/src/main/java/com/oursky/authgear/Biometric.kt index 9371d71b..4931bfd5 100644 --- a/sdk/src/main/java/com/oursky/authgear/Biometric.kt +++ b/sdk/src/main/java/com/oursky/authgear/Biometric.kt @@ -174,3 +174,10 @@ internal fun buildPromptInfo( } return builder.build() } + +internal fun isBiometricCancelError(errorCode: Int): Boolean { + if (errorCode == BiometricPrompt.ERROR_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || errorCode == BiometricPrompt.ERROR_USER_CANCELED) { + return true + } + return false +} \ No newline at end of file diff --git a/sdk/src/main/java/com/oursky/authgear/latte/BiometricOptions.kt b/sdk/src/main/java/com/oursky/authgear/latte/BiometricOptions.kt new file mode 100644 index 00000000..3ad19a48 --- /dev/null +++ b/sdk/src/main/java/com/oursky/authgear/latte/BiometricOptions.kt @@ -0,0 +1,12 @@ +package com.oursky.authgear.latte + +import androidx.fragment.app.FragmentActivity + +data class BiometricOptions constructor( + var activity: FragmentActivity, + var title: String, + var subtitle: String? = null, + var description: String? = null, + var negativeButtonText: String? = null, + var allowedAuthenticators: Int = androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +) \ No newline at end of file diff --git a/sdk/src/main/java/com/oursky/authgear/latte/Capability.kt b/sdk/src/main/java/com/oursky/authgear/latte/Capability.kt new file mode 100644 index 00000000..2d67d154 --- /dev/null +++ b/sdk/src/main/java/com/oursky/authgear/latte/Capability.kt @@ -0,0 +1,5 @@ +package com.oursky.authgear.latte + +enum class Capability(val raw: String) { + BIOMETRIC("biometric") +} \ No newline at end of file diff --git a/sdk/src/main/java/com/oursky/authgear/latte/Latte.kt b/sdk/src/main/java/com/oursky/authgear/latte/Latte.kt index 05c18be5..f8daadf1 100644 --- a/sdk/src/main/java/com/oursky/authgear/latte/Latte.kt +++ b/sdk/src/main/java/com/oursky/authgear/latte/Latte.kt @@ -5,10 +5,15 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri +import android.os.Handler +import android.os.Looper +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt import androidx.fragment.app.Fragment import com.oursky.authgear.* import com.oursky.authgear.data.HttpClient import com.oursky.authgear.latte.fragment.LatteFragment +import com.oursky.authgear.latte.fragment.LatteFragmentListener import com.oursky.authgear.net.toQueryParameter import kotlinx.coroutines.* import kotlinx.coroutines.channels.BufferOverflow @@ -57,9 +62,26 @@ class Latte( } private suspend fun waitForResult(fragment: LatteFragment): LatteResult { + return waitForResult(fragment, null) { result, resumeWith -> + resumeWith(Result.success(result)) + } + } + + private suspend fun waitForResult( + fragment: LatteFragment, + listener: LatteFragmentListener?, + callback: (LatteResult, (Result) -> Unit) -> Unit + ): T { val application = authgear.core.application - val result: LatteResult = suspendCoroutine { k -> + val result: T = suspendCoroutine { k -> var isResumed = false + val resumeWith = fun(result: Result) { + if (isResumed) { + return + } + isResumed = true + k.resumeWith(result) + } val intentFilter = IntentFilter(fragment.latteID) val br = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -81,12 +103,18 @@ class Latte( if (isResumed) { return } - isResumed = true val resultStr = intent.getStringExtra(LatteFragment.INTENT_KEY_RESULT) ?: return val result = Json.decodeFromString(resultStr) - k.resumeWith(Result.success(result)) + try { + callback(result, resumeWith) + } catch (e: Throwable) { + resumeWith(Result.failure(e)) + } application.unregisterReceiver(this) } + LatteFragment.BroadcastType.REAUTH_WITH_BIOMETRIC.name -> { + listener?.onReauthWithBiometric(resumeWith) + } } } } @@ -121,7 +149,7 @@ class Latte( coroutineScope: CoroutineScope, options: ReauthenticateOptions ): Pair> { - val request = authgear.createReauthenticateRequest(makeAuthgearReauthenticateOptions(options)) + val request = authgear.createReauthenticateRequest(makeAuthgearReauthenticateOptions(context, options)) val fragment = LatteFragment.makeWithPreCreatedWebView( context = context, id = makeID(), @@ -131,10 +159,77 @@ class Latte( ) fragment.waitWebViewToLoad() + val listener = object : LatteFragmentListener { + override fun onReauthWithBiometric(resumeWith: (Result) -> Unit) { + val biometricOptions = options.biometricOptions ?: return + val builder = BiometricPrompt.PromptInfo.Builder() + .setTitle(biometricOptions.title) + .setAllowedAuthenticators(biometricOptions.allowedAuthenticators) + val subtitle = biometricOptions.subtitle + if (subtitle != null) { + builder.setSubtitle(subtitle) + } + val description = biometricOptions.description + if (description != null) { + builder.setSubtitle(description) + } + val negativeButtonText = biometricOptions.negativeButtonText + if (negativeButtonText != null) { + builder.setNegativeButtonText(negativeButtonText) + } + val promptInfo = builder.build() + val prompt = + BiometricPrompt( + biometricOptions.activity, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationFailed() { + // This callback will be invoked EVERY time the recognition failed. + // So while the prompt is still opened, this callback can be called repetitively. + // Finally, either onAuthenticationError or onAuthenticationSucceeded will be called. + // So this callback is not important to the developer. + } + + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) { + if (isBiometricCancelError(errorCode)) { + return + } + resumeWith( + Result.failure( + wrapException(BiometricPromptAuthenticationException( + errorCode + )) + ) + ) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + resumeWith(Result.success(true)) + } + }) + + val handler = Handler(Looper.getMainLooper()) + handler.post { + prompt.authenticate(promptInfo) + } + } + } + val d = coroutineScope.async { - val result = waitForResult(fragment) - authgear.finishAuthentication(result.getOrThrow().toString(), request) - true + val result = waitForResult(fragment, listener) { r, resumeWith -> + val uri = r.getOrThrow() + coroutineScope.launch { + try { + authgear.finishAuthentication(uri.toString(), request) + resumeWith(Result.success(true)) + } catch (e: Throwable) { + resumeWith(Result.failure(e)) + } + } + } + result } val handle = LatteHandle(fragment.latteID, d) @@ -391,8 +486,22 @@ class Latte( ) } - private suspend fun makeAuthgearReauthenticateOptions(latteOptions: ReauthenticateOptions): com.oursky.authgear.ReauthentcateOptions { - val finalXState = makeXStateWithSecrets(latteOptions.xState, latteOptions.xSecrets) + private suspend fun makeAuthgearReauthenticateOptions(context: Context, latteOptions: ReauthenticateOptions): com.oursky.authgear.ReauthentcateOptions { + val reauthXState = HashMap(latteOptions.xState) + reauthXState["user_initiate"] = "reauth" + val capabilities = mutableListOf() + + val biometricOptions = latteOptions.biometricOptions + if (biometricOptions != null) { + val result = BiometricManager.from(context).canAuthenticate(biometricOptions.allowedAuthenticators) + if (result == BiometricManager.BIOMETRIC_SUCCESS) { + capabilities.add(Capability.BIOMETRIC) + } + } + + reauthXState["capabilities"] = capabilities.joinToString(separator = ",") { it.raw } + + val finalXState = makeXStateWithSecrets(reauthXState, latteOptions.xSecrets) return ReauthentcateOptions( xState = finalXState.toQueryParameter(), diff --git a/sdk/src/main/java/com/oursky/authgear/latte/ReauthenticateOptions.kt b/sdk/src/main/java/com/oursky/authgear/latte/ReauthenticateOptions.kt index 4270c778..03271991 100644 --- a/sdk/src/main/java/com/oursky/authgear/latte/ReauthenticateOptions.kt +++ b/sdk/src/main/java/com/oursky/authgear/latte/ReauthenticateOptions.kt @@ -3,11 +3,6 @@ package com.oursky.authgear.latte data class ReauthenticateOptions @JvmOverloads constructor( var xSecrets: Map = mapOf(), var xState: Map = mapOf(), - var uiLocales: List? = null -) { - init { - val newXState = HashMap(xState) - newXState["user_initiate"] = "reauth" - xState = newXState - } -} + var uiLocales: List? = null, + var biometricOptions: BiometricOptions? = null +) \ No newline at end of file diff --git a/sdk/src/main/java/com/oursky/authgear/latte/WebViewEvent.kt b/sdk/src/main/java/com/oursky/authgear/latte/WebViewEvent.kt index 1a516b68..7e4e8084 100644 --- a/sdk/src/main/java/com/oursky/authgear/latte/WebViewEvent.kt +++ b/sdk/src/main/java/com/oursky/authgear/latte/WebViewEvent.kt @@ -3,5 +3,6 @@ package com.oursky.authgear.latte internal sealed class WebViewEvent { object OpenEmailClient : WebViewEvent() object OpenSMSClient : WebViewEvent() + object ReauthWithBiometric : WebViewEvent() data class Tracking(val event: LatteTrackingEvent) : WebViewEvent() } diff --git a/sdk/src/main/java/com/oursky/authgear/latte/WebViewJSInterface.kt b/sdk/src/main/java/com/oursky/authgear/latte/WebViewJSInterface.kt index 658dcb28..daf37555 100644 --- a/sdk/src/main/java/com/oursky/authgear/latte/WebViewJSInterface.kt +++ b/sdk/src/main/java/com/oursky/authgear/latte/WebViewJSInterface.kt @@ -22,7 +22,8 @@ internal class WebViewJSInterface(private val webView: WebView) { OPEN_EMAIL_CLIENT("openEmailClient"), OPEN_SMS_CLIENT("openSMSClient"), TRACKING("tracking"), - READY("ready") + READY("ready"), + REAUTH_WITH_BIOMETRIC("reauthWithBiometric") } @JavascriptInterface @@ -49,6 +50,9 @@ internal class WebViewJSInterface(private val webView: WebView) { BuiltInEvent.READY -> { this.webView.listener?.onReady(this.webView) } + BuiltInEvent.REAUTH_WITH_BIOMETRIC -> { + this.webView.listener?.onEvent(WebViewEvent.ReauthWithBiometric) + } } } } \ No newline at end of file diff --git a/sdk/src/main/java/com/oursky/authgear/latte/fragment/LatteFragment.kt b/sdk/src/main/java/com/oursky/authgear/latte/fragment/LatteFragment.kt index 5751c496..bec1aeb9 100644 --- a/sdk/src/main/java/com/oursky/authgear/latte/fragment/LatteFragment.kt +++ b/sdk/src/main/java/com/oursky/authgear/latte/fragment/LatteFragment.kt @@ -59,7 +59,8 @@ internal class LatteFragment() : Fragment() { COMPLETE, OPEN_EMAIL_CLIENT, OPEN_SMS_CLIENT, - TRACKING + TRACKING, + REAUTH_WITH_BIOMETRIC } val latteID: String @@ -94,6 +95,9 @@ internal class LatteFragment() : Fragment() { is WebViewEvent.Tracking -> { fragment.broadcastTrackingIntent(event.event) } + is WebViewEvent.ReauthWithBiometric -> { + fragment.broadcastOnReauthWithBiometricIntent() + } } } @@ -162,6 +166,13 @@ internal class LatteFragment() : Fragment() { ctx.sendOrderedBroadcast(broadcastIntent, null) } + private fun broadcastOnReauthWithBiometricIntent() { + val ctx = context ?: return + val broadcastIntent = Intent(latteID) + broadcastIntent.putExtra(INTENT_KEY_TYPE, BroadcastType.REAUTH_WITH_BIOMETRIC.toString()) + ctx.sendOrderedBroadcast(broadcastIntent, null) + } + private fun broadcastTrackingIntent(event: LatteTrackingEvent) { val ctx = context ?: return val broadcastIntent = Intent(latteID) diff --git a/sdk/src/main/java/com/oursky/authgear/latte/fragment/LatteFragmentListener.kt b/sdk/src/main/java/com/oursky/authgear/latte/fragment/LatteFragmentListener.kt new file mode 100644 index 00000000..267df0f9 --- /dev/null +++ b/sdk/src/main/java/com/oursky/authgear/latte/fragment/LatteFragmentListener.kt @@ -0,0 +1,5 @@ +package com.oursky.authgear.latte.fragment + +interface LatteFragmentListener { + fun onReauthWithBiometric(resumeWith: (Result) -> Unit) {} +} \ No newline at end of file