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)]