pull/829/head
Lucas Nogueira 2 years ago
parent 85c918eb8d
commit a0a957d1b6
No known key found for this signature in database
GPG Key ID: 3AFF5CAD641DD470

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
</component>
</project>

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$USER_HOME$/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tauri-2.0.0-alpha.15/mobile/android" />
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/buildSrc" />
<option value="$PROJECT_DIR$/../../../../../plugins/barcode-scanner/android" />
<option value="$PROJECT_DIR$/../../../../../plugins/biometric/android" />
<option value="$PROJECT_DIR$/../../../../../plugins/clipboard-manager/android" />
<option value="$PROJECT_DIR$/../../../../../plugins/dialog/android" />
<option value="$PROJECT_DIR$/../../../../../plugins/notification/android" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.10" />
</component>
</project>

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../../../.." vcs="Git" />
</component>
</project>

@ -34,7 +34,7 @@ android {
} }
dependencies { dependencies {
implementation("androidx.biometric:biometric:1.1.0")
implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0") implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0") implementation("com.google.android.material:material:1.7.0")

@ -1,3 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".BiometricActivity"
android:label="BiometricActivity"
android:theme="@style/AppTheme.Transparent"/>
</application>
</manifest> </manifest>

@ -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
}
}

@ -5,19 +5,248 @@
package app.tauri.biometric package app.tauri.biometric
import android.app.Activity 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.Command
import app.tauri.annotation.TauriPlugin import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin 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 @TauriPlugin
class BiometricPlugin(private val activity: Activity): Plugin(activity) { class BiometricPlugin(private val activity: Activity): Plugin(activity) {
private var biometryTypes: ArrayList<BiometryType> = 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<Int, String> = HashMap()
private var biometryNameMap: MutableMap<BiometryType, String> = 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 @Command
fun ping(invoke: Invoke) { fun getStatus(invoke: Invoke) {
val value = invoke.getString("value") ?: "" 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() 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) 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);
}
} }

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="app.tauri.biometric.BiometricActivity">
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,10 @@
<resources>
<style name="AppTheme.Transparent" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>

@ -10,8 +10,12 @@ declare global {
export enum BiometryType { export enum BiometryType {
None = 0, None = 0,
// Apple TouchID or Android fingerprint
TouchID = 1, TouchID = 1,
// Apple FaceID or Android face authentication
FaceID = 2, FaceID = 2,
// Android iris authentication
Iris = 3,
} }
export interface Status { export interface Status {
@ -33,9 +37,16 @@ export interface Status {
} }
export interface AuthOptions { export interface AuthOptions {
allowDeviceCredential?: boolean;
// iOS options
fallbackTitle?: string; fallbackTitle?: string;
cancelTitle?: string; cancelTitle?: string;
allowDeviceCredential?: boolean;
// android options
title?: string;
subtitle?: string;
confirmationRequired?: boolean;
} }
export async function checkStatus(): Promise<Status> { export async function checkStatus(): Promise<Status> {

@ -7,9 +7,17 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize)] #[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AuthOptions { pub struct AuthOptions {
pub allow_device_credential: bool,
/// iOS only.
pub fallback_title: Option<String>, pub fallback_title: Option<String>,
/// iOS only.
pub cancel_title: Option<String>, pub cancel_title: Option<String>,
pub allow_device_credential: bool, /// Android only.
pub title: Option<String>,
/// Android only.
pub subtitle: Option<String>,
/// Android only.
pub confirmation_required: Option<bool>,
} }
#[derive(Debug, Clone, serde_repr::Deserialize_repr)] #[derive(Debug, Clone, serde_repr::Deserialize_repr)]

Loading…
Cancel
Save