feat(native): an app lock with Face ID, Touch ID or the device passcode

A switch in the account settings, inside the app on a device that can unlock
that way (iOS LocalAuthentication, Android BiometricPrompt with the screen
lock as fallback). Turning it on asks once, so nobody locks themselves out.
With it on, TREK asks at launch and after half a minute in the background
before showing anything, and the app switcher shows a blurred cover (iOS) or
no snapshot (Android) instead of the screen. The setting stays on the
device, not the account.
This commit is contained in:
Maurice
2026-11-06 13:21:19 +01:00
parent dec6babe1d
commit 017f229cfc
42 changed files with 721 additions and 2 deletions
+1
View File
@@ -56,6 +56,7 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation "androidx.browser:browser:$androidxBrowserVersion"
implementation "androidx.biometric:biometric:1.1.0"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
@@ -31,7 +31,7 @@ public class TrekShellPlugin extends Plugin {
*/
static final int SHELL_API = 2;
/** The native features by name, so the UI can turn each on by itself. */
static final String[] FEATURES = { "capabilities" };
static final String[] FEATURES = { "capabilities", "biometricLock" };
@PluginMethod
public void getCapabilities(PluginCall call) {
@@ -55,6 +55,72 @@ public class TrekShellPlugin extends Plugin {
call.resolve(result);
}
// ---- App lock ----
private static final int AUTHENTICATORS =
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
| androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL;
/** Which biometry the device offers for the app lock, if any. */
@PluginMethod
public void biometryInfo(PluginCall call) {
int status = androidx.biometric.BiometricManager.from(getContext()).canAuthenticate(AUTHENTICATORS);
JSObject result = new JSObject();
result.put("available", status == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS);
result.put("type", "biometric");
call.resolve(result);
}
/** The system biometric prompt with the screen lock as the way out. */
@PluginMethod
public void authenticateBiometric(PluginCall call) {
String reason = call.getString("reason", "Unlock TREK");
getActivity().runOnUiThread(() -> {
androidx.fragment.app.FragmentActivity activity = (androidx.fragment.app.FragmentActivity) getActivity();
androidx.biometric.BiometricPrompt prompt = new androidx.biometric.BiometricPrompt(
activity,
androidx.core.content.ContextCompat.getMainExecutor(getContext()),
new androidx.biometric.BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(androidx.biometric.BiometricPrompt.AuthenticationResult result) {
JSObject ok = new JSObject();
ok.put("ok", true);
call.resolve(ok);
}
@Override
public void onAuthenticationError(int code, CharSequence message) {
boolean cancelled = code == androidx.biometric.BiometricPrompt.ERROR_USER_CANCELED
|| code == androidx.biometric.BiometricPrompt.ERROR_NEGATIVE_BUTTON
|| code == androidx.biometric.BiometricPrompt.ERROR_CANCELED;
call.reject(message.toString(), cancelled ? "CANCELLED" : "FAILED");
}
}
);
androidx.biometric.BiometricPrompt.PromptInfo info = new androidx.biometric.BiometricPrompt.PromptInfo.Builder()
.setTitle(reason)
.setAllowedAuthenticators(AUTHENTICATORS)
.build();
prompt.authenticate(info);
});
}
/** While on, the recent-apps screen shows no snapshot of the app. */
@PluginMethod
public void setPrivacyCover(PluginCall call) {
boolean enabled = Boolean.TRUE.equals(call.getBoolean("enabled", false));
getActivity().runOnUiThread(() -> {
if (android.os.Build.VERSION.SDK_INT >= 33) {
getActivity().setRecentsScreenshotEnabled(!enabled);
} else if (enabled) {
getActivity().getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
} else {
getActivity().getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE);
}
});
call.resolve();
}
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
+2
View File
@@ -71,6 +71,8 @@
<key>NSPhotoLibraryAddUsageDescription</key>
<string>TREK saves photos and exports you download from your trips back to your photo library.</string>
<!-- navigator.geolocation in the WebView ("locate me" on the map). -->
<key>NSFaceIDUsageDescription</key>
<string>TREK uses Face ID to unlock the app when you turn on the app lock.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>TREK uses your location to centre the map on you and to find places near you.</string>
<!-- Deep links (@capacitor/app appUrlOpen), matching the intent-filter in
+36
View File
@@ -14,6 +14,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}
// The app switcher takes its snapshot as the scene resigns active, so the
// cover goes on there and comes off when the app is back.
func sceneWillResignActive(_ scene: UIScene) {
TrekPrivacyCover.show(in: window)
}
func sceneDidBecomeActive(_ scene: UIScene) {
TrekPrivacyCover.hide()
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
SceneDelegateProxy.shared.scene(scene, openURLContexts: URLContexts)
}
@@ -22,3 +32,29 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
}
/// The blur over the app while it sits in the app switcher, when the app lock
/// is on (TrekShell.setPrivacyCover from the UI's lock setting).
enum TrekPrivacyCover {
private static let key = "TrekPrivacyCover"
private static weak var cover: UIView?
static var enabled: Bool {
get { UserDefaults.standard.bool(forKey: key) }
set { UserDefaults.standard.set(newValue, forKey: key) }
}
static func show(in window: UIWindow?) {
guard enabled, cover == nil, let window else { return }
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))
view.frame = window.bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(view)
cover = view
}
static func hide() {
cover?.removeFromSuperview()
cover = nil
}
}
+44 -1
View File
@@ -1,5 +1,6 @@
import AuthenticationServices
import Capacitor
import LocalAuthentication
import UIKit
import WebKit
@@ -19,6 +20,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getTileCacheUsage", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "clearTileCache", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "biometryInfo", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "authenticateBiometric", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setPrivacyCover", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -26,7 +30,46 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
static let shellApi = 2
/// The native features by name, so the UI can turn each on by itself.
static let features: [String] = ["capabilities", "edgeBack", "tileCache"]
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock"]
// MARK: - App lock
/// Which biometry the device offers for the app lock, if any.
@objc func biometryInfo(_ call: CAPPluginCall) {
let context = LAContext()
var error: NSError?
let available = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
let type: String
switch context.biometryType {
case .faceID: type = "faceID"
case .touchID: type = "touchID"
default: type = "none"
}
call.resolve(["available": available, "type": type])
}
/// Face ID / Touch ID with the passcode as the way out, the way system apps
/// lock. Rejects with CANCELLED when the user backs out.
@objc func authenticateBiometric(_ call: CAPPluginCall) {
let reason = call.getString("reason") ?? "Unlock TREK"
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { ok, error in
if ok {
call.resolve(["ok": true])
} else if let laError = error as? LAError, [.userCancel, .appCancel, .systemCancel].contains(laError.code) {
call.reject("Unlock cancelled", "CANCELLED")
} else {
call.reject(error?.localizedDescription ?? "Unlock failed", "FAILED")
}
}
}
/// While on, the app switcher shows a blurred cover instead of the screen
/// (SceneDelegate), so a locked app does not show its content there.
@objc func setPrivacyCover(_ call: CAPPluginCall) {
TrekPrivacyCover.enabled = call.getBool("enabled") ?? false
call.resolve()
}
// MARK: - Map tile store
+2
View File
@@ -33,6 +33,7 @@ import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTrigger
import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
import AppLockGate from './components/Layout/AppLockGate'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
// Notice action registrations (side-effect imports):
@@ -444,6 +445,7 @@ export default function App() {
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:native-update" fallback={null}><NativeUpdateNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:app-lock" fallback={null}><AppLockGate /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
@@ -0,0 +1,63 @@
import { App } from '@capacitor/app';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { isAppLockEnabled, unlockApp } from '../../native/appLock';
import AppLockGate from './AppLockGate';
vi.mock('@capacitor/app', () => ({ App: { addListener: vi.fn(() => Promise.resolve({ remove: vi.fn() })) } }));
vi.mock('../../native/appLock', () => ({
RELOCK_AFTER_MS: 30_000,
isAppLockEnabled: vi.fn(),
unlockApp: vi.fn(),
}));
function stateListener(): (state: { isActive: boolean }) => void {
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange');
if (!call) throw new Error('no appStateChange listener');
return call[1] as unknown as (state: { isActive: boolean }) => void;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isAppLockEnabled).mockReturnValue(true);
vi.mocked(unlockApp).mockResolvedValue(false);
});
describe('AppLockGate', () => {
it('covers the app at launch and asks at once; a successful unlock lifts it', async () => {
render(<AppLockGate />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
await waitFor(() => expect(unlockApp).toHaveBeenCalledTimes(1));
vi.mocked(unlockApp).mockResolvedValue(true);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('locks again after half a minute in the background, not after a glance away', async () => {
vi.mocked(unlockApp).mockResolvedValue(true);
const now = vi.spyOn(Date, 'now');
render(<AppLockGate />);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
vi.mocked(unlockApp).mockResolvedValue(false);
now.mockReturnValue(1_000);
act(() => stateListener()({ isActive: false }));
now.mockReturnValue(10_000);
act(() => stateListener()({ isActive: true }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
act(() => stateListener()({ isActive: false }));
now.mockReturnValue(60_000);
act(() => stateListener()({ isActive: true }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
now.mockRestore();
});
it('is nothing while the lock is off', () => {
vi.mocked(isAppLockEnabled).mockReturnValue(false);
const { container } = render(<AppLockGate />);
expect(container).toBeEmptyDOMElement();
expect(unlockApp).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,80 @@
/**
* AppLockGate: covers TREK until Face ID, Touch ID or the passcode unlocks it,
* when the app lock is on (native/appLock.ts). It locks at launch and again
* after the app spent more than half a minute in the background, and asks at
* once rather than waiting for a tap; the button is there for a second try.
* Rendered once in App; outside the app, or with the lock off, it is nothing.
*/
import { App } from '@capacitor/app';
import { Lock } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { RELOCK_AFTER_MS, isAppLockEnabled, unlockApp } from '../../native/appLock';
import { useNativeCapabilities } from '../../native/capabilities';
export default function AppLockGate(): React.ReactElement | null {
const { t } = useTranslation();
// Re-read once the binary has said what it can do.
const { caps } = useNativeCapabilities();
const [locked, setLocked] = useState(false);
const asking = useRef(false);
const backgroundSince = useRef<number | null>(null);
const ask = useCallback(async () => {
if (asking.current) return;
asking.current = true;
const ok = await unlockApp(t('native.lock.reason'));
asking.current = false;
if (ok) setLocked(false);
}, [t]);
// At launch, once the capabilities are known.
useEffect(() => {
if (!isAppLockEnabled()) return;
setLocked(true);
void ask();
}, [caps, ask]);
// Back from the background after a while.
useEffect(() => {
if (!isAppLockEnabled()) return;
const listener = App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) {
backgroundSince.current = Date.now();
return;
}
const since = backgroundSince.current;
backgroundSince.current = null;
if (since != null && Date.now() - since > RELOCK_AFTER_MS) {
setLocked(true);
void ask();
}
});
return () => {
void listener.then((handle) => handle.remove());
};
}, [caps, ask]);
if (!locked) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('native.lock.title')}
className="fixed inset-0 z-[100001] flex flex-col items-center justify-center gap-5 bg-surface p-8 text-center backdrop-blur-2xl"
>
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-surface-card text-content shadow-lg">
<Lock size={26} strokeWidth={2} aria-hidden="true" />
</span>
<p className="text-title font-semibold text-content">{t('native.lock.title')}</p>
<button
type="button"
onClick={() => void ask()}
className="rounded-full bg-accent px-6 py-3 text-body font-semibold text-accent-text transition-transform active:scale-[.98]"
>
{t('native.lock.unlock')}
</button>
</div>
);
}
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import {
appLockSupported,
biometryAvailable,
isAppLockEnabled,
setAppLockEnabled,
unlockApp,
} from '../../../native/appLock';
import MAppLockCard from './MAppLockCard';
vi.mock('../../../native/appLock', () => ({
appLockSupported: vi.fn(),
biometryAvailable: vi.fn(),
isAppLockEnabled: vi.fn(),
setAppLockEnabled: vi.fn(async () => {}),
unlockApp: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(appLockSupported).mockReturnValue(true);
vi.mocked(biometryAvailable).mockResolvedValue({ available: true, type: 'faceID' });
vi.mocked(isAppLockEnabled).mockReturnValue(false);
vi.mocked(unlockApp).mockResolvedValue(true);
});
describe('MAppLockCard', () => {
it('turns the lock on only after one successful unlock', async () => {
render(<MAppLockCard />);
const toggle = await screen.findByRole('switch');
vi.mocked(unlockApp).mockResolvedValueOnce(false);
fireEvent.click(toggle);
await waitFor(() => expect(unlockApp).toHaveBeenCalledTimes(1));
expect(setAppLockEnabled).not.toHaveBeenCalled();
fireEvent.click(toggle);
await waitFor(() => expect(setAppLockEnabled).toHaveBeenCalledWith(true));
});
it('turns it off without asking', async () => {
vi.mocked(isAppLockEnabled).mockReturnValue(true);
render(<MAppLockCard />);
fireEvent.click(await screen.findByRole('switch'));
await waitFor(() => expect(setAppLockEnabled).toHaveBeenCalledWith(false));
expect(unlockApp).not.toHaveBeenCalled();
});
it('is not offered on a device without Face ID, Touch ID or a passcode', async () => {
vi.mocked(biometryAvailable).mockResolvedValue({ available: false, type: 'none' });
const { container } = render(<MAppLockCard />);
await waitFor(() => expect(biometryAvailable).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,54 @@
import { ScanFace } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from '../../../i18n';
import {
appLockSupported,
biometryAvailable,
isAppLockEnabled,
setAppLockEnabled,
unlockApp,
} from '../../../native/appLock';
import MToggle from '../../components/MToggle';
import { MSetCard, MSetRow } from './MSettingsUi';
/**
* The app lock switch, inside the app on a device that can unlock with Face ID,
* Touch ID or its passcode. Turning it on asks once, so nobody locks themselves
* out with a lock that does not work on this phone.
*/
export default function MAppLockCard() {
const { t } = useTranslation();
const [available, setAvailable] = useState(false);
const [enabled, setEnabled] = useState(isAppLockEnabled);
useEffect(() => {
if (!appLockSupported()) return;
void biometryAvailable().then((info) => setAvailable(info.available));
}, []);
if (!available) return null;
const toggle = async () => {
if (enabled) {
await setAppLockEnabled(false);
setEnabled(false);
return;
}
if (!(await unlockApp(t('native.lock.reason')))) return;
await setAppLockEnabled(true);
setEnabled(true);
};
return (
<MSetCard title={t('native.lock.settingTitle')} icon={ScanFace} className="mt-3">
<div className="-mt-[4px]">
<MSetRow
first
label={t('native.lock.setting')}
sub={t('native.lock.settingHint')}
trailing={<MToggle checked={enabled} onChange={() => void toggle()} ariaLabel={t('native.lock.setting')} />}
/>
</div>
</MSetCard>
);
}
@@ -19,6 +19,7 @@ import {
import { useTranslation } from '../../../i18n'
import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
import MConfirmSheet from './MConfirmSheet'
import MAppLockCard from './MAppLockCard'
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
@@ -179,6 +180,9 @@ export default function MSettingsAccount() {
)}
{/* ── Two-factor authentication ───────────────────────────── */}
{/* The app lock, inside the app only (Face ID, Touch ID or the passcode). */}
<MAppLockCard />
<MSetCard title={t('settings.mfa.title')} icon={KeyRound} className="mt-3">
{mfaRequiredByPolicy && (
<div className="mb-3 flex gap-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
+53
View File
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { appLockSupported, biometryAvailable, isAppLockEnabled, setAppLockEnabled, unlockApp } from './appLock';
import { hasNativeFeature } from './capabilities';
import { isNativeApp } from './platform';
import { TrekShell } from './trekShell';
vi.mock('./platform', () => ({ isNativeApp: vi.fn() }));
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() }));
vi.mock('./trekShell', () => ({
TrekShell: { setPrivacyCover: vi.fn(), biometryInfo: vi.fn(), authenticateBiometric: vi.fn() },
}));
beforeEach(() => {
window.localStorage.clear();
vi.mocked(isNativeApp).mockReturnValue(true);
vi.mocked(hasNativeFeature).mockReturnValue(true);
vi.mocked(TrekShell.setPrivacyCover).mockReset().mockResolvedValue();
vi.mocked(TrekShell.biometryInfo).mockReset().mockResolvedValue({ available: true, type: 'faceID' });
vi.mocked(TrekShell.authenticateBiometric).mockReset().mockResolvedValue({ ok: true });
});
describe('appLock', () => {
it('is only offered in a binary with the feature', () => {
expect(appLockSupported()).toBe(true);
vi.mocked(hasNativeFeature).mockReturnValue(false);
expect(appLockSupported()).toBe(false);
});
it('keeps the switch on the device and tells the native side for the app-switcher cover', async () => {
expect(isAppLockEnabled()).toBe(false);
await setAppLockEnabled(true);
expect(isAppLockEnabled()).toBe(true);
expect(TrekShell.setPrivacyCover).toHaveBeenLastCalledWith({ enabled: true });
await setAppLockEnabled(false);
expect(isAppLockEnabled()).toBe(false);
expect(TrekShell.setPrivacyCover).toHaveBeenLastCalledWith({ enabled: false });
});
it('reads off as off without the feature, whatever is stored', async () => {
await setAppLockEnabled(true);
vi.mocked(hasNativeFeature).mockReturnValue(false);
expect(isAppLockEnabled()).toBe(false);
expect(await biometryAvailable()).toEqual({ available: false, type: 'none' });
});
it('unlocks on success and stays locked on a cancel or a failure', async () => {
expect(await unlockApp('Unlock TREK')).toBe(true);
vi.mocked(TrekShell.authenticateBiometric).mockRejectedValue({ code: 'CANCELLED' });
expect(await unlockApp('Unlock TREK')).toBe(false);
vi.mocked(TrekShell.authenticateBiometric).mockRejectedValue({ code: 'FAILED' });
expect(await unlockApp('Unlock TREK')).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { hasNativeFeature } from './capabilities';
import { isNativeApp } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
/**
* The app lock: Face ID, Touch ID or the device passcode before TREK shows
* anything, at launch and after a while in the background, with a blurred
* cover in the app switcher meanwhile.
*
* The setting belongs to the device, not the account (another phone of the
* same user may well not want it), so it lives in local storage, and the
* native side keeps its own copy for the app-switcher cover, which has to be
* up before the UI could run.
*/
const KEY = 'trek.appLock';
/** Back from the background after this long, the app asks again. */
export const RELOCK_AFTER_MS = 30_000;
export function appLockSupported(): boolean {
return isNativeApp() && hasNativeFeature('biometricLock');
}
export function isAppLockEnabled(): boolean {
if (!appLockSupported()) return false;
try {
return window.localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
export async function setAppLockEnabled(enabled: boolean): Promise<void> {
try {
if (enabled) window.localStorage.setItem(KEY, '1');
else window.localStorage.removeItem(KEY);
} catch {
// Storage refused: the lock then stays off, which the toggle shows.
}
await TrekShell.setPrivacyCover({ enabled }).catch(reportNativeFailure);
}
export async function biometryAvailable(): Promise<{ available: boolean; type: string }> {
if (!appLockSupported()) return { available: false, type: 'none' };
try {
return await TrekShell.biometryInfo();
} catch (err) {
reportNativeFailure(err);
return { available: false, type: 'none' };
}
}
/** Asks for Face ID, Touch ID or the passcode. True once unlocked. */
export async function unlockApp(reason: string): Promise<boolean> {
try {
await TrekShell.authenticateBiometric({ reason });
return true;
} catch (err) {
const code = (err as { code?: string } | null)?.code;
if (code !== 'CANCELLED' && code !== 'FAILED') reportNativeFailure(err);
return false;
}
}
+6
View File
@@ -36,6 +36,12 @@ export interface TrekShellPlugin {
getTileCacheUsage(): Promise<{ bytes: number; limit: number }>;
/** iOS, feature tileCache: empties the map tile store. */
clearTileCache(): Promise<void>;
/** Feature biometricLock: which biometry the device offers for the app lock. */
biometryInfo(): Promise<{ available: boolean; type: string }>;
/** Feature biometricLock: Face ID, Touch ID or the passcode. Rejects with CANCELLED or FAILED. */
authenticateBiometric(options: { reason: string }): Promise<{ ok: true }>;
/** Feature biometricLock: a blurred cover in the app switcher while the lock is on. */
setPrivacyCover(options: { enabled: boolean }): Promise<void>;
}
export const TrekShell = registerPlugin<TrekShellPlugin>('TrekShell');
+6
View File
@@ -74,6 +74,12 @@ const native: TranslationStrings = {
'native.update.title': 'حدّث تطبيق TREK',
'native.update.body': 'يحتاج هذا الخادم إلى إصدار أحدث من التطبيق ليعمل كل شيء.',
'native.update.open': 'تحديث',
'native.lock.title': 'TREK مقفل',
'native.lock.unlock': 'فتح القفل',
'native.lock.reason': 'افتح قفل TREK',
'native.lock.settingTitle': 'قفل التطبيق',
'native.lock.setting': 'القفل باستخدام Face ID أو رمز المرور',
'native.lock.settingHint': 'يطلب ذلك عند فتح TREK وبعد نصف دقيقة في الخلفية، ويخفي التطبيق في مبدّل التطبيقات.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'TREK tətbiqini yeniləyin',
'native.update.body': 'Hər şeyin işləməsi üçün bu server tətbiqin daha yeni versiyasını tələb edir.',
'native.update.open': 'Yenilə',
'native.lock.title': 'TREK kilidlənib',
'native.lock.unlock': 'Kilidi aç',
'native.lock.reason': 'TREK kilidini aç',
'native.lock.settingTitle': 'Tətbiq kilidi',
'native.lock.setting': 'Face ID və ya kodla kilidlə',
'native.lock.settingHint':
'TREK açılanda və arxa fonda yarım dəqiqədən sonra soruşur, tətbiqi tətbiq dəyişdiricisində gizlədir.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Atualize o app TREK',
'native.update.body': 'Este servidor precisa de uma versão mais nova do app para tudo funcionar.',
'native.update.open': 'Atualizar',
'native.lock.title': 'O TREK está bloqueado',
'native.lock.unlock': 'Desbloquear',
'native.lock.reason': 'Desbloquear o TREK',
'native.lock.settingTitle': 'Bloqueio do app',
'native.lock.setting': 'Bloquear com Face ID ou código',
'native.lock.settingHint':
'Pede ao abrir o TREK e após meio minuto em segundo plano, e oculta o app no seletor de apps.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Actualitza l’app TREK',
'native.update.body': 'Aquest servidor necessita una versió més nova de l’app perquè tot funcioni.',
'native.update.open': 'Actualitza',
'native.lock.title': 'TREK està bloquejat',
'native.lock.unlock': 'Desbloqueja',
'native.lock.reason': 'Desbloqueja TREK',
'native.lock.settingTitle': 'Bloqueig de l’app',
'native.lock.setting': 'Bloqueja amb Face ID o codi',
'native.lock.settingHint':
'Ho demana en obrir TREK i després de mig minut en segon pla, i amaga l’app al selector d’apps.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Aktualizujte aplikaci TREK',
'native.update.body': 'Tento server potřebuje novější verzi aplikace, aby vše fungovalo.',
'native.update.open': 'Aktualizovat',
'native.lock.title': 'TREK je zamčený',
'native.lock.unlock': 'Odemknout',
'native.lock.reason': 'Odemknout TREK',
'native.lock.settingTitle': 'Zámek aplikace',
'native.lock.setting': 'Zamknout pomocí Face ID nebo kódu',
'native.lock.settingHint':
'Zeptá se při otevření TREK a po půl minutě na pozadí a skryje aplikaci v přepínači aplikací.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'TREK-App aktualisieren',
'native.update.body': 'Dieser Server braucht eine neuere App-Version, damit alles funktioniert.',
'native.update.open': 'Aktualisieren',
'native.lock.title': 'TREK ist gesperrt',
'native.lock.unlock': 'Entsperren',
'native.lock.reason': 'TREK entsperren',
'native.lock.settingTitle': 'App-Sperre',
'native.lock.setting': 'Mit Face ID oder Code sperren',
'native.lock.settingHint':
'Fragt beim Öffnen und nach einer halben Minute im Hintergrund nach und verbirgt TREK im App-Umschalter.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Update the TREK app',
'native.update.body': 'This server needs a newer version of the app for everything to work.',
'native.update.open': 'Update',
'native.lock.title': 'TREK is locked',
'native.lock.unlock': 'Unlock',
'native.lock.reason': 'Unlock TREK',
'native.lock.settingTitle': 'App lock',
'native.lock.setting': 'Lock with Face ID or passcode',
'native.lock.settingHint':
'Asks when TREK opens and after half a minute in the background, and hides the app in the app switcher.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'Actualiza la app TREK',
'native.update.body': 'Este servidor necesita una versión más reciente de la app para que todo funcione.',
'native.update.open': 'Actualizar',
'native.lock.title': 'TREK está bloqueado',
'native.lock.unlock': 'Desbloquear',
'native.lock.reason': 'Desbloquear TREK',
'native.lock.settingTitle': 'Bloqueo de la app',
'native.lock.setting': 'Bloquear con Face ID o código',
'native.lock.settingHint':
'Lo pide al abrir TREK y tras medio minuto en segundo plano, y oculta la app en el selector de apps.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Uuenda TREK-i rakendust',
'native.update.body': 'See server vajab kõige toimimiseks rakenduse uuemat versiooni.',
'native.update.open': 'Uuenda',
'native.lock.title': 'TREK on lukus',
'native.lock.unlock': 'Ava lukk',
'native.lock.reason': 'Ava TREK',
'native.lock.settingTitle': 'Rakenduse lukk',
'native.lock.setting': 'Lukusta Face ID või pääsukoodiga',
'native.lock.settingHint':
'Küsib TREK-i avamisel ja pärast poolt minutit taustal ning peidab rakenduse rakenduste vahetajas.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'Mettez à jour l’app TREK',
'native.update.body': 'Ce serveur a besoin d’une version plus récente de l’app pour que tout fonctionne.',
'native.update.open': 'Mettre à jour',
'native.lock.title': 'TREK est verrouillé',
'native.lock.unlock': 'Déverrouiller',
'native.lock.reason': 'Déverrouiller TREK',
'native.lock.settingTitle': 'Verrouillage de l’app',
'native.lock.setting': 'Verrouiller avec Face ID ou code',
'native.lock.settingHint':
'Le demande à l’ouverture de TREK et après une demi-minute en arrière-plan, et masque l’app dans le sélecteur d’apps.',
};
export default native;
+7
View File
@@ -78,6 +78,13 @@ const native: TranslationStrings = {
'native.update.title': 'Ενημερώστε την εφαρμογή TREK',
'native.update.body': 'Αυτός ο διακομιστής χρειάζεται νεότερη έκδοση της εφαρμογής για να λειτουργούν όλα.',
'native.update.open': 'Ενημέρωση',
'native.lock.title': 'Το TREK είναι κλειδωμένο',
'native.lock.unlock': 'Ξεκλείδωμα',
'native.lock.reason': 'Ξεκλείδωμα του TREK',
'native.lock.settingTitle': 'Κλείδωμα εφαρμογής',
'native.lock.setting': 'Κλείδωμα με Face ID ή κωδικό',
'native.lock.settingHint':
'Ζητείται κατά το άνοιγμα του TREK και μετά από μισό λεπτό στο παρασκήνιο, και η εφαρμογή κρύβεται στην εναλλαγή εφαρμογών.',
};
export default native;
+6
View File
@@ -74,6 +74,12 @@ const native: TranslationStrings = {
'native.update.title': 'עדכנו את אפליקציית TREK',
'native.update.body': 'השרת הזה צריך גרסה חדשה יותר של האפליקציה כדי שהכול יעבוד.',
'native.update.open': 'עדכון',
'native.lock.title': 'TREK נעול',
'native.lock.unlock': 'ביטול נעילה',
'native.lock.reason': 'ביטול נעילת TREK',
'native.lock.settingTitle': 'נעילת אפליקציה',
'native.lock.setting': 'נעילה עם Face ID או קוד',
'native.lock.settingHint': 'מבקש בעת פתיחת TREK ואחרי חצי דקה ברקע, ומסתיר את האפליקציה במחליף האפליקציות.',
};
export default native;
+7
View File
@@ -77,6 +77,13 @@ const native: TranslationStrings = {
'native.update.title': 'Frissítsd a TREK alkalmazást',
'native.update.body': 'Ehhez a szerverhez az alkalmazás újabb verziója kell, hogy minden működjön.',
'native.update.open': 'Frissítés',
'native.lock.title': 'A TREK zárolva van',
'native.lock.unlock': 'Feloldás',
'native.lock.reason': 'TREK feloldása',
'native.lock.settingTitle': 'Alkalmazászár',
'native.lock.setting': 'Zárolás Face ID-val vagy kóddal',
'native.lock.settingHint':
'Rákérdez a TREK megnyitásakor és fél perc háttérben töltött idő után, és elrejti az alkalmazást az alkalmazásváltóban.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'Perbarui aplikasi TREK',
'native.update.body': 'Server ini memerlukan versi aplikasi yang lebih baru agar semuanya berfungsi.',
'native.update.open': 'Perbarui',
'native.lock.title': 'TREK terkunci',
'native.lock.unlock': 'Buka kunci',
'native.lock.reason': 'Buka kunci TREK',
'native.lock.settingTitle': 'Kunci aplikasi',
'native.lock.setting': 'Kunci dengan Face ID atau kode sandi',
'native.lock.settingHint':
'Meminta saat TREK dibuka dan setelah setengah menit di latar belakang, serta menyembunyikan aplikasi di pengalih aplikasi.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'Aggiorna l’app TREK',
'native.update.body': 'Questo server richiede una versione più recente dell’app perché tutto funzioni.',
'native.update.open': 'Aggiorna',
'native.lock.title': 'TREK è bloccato',
'native.lock.unlock': 'Sblocca',
'native.lock.reason': 'Sblocca TREK',
'native.lock.settingTitle': 'Blocco app',
'native.lock.setting': 'Blocca con Face ID o codice',
'native.lock.settingHint':
'Lo chiede all’apertura di TREK e dopo mezzo minuto in background, e nasconde l’app nel selettore delle app.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'TREKアプリを更新してください',
'native.update.body': 'すべての機能を使うには、このサーバーに対応した新しいバージョンのアプリが必要です。',
'native.update.open': '更新',
'native.lock.title': 'TREKはロックされています',
'native.lock.unlock': 'ロック解除',
'native.lock.reason': 'TREKのロックを解除',
'native.lock.settingTitle': 'アプリロック',
'native.lock.setting': 'Face IDまたはパスコードでロック',
'native.lock.settingHint':
'TREKを開いたときと、バックグラウンドで30秒経過したあとに確認し、アプリスイッチャーでは内容を隠します。',
};
export default native;
+7
View File
@@ -74,6 +74,13 @@ const native: TranslationStrings = {
'native.update.title': 'TREK 앱을 업데이트하세요',
'native.update.body': '모든 기능을 사용하려면 이 서버에 맞는 최신 앱 버전이 필요합니다.',
'native.update.open': '업데이트',
'native.lock.title': 'TREK이 잠겨 있습니다',
'native.lock.unlock': '잠금 해제',
'native.lock.reason': 'TREK 잠금 해제',
'native.lock.settingTitle': '앱 잠금',
'native.lock.setting': 'Face ID 또는 암호로 잠금',
'native.lock.settingHint':
'TREK을 열 때와 백그라운드에서 30초가 지난 후에 확인하며, 앱 전환기에서는 내용을 숨깁니다.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Werk de TREK-app bij',
'native.update.body': 'Deze server heeft een nieuwere versie van de app nodig om alles te laten werken.',
'native.update.open': 'Bijwerken',
'native.lock.title': 'TREK is vergrendeld',
'native.lock.unlock': 'Ontgrendelen',
'native.lock.reason': 'TREK ontgrendelen',
'native.lock.settingTitle': 'App-vergrendeling',
'native.lock.setting': 'Vergrendelen met Face ID of toegangscode',
'native.lock.settingHint':
'Vraagt erom bij het openen van TREK en na een halve minuut op de achtergrond, en verbergt de app in de appkiezer.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Zaktualizuj aplikację TREK',
'native.update.body': 'Ten serwer wymaga nowszej wersji aplikacji, aby wszystko działało.',
'native.update.open': 'Aktualizuj',
'native.lock.title': 'TREK jest zablokowany',
'native.lock.unlock': 'Odblokuj',
'native.lock.reason': 'Odblokuj TREK',
'native.lock.settingTitle': 'Blokada aplikacji',
'native.lock.setting': 'Blokuj za pomocą Face ID lub kodu',
'native.lock.settingHint':
'Pyta przy otwieraniu TREK i po pół minuty w tle oraz ukrywa aplikację w przełączniku aplikacji.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'Обновите приложение TREK',
'native.update.body': 'Для полной работы этому серверу нужна более новая версия приложения.',
'native.update.open': 'Обновить',
'native.lock.title': 'TREK заблокирован',
'native.lock.unlock': 'Разблокировать',
'native.lock.reason': 'Разблокировать TREK',
'native.lock.settingTitle': 'Блокировка приложения',
'native.lock.setting': 'Блокировать с Face ID или кодом',
'native.lock.settingHint':
'Запрашивается при открытии TREK и через полминуты в фоне, а в переключателе приложений содержимое скрыто.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Aktualizujte aplikáciu TREK',
'native.update.body': 'Tento server potrebuje novšiu verziu aplikácie, aby všetko fungovalo.',
'native.update.open': 'Aktualizovať',
'native.lock.title': 'TREK je zamknutý',
'native.lock.unlock': 'Odomknúť',
'native.lock.reason': 'Odomknúť TREK',
'native.lock.settingTitle': 'Zámok aplikácie',
'native.lock.setting': 'Zamknúť pomocou Face ID alebo kódu',
'native.lock.settingHint':
'Opýta sa pri otvorení TREK a po pol minúte na pozadí a skryje aplikáciu v prepínači aplikácií.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'Uppdatera TREK-appen',
'native.update.body': 'Den här servern behöver en nyare version av appen för att allt ska fungera.',
'native.update.open': 'Uppdatera',
'native.lock.title': 'TREK är låst',
'native.lock.unlock': 'Lås upp',
'native.lock.reason': 'Lås upp TREK',
'native.lock.settingTitle': 'Applås',
'native.lock.setting': 'Lås med Face ID eller lösenkod',
'native.lock.settingHint':
'Frågar när TREK öppnas och efter en halv minut i bakgrunden, och döljer appen i appväxlaren.',
};
export default native;
+6
View File
@@ -74,6 +74,12 @@ const native: TranslationStrings = {
'native.update.title': 'อัปเดตแอป TREK',
'native.update.body': 'เซิร์ฟเวอร์นี้ต้องใช้แอปเวอร์ชันใหม่กว่าเพื่อให้ทุกอย่างทำงานได้',
'native.update.open': 'อัปเดต',
'native.lock.title': 'TREK ถูกล็อกอยู่',
'native.lock.unlock': 'ปลดล็อก',
'native.lock.reason': 'ปลดล็อก TREK',
'native.lock.settingTitle': 'ล็อกแอป',
'native.lock.setting': 'ล็อกด้วย Face ID หรือรหัส',
'native.lock.settingHint': 'ถามเมื่อเปิด TREK และหลังจากอยู่เบื้องหลังครึ่งนาที และซ่อนแอปในตัวสลับแอป',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'TREK uygulamasını güncelleyin',
'native.update.body': 'Her şeyin çalışması için bu sunucu uygulamanın daha yeni bir sürümüne ihtiyaç duyuyor.',
'native.update.open': 'Güncelle',
'native.lock.title': 'TREK kilitli',
'native.lock.unlock': 'Kilidi aç',
'native.lock.reason': 'TREK kilidini aç',
'native.lock.settingTitle': 'Uygulama kilidi',
'native.lock.setting': 'Face ID veya parola ile kilitle',
'native.lock.settingHint':
'TREK açılırken ve arka planda yarım dakika sonra sorar, uygulamayı uygulama değiştiricide gizler.',
};
export default native;
+7
View File
@@ -75,6 +75,13 @@ const native: TranslationStrings = {
'native.update.title': 'Оновіть застосунок TREK',
'native.update.body': 'Для повної роботи цьому серверу потрібна новіша версія застосунку.',
'native.update.open': 'Оновити',
'native.lock.title': 'TREK заблоковано',
'native.lock.unlock': 'Розблокувати',
'native.lock.reason': 'Розблокувати TREK',
'native.lock.settingTitle': 'Блокування застосунку',
'native.lock.setting': 'Блокувати за допомогою Face ID або коду',
'native.lock.settingHint':
'Запитує під час відкриття TREK і після пів хвилини у фоні, а в перемикачі застосунків вміст приховано.',
};
export default native;
+7
View File
@@ -76,6 +76,13 @@ const native: TranslationStrings = {
'native.update.title': 'Cập nhật ứng dụng TREK',
'native.update.body': 'Máy chủ này cần phiên bản ứng dụng mới hơn để mọi thứ hoạt động.',
'native.update.open': 'Cập nhật',
'native.lock.title': 'TREK đang bị khóa',
'native.lock.unlock': 'Mở khóa',
'native.lock.reason': 'Mở khóa TREK',
'native.lock.settingTitle': 'Khóa ứng dụng',
'native.lock.setting': 'Khóa bằng Face ID hoặc mật mã',
'native.lock.settingHint':
'Hỏi khi mở TREK và sau nửa phút chạy nền, đồng thời ẩn ứng dụng trong trình chuyển ứng dụng.',
};
export default native;
+6
View File
@@ -74,6 +74,12 @@ const native: TranslationStrings = {
'native.update.title': '請更新 TREK 應用程式',
'native.update.body': '此伺服器需要較新版本的應用程式才能正常使用所有功能。',
'native.update.open': '更新',
'native.lock.title': 'TREK 已鎖定',
'native.lock.unlock': '解鎖',
'native.lock.reason': '解鎖 TREK',
'native.lock.settingTitle': 'App 鎖定',
'native.lock.setting': '使用 Face ID 或密碼鎖定',
'native.lock.settingHint': '開啟 TREK 時以及在背景停留半分鐘後會要求驗證,並在 App 切換器中隱藏內容。',
};
export default native;
+6
View File
@@ -74,6 +74,12 @@ const native: TranslationStrings = {
'native.update.title': '请更新 TREK 应用',
'native.update.body': '此服务器需要更新版本的应用才能正常使用全部功能。',
'native.update.open': '更新',
'native.lock.title': 'TREK 已锁定',
'native.lock.unlock': '解锁',
'native.lock.reason': '解锁 TREK',
'native.lock.settingTitle': '应用锁',
'native.lock.setting': '使用面容 ID 或密码锁定',
'native.lock.settingHint': '打开 TREK 时以及在后台停留半分钟后会要求验证,并在应用切换器中隐藏内容。',
};
export default native;