From a0a957d1b6c55c438c96241027ef0195bd69845f Mon Sep 17 00:00:00 2001 From: Lucas Nogueira Date: Tue, 10 Oct 2023 16:08:46 -0300 Subject: [PATCH] android --- .../src-tauri/gen/android/.idea/.gitignore | 3 + .../src-tauri/gen/android/.idea/compiler.xml | 6 + .../src-tauri/gen/android/.idea/gradle.xml | 26 ++ .../gen/android/.idea/jarRepositories.xml | 25 ++ .../src-tauri/gen/android/.idea/kotlinc.xml | 6 + .../api/src-tauri/gen/android/.idea/misc.xml | 9 + .../api/src-tauri/gen/android/.idea/vcs.xml | 6 + plugins/biometric/android/build.gradle.kts | 2 +- .../android/src/main/AndroidManifest.xml | 6 + .../src/main/java/BiometricActivity.kt | 122 +++++++++ .../android/src/main/java/BiometricPlugin.kt | 237 +++++++++++++++++- .../src/main/res/layout/auth_activity.xml | 9 + .../android/src/main/res/values/styles.xml | 10 + plugins/biometric/guest-js/index.ts | 13 +- plugins/biometric/src/models.rs | 10 +- 15 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 examples/api/src-tauri/gen/android/.idea/.gitignore create mode 100644 examples/api/src-tauri/gen/android/.idea/compiler.xml create mode 100644 examples/api/src-tauri/gen/android/.idea/gradle.xml create mode 100644 examples/api/src-tauri/gen/android/.idea/jarRepositories.xml create mode 100644 examples/api/src-tauri/gen/android/.idea/kotlinc.xml create mode 100644 examples/api/src-tauri/gen/android/.idea/misc.xml create mode 100644 examples/api/src-tauri/gen/android/.idea/vcs.xml create mode 100644 plugins/biometric/android/src/main/java/BiometricActivity.kt create mode 100644 plugins/biometric/android/src/main/res/layout/auth_activity.xml create mode 100644 plugins/biometric/android/src/main/res/values/styles.xml diff --git a/examples/api/src-tauri/gen/android/.idea/.gitignore b/examples/api/src-tauri/gen/android/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/examples/api/src-tauri/gen/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/examples/api/src-tauri/gen/android/.idea/compiler.xml b/examples/api/src-tauri/gen/android/.idea/compiler.xml new file mode 100644 index 00000000..b589d56e --- /dev/null +++ b/examples/api/src-tauri/gen/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/.idea/gradle.xml b/examples/api/src-tauri/gen/android/.idea/gradle.xml new file mode 100644 index 00000000..35d2b200 --- /dev/null +++ b/examples/api/src-tauri/gen/android/.idea/gradle.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/.idea/jarRepositories.xml b/examples/api/src-tauri/gen/android/.idea/jarRepositories.xml new file mode 100644 index 00000000..d2ce72d1 --- /dev/null +++ b/examples/api/src-tauri/gen/android/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/.idea/kotlinc.xml b/examples/api/src-tauri/gen/android/.idea/kotlinc.xml new file mode 100644 index 00000000..0fc31131 --- /dev/null +++ b/examples/api/src-tauri/gen/android/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/.idea/misc.xml b/examples/api/src-tauri/gen/android/.idea/misc.xml new file mode 100644 index 00000000..773fe0fb --- /dev/null +++ b/examples/api/src-tauri/gen/android/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/examples/api/src-tauri/gen/android/.idea/vcs.xml b/examples/api/src-tauri/gen/android/.idea/vcs.xml new file mode 100644 index 00000000..bc599707 --- /dev/null +++ b/examples/api/src-tauri/gen/android/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/plugins/biometric/android/build.gradle.kts b/plugins/biometric/android/build.gradle.kts index 2375f755..81d4f70e 100644 --- a/plugins/biometric/android/build.gradle.kts +++ b/plugins/biometric/android/build.gradle.kts @@ -34,7 +34,7 @@ android { } dependencies { - + implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.6.0") implementation("com.google.android.material:material:1.7.0") diff --git a/plugins/biometric/android/src/main/AndroidManifest.xml b/plugins/biometric/android/src/main/AndroidManifest.xml index 9a40236b..90328cb7 100644 --- a/plugins/biometric/android/src/main/AndroidManifest.xml +++ b/plugins/biometric/android/src/main/AndroidManifest.xml @@ -1,3 +1,9 @@ + + + diff --git a/plugins/biometric/android/src/main/java/BiometricActivity.kt b/plugins/biometric/android/src/main/java/BiometricActivity.kt new file mode 100644 index 00000000..60a59a92 --- /dev/null +++ b/plugins/biometric/android/src/main/java/BiometricActivity.kt @@ -0,0 +1,122 @@ +package app.tauri.biometric + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.content.Intent +import android.hardware.biometrics.BiometricManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricPrompt +import java.util.concurrent.Executor + +class BiometricActivity : AppCompatActivity() { + @SuppressLint("WrongConstant") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.auth_activity) + + val executor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + this.mainExecutor + } else { + Executor { command: Runnable? -> + Handler(this.mainLooper).post( + command!! + ) + } + } + + val builder = BiometricPrompt.PromptInfo.Builder() + val intent = intent + var title = intent.getStringExtra(BiometricPlugin.TITLE) + val subtitle = intent.getStringExtra(BiometricPlugin.SUBTITLE) + val description = intent.getStringExtra(BiometricPlugin.REASON) + allowDeviceCredential = false + // Android docs say we should check if the device is secure before enabling device credential fallback + val manager = getSystemService( + Context.KEYGUARD_SERVICE + ) as KeyguardManager + if (manager.isDeviceSecure) { + allowDeviceCredential = + intent.getBooleanExtra(BiometricPlugin.DEVICE_CREDENTIAL, false) + } + + if (title.isNullOrEmpty()) { + title = "Authenticate" + } + + builder.setTitle(title).setSubtitle(subtitle).setDescription(description) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + var authenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK + if (allowDeviceCredential) { + authenticators = authenticators or BiometricManager.Authenticators.DEVICE_CREDENTIAL + } + builder.setAllowedAuthenticators(authenticators) + } else { + builder.setDeviceCredentialAllowed(allowDeviceCredential) + } + + // Android docs say that negative button text should not be set if device credential is allowed + if (!allowDeviceCredential) { + val negativeButtonText = intent.getStringExtra(BiometricPlugin.CANCEL_TITLE) + builder.setNegativeButtonText( + if (negativeButtonText.isNullOrEmpty()) "Cancel" else negativeButtonText + ) + } + builder.setConfirmationRequired( + intent.getBooleanExtra(BiometricPlugin.CONFIRMATION_REQUIRED, true) + ) + val promptInfo = builder.build() + val prompt = BiometricPrompt( + this, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errorMessage: CharSequence + ) { + super.onAuthenticationError(errorCode, errorMessage) + finishActivity( + BiometryResultType.ERROR, + errorCode, + errorMessage as String + ) + } + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + finishActivity() + } + } + ) + prompt.authenticate(promptInfo) + } + + @JvmOverloads + fun finishActivity( + resultType: BiometryResultType = BiometryResultType.SUCCESS, + errorCode: Int = 0, + errorMessage: String? = "" + ) { + val intent = Intent() + val prefix = BiometricPlugin.RESULT_EXTRA_PREFIX + intent + .putExtra(prefix + BiometricPlugin.RESULT_TYPE, resultType.toString()) + .putExtra(prefix + BiometricPlugin.RESULT_ERROR_CODE, errorCode) + .putExtra( + prefix + BiometricPlugin.RESULT_ERROR_MESSAGE, + errorMessage + ) + setResult(Activity.RESULT_OK, intent) + finish() + } + + companion object { + var allowDeviceCredential = false + } +} \ No newline at end of file diff --git a/plugins/biometric/android/src/main/java/BiometricPlugin.kt b/plugins/biometric/android/src/main/java/BiometricPlugin.kt index 859ddcd8..941ef7a3 100644 --- a/plugins/biometric/android/src/main/java/BiometricPlugin.kt +++ b/plugins/biometric/android/src/main/java/BiometricPlugin.kt @@ -5,19 +5,248 @@ package app.tauri.biometric import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.webkit.WebView +import androidx.activity.result.ActivityResult +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import app.tauri.annotation.ActivityCallback import app.tauri.annotation.Command import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin -import app.tauri.plugin.Invoke +import java.util.EnumMap +import java.util.HashMap +import kotlin.math.max + +enum class BiometryResultType { + SUCCESS, FAILURE, ERROR +} + +private const val MAX_ATTEMPTS = "androidMaxAttempts" +private const val DEFAULT_MAX_ATTEMPTS = 3 +private const val BIOMETRIC_FAILURE = "authenticationFailed" +private const val INVALID_CONTEXT_ERROR = "invalidContext" @TauriPlugin class BiometricPlugin(private val activity: Activity): Plugin(activity) { + private var biometryTypes: ArrayList = arrayListOf() + + companion object { + var RESULT_EXTRA_PREFIX = "" + const val TITLE = "title" + const val SUBTITLE = "subtitle" + const val REASON = "reason" + const val CANCEL_TITLE = "cancelTitle" + const val RESULT_TYPE = "type" + const val RESULT_ERROR_CODE = "errorCode" + const val RESULT_ERROR_MESSAGE = "errorMessage" + const val DEVICE_CREDENTIAL = "allowDeviceCredential" + const val CONFIRMATION_REQUIRED = "confirmationRequired" + + // Maps biometry error numbers to string error codes + private var biometryErrorCodeMap: MutableMap = HashMap() + private var biometryNameMap: MutableMap = EnumMap(BiometryType::class.java) + + init { + biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = "" + biometryErrorCodeMap[BiometricManager.BIOMETRIC_SUCCESS] = "" + biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED] = "systemCancel" + biometryErrorCodeMap[BiometricPrompt.ERROR_HW_NOT_PRESENT] = "biometryNotAvailable" + biometryErrorCodeMap[BiometricPrompt.ERROR_HW_UNAVAILABLE] = "biometryNotAvailable" + biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT] = "biometryLockout" + biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT_PERMANENT] = "biometryLockout" + biometryErrorCodeMap[BiometricPrompt.ERROR_NEGATIVE_BUTTON] = "userCancel" + biometryErrorCodeMap.put( + BiometricPrompt.ERROR_NO_BIOMETRICS, + "biometryNotEnrolled" + ) + biometryErrorCodeMap[BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL] = "noDeviceCredential" + biometryErrorCodeMap[BiometricPrompt.ERROR_NO_SPACE] = "systemCancel" + biometryErrorCodeMap[BiometricPrompt.ERROR_TIMEOUT] = "systemCancel" + biometryErrorCodeMap[BiometricPrompt.ERROR_UNABLE_TO_PROCESS] = "systemCancel" + biometryErrorCodeMap[BiometricPrompt.ERROR_USER_CANCELED] = "userCancel" + biometryErrorCodeMap[BiometricPrompt.ERROR_VENDOR] = "systemCancel" + + biometryNameMap[BiometryType.NONE] = "No Authentication" + biometryNameMap[BiometryType.FINGERPRINT] = "Fingerprint Authentication" + biometryNameMap[BiometryType.FACE] = "Face Authentication" + biometryNameMap[BiometryType.IRIS] = "Iris Authentication" + } + } + + override fun load(webView: WebView) { + super.load(webView) + + biometryTypes = ArrayList() + val manager = activity.packageManager + if (manager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)) { + biometryTypes.add(BiometryType.FINGERPRINT) + } + if (manager.hasSystemFeature(PackageManager.FEATURE_FACE)) { + biometryTypes.add(BiometryType.FACE) + } + if (manager.hasSystemFeature(PackageManager.FEATURE_IRIS)) { + biometryTypes.add(BiometryType.IRIS) + } + if (biometryTypes.size == 0) { + biometryTypes.add(BiometryType.NONE) + } + } + + /** + * Check the device's availability and type of biometric authentication. + */ @Command - fun ping(invoke: Invoke) { - val value = invoke.getString("value") ?: "" + fun getStatus(invoke: Invoke) { + val manager = BiometricManager.from(activity) + val biometryResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + } else { + manager.canAuthenticate() + } val ret = JSObject() - ret.put("value", value) + + val available = biometryResult == BiometricManager.BIOMETRIC_SUCCESS + ret.put( + "isAvailable", + available + ) + + ret.put("biometryType", biometryTypes[0].type) + + if (!available) { + var reason = "" + when (biometryResult) { + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> reason = + "Biometry unavailable." + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> reason = + "Biometrics not enrolled." + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> reason = + "No biometric on this device." + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> reason = + "A security update is required." + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> reason = + "Unsupported biometry." + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> reason = + "Unknown biometry state." + } + + var errorCode = biometryErrorCodeMap[biometryResult] + if (errorCode == null) { + errorCode = "biometryNotAvailable" + } + ret.put("error", reason) + ret.put("errorCode", errorCode) + } + invoke.resolve(ret) } + + /** + * Prompt the user for biometric authentication. + */ + @Command + fun authenticate(invoke: Invoke) { + // The result of an intent is supposed to have the package name as a prefix + RESULT_EXTRA_PREFIX = activity.packageName + "." + val intent = Intent( + activity, + BiometricActivity::class.java + ) + + // Pass the options to the activity + intent.putExtra( + TITLE, + invoke.getString(TITLE, biometryNameMap[biometryTypes[0]] ?: "") + ) + intent.putExtra(SUBTITLE, invoke.getString(SUBTITLE)) + intent.putExtra(REASON, invoke.getString(REASON)) + intent.putExtra(CANCEL_TITLE, invoke.getString(CANCEL_TITLE)) + intent.putExtra( + DEVICE_CREDENTIAL, + invoke.getBoolean(DEVICE_CREDENTIAL, false) + ) + if (invoke.hasOption(CONFIRMATION_REQUIRED)) { + intent.putExtra( + CONFIRMATION_REQUIRED, + invoke.getBoolean(CONFIRMATION_REQUIRED, true) + ) + } + + val maxAttemptsConfig = invoke.getInt(MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS) + val maxAttempts = max( + maxAttemptsConfig, + 1 + ) + intent.putExtra(MAX_ATTEMPTS, maxAttempts) + startActivityForResult(invoke, intent, "authenticateResult") + } + + @ActivityCallback + private fun authenticateResult(invoke: Invoke, result: ActivityResult) { + val resultCode = result.resultCode + + // If the system canceled the activity, we might get RESULT_CANCELED in resultCode. + // In that case return that immediately, because there won't be any data. + if (resultCode == Activity.RESULT_CANCELED) { + invoke.reject( + "The system canceled authentication", + biometryErrorCodeMap[BiometricPrompt.ERROR_CANCELED] + ) + return + } + + // Convert the string result type to an enum + val data = result.data + val resultTypeName = data?.getStringExtra( + RESULT_EXTRA_PREFIX + RESULT_TYPE + ) + if (resultTypeName == null) { + invoke.reject( + "Missing data in the result of the activity", + INVALID_CONTEXT_ERROR + ) + return + } + val resultType = try { + BiometryResultType.valueOf(resultTypeName) + } catch (e: IllegalArgumentException) { + invoke.reject( + "Invalid data in the result of the activity", + INVALID_CONTEXT_ERROR + ) + return + } + val errorCode = data.getIntExtra( + RESULT_EXTRA_PREFIX + RESULT_ERROR_CODE, + 0 + ) + var errorMessage = data.getStringExtra( + RESULT_EXTRA_PREFIX + RESULT_ERROR_MESSAGE + ) + when (resultType) { + BiometryResultType.SUCCESS -> invoke.resolve() + BiometryResultType.FAILURE -> // Biometry was successfully presented but was not recognized + invoke.reject(errorMessage, BIOMETRIC_FAILURE) + + BiometryResultType.ERROR -> { + // The user cancelled, the system cancelled, or some error occurred. + // If the user cancelled, errorMessage is the text of the "negative" button, + // which is not especially descriptive. + if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) { + errorMessage = "Cancel button was pressed" + } + invoke.reject(errorMessage, biometryErrorCodeMap[errorCode]) + } + } + } + + internal enum class BiometryType(val type: Int) { + NONE(0), FINGERPRINT(1), FACE(2), IRIS(3); + } } diff --git a/plugins/biometric/android/src/main/res/layout/auth_activity.xml b/plugins/biometric/android/src/main/res/layout/auth_activity.xml new file mode 100644 index 00000000..d88f20da --- /dev/null +++ b/plugins/biometric/android/src/main/res/layout/auth_activity.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/plugins/biometric/android/src/main/res/values/styles.xml b/plugins/biometric/android/src/main/res/values/styles.xml new file mode 100644 index 00000000..3caed83b --- /dev/null +++ b/plugins/biometric/android/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/plugins/biometric/guest-js/index.ts b/plugins/biometric/guest-js/index.ts index c15256bf..8791f07a 100644 --- a/plugins/biometric/guest-js/index.ts +++ b/plugins/biometric/guest-js/index.ts @@ -10,8 +10,12 @@ declare global { export enum BiometryType { None = 0, + // Apple TouchID or Android fingerprint TouchID = 1, + // Apple FaceID or Android face authentication FaceID = 2, + // Android iris authentication + Iris = 3, } export interface Status { @@ -33,9 +37,16 @@ export interface Status { } export interface AuthOptions { + allowDeviceCredential?: boolean; + + // iOS options fallbackTitle?: string; cancelTitle?: string; - allowDeviceCredential?: boolean; + + // android options + title?: string; + subtitle?: string; + confirmationRequired?: boolean; } export async function checkStatus(): Promise { diff --git a/plugins/biometric/src/models.rs b/plugins/biometric/src/models.rs index 42e84475..e4fceedc 100644 --- a/plugins/biometric/src/models.rs +++ b/plugins/biometric/src/models.rs @@ -7,9 +7,17 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct AuthOptions { + pub allow_device_credential: bool, + /// iOS only. pub fallback_title: Option, + /// iOS only. pub cancel_title: Option, - pub allow_device_credential: bool, + /// Android only. + pub title: Option, + /// Android only. + pub subtitle: Option, + /// Android only. + pub confirmation_required: Option, } #[derive(Debug, Clone, serde_repr::Deserialize_repr)]