diff --git a/.changes/fix-bio-fallback-error-android.md b/.changes/fix-bio-fallback-error-android.md new file mode 100644 index 000000000..353ea38ce --- /dev/null +++ b/.changes/fix-bio-fallback-error-android.md @@ -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`. diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md index 07e97920c..745111307 100644 --- a/packages/api-e2e/README.md +++ b/packages/api-e2e/README.md @@ -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 diff --git a/packages/api-e2e/test/specs/biometric.spec.ts b/packages/api-e2e/test/specs/biometric.spec.ts new file mode 100644 index 000000000..6b6b559be --- /dev/null +++ b/packages/api-e2e/test/specs/biometric.spec.ts @@ -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') + } + ) +}) diff --git a/plugins/biometric/android/src/main/java/BiometricActivity.kt b/plugins/biometric/android/src/main/java/BiometricActivity.kt index 011de4d5f..83cc1241a 100644 --- a/plugins/biometric/android/src/main/java/BiometricActivity.kt +++ b/plugins/biometric/android/src/main/java/BiometricActivity.kt @@ -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 - } } \ 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 b3436fd48..b9b7ab05d 100644 --- a/plugins/biometric/android/src/main/java/BiometricPlugin.kt +++ b/plugins/biometric/android/src/main/java/BiometricPlugin.kt @@ -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" diff --git a/plugins/biometric/android/src/main/res/values/strings.xml b/plugins/biometric/android/src/main/res/values/strings.xml new file mode 100644 index 000000000..5d41e4258 --- /dev/null +++ b/plugins/biometric/android/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + No device credential set +