mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
[feat] [biometric] Adding the capability of encrypting/decrypting data using biometric authentication on Android (Ref. #2306) (#2454)
* feat: adding the ability to encrypt/decrypt data based on the biometric auth Adding the method biometricCipher() that allows to use the biometric authentication to use a generated key from android that can be accessed only through the biometric authentication. The method uses this key to encrypt/decrypt the data provided by the caller in the "options" parameter, with "dataToEncrypt" or "dataToDecrypt" filled. Changes were done both on Kotlin, Rust and JS sides. * Improving the way we encrypt data by allowing only if the biometric was successful. * Adding a warning on the JS side * Adding doc changes in readme.md * Adding doc changes in readme.md * Update README.md - it was updated incomplete * update docs * enhance error handling * update example --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
1 parent
b3e52ce036
commit
0374bec1e2
19 files changed
+915
-141
No files matched your search
+25
-25
@@ -64,31 +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. |
|
||||
| `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. |
|
||||
| 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`). On an emulator without a lock screen, a PIN and fingerprint are enrolled (then removed) to cover `biometricCipher`: the encrypt/decrypt round trip, tampered and malformed input, and canceling the prompt, with the sensor touched over `adb emu`. | 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
|
||||
|
||||
@@ -3,35 +3,208 @@
|
||||
// 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'
|
||||
import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
|
||||
import { browser, expect } from '@wdio/globals'
|
||||
import {
|
||||
tauri,
|
||||
tauriError,
|
||||
describePlugin,
|
||||
eventually,
|
||||
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.
|
||||
// The driver cannot provide a finger or a face, so `authenticate` is only
|
||||
// covered on the paths that reject without showing the prompt. On an Android
|
||||
// emulator a fingerprint can be enrolled and touched over `adb emu`, which is
|
||||
// what the `biometricCipher` specs below rely on.
|
||||
|
||||
const appId = 'com.tauri.api'
|
||||
/** Lock screen PIN set for the fingerprint enrollment, cleared afterwards. */
|
||||
const PIN = '1234'
|
||||
|
||||
/** `adb` from the Android SDK (else `PATH`), on `E2E_ANDROID_DEVICE` if set. */
|
||||
function adb(args: string[]): SpawnSyncReturns<string> {
|
||||
const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT
|
||||
const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb'
|
||||
const device = process.env.E2E_ANDROID_DEVICE
|
||||
? ['-s', process.env.E2E_ANDROID_DEVICE]
|
||||
: []
|
||||
return spawnSync(binary, [...device, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 20_000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
)
|
||||
const result = adb(['shell', 'locksettings', 'verify'])
|
||||
return result.status === 0 && /verified successfully/.test(result.stdout)
|
||||
}
|
||||
|
||||
/** Whether the Android device is an emulator, whose fingerprint sensor `adb emu` drives. */
|
||||
function androidIsEmulator(): boolean {
|
||||
const result = adb(['shell', 'getprop', 'ro.kernel.qemu'])
|
||||
const legacy = result.stdout?.trim() === '1'
|
||||
return (
|
||||
legacy || adb(['shell', 'getprop', 'ro.boot.qemu']).stdout?.trim() === '1'
|
||||
)
|
||||
}
|
||||
|
||||
/** Puts finger 1 on the emulator's sensor and lifts it again. */
|
||||
function touchFingerprintSensor(): void {
|
||||
adb(['emu', 'finger', 'touch', '1'])
|
||||
adb(['emu', 'finger', 'remove', '1'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the lock screen PIN and enrolls finger 1 through the Settings
|
||||
* enrollment flow, driven from the native context. The screens differ between
|
||||
* Android versions, so this answers whichever one is showing until the flow
|
||||
* reports the fingerprint as added.
|
||||
*/
|
||||
async function enrollFingerprint(): Promise<void> {
|
||||
const pin = adb(['shell', 'locksettings', 'set-pin', PIN])
|
||||
if (pin.status !== 0) {
|
||||
throw new Error(`locksettings set-pin failed: ${pin.stderr || pin.stdout}`)
|
||||
}
|
||||
adb(['shell', 'am', 'start', '-a', 'android.settings.FINGERPRINT_ENROLL'])
|
||||
|
||||
await browser.switchAppiumContext('NATIVE_APP')
|
||||
const pinEntry = browser.$('id=com.android.settings:id/password_entry')
|
||||
const done = browser.$(
|
||||
'android=new UiSelector().clickable(true).textMatches("(?i)^done$")'
|
||||
)
|
||||
// Intro and education screens. "More" scrolls the consent text until it
|
||||
// turns into "I agree".
|
||||
const proceed = browser.$(
|
||||
'android=new UiSelector().clickable(true).textMatches("(?i)^(i agree|more|next|start|continue|ok)$")'
|
||||
)
|
||||
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
if (await done.isExisting()) {
|
||||
await done.click()
|
||||
return true
|
||||
}
|
||||
if (await pinEntry.isExisting()) {
|
||||
await pinEntry.setValue(PIN)
|
||||
await browser.pressKeyCode(66 /* KEYCODE_ENTER */)
|
||||
} else if (await proceed.isExisting()) {
|
||||
await proceed.click()
|
||||
} else {
|
||||
// "Touch the sensor" and the enrollment progress screens.
|
||||
touchFingerprintSensor()
|
||||
}
|
||||
return false
|
||||
},
|
||||
{
|
||||
timeout: 120_000,
|
||||
interval: 1000,
|
||||
timeoutMsg: 'the fingerprint enrollment flow did not complete'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Brings the app back to the foreground and returns to its webview. */
|
||||
async function returnToApp(): Promise<void> {
|
||||
await browser.activateApp(appId)
|
||||
await switchToWebview()
|
||||
}
|
||||
|
||||
/** Switches back to the app's webview once it is attachable again. */
|
||||
async function switchToWebview(): Promise<void> {
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
try {
|
||||
await browser.switchAppiumContext(`WEBVIEW_${appId}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
interval: 1000,
|
||||
timeoutMsg: 'the webview did not come back'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type CipherOptions = { dataToEncrypt: string } | { dataToDecrypt: string }
|
||||
|
||||
type CipherOutcome =
|
||||
| { ok: true; data: string }
|
||||
| { ok: false; code: string | null; message: string }
|
||||
|
||||
/**
|
||||
* Starts a `biometricCipher` call without waiting for it, since it only
|
||||
* settles once the prompt is answered. See {@link cipherOutcome}.
|
||||
*/
|
||||
async function startCipher(options: CipherOptions): Promise<void> {
|
||||
await tauri((api, options) => {
|
||||
const page = window as unknown as { __e2eCipher?: unknown }
|
||||
page.__e2eCipher = undefined
|
||||
api.biometric.biometricCipher('e2e', options).then(
|
||||
(result) => {
|
||||
page.__e2eCipher = { ok: true, data: result.data }
|
||||
},
|
||||
(error: { code?: string; message?: string } | string) => {
|
||||
page.__e2eCipher =
|
||||
typeof error === 'string'
|
||||
? { ok: false, code: null, message: error }
|
||||
: {
|
||||
ok: false,
|
||||
code: error.code ?? null,
|
||||
message: error.message ?? ''
|
||||
}
|
||||
}
|
||||
)
|
||||
}, options)
|
||||
}
|
||||
|
||||
/** The outcome of the last {@link startCipher} call, or `null` while it is pending. */
|
||||
async function cipherOutcome(): Promise<CipherOutcome | null> {
|
||||
return await tauri(
|
||||
() =>
|
||||
(window as unknown as { __e2eCipher?: CipherOutcome }).__e2eCipher ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** Runs `biometricCipher`, answering its prompt with the enrolled finger. */
|
||||
async function cipherWithFingerprint(
|
||||
options: CipherOptions
|
||||
): Promise<CipherOutcome> {
|
||||
await startCipher(options)
|
||||
return await eventually(
|
||||
async () => {
|
||||
const outcome = await cipherOutcome()
|
||||
if (outcome) {
|
||||
return outcome
|
||||
}
|
||||
// Touching before the prompt is up is harmless, so keep at it.
|
||||
touchFingerprintSensor()
|
||||
throw new Error('biometricCipher did not settle')
|
||||
},
|
||||
{ timeout: 60_000, interval: 1000 }
|
||||
)
|
||||
}
|
||||
|
||||
async function encrypt(data: string): Promise<string> {
|
||||
const outcome = await cipherWithFingerprint({ dataToEncrypt: data })
|
||||
if (!outcome.ok) {
|
||||
throw new Error(`encryption failed: ${outcome.message} (${outcome.code})`)
|
||||
}
|
||||
return outcome.data
|
||||
}
|
||||
|
||||
describePlugin('biometric', { mobileOnly: true }, () => {
|
||||
// Android only, where the lock screen state can be read over adb.
|
||||
const itNoLockCredential =
|
||||
platform === 'android' && androidHasNoLockCredential() ? it : it.skip
|
||||
const noLockCredential =
|
||||
platform === 'android' && androidHasNoLockCredential()
|
||||
const itNoLockCredential = noLockCredential ? it : it.skip
|
||||
|
||||
itNoLockCredential(
|
||||
'rejects with passcodeNotSet when the fallback is allowed and no credential is set',
|
||||
@@ -49,4 +222,129 @@ describePlugin('biometric', { mobileOnly: true }, () => {
|
||||
expect(code).toBe('passcodeNotSet')
|
||||
}
|
||||
)
|
||||
|
||||
const itAndroid = platform === 'android' ? it : it.skip
|
||||
|
||||
itAndroid(
|
||||
'biometricCipher rejects without data to encrypt or decrypt',
|
||||
async () => {
|
||||
const message = await tauriError((api) =>
|
||||
api.biometric.biometricCipher('e2e', {})
|
||||
)
|
||||
expect(message).toMatch(/dataToEncrypt or dataToDecrypt/)
|
||||
}
|
||||
)
|
||||
|
||||
// Enrolling changes the device's lock screen, so this only runs on an
|
||||
// emulator that has none (which it then restores).
|
||||
const describeEnrolled =
|
||||
noLockCredential && androidIsEmulator() ? describe : describe.skip
|
||||
|
||||
describeEnrolled('biometricCipher with an enrolled fingerprint', () => {
|
||||
before(async () => {
|
||||
try {
|
||||
await enrollFingerprint()
|
||||
} finally {
|
||||
await returnToApp()
|
||||
}
|
||||
const status = await tauri((api) => api.biometric.checkStatus())
|
||||
expect(status.isAvailable).toBe(true)
|
||||
})
|
||||
|
||||
after(() => {
|
||||
// Removing the PIN also removes the enrolled fingerprints.
|
||||
adb(['shell', 'locksettings', 'clear', '--old', PIN])
|
||||
})
|
||||
|
||||
it('round-trips data through encryption and decryption', async () => {
|
||||
const plain = 'e2e sécret 🔐'
|
||||
const encrypted = await encrypt(plain)
|
||||
// Base64 `data;iv`
|
||||
expect(encrypted).toMatch(/^[A-Za-z0-9+/]+=*;[A-Za-z0-9+/]+=*$/)
|
||||
expect(encrypted).not.toContain(plain)
|
||||
|
||||
const decrypted = await cipherWithFingerprint({
|
||||
dataToDecrypt: encrypted
|
||||
})
|
||||
expect(decrypted).toEqual({ ok: true, data: plain })
|
||||
})
|
||||
|
||||
it('uses a fresh IV for every encryption', async () => {
|
||||
const first = await encrypt('same input')
|
||||
const second = await encrypt('same input')
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
it('rejects decrypting data that was tampered with', async () => {
|
||||
const [data, iv] = (await encrypt('do not touch')).split(';')
|
||||
const bytes = Buffer.from(data, 'base64')
|
||||
bytes[0] ^= 0xff
|
||||
const outcome = await cipherWithFingerprint({
|
||||
dataToDecrypt: `${bytes.toString('base64')};${iv}`
|
||||
})
|
||||
expect(outcome.ok).toBe(false)
|
||||
expect(!outcome.ok && outcome.message).toMatch(/Failed to decrypt/)
|
||||
})
|
||||
|
||||
it('rejects malformed encrypted data before prompting', async () => {
|
||||
await startCipher({ dataToDecrypt: 'not encrypted data' })
|
||||
const outcome = await eventually(
|
||||
async () => {
|
||||
const outcome = await cipherOutcome()
|
||||
if (!outcome) {
|
||||
throw new Error('biometricCipher did not settle')
|
||||
}
|
||||
return outcome
|
||||
},
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
expect(outcome.ok).toBe(false)
|
||||
expect(!outcome.ok && outcome.message).toMatch(
|
||||
/Failed to initialize the cipher/
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects with userCancel when the prompt is canceled', async () => {
|
||||
await startCipher({ dataToEncrypt: 'canceled' })
|
||||
await browser.switchAppiumContext('NATIVE_APP')
|
||||
try {
|
||||
const cancel = browser.$(
|
||||
'android=new UiSelector().clickable(true).textMatches("(?i)^cancel$")'
|
||||
)
|
||||
await cancel.waitForDisplayed({ timeout: 30_000 })
|
||||
// A click while the prompt is still animating in is ignored, so keep
|
||||
// clicking until it is gone.
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
if (!(await cancel.isExisting())) {
|
||||
return true
|
||||
}
|
||||
await cancel.click().catch(() => {})
|
||||
return false
|
||||
},
|
||||
{
|
||||
timeout: 15_000,
|
||||
interval: 500,
|
||||
timeoutMsg: 'the biometric prompt did not close'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
// The app comes back on its own once the prompt closes. Activating it
|
||||
// here instead would tear down a prompt that is still up, which the
|
||||
// plugin reports as systemCancel.
|
||||
await switchToWebview()
|
||||
}
|
||||
const outcome = await eventually(
|
||||
async () => {
|
||||
const outcome = await cipherOutcome()
|
||||
if (!outcome) {
|
||||
throw new Error('biometricCipher did not settle')
|
||||
}
|
||||
return outcome
|
||||
},
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
expect(outcome).toMatchObject({ ok: false, code: 'userCancel' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ type Surface<T> = { [P in keyof T]: (keyof T[P])[] }
|
||||
|
||||
/** Plugins the example registers on every platform. */
|
||||
const commonSurface: Surface<CommonPluginApi> = {
|
||||
biometric: ['BiometryType', 'checkStatus', 'authenticate'],
|
||||
biometric: ['BiometryType', 'checkStatus', 'authenticate', 'biometricCipher'],
|
||||
clipboardManager: [
|
||||
'writeText',
|
||||
'readText',
|
||||
|
||||
Reference in new issue
Block a user