mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
fix(biometric) Incorrect error reported on Android with no device passcode set and fallback allowed (#2648)
* Fix incorrect error being reported in certain cases with fallback allowed * Add change file * Rework original solution due to overlooking authenticators logic on API >= `Build.VERSION_CODES.R` * fix(biometric): don't cast the Android error message to String * fix(biometric): report a missing device credential before showing the Android prompt Replaces the override of ERROR_NO_BIOMETRICS in the prompt callback, which keyed on the caller's flag rather than the device state and missed the no-hardware errors. * fix(biometric): load the missing device credential message from a string resource * chore(biometric): drop stray semicolon * docs(biometric): mention the passcodeNotSet error code rename in the change file * test(e2e): cover the biometric passcodeNotSet rejection on Android --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
1 parent
2ebe54abce
commit
dff0dd4b23
6 files changed
+102
-34
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"biometric": patch:bug
|
||||
"biometric-js": patch:bug
|
||||
---
|
||||
|
||||
Fix biometric plugin reporting an inconsistent and incorrect error on Android when no device passcode was set and `allowDeviceCredential` is enabled. Android now rejects with the `passcodeNotSet` error code like iOS does; it previously used the undocumented `noDeviceCredential` code, so code matching that string must switch to `passcodeNotSet`.
|
||||
+25
-24
@@ -64,30 +64,31 @@ The example only registers each plugin on the platforms it supports, so the suit
|
||||
split three ways: desktop-only plugins are skipped on mobile, mobile-only plugins are
|
||||
skipped on desktop, and the rest run everywhere with the odd test gated.
|
||||
|
||||
| Plugin | Coverage | On mobile |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. |
|
||||
| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
|
||||
| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
|
||||
| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. |
|
||||
| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
|
||||
| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
|
||||
| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
|
||||
| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
|
||||
| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
|
||||
| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. |
|
||||
| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
|
||||
| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. |
|
||||
| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. |
|
||||
| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
|
||||
| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
|
||||
| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
|
||||
| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
|
||||
| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
|
||||
| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
|
||||
| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
|
||||
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
|
||||
| mobile-only plugins | `barcode-scanner`, `biometric`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
|
||||
| Plugin | Coverage | On mobile |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. |
|
||||
| `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. |
|
||||
| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. |
|
||||
| `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. |
|
||||
| `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. |
|
||||
| `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). |
|
||||
| `global-shortcut` | register/unregister/isRegistered/unregisterAll and error paths. Shortcuts cannot be triggered. | Skipped — desktop-only plugin. |
|
||||
| `http` | `fetch` methods, headers, JSON/bytes/multipart bodies, the cookie jar, abort, scope, failures. | Same. |
|
||||
| `log` | `attachLogger`/`attachConsole` for webview and Rust records, level filtering. | Same. |
|
||||
| `notification` | Permission model and the `window.Notification` override. Sending is fire-and-forget and display is not observable. | Action types, pending/active lists after cancel/remove, channels, listeners — see below. |
|
||||
| `opener` | Scope enforcement only — a successful open launches an external app the suite cannot close. | Same. |
|
||||
| `os` | Every function but `version` (which has nothing to compare against), checked against what the app was built for and the host. | Same, minus `hostname`. |
|
||||
| `positioner` | Screen and tray positions (with a tray rect handed in through `handleIconState`), `moveWindowConstrained`, the missing-tray error (WM dependent). | Skipped — desktop-only plugin. |
|
||||
| `process` | Only that the API is registered — `exit`/`relaunch` terminate the app under test. | Same. |
|
||||
| `shell` | `execute`, `spawn` with stdout/stderr/close events, stdin, `kill`, scope enforcement. | Scope only on iOS, which cannot spawn a process at all. |
|
||||
| `sql` | SQLite `load`/`execute`/`select`/`close`, bound values, column types, app-registered migrations, error paths. | Same. |
|
||||
| `store` | CRUD, persistence, auto-save, defaults/reset, reload, `getStore`, `LazyStore`, change events. | Same. |
|
||||
| `updater` | `check` against the fixture manifest (update / 204 / older release). Installing is never exercised. | Skipped — desktop-only plugin. |
|
||||
| `upload` | `download` and `upload` with progress, methods, headers and error paths. | Same (through `adb reverse` on Android). |
|
||||
| `websocket` | Text/binary echo, ping/pong, handshake headers, listener removal, server and client close, connection and argument errors, against the fixture server. | Same (through `adb reverse` on Android). |
|
||||
| `window-state` | `filename`, save/restore, and that a re-created window gets its saved size (WM dependent). | Skipped — desktop-only plugin. |
|
||||
| `biometric` | The `passcodeNotSet` rejection when `allowDeviceCredential` is set and the device has no lock screen (checked over `adb`), the only path that skips the prompt. | Android only; skipped on desktop. |
|
||||
| mobile-only plugins | `barcode-scanner`, `geolocation`, `haptics` and `nfc` need hardware or native UI the driver cannot operate, so only their global API surface is asserted. | Only there. |
|
||||
|
||||
The notification permission specs are desktop-only: a mobile app starts out ungranted and
|
||||
`requestPermission` puts up a system dialog the session would then block on. The rest of the
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { expect } from '@wdio/globals'
|
||||
import { tauri, describePlugin, platform } from '../helpers/index.js'
|
||||
|
||||
// A successful authentication needs a finger or a face, which the driver cannot
|
||||
// provide, so only the paths that reject without showing the prompt are covered.
|
||||
|
||||
/**
|
||||
* Whether the Android device has no lock screen credential (the default on a
|
||||
* fresh emulator). `locksettings verify` succeeds without a credential only then.
|
||||
*/
|
||||
function androidHasNoLockCredential(): boolean {
|
||||
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
|
||||
const adb = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
|
||||
const device = process.env.E2E_ANDROID_DEVICE
|
||||
? ['-s', process.env.E2E_ANDROID_DEVICE]
|
||||
: []
|
||||
const result = spawnSync(
|
||||
adb,
|
||||
[...device, 'shell', 'locksettings', 'verify'],
|
||||
{ encoding: 'utf8', timeout: 20_000 }
|
||||
)
|
||||
return result.status === 0 && /verified successfully/.test(result.stdout)
|
||||
}
|
||||
|
||||
describePlugin('biometric', { mobileOnly: true }, () => {
|
||||
// Android only, where the lock screen state can be read over adb.
|
||||
const itNoLockCredential =
|
||||
platform === 'android' && androidHasNoLockCredential() ? it : it.skip
|
||||
|
||||
itNoLockCredential(
|
||||
'rejects with passcodeNotSet when the fallback is allowed and no credential is set',
|
||||
async () => {
|
||||
const code = await tauri(async (api) => {
|
||||
try {
|
||||
await api.biometric.authenticate('e2e', {
|
||||
allowDeviceCredential: true
|
||||
})
|
||||
return 'resolved'
|
||||
} catch (error) {
|
||||
return (error as { code?: string }).code ?? null
|
||||
}
|
||||
})
|
||||
expect(code).toBe('passcodeNotSet')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -38,14 +38,23 @@ class BiometricActivity : AppCompatActivity() {
|
||||
var title = intent.getStringExtra(BiometricPlugin.TITLE)
|
||||
val subtitle = intent.getStringExtra(BiometricPlugin.SUBTITLE)
|
||||
val description = intent.getStringExtra(BiometricPlugin.REASON)
|
||||
allowDeviceCredential = false
|
||||
val allowDeviceCredentialArg = intent.getBooleanExtra(BiometricPlugin.DEVICE_CREDENTIAL, 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)
|
||||
val allowDeviceCredential = allowDeviceCredentialArg && manager.isDeviceSecure
|
||||
|
||||
// Android requires a secure lock screen to enroll biometrics, so with the fallback
|
||||
// allowed and no device credential set there is nothing to authenticate with.
|
||||
// Report it up front (like iOS does) instead of relying on the prompt's error.
|
||||
if (allowDeviceCredentialArg && !manager.isDeviceSecure) {
|
||||
finishActivity(
|
||||
BiometryResultType.ERROR,
|
||||
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL,
|
||||
getString(R.string.tauri_biometric_no_device_credential)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (title.isNullOrEmpty()) {
|
||||
@@ -73,6 +82,7 @@ class BiometricActivity : AppCompatActivity() {
|
||||
if (negativeButtonText.isNullOrEmpty()) "Cancel" else negativeButtonText
|
||||
)
|
||||
}
|
||||
|
||||
builder.setConfirmationRequired(
|
||||
intent.getBooleanExtra(BiometricPlugin.CONFIRMATION_REQUIRED, true)
|
||||
)
|
||||
@@ -89,7 +99,7 @@ class BiometricActivity : AppCompatActivity() {
|
||||
finishActivity(
|
||||
BiometryResultType.ERROR,
|
||||
errorCode,
|
||||
errorMessage as String
|
||||
errorMessage.toString()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,8 +132,4 @@ class BiometricActivity : AppCompatActivity() {
|
||||
setResult(Activity.RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var allowDeviceCredential = false
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ class BiometricPlugin(private val activity: Activity): Plugin(activity) {
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_LOCKOUT_PERMANENT] = "biometryLockout"
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_NEGATIVE_BUTTON] = "userCancel"
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_BIOMETRICS] = "biometryNotEnrolled"
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL] = "noDeviceCredential"
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL] = "passcodeNotSet"
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_NO_SPACE] = "systemCancel"
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_TIMEOUT] = "systemCancel"
|
||||
biometryErrorCodeMap[BiometricPrompt.ERROR_UNABLE_TO_PROCESS] = "systemCancel"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="tauri_biometric_no_device_credential">No device credential set</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user