diff --git a/.changeset/145-app-lock.md b/.changeset/145-app-lock.md new file mode 100644 index 0000000..a6fc035 --- /dev/null +++ b/.changeset/145-app-lock.md @@ -0,0 +1,33 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +The app can lock itself behind Face ID, Touch ID or fingerprint, with the +device passcode as fallback. It is off by default; the user turns it on, and +picks how long the app may stay in the background before it locks +(immediately, 1, 5, 15 or 60 minutes), on a new native "App lock" settings +screen. Every change there (on, off, another timeout) asks for the same +authentication first, and turning it on needs a screen lock on the device. + +- On a return from the background past the timeout, and on a cold start with + the lock on, an opaque native overlay covers the app until the user + authenticates. The Console's WebView stays mounted under it and is not + reloaded; Android back does nothing while it is up. +- While the lock is on, the app-switcher snapshot is protected: blurred on + iOS, and on Android the window is `FLAG_SECURE` (the recents thumbnail is + blank, and screenshots and screen recording are blocked). +- The settings screen opens from the "App lock" row of the Switch organization + sheet, and from the bridge's new `openAppLockSettings()`. The bridge's + `getAppLock()` reads the setting (`{ enabled, timeoutSeconds }`). Both are + read-only capabilities behind the origin check: no bridge method changes the + lock. While the app is locked, bridge methods that open native UI + (`openExternal`, `scan`, `showConnectionSwitcher`, `pickImage`, `share`, + `openFile`, `openAppLockSettings`) fail with "The app is locked". +- New native dependency `expo-screen-capture` (SDK 55). Two permissions it + declares for a screenshot listener the app does not use, `READ_MEDIA_IMAGES` + and `DETECT_SCREEN_CAPTURE`, are blocked. The + Face ID usage text is localized in all ten languages, and the unlock prompt + says "Unlock ObjectOS". diff --git a/__tests__/components/AppLockOverlay.test.tsx b/__tests__/components/AppLockOverlay.test.tsx new file mode 100644 index 0000000..ae2f3d0 --- /dev/null +++ b/__tests__/components/AppLockOverlay.test.tsx @@ -0,0 +1,163 @@ +/** + * The app lock's overlay: what each lock state shows, when the prompt starts + * by itself, Android back, and the layout direction. + */ +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { BackHandler, StyleSheet } from "react-native"; +// Types for the matchers jest.config registers through setupFilesAfterEnv. +import type {} from "@testing-library/react-native/extend-expect"; +import i18n from "~/lib/i18n"; + +// jest.setup.ts mocks this module without a `default`, which is what +// `react-native`'s `AppState` getter reads; give it one for this file. +jest.mock("react-native/Libraries/AppState/AppState", () => { + const appState = { + currentState: "active", + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }; + return { __esModule: true, default: appState, ...appState }; +}); + +const mockUnlockApp = jest.fn(async () => false); +jest.mock("~/lib/app-lock", () => ({ unlockApp: () => mockUnlockApp() })); + +import { AppState } from "react-native"; +import { AppLockOverlay } from "~/components/shell/AppLockOverlay"; +import { + INITIAL_APP_LOCK_STATE, + useSecurityStore, + type AppLockState, +} from "~/stores/security-store"; + +const appState = AppState as unknown as { currentState: string; addEventListener: jest.Mock }; +const ON = { enabled: true, timeoutSeconds: 300 } as const; + +function setLock(state: Partial) { + act(() => useSecurityStore.setState({ ...INITIAL_APP_LOCK_STATE, settings: ON, ...state })); +} + +/** + * Cold-load cost, paid once outside any test's 5 s budget — the same warm-up as + * ServerConfigScreen.test.tsx: the first render requires and transforms + * react-native's lazy component getters (Pressable, Text). + */ +const COLD_LOAD_TIMEOUT_MS = 30_000; +beforeAll(() => { + useSecurityStore.setState({ ...INITIAL_APP_LOCK_STATE, settings: ON, phase: "locked" }); + render().unmount(); +}, COLD_LOAD_TIMEOUT_MS); + +beforeEach(() => { + mockUnlockApp.mockClear(); + appState.currentState = "active"; + appState.addEventListener.mockClear(); + useSecurityStore.setState(INITIAL_APP_LOCK_STATE); +}); + +afterEach(async () => { + await act(async () => { + await i18n.changeLanguage("en"); + }); +}); + +describe("AppLockOverlay", () => { + it("renders nothing while unlocked", () => { + render(); + expect(screen.queryByTestId("app-lock-overlay")).toBeNull(); + }); + + it("covers with the mark alone while the timeout is judged, and does not prompt", () => { + setLock({ phase: "covered", backgroundAt: 0 }); + render(); + expect(screen.getByTestId("app-lock-overlay")).toBeTruthy(); + expect(screen.queryByTestId("app-lock-title")).toBeNull(); + expect(screen.queryByTestId("app-lock-unlock")).toBeNull(); + expect(mockUnlockApp).not.toHaveBeenCalled(); + }); + + it("fills the screen, opaque, above what it covers", () => { + setLock({ phase: "covered" }); + render(); + const style = StyleSheet.flatten(screen.getByTestId("app-lock-overlay").props.style); + expect(style).toMatchObject({ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }); + expect(screen.getByTestId("app-lock-overlay").props.className).toContain("bg-background"); + }); + + it("when locked, shows the title and Unlock and starts the prompt once", () => { + setLock({ phase: "locked", promptPending: true }); + render(); + expect(screen.getByTestId("app-lock-title")).toHaveTextContent("ObjectOS is locked"); + expect(screen.getByTestId("app-lock-unlock")).toHaveTextContent("Unlock"); + expect(mockUnlockApp).toHaveBeenCalledTimes(1); + }); + + it("does not prompt by itself after a cancel; Unlock asks again", () => { + setLock({ phase: "locked", promptPending: false }); + render(); + expect(mockUnlockApp).not.toHaveBeenCalled(); + fireEvent.press(screen.getByTestId("app-lock-unlock")); + expect(mockUnlockApp).toHaveBeenCalledTimes(1); + }); + + it("waits for the app to be active before prompting", () => { + appState.currentState = "background"; + setLock({ phase: "locked", promptPending: true }); + render(); + expect(mockUnlockApp).not.toHaveBeenCalled(); + const calls = appState.addEventListener.mock.calls as unknown as [string, (s: string) => void][]; + const onChange = calls[calls.length - 1][1]; + onChange("inactive"); + expect(mockUnlockApp).not.toHaveBeenCalled(); + onChange("active"); + expect(mockUnlockApp).toHaveBeenCalledTimes(1); + }); + + it("disables Unlock while the prompt is on screen", () => { + setLock({ phase: "unlocking" }); + render(); + expect(screen.getByTestId("app-lock-unlock")).toBeDisabled(); + }); + + it("says why the last attempt did not unlock", () => { + setLock({ phase: "locked", unlockError: "failed" }); + render(); + expect(screen.getByTestId("app-lock-error")).toHaveTextContent( + "Couldn't confirm it's you. Try again." + ); + setLock({ phase: "locked", unlockError: "no-device-lock" }); + expect(screen.getByTestId("app-lock-error")).toHaveTextContent( + "Set up a screen lock in your phone's settings to unlock ObjectOS." + ); + }); + + it("goes away once unlocked", () => { + setLock({ phase: "unlocking" }); + render(); + act(() => useSecurityStore.getState().dispatch({ type: "unlockFinished", ok: true })); + expect(screen.queryByTestId("app-lock-overlay")).toBeNull(); + }); + + it("swallows Android back while up, and only then", () => { + const add = jest.spyOn(BackHandler, "addEventListener"); + render(); + expect(add).not.toHaveBeenCalled(); + setLock({ phase: "locked" }); + expect(add).toHaveBeenCalledWith("hardwareBackPress", expect.any(Function)); + const handler = add.mock.calls[add.mock.calls.length - 1][1] as () => boolean; + expect(handler()).toBe(true); + add.mockRestore(); + }); + + it("lays out right-to-left under Arabic, with the Arabic copy", async () => { + await act(async () => { + await i18n.changeLanguage("ar"); + }); + setLock({ phase: "locked" }); + render(); + expect(StyleSheet.flatten(screen.getByTestId("app-lock-overlay").props.style).direction).toBe( + "rtl" + ); + expect(screen.getByTestId("app-lock-title")).toHaveTextContent("ObjectOS مقفل"); + }); +}); diff --git a/__tests__/components/AppLockSettingsScreen.test.tsx b/__tests__/components/AppLockSettingsScreen.test.tsx new file mode 100644 index 0000000..80b0e69 --- /dev/null +++ b/__tests__/components/AppLockSettingsScreen.test.tsx @@ -0,0 +1,238 @@ +/** + * The native app-lock settings screen: every change goes through the one + * writer, which asks for biometrics; what the screen shows for each outcome; + * a device with no screen lock; leaving; the layout direction. + */ +import React from "react"; +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { Platform, StyleSheet } from "react-native"; +// Types for the matchers jest.config registers through setupFilesAfterEnv. +import type {} from "@testing-library/react-native/extend-expect"; +import i18n from "~/lib/i18n"; + +const mockBack = jest.fn(); +const mockReplace = jest.fn(); +let mockCanGoBack = true; +jest.mock("expo-router", () => ({ + useRouter: () => ({ back: mockBack, replace: mockReplace, canGoBack: () => mockCanGoBack }), +})); + +jest.mock("react-native-safe-area-context", () => { + const { View } = require("react-native"); + return { SafeAreaView: View }; +}); + +const mockChange = jest.fn(); +jest.mock("~/lib/app-lock", () => ({ + changeAppLockSettings: (settings: unknown) => mockChange(settings), +})); + +const mockLevel = jest.fn(); +jest.mock("~/lib/biometric-auth", () => ({ getDeviceLockLevel: () => mockLevel() })); + +import AppLockSettingsScreen from "~/app/app-lock-settings"; +import { + APP_LOCK_OFF, + INITIAL_APP_LOCK_STATE, + useSecurityStore, + type AppLockSettings, +} from "~/stores/security-store"; + +const ON_5_MIN: AppLockSettings = { enabled: true, timeoutSeconds: 300 }; + +/** The stand-in writer commits like the real one when the outcome is "changed". */ +function writerAnswers(outcome: string) { + mockChange.mockImplementation(async (settings: AppLockSettings) => { + if (outcome === "changed") { + useSecurityStore.getState().dispatch({ type: "settingsCommitted", settings }); + } + return outcome; + }); +} + +async function renderScreen() { + render(); + // The device lock level is read on mount. + await act(async () => {}); +} + +/** + * Cold-load cost, paid once outside any test's 5 s budget — the same warm-up as + * ServerConfigScreen.test.tsx: the first render requires and transforms + * react-native's lazy component getters (ScrollView, Pressable, ActivityIndicator). + */ +const COLD_LOAD_TIMEOUT_MS = 30_000; +beforeAll(() => { + mockLevel.mockResolvedValue("biometric"); + render().unmount(); +}, COLD_LOAD_TIMEOUT_MS); + +let originalOS: typeof Platform.OS; + +beforeEach(() => { + mockChange.mockReset(); + mockBack.mockReset(); + mockReplace.mockReset(); + mockCanGoBack = true; + mockLevel.mockReset().mockResolvedValue("biometric"); + writerAnswers("changed"); + useSecurityStore.setState(INITIAL_APP_LOCK_STATE); + originalOS = Platform.OS; +}); + +afterEach(async () => { + Object.defineProperty(Platform, "OS", { value: originalOS, configurable: true }); + await act(async () => { + await i18n.changeLanguage("en"); + }); +}); + +describe("AppLockSettingsScreen", () => { + it("shows the lock off, with no timeouts", async () => { + await renderScreen(); + expect(screen.getByText("App lock")).toBeTruthy(); + expect(screen.getByTestId("app-lock-toggle")).not.toBeChecked(); + expect(screen.queryByTestId("app-lock-timeout-300")).toBeNull(); + expect( + screen.getByText( + "Changing the app lock asks for Face ID, Touch ID or fingerprint, or your phone's passcode." + ) + ).toBeTruthy(); + }); + + it("turning it on goes through the writer, at the current timeout", async () => { + await renderScreen(); + await act(async () => { + fireEvent.press(screen.getByTestId("app-lock-toggle")); + }); + expect(mockChange).toHaveBeenCalledWith({ enabled: true, timeoutSeconds: 300 }); + expect(screen.getByTestId("app-lock-toggle")).toBeChecked(); + expect(screen.getByTestId("app-lock-timeout-selected-300")).toBeTruthy(); + expect(screen.queryByTestId("app-lock-message")).toBeNull(); + }); + + it("offers the five timeouts and changes one through the writer", async () => { + useSecurityStore.setState({ settings: ON_5_MIN }); + await renderScreen(); + for (const [seconds, label] of [ + [0, "Immediately"], + [60, "After 1 minute"], + [300, "After 5 minutes"], + [900, "After 15 minutes"], + [3600, "After 1 hour"], + ] as const) { + expect(screen.getByTestId(`app-lock-timeout-${seconds}`)).toHaveTextContent(label); + } + await act(async () => { + fireEvent.press(screen.getByTestId("app-lock-timeout-60")); + }); + expect(mockChange).toHaveBeenCalledWith({ enabled: true, timeoutSeconds: 60 }); + expect(screen.getByTestId("app-lock-timeout-selected-60")).toBeTruthy(); + expect(screen.queryByTestId("app-lock-timeout-selected-300")).toBeNull(); + }); + + it("turning it off goes through the writer too", async () => { + useSecurityStore.setState({ settings: ON_5_MIN }); + await renderScreen(); + await act(async () => { + fireEvent.press(screen.getByTestId("app-lock-toggle")); + }); + expect(mockChange).toHaveBeenCalledWith({ enabled: false, timeoutSeconds: 300 }); + expect(useSecurityStore.getState().settings).toEqual(APP_LOCK_OFF); + }); + + it("keeps the setting and says so when the prompt is cancelled or fails", async () => { + useSecurityStore.setState({ settings: ON_5_MIN }); + writerAnswers("not-authenticated"); + await renderScreen(); + await act(async () => { + fireEvent.press(screen.getByTestId("app-lock-toggle")); + }); + expect(screen.getByTestId("app-lock-toggle")).toBeChecked(); + expect(screen.getByTestId("app-lock-message")).toHaveTextContent( + "Not changed: it couldn't be confirmed that it's you." + ); + }); + + it("says so when the setting could not be saved", async () => { + writerAnswers("save-failed"); + await renderScreen(); + await act(async () => { + fireEvent.press(screen.getByTestId("app-lock-toggle")); + }); + expect(screen.getByTestId("app-lock-message")).toHaveTextContent( + "The setting couldn't be saved. Try again." + ); + }); + + it("cannot be turned on on a device with no screen lock", async () => { + mockLevel.mockResolvedValue("none"); + await renderScreen(); + expect(screen.getByTestId("app-lock-toggle")).toBeDisabled(); + expect(screen.getByTestId("app-lock-unavailable")).toHaveTextContent( + "Set up a screen lock (passcode, Face ID or fingerprint) in your phone's settings to use the app lock." + ); + fireEvent.press(screen.getByTestId("app-lock-toggle")); + expect(mockChange).not.toHaveBeenCalled(); + }); + + it("can still be turned off on such a device", async () => { + mockLevel.mockResolvedValue("none"); + useSecurityStore.setState({ settings: ON_5_MIN }); + await renderScreen(); + expect(screen.getByTestId("app-lock-toggle")).not.toBeDisabled(); + }); + + it("learns a missing screen lock from the writer, too", async () => { + writerAnswers("unavailable"); + await renderScreen(); + await act(async () => { + fireEvent.press(screen.getByTestId("app-lock-toggle")); + }); + expect(screen.getByTestId("app-lock-toggle")).toBeDisabled(); + }); + + it("asks once at a time: the controls wait while a change runs", async () => { + useSecurityStore.setState({ settings: ON_5_MIN }); + let finish!: (outcome: string) => void; + mockChange.mockImplementation(() => new Promise((resolve) => (finish = resolve))); + await renderScreen(); + await act(async () => { + fireEvent.press(screen.getByTestId("app-lock-timeout-60")); + }); + expect(screen.getByTestId("app-lock-busy")).toBeTruthy(); + expect(screen.getByTestId("app-lock-timeout-900")).toBeDisabled(); + fireEvent.press(screen.getByTestId("app-lock-timeout-900")); + expect(mockChange).toHaveBeenCalledTimes(1); + await act(async () => finish("not-authenticated")); + expect(screen.queryByTestId("app-lock-busy")).toBeNull(); + }); + + it("names the platform's snapshot protection", async () => { + Object.defineProperty(Platform, "OS", { value: "android", configurable: true }); + await renderScreen(); + expect(screen.getByTestId("app-lock-privacy")).toHaveTextContent( + "While the app lock is on, the recent apps screen shows ObjectOS blank, and screenshots and screen recording are blocked." + ); + }); + + it("goes back, or home when there is nothing behind it", async () => { + await renderScreen(); + fireEvent.press(screen.getByTestId("app-lock-back")); + expect(mockBack).toHaveBeenCalledTimes(1); + mockCanGoBack = false; + fireEvent.press(screen.getByTestId("app-lock-back")); + expect(mockReplace).toHaveBeenCalledWith("/"); + }); + + it("lays out right-to-left under Arabic", async () => { + await act(async () => { + await i18n.changeLanguage("ar"); + }); + await renderScreen(); + expect(StyleSheet.flatten(screen.getByTestId("app-lock-settings").props.style).direction).toBe( + "rtl" + ); + expect(screen.getByTestId("app-lock-toggle")).toHaveTextContent(/قفل التطبيق/); + }); +}); diff --git a/__tests__/components/ConnectionSwitcher.test.tsx b/__tests__/components/ConnectionSwitcher.test.tsx index f3256e3..5f77248 100644 --- a/__tests__/components/ConnectionSwitcher.test.tsx +++ b/__tests__/components/ConnectionSwitcher.test.tsx @@ -228,6 +228,32 @@ describe("ConnectionSwitcher", () => { expect(screen.getByTestId("switcher-add")).toBeTruthy(); }); + it("offers App lock with its state, which opens the native settings screen", () => { + const onOpenAppLock = jest.fn(); + renderSwitcher({ onOpenAppLock, appLockEnabled: false }); + expect(screen.getByTestId("switcher-app-lock")).toHaveTextContent(/App lock/); + expect(screen.getByTestId("switcher-app-lock-state")).toHaveTextContent("Off"); + expect(screen.getByLabelText("App lock, Off")).toBeTruthy(); + fireEvent.press(screen.getByTestId("switcher-app-lock")); + expect(onOpenAppLock).toHaveBeenCalledTimes(1); + }); + + it("reads On while the app lock is on", () => { + renderSwitcher({ onOpenAppLock: jest.fn(), appLockEnabled: true }); + expect(screen.getByTestId("switcher-app-lock-state")).toHaveTextContent("On"); + }); + + it("has no App lock row without a way to open it", () => { + renderSwitcher(); + expect(screen.queryByTestId("switcher-app-lock")).toBeNull(); + }); + + it("keeps App lock while the organization locks the server (the two locks are unrelated)", () => { + renderSwitcher({ onOpenAppLock: jest.fn(), lockedHost: "erp.example.com" }); + expect(screen.queryByTestId("switcher-add")).toBeNull(); + expect(screen.getByTestId("switcher-app-lock")).toBeTruthy(); + }); + it("renders nothing while closed", () => { renderSwitcher({ open: false }); expect(screen.queryByTestId("connection-switcher")).toBeNull(); diff --git a/__tests__/components/ConsoleScreen.test.tsx b/__tests__/components/ConsoleScreen.test.tsx index dd66a8a..1851ba3 100644 --- a/__tests__/components/ConsoleScreen.test.tsx +++ b/__tests__/components/ConsoleScreen.test.tsx @@ -14,8 +14,32 @@ const mockWebView = { props: null as any, mounts: 0, injectJavaScript: jest.fn(), + reload: jest.fn(), }; +// jest.setup.ts mocks this module without a `default`, which is what +// `react-native`'s `AppState` getter reads (the app lock's overlay reads it). +jest.mock("react-native/Libraries/AppState/AppState", () => { + const appState = { + currentState: "active", + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }; + return { __esModule: true, default: appState, ...appState }; +}); + +// The lock's only writer, as a stand-in that really writes: if any bridge +// path reached it, the setting would change and these tests would see it. +const mockChangeAppLockSettings = jest.fn(async (settings: unknown) => { + const { useSecurityStore } = require("~/stores/security-store"); + useSecurityStore.getState().dispatch({ type: "settingsCommitted", settings }); + return "changed"; +}); +const mockUnlockApp = jest.fn(async () => false); +jest.mock("~/lib/app-lock", () => ({ + changeAppLockSettings: (settings: unknown) => mockChangeAppLockSettings(settings), + unlockApp: () => mockUnlockApp(), +})); + jest.mock("react-native-webview", () => { const React = require("react"); const { View } = require("react-native"); @@ -26,7 +50,7 @@ jest.mock("react-native-webview", () => { }, []); React.useImperativeHandle(ref, () => ({ injectJavaScript: mockWebView.injectJavaScript, - reload: jest.fn(), + reload: mockWebView.reload, goBack: jest.fn(), })); return ; @@ -89,7 +113,12 @@ jest.mock("~/lib/shell/file-handoff", () => ({ openFileBytes: (...args: unknown[]) => mockFileHandoff.openFileBytes(...args), })); +import { readFileSync } from "fs"; +import { join } from "path"; import ConsoleScreen from "~/app/console"; +import { AppLockOverlay } from "~/components/shell/AppLockOverlay"; +import { BRIDGE_METHODS } from "~/lib/shell/bridge/protocol"; +import { INITIAL_APP_LOCK_STATE, useSecurityStore } from "~/stores/security-store"; import { EMPTY_CONNECTIONS, addConnection, rememberRoute } from "~/lib/connections/model"; import { NO_MANAGED_CONFIG } from "~/lib/managed-config/model"; import { CONNECTION_REMOVED_EVENT } from "~/lib/shell/bridge/protocol"; @@ -154,6 +183,10 @@ beforeEach(() => { mockWebView.props = null; mockWebView.mounts = 0; mockWebView.injectJavaScript.mockReset(); + mockWebView.reload.mockReset(); + mockChangeAppLockSettings.mockClear(); + mockUnlockApp.mockClear(); + useSecurityStore.setState(INITIAL_APP_LOCK_STATE); mockPush.mockReset(); mockReplace.mockReset(); mockClearHostCookies.mockReset().mockResolvedValue({ kind: "cleared", cleared: 1, kept: 0 }); @@ -376,3 +409,130 @@ describe("ConsoleScreen under a managed deployment (MDM)", () => { }); }); }); + +describe("ConsoleScreen and the app lock", () => { + const ON_5_MIN = { enabled: true, timeoutSeconds: 300 } as const; + + /** The value the shell answered call `id` with, from the resolve script it injected. */ + function answerTo(id: string): unknown { + for (const [script] of mockWebView.injectJavaScript.mock.calls) { + const match = /__objectstackNativeResolve\((\{.*\})\);/.exec(script); + if (match) { + const result = JSON.parse(match[1]); + if (result.id === id) return result; + } + } + return undefined; + } + + /** Lock the app the way a return past the timeout does. */ + function lockNow() { + act(() => { + const { dispatch } = useSecurityStore.getState(); + dispatch({ type: "background", at: 0 }); + dispatch({ type: "active", at: 300_000 }); + }); + } + + beforeEach(() => useSecurityStore.setState({ settings: ON_5_MIN })); + + it("getAppLock reads the setting; openAppLockSettings opens the native screen", async () => { + render(); + await post({ type: "call", id: "g1", method: "getAppLock" }, `${ERP}/_console/home`); + expect(answerTo("g1")).toEqual({ id: "g1", ok: true, value: ON_5_MIN }); + await post({ type: "call", id: "o1", method: "openAppLockSettings" }, `${ERP}/_console/home`); + expect(mockPush).toHaveBeenCalledWith("/app-lock-settings"); + expect(answerTo("o1")).toEqual({ id: "o1", ok: true, value: null }); + }); + + it("the switcher's App lock row opens the native screen and shows the state", async () => { + render(); + await openSwitcherFromConsole(); + expect(screen.getByTestId("switcher-app-lock-state")).toHaveTextContent("On"); + fireEvent.press(screen.getByTestId("switcher-app-lock")); + expect(mockPush).toHaveBeenCalledWith("/app-lock-settings"); + expect(screen.queryByTestId("connection-switcher")).toBeNull(); + }); + + it("no bridge method changes the lock, whatever it is sent", async () => { + // Every method the handler dispatches on, read from its source, so a case + // added under an innocent name is covered too; plus write-shaped names. + const handlerSource = readFileSync( + join(__dirname, "../../lib/shell/bridge/handler.ts"), + "utf8" + ); + const handled = [...handlerSource.matchAll(/case "([A-Za-z]+)"/g)].map((m) => m[1]); + const methods = [ + ...new Set([...BRIDGE_METHODS, ...handled, "setAppLock", "disableAppLock", "updateAppLock"]), + ].filter((m) => m !== "resetServer"); + const hostile = { enabled: false, timeoutSeconds: 0, lock: false, appLock: false }; + render(); + await consoleReady(); + let n = 0; + for (const method of [...methods, "resetServer"]) { + for (const args of [hostile, false, undefined]) { + n += 1; + await post({ type: "call", id: `w${n}`, method, args }, `${ERP}/_console/home`); + } + } + expect(methods).toEqual(expect.arrayContaining(["getAppLock", "openAppLockSettings"])); + expect(mockChangeAppLockSettings).not.toHaveBeenCalled(); + expect(useSecurityStore.getState().settings).toEqual(ON_5_MIN); + }); + + it("the WebView stays mounted under the lock, and nothing reloads or re-arms the load state", async () => { + render( + <> + + + + ); + await consoleReady(); + const source = mockWebView.props.source; + expect(mockWebView.mounts).toBe(1); + + lockNow(); + expect(screen.getByTestId("app-lock-overlay")).toBeTruthy(); + expect(screen.getByTestId("app-lock-title")).toHaveTextContent("ObjectOS is locked"); + expect(mockUnlockApp).toHaveBeenCalledTimes(1); + + act(() => { + const { dispatch } = useSecurityStore.getState(); + dispatch({ type: "unlockStarted" }); + dispatch({ type: "unlockFinished", ok: true }); + }); + expect(screen.queryByTestId("app-lock-overlay")).toBeNull(); + expect(mockWebView.mounts).toBe(1); + expect(mockWebView.props.source).toBe(source); + expect(mockWebView.reload).not.toHaveBeenCalled(); + expect(mockWebView.injectJavaScript).not.toHaveBeenCalled(); + + // Still `ready`: a route goes straight into the page, not held for a new load. + await post( + { type: "call", id: "n1", method: "navigate", args: { route: "/apps/crm" } }, + `${ERP}/_console/home` + ); + const scripts = mockWebView.injectJavaScript.mock.calls.map(([script]) => script).join("\n"); + expect(scripts).toContain("objectstack-native:navigate"); + expect(scripts).toContain("/apps/crm"); + }); + + it("while locked, the page cannot open UI above the overlay; the switcher closes when the lock engages", async () => { + render(); + await consoleReady(); + await openSwitcherFromConsole(); + expect(screen.getByTestId("connection-switcher")).toBeTruthy(); + lockNow(); + expect(screen.queryByTestId("connection-switcher")).toBeNull(); + + await post({ type: "call", id: "s2", method: "showConnectionSwitcher" }, `${ERP}/_console/home`); + expect(answerTo("s2")).toEqual({ + id: "s2", + ok: false, + error: { code: "failed", message: "The app is locked" }, + }); + expect(screen.queryByTestId("connection-switcher")).toBeNull(); + await post({ type: "call", id: "o2", method: "openAppLockSettings" }, `${ERP}/_console/home`); + expect(mockPush).not.toHaveBeenCalledWith("/app-lock-settings"); + }); +}); diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index 962bae2..4c96e0e 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -11,6 +11,7 @@ import { import { READY_TIMEOUT_MS } from "~/lib/shell/console-load"; import { EXCURSION_MAX_NAVIGATIONS, EXCURSION_TIMEOUT_MS } from "~/lib/shell/sign-in-excursion"; import { BRIDGE_RESOLVE_GLOBAL, CONNECTION_REMOVED_EVENT } from "~/lib/shell/bridge/protocol"; +import { INITIAL_APP_LOCK_STATE, useSecurityStore } from "~/stores/security-store"; const mockWebView = { props: null as any, @@ -89,6 +90,8 @@ function renderConsole(overrides: Partial = {}) { pickImage: jest.fn().mockResolvedValue(null), share: jest.fn().mockResolvedValue(undefined), openFile: jest.fn().mockResolvedValue(undefined), + getAppLock: jest.fn(() => ({ enabled: false, timeoutSeconds: 300 })), + openAppLockSettings: jest.fn(), }, onOpenNative: jest.fn(), onOpenExternal: jest.fn(), @@ -415,6 +418,8 @@ describe("ConsoleWebView", () => { pickImage: jest.fn().mockResolvedValue(null), share: jest.fn().mockResolvedValue(undefined), openFile: jest.fn().mockResolvedValue(undefined), + getAppLock: jest.fn(() => ({ enabled: false, timeoutSeconds: 300 })), + openAppLockSettings: jest.fn(), }, }); loadStart(); @@ -626,6 +631,8 @@ describe("ConsoleWebView", () => { pickImage: jest.fn().mockResolvedValue(null), share: jest.fn().mockResolvedValue(undefined), openFile: jest.fn().mockResolvedValue(undefined), + getAppLock: jest.fn(() => ({ enabled: false, timeoutSeconds: 300 })), + openAppLockSettings: jest.fn(), }, onOpenNative: jest.fn(), onOpenExternal: jest.fn(), @@ -715,6 +722,28 @@ describe("ConsoleWebView", () => { addSpy.mockRestore(); } }); + + it("never walks the history behind the app lock's overlay", () => { + const addSpy = jest.spyOn(BackHandler, "addEventListener"); + const Platform = require("react-native").Platform; + const original = Platform.OS; + Platform.OS = "android"; + try { + renderConsole(); + act(() => mockWebView.props.onNavigationStateChange({ canGoBack: true })); + const handler = addSpy.mock.calls[addSpy.mock.calls.length - 1][1] as () => boolean; + useSecurityStore.setState({ phase: "locked" }); + expect(handler()).toBe(false); + expect(mockWebView.goBack).not.toHaveBeenCalled(); + useSecurityStore.setState({ phase: "unlocked" }); + expect(handler()).toBe(true); + expect(mockWebView.goBack).toHaveBeenCalledTimes(1); + } finally { + Platform.OS = original; + addSpy.mockRestore(); + useSecurityStore.setState(INITIAL_APP_LOCK_STATE); + } + }); }); describe("ConsoleWebView — saved connections", () => { @@ -1212,6 +1241,8 @@ describe("ConsoleWebView — sign-in excursion", () => { pickImage: jest.fn().mockResolvedValue(null), share: jest.fn().mockResolvedValue(undefined), openFile: jest.fn().mockResolvedValue(undefined), + getAppLock: jest.fn(() => ({ enabled: false, timeoutSeconds: 300 })), + openAppLockSettings: jest.fn(), }, }); await postCall("getPushToken", LOGIN, "d1.1"); diff --git a/__tests__/lib/app-config.test.ts b/__tests__/lib/app-config.test.ts index b0e9764..1cff360 100644 --- a/__tests__/lib/app-config.test.ts +++ b/__tests__/lib/app-config.test.ts @@ -1,7 +1,7 @@ /** * The native permission texts `app.config.ts` writes: every shell language - * gets its own camera and photo-library prompt, and the image picker adds no - * microphone. + * gets its own camera, photo-library and Face ID prompt, and the image picker + * adds no microphone. */ import appConfig from "~/app.config"; import en from "~/locales/en.json"; @@ -20,14 +20,30 @@ describe("app.config permissions", () => { for (const [lproj, { ios }] of Object.entries(locales)) { expect(ios.NSCameraUsageDescription).toEqual(expect.any(String)); expect(ios.NSPhotoLibraryUsageDescription).toEqual(expect.any(String)); - if (lproj !== "en") expect(ios.NSPhotoLibraryUsageDescription).not.toBe(en.permissions.photos); + expect(ios.NSFaceIDUsageDescription).toEqual(expect.any(String)); + if (lproj !== "en") { + expect(ios.NSPhotoLibraryUsageDescription).not.toBe(en.permissions.photos); + expect(ios.NSFaceIDUsageDescription).not.toBe(en.permissions.faceId); + } } expect(locales["zh-Hant"].ios).toEqual({ NSCameraUsageDescription: zh.permissions.camera, NSPhotoLibraryUsageDescription: zh.permissions.photos, + NSFaceIDUsageDescription: zh.permissions.faceId, }); }); + it("gives the app lock its Face ID text, and blocks only the screen-capture module's unused permissions", () => { + const plugins = config.plugins as Plugin[]; + const auth = plugins.find((p) => Array.isArray(p) && p[0] === "expo-local-authentication"); + expect(auth).toEqual(["expo-local-authentication", { faceIDPermission: en.permissions.faceId }]); + // READ_EXTERNAL_STORAGE stays: the image picker and the file system module declare it too. + expect(config.android?.blockedPermissions).toEqual([ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.DETECT_SCREEN_CAPTURE", + ]); + }); + it("configures the image picker for photos only, with the same camera text as the scanner", () => { const plugins = config.plugins as Plugin[]; const options = (name: string) => diff --git a/__tests__/lib/app-lock.test.ts b/__tests__/lib/app-lock.test.ts index ee7b805..063662d 100644 --- a/__tests__/lib/app-lock.test.ts +++ b/__tests__/lib/app-lock.test.ts @@ -1,32 +1,405 @@ /** - * Tests for app-lock — AppState lifecycle monitoring and biometric lock + * The app lock's side effects: the stored setting, the AppState monitor, the + * unlock prompt, the one writer of the setting, and the app-switcher + * protection. */ -import { unlockApp } from "~/lib/app-lock"; -import { useSecurityStore } from "~/stores/security-store"; +// jest.setup.ts mocks this module without a `default`, which is what +// `react-native`'s `AppState` getter reads; give it one for this file. +jest.mock("react-native/Libraries/AppState/AppState", () => { + const appState = { + currentState: "active", + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }; + return { __esModule: true, default: appState, ...appState }; +}); + +import { AppState, Platform } from "react-native"; +import * as SecureStore from "expo-secure-store"; + +// Whole-module stand-ins: the code under test reads these modules through +// `import * as`, which copies the mock's properties at import time, so a +// property added to a shared mock later would never reach it. +jest.mock("expo-local-authentication", () => ({ + __esModule: true, + authenticateAsync: jest.fn(), + getEnrolledLevelAsync: jest.fn(), + SecurityLevel: { NONE: 0, SECRET: 1, BIOMETRIC_WEAK: 2, BIOMETRIC_STRONG: 3 }, +})); +jest.mock("expo-secure-store", () => ({ + __esModule: true, + AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 7, + getItem: jest.fn(), + setItemAsync: jest.fn(), +})); +jest.mock("expo-screen-capture", () => ({ + __esModule: true, + enableAppSwitcherProtectionAsync: jest.fn(), + disableAppSwitcherProtectionAsync: jest.fn(), + preventScreenCaptureAsync: jest.fn(), + allowScreenCaptureAsync: jest.fn(), +})); +jest.mock("expo-web-browser", () => ({ __esModule: true, dismissBrowser: jest.fn() })); + +import { + APP_LOCK_STORAGE_KEY, + _resetAppLockForTests, + applySnapshotProtection, + changeAppLockSettings, + hydrateAppLock, + parseAppLockRecord, + readAppLockSettings, + serializeAppLockSettings, + startAppLock, + startAppLockMonitor, + unlockApp, +} from "~/lib/app-lock"; +import { + APP_LOCK_OFF, + INITIAL_APP_LOCK_STATE, + useSecurityStore, + type AppLockSettings, +} from "~/stores/security-store"; + +const LocalAuth = jest.requireMock("expo-local-authentication") as Record; +const Secure = jest.requireMock("expo-secure-store") as Record; +const mockScreenCapture = jest.requireMock("expo-screen-capture") as Record; +const mockDismissBrowser = (jest.requireMock("expo-web-browser") as Record) + .dismissBrowser; +const appState = AppState as unknown as { currentState: string; addEventListener: jest.Mock }; + +const ON_5_MIN: AppLockSettings = { enabled: true, timeoutSeconds: 300 }; +const ON_IMMEDIATELY: AppLockSettings = { enabled: true, timeoutSeconds: 0 }; + +/** The listener the last monitor registered. */ +function appStateListener(): (next: string) => void { + const calls = appState.addEventListener.mock.calls; + return calls[calls.length - 1][1]; +} + +let originalOS: typeof Platform.OS; + +/** Let pending promise callbacks run. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +beforeEach(() => { + jest.resetAllMocks(); + _resetAppLockForTests(); + useSecurityStore.setState(INITIAL_APP_LOCK_STATE); + appState.currentState = "active"; + appState.addEventListener.mockImplementation(() => ({ remove: jest.fn() })); + Secure.getItem.mockReturnValue(null); + Secure.setItemAsync.mockResolvedValue(undefined); + LocalAuth.authenticateAsync.mockResolvedValue({ success: true }); + LocalAuth.getEnrolledLevelAsync.mockResolvedValue(3); + for (const fn of Object.values(mockScreenCapture)) { + if (typeof fn === "function") fn.mockResolvedValue(undefined); + } + mockDismissBrowser.mockResolvedValue({ type: "dismiss" }); + originalOS = Platform.OS; +}); + +afterEach(() => { + Object.defineProperty(Platform, "OS", { value: originalOS, configurable: true }); +}); + +function setOS(os: "ios" | "android") { + Object.defineProperty(Platform, "OS", { value: os, configurable: true }); +} + +describe("the stored setting", () => { + it("round-trips through its record", () => { + for (const settings of [ON_5_MIN, ON_IMMEDIATELY, APP_LOCK_OFF]) { + expect(parseAppLockRecord(serializeAppLockSettings(settings))).toEqual(settings); + } + expect(JSON.parse(serializeAppLockSettings(ON_5_MIN))).toEqual({ v: 1, enabled: true, timeoutSeconds: 300 }); + }); + + it("reads no record as the lock off", () => { + expect(parseAppLockRecord(null)).toEqual(APP_LOCK_OFF); + }); + + it("fails closed on a record that does not check out", () => { + for (const raw of [ + "not json", + "null", + "[]", + JSON.stringify({ v: 2, enabled: false, timeoutSeconds: 300 }), + JSON.stringify({ v: 1, enabled: "no", timeoutSeconds: 300 }), + JSON.stringify({ v: 1, enabled: false, timeoutSeconds: 42 }), + ]) { + expect(parseAppLockRecord(raw)).toEqual({ enabled: true, timeoutSeconds: 300 }); + } + }); + + it("reads synchronously from SecureStore, and fails closed when it throws", () => { + Secure.getItem.mockReturnValueOnce(serializeAppLockSettings(ON_IMMEDIATELY)); + expect(readAppLockSettings()).toEqual(ON_IMMEDIATELY); + expect(Secure.getItem).toHaveBeenCalledWith(APP_LOCK_STORAGE_KEY, expect.any(Object)); + Secure.getItem.mockImplementationOnce(() => { + throw new Error("keychain unavailable"); + }); + expect(readAppLockSettings()).toEqual({ enabled: true, timeoutSeconds: 300 }); + }); + + it("hydrates once per process: a launch with the lock on starts locked", () => { + Secure.getItem.mockReturnValue(serializeAppLockSettings(ON_5_MIN)); + hydrateAppLock(); + expect(useSecurityStore.getState()).toMatchObject({ phase: "locked", settings: ON_5_MIN }); + useSecurityStore.setState({ phase: "unlocked" }); + hydrateAppLock(); + expect(useSecurityStore.getState().phase).toBe("unlocked"); + expect(Secure.getItem).toHaveBeenCalledTimes(1); + }); +}); + +describe("the AppState monitor", () => { + beforeEach(() => useSecurityStore.setState({ settings: ON_5_MIN })); + + it("covers on background and locks on a return past the timeout", () => { + let now = 1_000; + startAppLockMonitor(() => now); + const onChange = appStateListener(); + onChange("inactive"); + onChange("background"); + expect(useSecurityStore.getState()).toMatchObject({ phase: "covered", backgroundAt: 1_000 }); + now += 300_000; + onChange("active"); + expect(useSecurityStore.getState().phase).toBe("locked"); + }); + + it("unlocks on a return within the timeout", () => { + let now = 0; + startAppLockMonitor(() => now); + const onChange = appStateListener(); + onChange("background"); + now = 299_000; + onChange("active"); + expect(useSecurityStore.getState().phase).toBe("unlocked"); + }); + + it("never locks for inactive alone (a Face ID prompt, Control Center)", () => { + useSecurityStore.setState({ settings: ON_IMMEDIATELY }); + startAppLockMonitor(() => 0); + const onChange = appStateListener(); + onChange("inactive"); + onChange("active"); + expect(useSecurityStore.getState().phase).toBe("unlocked"); + }); + + it("removes its listener on dispose", () => { + const remove = jest.fn(); + appState.addEventListener.mockReturnValueOnce({ remove }); + startAppLockMonitor().dispose(); + expect(remove).toHaveBeenCalledTimes(1); + }); + + describe("while a prompt runs", () => { + function pendingPrompt() { + let resolve!: (r: { success: boolean; error?: string }) => void; + LocalAuth.authenticateAsync.mockImplementationOnce( + () => new Promise((r) => (resolve = r)) + ); + return (r: { success: boolean; error?: string }) => resolve(r); + } + + it("ignores the background of Android's passcode screen", async () => { + useSecurityStore.setState({ settings: ON_IMMEDIATELY }); + startAppLockMonitor(() => 0); + const onChange = appStateListener(); + const finish = pendingPrompt(); + const change = changeAppLockSettings({ enabled: true, timeoutSeconds: 60 }); + await flush(); + onChange("background"); + onChange("active"); + finish({ success: true }); + await expect(change).resolves.toBe("changed"); + expect(useSecurityStore.getState()).toMatchObject({ phase: "unlocked", settings: { timeoutSeconds: 60 } }); + }); + + it("reports the background once the prompt ends if the user really left", async () => { + startAppLockMonitor(() => 7); + const onChange = appStateListener(); + const finish = pendingPrompt(); + const change = changeAppLockSettings({ enabled: true, timeoutSeconds: 0 }); + await flush(); + onChange("background"); + appState.currentState = "background"; + finish({ success: false, error: "system_cancel" }); + await expect(change).resolves.toBe("not-authenticated"); + expect(useSecurityStore.getState()).toMatchObject({ phase: "covered", backgroundAt: 7 }); + appState.currentState = "active"; + onChange("active"); + expect(useSecurityStore.getState().phase).toBe("unlocked"); + }); + + it("covers an unlock that succeeded while the user had left", async () => { + useSecurityStore.setState({ settings: ON_IMMEDIATELY, phase: "locked", promptPending: true }); + startAppLockMonitor(() => 0); + const onChange = appStateListener(); + const finish = pendingPrompt(); + const unlock = unlockApp(); + onChange("background"); + appState.currentState = "background"; + finish({ success: true }); + await expect(unlock).resolves.toBe(true); + expect(useSecurityStore.getState().phase).toBe("covered"); + appState.currentState = "active"; + onChange("active"); + expect(useSecurityStore.getState().phase).toBe("locked"); + }); + }); +}); describe("unlockApp", () => { beforeEach(() => { - jest.clearAllMocks(); - // Reset security store - useSecurityStore.setState({ - biometricEnabled: true, - isLocked: true, - inactivityTimeout: 60, - lastActiveAt: Date.now(), + useSecurityStore.setState({ settings: ON_5_MIN, phase: "locked", promptPending: true }); + }); + + it("unlocks when the prompt succeeds, asking with the passcode fallback and the ObjectOS prompt", async () => { + await expect(unlockApp()).resolves.toBe(true); + expect(useSecurityStore.getState()).toMatchObject({ phase: "unlocked", unlockError: null }); + expect(LocalAuth.authenticateAsync).toHaveBeenCalledWith({ + promptMessage: "Unlock ObjectOS", + cancelLabel: "Cancel", + disableDeviceFallback: false, }); }); - it("unlocks the app when biometric auth succeeds", async () => { - const result = await unlockApp(); - expect(result).toBe(true); - expect(useSecurityStore.getState().isLocked).toBe(false); + it("stays locked with no reason when cancelled", async () => { + LocalAuth.authenticateAsync.mockResolvedValueOnce({ success: false, error: "user_cancel" }); + await expect(unlockApp()).resolves.toBe(false); + expect(useSecurityStore.getState()).toMatchObject({ phase: "locked", unlockError: null, promptPending: false }); }); - it("returns false when auth fails", async () => { - const LocalAuth = require("expo-local-authentication"); - LocalAuth.authenticateAsync.mockResolvedValueOnce({ success: false }); + it("stays locked with the reason when it fails or the device has no lock", async () => { + LocalAuth.authenticateAsync.mockResolvedValueOnce({ success: false, error: "lockout" }); + await unlockApp(); + expect(useSecurityStore.getState().unlockError).toBe("failed"); + LocalAuth.authenticateAsync.mockResolvedValueOnce({ success: false, error: "passcode_not_set" }); + await unlockApp(); + expect(useSecurityStore.getState().unlockError).toBe("no-device-lock"); + }); + + it("treats a native error as a failed attempt, never leaving the prompt phase stuck", async () => { + LocalAuth.authenticateAsync.mockRejectedValueOnce(new Error("boom")); + await expect(unlockApp()).resolves.toBe(false); + expect(useSecurityStore.getState()).toMatchObject({ phase: "locked", unlockError: "failed" }); + }); + + it("does nothing unless locked", async () => { + useSecurityStore.setState({ phase: "unlocked" }); + await expect(unlockApp()).resolves.toBe(false); + useSecurityStore.setState({ phase: "unlocking" }); + await expect(unlockApp()).resolves.toBe(false); + expect(LocalAuth.authenticateAsync).not.toHaveBeenCalled(); + }); +}); + +describe("changeAppLockSettings — the only writer", () => { + it("authenticates, stores, then puts the setting in effect", async () => { + await expect(changeAppLockSettings(ON_5_MIN)).resolves.toBe("changed"); + expect(LocalAuth.authenticateAsync).toHaveBeenCalledWith( + expect.objectContaining({ promptMessage: "Change the app lock", disableDeviceFallback: false }) + ); + expect(Secure.setItemAsync).toHaveBeenCalledWith( + APP_LOCK_STORAGE_KEY, + serializeAppLockSettings(ON_5_MIN), + expect.objectContaining({ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY }) + ); + expect(useSecurityStore.getState().settings).toEqual(ON_5_MIN); + }); + + it("changes nothing when the prompt is cancelled or fails", async () => { + LocalAuth.authenticateAsync.mockResolvedValueOnce({ success: false, error: "user_cancel" }); + await expect(changeAppLockSettings(ON_5_MIN)).resolves.toBe("not-authenticated"); + useSecurityStore.setState({ settings: ON_5_MIN }); + LocalAuth.authenticateAsync.mockResolvedValueOnce({ success: false, error: "authentication_failed" }); + await expect(changeAppLockSettings(APP_LOCK_OFF)).resolves.toBe("not-authenticated"); + expect(Secure.setItemAsync).not.toHaveBeenCalled(); + expect(useSecurityStore.getState().settings).toEqual(ON_5_MIN); + }); + + it("turning the lock off asks too", async () => { + useSecurityStore.setState({ settings: ON_5_MIN }); + await expect(changeAppLockSettings(APP_LOCK_OFF)).resolves.toBe("changed"); + expect(LocalAuth.authenticateAsync).toHaveBeenCalledTimes(1); + expect(useSecurityStore.getState().settings).toEqual(APP_LOCK_OFF); + }); + + it("keeps the old setting when the store write fails", async () => { + Secure.setItemAsync.mockRejectedValueOnce(new Error("disk")); + await expect(changeAppLockSettings(ON_5_MIN)).resolves.toBe("save-failed"); + expect(useSecurityStore.getState().settings).toEqual(APP_LOCK_OFF); + }); + + it("cannot turn the lock on without a passcode or biometrics on the device", async () => { + LocalAuth.getEnrolledLevelAsync.mockResolvedValueOnce(0); + await expect(changeAppLockSettings(ON_5_MIN)).resolves.toBe("unavailable"); + LocalAuth.getEnrolledLevelAsync.mockRejectedValueOnce(new Error("no module")); + await expect(changeAppLockSettings(ON_5_MIN)).resolves.toBe("unavailable"); + LocalAuth.authenticateAsync.mockResolvedValueOnce({ success: false, error: "not_enrolled" }); + await expect(changeAppLockSettings(ON_5_MIN)).resolves.toBe("unavailable"); + expect(Secure.setItemAsync).not.toHaveBeenCalled(); + }); + + it("asks nothing for the current setting or a timeout it does not offer", async () => { + await expect(changeAppLockSettings(APP_LOCK_OFF)).resolves.toBe("unchanged"); + await expect( + changeAppLockSettings({ enabled: true, timeoutSeconds: 42 as unknown as 0 }) + ).resolves.toBe("unchanged"); + expect(LocalAuth.authenticateAsync).not.toHaveBeenCalled(); + }); +}); + +describe("app-switcher protection", () => { + it("blurs the snapshot natively on iOS", async () => { + setOS("ios"); + await applySnapshotProtection(true); + expect(mockScreenCapture.enableAppSwitcherProtectionAsync).toHaveBeenCalledWith(1); + await applySnapshotProtection(false); + expect(mockScreenCapture.disableAppSwitcherProtectionAsync).toHaveBeenCalled(); + expect(mockScreenCapture.preventScreenCaptureAsync).not.toHaveBeenCalled(); + }); + + it("sets FLAG_SECURE on Android under its own key", async () => { + setOS("android"); + await applySnapshotProtection(true); + expect(mockScreenCapture.preventScreenCaptureAsync).toHaveBeenCalledWith("app-lock"); + await applySnapshotProtection(false); + expect(mockScreenCapture.allowScreenCaptureAsync).toHaveBeenCalledWith("app-lock"); + expect(mockScreenCapture.enableAppSwitcherProtectionAsync).not.toHaveBeenCalled(); + }); + + it("survives a build without the module", async () => { + setOS("ios"); + mockScreenCapture.enableAppSwitcherProtectionAsync.mockRejectedValueOnce(new Error("missing")); + await expect(applySnapshotProtection(true)).resolves.toBeUndefined(); + }); +}); + +describe("startAppLock", () => { + it("follows the setting with the protection and closes the in-app browser when the lock engages", async () => { + setOS("ios"); + const stop = startAppLock(); + expect(mockScreenCapture.disableAppSwitcherProtectionAsync).toHaveBeenCalledTimes(1); + useSecurityStore.getState().dispatch({ type: "settingsCommitted", settings: ON_IMMEDIATELY }); + expect(mockScreenCapture.enableAppSwitcherProtectionAsync).toHaveBeenCalledTimes(1); + appStateListener()("background"); + await flush(); + expect(mockDismissBrowser).toHaveBeenCalledTimes(1); + stop(); + useSecurityStore.getState().dispatch({ type: "settingsCommitted", settings: APP_LOCK_OFF }); + expect(mockScreenCapture.disableAppSwitcherProtectionAsync).toHaveBeenCalledTimes(1); + }); - const result = await unlockApp(); - expect(result).toBe(false); + it("does not dismiss anything on Android, where the browser is another app's screen", () => { + setOS("android"); + const stop = startAppLock(); + useSecurityStore.getState().dispatch({ type: "settingsCommitted", settings: ON_IMMEDIATELY }); + expect(mockScreenCapture.preventScreenCaptureAsync).toHaveBeenCalledWith("app-lock"); + appStateListener()("background"); + expect(useSecurityStore.getState().phase).toBe("covered"); + expect(mockDismissBrowser).not.toHaveBeenCalled(); + stop(); }); }); diff --git a/__tests__/lib/biometric-auth-level.test.ts b/__tests__/lib/biometric-auth-level.test.ts new file mode 100644 index 0000000..e42bf2d --- /dev/null +++ b/__tests__/lib/biometric-auth-level.test.ts @@ -0,0 +1,45 @@ +/** + * What the device can unlock with, and how a failed prompt is read — the two + * questions the app lock asks of expo-local-authentication beyond `authenticate`. + */ +jest.mock("expo-local-authentication", () => ({ + __esModule: true, + getEnrolledLevelAsync: jest.fn(), + SecurityLevel: { NONE: 0, SECRET: 1, BIOMETRIC_WEAK: 2, BIOMETRIC_STRONG: 3 }, +})); + +import { authFailureKind, getDeviceLockLevel } from "~/lib/biometric-auth"; + +const LocalAuth = jest.requireMock("expo-local-authentication") as Record; + +describe("getDeviceLockLevel", () => { + it.each([ + [0, "none"], + [1, "passcode"], + [2, "biometric"], + [3, "biometric"], + ])("reads security level %i as %s", async (level, expected) => { + LocalAuth.getEnrolledLevelAsync.mockResolvedValueOnce(level); + await expect(getDeviceLockLevel()).resolves.toBe(expected); + }); +}); + +describe("authFailureKind", () => { + it("reads a dismissed prompt as cancelled", () => { + for (const error of ["user_cancel", "system_cancel", "app_cancel"]) { + expect(authFailureKind(error)).toBe("cancelled"); + } + }); + + it("reads a device with nothing to authenticate with as no-device-lock", () => { + for (const error of ["not_enrolled", "passcode_not_set", "not_available"]) { + expect(authFailureKind(error)).toBe("no-device-lock"); + } + }); + + it("reads everything else, and no code, as failed", () => { + for (const error of ["lockout", "authentication_failed", "unknown", "timeout", undefined]) { + expect(authFailureKind(error)).toBe("failed"); + } + }); +}); diff --git a/__tests__/lib/security-store.test.ts b/__tests__/lib/security-store.test.ts index 71d3ae5..9eb3da9 100644 --- a/__tests__/lib/security-store.test.ts +++ b/__tests__/lib/security-store.test.ts @@ -1,53 +1,223 @@ -import { useSecurityStore } from "~/stores/security-store"; +/** + * The app lock's state machine: the timeout, the overlay states, and what a + * setting change does to them. Pure — `stepAppLock` — plus the store holding it. + */ +import { + APP_LOCK_OFF, + APP_LOCK_TIMEOUTS, + DEFAULT_APP_LOCK_TIMEOUT, + INITIAL_APP_LOCK_STATE, + appLockSnapshot, + isAppLockTimeout, + isAppLocked, + stepAppLock, + useSecurityStore, + type AppLockEvent, + type AppLockSettings, + type AppLockState, +} from "~/stores/security-store"; -describe("security-store", () => { - beforeEach(() => { - useSecurityStore.setState({ - biometricEnabled: false, - isLocked: false, - inactivityTimeout: 300, - lastActiveAt: Date.now(), +const ON_5_MIN: AppLockSettings = { enabled: true, timeoutSeconds: 300 }; +const ON_IMMEDIATELY: AppLockSettings = { enabled: true, timeoutSeconds: 0 }; + +function run(state: AppLockState, ...events: AppLockEvent[]): AppLockState { + return events.reduce(stepAppLock, state); +} + +/** Unlocked with the lock on. */ +const unlocked = (settings: AppLockSettings = ON_5_MIN): AppLockState => ({ + ...INITIAL_APP_LOCK_STATE, + settings, +}); + +describe("app lock settings", () => { + it("offers immediately, 1, 5, 15 and 60 minutes, and defaults to 5", () => { + expect(APP_LOCK_TIMEOUTS).toEqual([0, 60, 300, 900, 3600]); + expect(DEFAULT_APP_LOCK_TIMEOUT).toBe(300); + expect(APP_LOCK_OFF).toEqual({ enabled: false, timeoutSeconds: 300 }); + }); + + it("accepts only the offered timeouts", () => { + for (const t of APP_LOCK_TIMEOUTS) expect(isAppLockTimeout(t)).toBe(true); + for (const t of [-1, 1, 30, 301, "300", null, undefined, Number.NaN]) { + expect(isAppLockTimeout(t)).toBe(false); + } + }); +}); + +describe("stepAppLock", () => { + describe("launch", () => { + it("starts locked, prompting, when the lock is on", () => { + const next = stepAppLock(INITIAL_APP_LOCK_STATE, { type: "hydrated", settings: ON_5_MIN }); + expect(next).toMatchObject({ phase: "locked", promptPending: true, settings: ON_5_MIN }); + }); + + it("starts unlocked when the lock is off", () => { + const next = stepAppLock(INITIAL_APP_LOCK_STATE, { type: "hydrated", settings: APP_LOCK_OFF }); + expect(next).toEqual({ ...INITIAL_APP_LOCK_STATE, settings: APP_LOCK_OFF }); }); }); - it("has correct default state", () => { - const state = useSecurityStore.getState(); - expect(state.biometricEnabled).toBe(false); - expect(state.isLocked).toBe(false); - expect(state.inactivityTimeout).toBe(300); - expect(typeof state.lastActiveAt).toBe("number"); + describe("the timeout", () => { + it("covers the app as it goes to the background, before the timeout is judged", () => { + const next = stepAppLock(unlocked(), { type: "background", at: 1_000 }); + expect(next).toMatchObject({ phase: "covered", backgroundAt: 1_000, promptPending: false }); + expect(isAppLocked(next)).toBe(true); + }); + + it("unlocks again on a return within the timeout", () => { + const next = run( + unlocked(), + { type: "background", at: 1_000 }, + { type: "active", at: 1_000 + 299_999 } + ); + expect(next).toMatchObject({ phase: "unlocked", backgroundAt: null }); + }); + + it("locks on a return at the timeout or past it", () => { + for (const away of [300_000, 300_001, 86_400_000]) { + const next = run( + unlocked(), + { type: "background", at: 1_000 }, + { type: "active", at: 1_000 + away } + ); + expect(next).toMatchObject({ phase: "locked", promptPending: true, backgroundAt: null }); + } + }); + + it("locks on any return with 'immediately'", () => { + const next = run( + unlocked(ON_IMMEDIATELY), + { type: "background", at: 5 }, + { type: "active", at: 5 } + ); + expect(next.phase).toBe("locked"); + }); + + it("reads each timeout in seconds", () => { + for (const seconds of APP_LOCK_TIMEOUTS.filter((s) => s > 0)) { + const settings: AppLockSettings = { enabled: true, timeoutSeconds: seconds }; + const before = run( + unlocked(settings), + { type: "background", at: 0 }, + { type: "active", at: seconds * 1000 - 1 } + ); + const after = run( + unlocked(settings), + { type: "background", at: 0 }, + { type: "active", at: seconds * 1000 } + ); + expect(before.phase).toBe("unlocked"); + expect(after.phase).toBe("locked"); + } + }); + + it("measures from the first background, not a repeat", () => { + const next = run( + unlocked(), + { type: "background", at: 0 }, + { type: "background", at: 299_000 }, + { type: "active", at: 300_000 } + ); + expect(next.phase).toBe("locked"); + }); + + it("never covers or locks while the lock is off", () => { + const next = run( + unlocked(APP_LOCK_OFF), + { type: "background", at: 0 }, + { type: "active", at: 10 * 86_400_000 } + ); + expect(next).toEqual(unlocked(APP_LOCK_OFF)); + }); + + it("ignores a return that was not preceded by a background", () => { + expect(stepAppLock(unlocked(), { type: "active", at: 1e12 })).toEqual(unlocked()); + }); }); - it("toggles biometric enabled", () => { - useSecurityStore.getState().setBiometricEnabled(true); - expect(useSecurityStore.getState().biometricEnabled).toBe(true); - useSecurityStore.getState().setBiometricEnabled(false); - expect(useSecurityStore.getState().biometricEnabled).toBe(false); + describe("the overlay states", () => { + const locked = () => stepAppLock(INITIAL_APP_LOCK_STATE, { type: "hydrated", settings: ON_5_MIN }); + + it("goes locked → unlocking → unlocked on a successful prompt", () => { + const unlocking = stepAppLock(locked(), { type: "unlockStarted" }); + expect(unlocking).toMatchObject({ phase: "unlocking", promptPending: false }); + const done = stepAppLock(unlocking, { type: "unlockFinished", ok: true }); + expect(done).toMatchObject({ phase: "unlocked", unlockError: null }); + expect(isAppLocked(done)).toBe(false); + }); + + it("stays locked, with the reason, on a failed prompt — and without one on a cancel", () => { + const failed = run(locked(), { type: "unlockStarted" }, { type: "unlockFinished", ok: false, error: "failed" }); + expect(failed).toMatchObject({ phase: "locked", unlockError: "failed", promptPending: false }); + const noLock = run( + locked(), + { type: "unlockStarted" }, + { type: "unlockFinished", ok: false, error: "no-device-lock" } + ); + expect(noLock.unlockError).toBe("no-device-lock"); + const cancelled = run(locked(), { type: "unlockStarted" }, { type: "unlockFinished", ok: false }); + expect(cancelled).toMatchObject({ phase: "locked", unlockError: null }); + }); + + it("prompts again on a return from the background while still locked", () => { + const cancelled = run(locked(), { type: "unlockStarted" }, { type: "unlockFinished", ok: false, error: "failed" }); + const back = run(cancelled, { type: "background", at: 1 }, { type: "active", at: 2 }); + expect(back).toMatchObject({ phase: "locked", promptPending: true, unlockError: null }); + }); + + it("keeps the prompt's phase through a background (Android's passcode screen)", () => { + const unlocking = stepAppLock(locked(), { type: "unlockStarted" }); + expect(stepAppLock(unlocking, { type: "background", at: 1 })).toBe(unlocking); + expect(stepAppLock(unlocking, { type: "active", at: 2 })).toBe(unlocking); + }); + + it("ignores an unlock outside its phase", () => { + expect(stepAppLock(unlocked(), { type: "unlockStarted" })).toEqual(unlocked()); + expect(stepAppLock(unlocked(), { type: "unlockFinished", ok: true })).toEqual(unlocked()); + const covered = stepAppLock(unlocked(), { type: "background", at: 0 }); + expect(stepAppLock(covered, { type: "unlockStarted" })).toBe(covered); + }); }); - it("locks and unlocks the app", () => { - useSecurityStore.getState().setLocked(true); - expect(useSecurityStore.getState().isLocked).toBe(true); - useSecurityStore.getState().setLocked(false); - expect(useSecurityStore.getState().isLocked).toBe(false); + describe("a committed setting", () => { + it("turning the lock on, or a new timeout, waits for the next background", () => { + const on = stepAppLock(unlocked(APP_LOCK_OFF), { type: "settingsCommitted", settings: ON_IMMEDIATELY }); + expect(on).toMatchObject({ phase: "unlocked", settings: ON_IMMEDIATELY }); + expect(stepAppLock(on, { type: "background", at: 0 }).phase).toBe("covered"); + }); + + it("turning the lock off lifts whatever it holds", () => { + const covered = stepAppLock(unlocked(), { type: "background", at: 0 }); + const off = stepAppLock(covered, { type: "settingsCommitted", settings: APP_LOCK_OFF }); + expect(off).toEqual(unlocked(APP_LOCK_OFF)); + }); }); +}); - it("sets inactivity timeout", () => { - useSecurityStore.getState().setInactivityTimeout(600); - expect(useSecurityStore.getState().inactivityTimeout).toBe(600); +describe("appLockSnapshot", () => { + it("is a copy of the setting, so a reader cannot change the store's", () => { + useSecurityStore.setState({ ...INITIAL_APP_LOCK_STATE, settings: ON_5_MIN }); + const snapshot = appLockSnapshot(useSecurityStore.getState()); + expect(snapshot).toEqual(ON_5_MIN); + (snapshot as { enabled: boolean }).enabled = false; + expect(useSecurityStore.getState().settings.enabled).toBe(true); }); +}); + +describe("useSecurityStore", () => { + beforeEach(() => useSecurityStore.setState(INITIAL_APP_LOCK_STATE)); - it("sets timeout to zero (disabled)", () => { - useSecurityStore.getState().setInactivityTimeout(0); - expect(useSecurityStore.getState().inactivityTimeout).toBe(0); + it("starts unlocked with the lock off", () => { + expect(useSecurityStore.getState()).toMatchObject(INITIAL_APP_LOCK_STATE); }); - it("records activity with a fresh timestamp", () => { - const before = Date.now(); - useSecurityStore.getState().recordActivity(); - const after = Date.now(); - const { lastActiveAt } = useSecurityStore.getState(); - expect(lastActiveAt).toBeGreaterThanOrEqual(before); - expect(lastActiveAt).toBeLessThanOrEqual(after); + it("applies events through the machine", () => { + const { dispatch } = useSecurityStore.getState(); + dispatch({ type: "hydrated", settings: ON_5_MIN }); + expect(useSecurityStore.getState().phase).toBe("locked"); + dispatch({ type: "unlockStarted" }); + dispatch({ type: "unlockFinished", ok: true }); + expect(useSecurityStore.getState().phase).toBe("unlocked"); }); }); diff --git a/__tests__/lib/shell/bridge-handler.test.ts b/__tests__/lib/shell/bridge-handler.test.ts index 2fb4cb0..91588cb 100644 --- a/__tests__/lib/shell/bridge-handler.test.ts +++ b/__tests__/lib/shell/bridge-handler.test.ts @@ -5,6 +5,7 @@ import { MAX_FILE_BYTES, MAX_MESSAGE_LENGTH, MAX_SHARE_TEXT_LENGTH, + LOCKED_REFUSED_METHODS, PICK_IMAGE_DEFAULTS, SCAN_FORMATS, } from "~/lib/shell/bridge/protocol"; @@ -40,6 +41,8 @@ function setup(overrides: Partial = {}) { pickImage: jest.fn().mockResolvedValue(null), share: jest.fn().mockResolvedValue(undefined), openFile: jest.fn().mockResolvedValue(undefined), + getAppLock: jest.fn(() => ({ enabled: true, timeoutSeconds: 300 })), + openAppLockSettings: jest.fn(), onEvent: jest.fn(), ...overrides, }; @@ -224,6 +227,106 @@ describe("bridge handler", () => { }); }); + describe("app lock", () => { + it("reads the setting with getAppLock", async () => { + const { handle, deps } = setup(); + await expect(handle(call("getAppLock"), PAGE)).resolves.toEqual({ + id: "c1", + ok: true, + value: { enabled: true, timeoutSeconds: 300 }, + }); + expect(deps.getAppLock).toHaveBeenCalledTimes(1); + }); + + it("answers getAppLock with exactly the setting, whatever else the source carries", async () => { + const { handle } = setup({ + getAppLock: () => ({ enabled: false, timeoutSeconds: 0, extra: "x" }) as never, + }); + await expect(handle(call("getAppLock"), PAGE)).resolves.toMatchObject({ + value: { enabled: false, timeoutSeconds: 0 }, + }); + const result = await handle(call("getAppLock"), PAGE); + expect(result && result.ok && Object.keys(result.value as object)).toEqual([ + "enabled", + "timeoutSeconds", + ]); + }); + + it("opens the native settings screen with openAppLockSettings and resolves null", async () => { + const { handle, deps } = setup(); + await expect(handle(call("openAppLockSettings"), PAGE)).resolves.toEqual({ + id: "c1", + ok: true, + value: null, + }); + expect(deps.openAppLockSettings).toHaveBeenCalledTimes(1); + }); + + it("passes both through the origin gate: another page gets no answer and opens nothing", async () => { + const { handle, deps } = setup(); + for (const method of ["getAppLock", "openAppLockSettings"]) { + await expect(handle(call(method), "https://evil.example.com/")).resolves.toBeNull(); + } + expect(deps.getAppLock).not.toHaveBeenCalled(); + expect(deps.openAppLockSettings).not.toHaveBeenCalled(); + }); + + it("answers nothing during a sign-in excursion", async () => { + const { handle, deps } = setup({ inExcursion: () => true }); + await expect(handle(call("getAppLock"), PAGE)).resolves.toBeNull(); + await expect(handle(call("openAppLockSettings"), PAGE)).resolves.toBeNull(); + expect(deps.openAppLockSettings).not.toHaveBeenCalled(); + }); + + it("refuses methods that bring up native UI while the app is locked", async () => { + const { handle, deps } = setup({ isLocked: () => true }); + for (const [method, args] of [ + ["openExternal", { url: "https://example.com" }], + ["scan", undefined], + ["showConnectionSwitcher", undefined], + ["pickImage", undefined], + ["share", { url: "https://acme.example.com/x" }], + ["openFile", { base64: "QUJD", name: "a.txt", mime: "text/plain" }], + ["openAppLockSettings", undefined], + ] as const) { + await expect(handle(call(method, args), PAGE)).resolves.toEqual({ + id: "c1", + ok: false, + error: { code: "failed", message: "The app is locked" }, + }); + } + expect(deps.openExternal).not.toHaveBeenCalled(); + expect(deps.scan).not.toHaveBeenCalled(); + expect(deps.showConnectionSwitcher).not.toHaveBeenCalled(); + expect(deps.pickImage).not.toHaveBeenCalled(); + expect(deps.share).not.toHaveBeenCalled(); + expect(deps.openFile).not.toHaveBeenCalled(); + expect(deps.openAppLockSettings).not.toHaveBeenCalled(); + }); + + it("refuses exactly the methods that bring up native UI", () => { + expect([...LOCKED_REFUSED_METHODS].sort()).toEqual([ + "openAppLockSettings", + "openExternal", + "openFile", + "pickImage", + "scan", + "share", + "showConnectionSwitcher", + ]); + }); + + it("keeps answering the rest while locked, so a Console booting under the lock still works", async () => { + const { handle, deps } = setup({ isLocked: () => true }); + await expect(handle(call("getPushToken"), PAGE)).resolves.toMatchObject({ ok: true }); + await expect(handle(call("getAppLock"), PAGE)).resolves.toMatchObject({ ok: true }); + await expect(handle(call("navigate", { route: "/home" }), PAGE)).resolves.toMatchObject({ + ok: true, + }); + expect(deps.navigate).toHaveBeenCalledWith("/home"); + }); + }); + it("reports unknown methods", async () => { const { handle } = setup(); await expect(handle(call("selfDestruct"), PAGE)).resolves.toMatchObject({ diff --git a/__tests__/lib/shell/bridge-protocol.test.ts b/__tests__/lib/shell/bridge-protocol.test.ts index 6febc57..0b58805 100644 --- a/__tests__/lib/shell/bridge-protocol.test.ts +++ b/__tests__/lib/shell/bridge-protocol.test.ts @@ -1,4 +1,5 @@ import { + APP_LOCK_BRIDGE_METHODS, BRIDGE_CAPABILITIES, BRIDGE_METHODS, CONNECTION_REMOVED_EVENT, @@ -36,6 +37,18 @@ describe("capabilities", () => { expect(CONNECTION_REMOVED_EVENT).toBe("objectstack-native:connectionRemoved"); }); + it("announces the read-only app-lock methods", () => { + expect(BRIDGE_CAPABILITIES).toContain("getAppLock"); + expect(BRIDGE_CAPABILITIES).toContain("openAppLockSettings"); + }); + + it("has no app-lock method beyond reading it and opening the native screen", () => { + // Ruled: the lock changes only on the native screen, behind biometrics. + expect(APP_LOCK_BRIDGE_METHODS).toEqual(["getAppLock", "openAppLockSettings"]); + const lockNamed = BRIDGE_CAPABILITIES.filter((name) => /lock/i.test(name)); + expect(lockNamed).toEqual([...APP_LOCK_BRIDGE_METHODS]); + }); + it("announces pickImage, share and openFile", () => { expect(BRIDGE_CAPABILITIES).toEqual(expect.arrayContaining(["pickImage", "share", "openFile"])); }); diff --git a/__tests__/lib/shell/bridge-scripts.test.ts b/__tests__/lib/shell/bridge-scripts.test.ts index 27cbd82..370fdb8 100644 --- a/__tests__/lib/shell/bridge-scripts.test.ts +++ b/__tests__/lib/shell/bridge-scripts.test.ts @@ -191,6 +191,32 @@ describe("bootstrap script", () => { ]); }); + it("exposes no app-lock function beyond reading it and opening the native screen", () => { + const page = makePage(); + page.run(buildBootstrapScript(info, SERVER_URL)); + const lockNamed = Object.keys(page.window.ObjectStackNative).filter((key) => /lock/i.test(key)); + expect(lockNamed.sort()).toEqual(["getAppLock", "openAppLockSettings"]); + }); + + it("posts getAppLock and openAppLockSettings calls, with no arguments", () => { + const page = makePage(); + page.run( + buildBootstrapScript( + { ...info, capabilities: ["getAppLock", "openAppLockSettings"] }, + SERVER_URL + ) + ); + const api = page.window.ObjectStackNative; + expect(api.has("getAppLock")).toBe(true); + expect(api.has("openAppLockSettings")).toBe(true); + void api.getAppLock({ enabled: false }); + void api.openAppLockSettings({ enabled: false }); + expect(page.posted).toEqual([ + { type: "call", id: expect.any(String), method: "getAppLock" }, + { type: "call", id: expect.any(String), method: "openAppLockSettings" }, + ]); + }); + it("posts pickImage, share and openFile calls with the caller's arguments", () => { const page = makePage(); page.run(buildBootstrapScript(info, SERVER_URL)); diff --git a/app.config.ts b/app.config.ts index 536bac1..8f0bcd8 100644 --- a/app.config.ts +++ b/app.config.ts @@ -11,15 +11,18 @@ import ru from "./locales/ru.json"; import zh from "./locales/zh.json"; // System permission prompts, localized from the app's own bundles (keys -// `permissions.camera` and `permissions.photos`) so one file holds each -// language's copy. iOS reads them +// `permissions.camera`, `permissions.photos` and `permissions.faceId`) so one +// file holds each language's copy. iOS reads them // from `/InfoPlist.strings`; Android's camera prompt is the system's own // text. Keys are iOS localization names, written verbatim as `.lproj`: // one per shell language (lib/i18n.ts). `zh` is Simplified Chinese, so it is // `zh-Hans`; Traditional Chinese (`zh-Hant`) gets the same `zh` copy, because // the shell resolves `zh-TW`/`zh-HK` to `zh` as the Console does. `pt` and `es` // stay unqualified so every regional variant falls back to them. -const PERMISSION_LOCALES: Record = { +const PERMISSION_LOCALES: Record< + string, + { permissions: { camera: string; photos: string; faceId: string } } +> = { en, "zh-Hans": zh, "zh-Hant": zh, @@ -51,6 +54,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { NSCameraUsageDescription: bundle.permissions.camera, NSPhotoLibraryUsageDescription: bundle.permissions.photos, + NSFaceIDUsageDescription: bundle.permissions.faceId, }, }, ]) @@ -71,6 +75,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, // SDK 55 removed `edgeToEdgeEnabled` — edge-to-edge is always on now. package: "ai.objectos.mobile", + // expo-screen-capture (the app lock's FLAG_SECURE) declares these for its + // screenshot listener, which the shell does not use: the app detects no + // screenshots, and pickImage() goes through the system photo picker, which + // needs no media permission. Its READ_EXTERNAL_STORAGE (API 32 and below) + // is not blocked: expo-image-picker and expo-file-system declare it too. + blockedPermissions: [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.DETECT_SCREEN_CAPTURE", + ], }, web: { favicon: "./assets/favicon.png", @@ -102,6 +115,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ microphonePermission: false, }, ], + // The app lock (lib/app-lock.ts): Face ID needs its usage string; the + // biometric permissions on Android come from the same plugin. + [ + "expo-local-authentication", + { faceIDPermission: en.permissions.faceId }, + ], // Cookie manager (removing a connection clears that host's cookies, // lib/connections/cookies.ts). Autolinking links the TurboModule; the // plugin only pins `androidx.webkit` when given `androidWebkitVersion`, diff --git a/app/_layout.tsx b/app/_layout.tsx index 797fa5e..22f2904 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,21 +3,29 @@ import i18n, { followDeviceLanguageOnForeground } from "~/lib/i18n"; // Initiali import { syncRTL, useLayoutDirection } from "~/lib/rtl"; import { useCallback, useEffect } from "react"; +import { View } from "react-native"; import { Stack, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { ErrorBoundary } from "~/components/common/ErrorBoundary"; +import { AppLockOverlay } from "~/components/shell/AppLockOverlay"; +import { hydrateAppLock, startAppLock } from "~/lib/app-lock"; import { initSentry } from "~/lib/sentry"; import { shellConfig } from "~/lib/shell/config"; import { consoleScreenHref } from "~/lib/shell/incoming-link"; import { useServerStore } from "~/stores/server-store"; +import { isAppLocked, useSecurityStore } from "~/stores/security-store"; import { usePushRouting } from "~/hooks/usePushRouting"; if (shellConfig.sentryDsn) { initSentry({ dsn: shellConfig.sentryDsn, release: shellConfig.appVersion }); } +// The app-lock setting is read before the first frame, so a launch with the +// lock on draws the overlay from the start. +hydrateAppLock(); + /** * Root of the shell. Three screens: * index — waits for the persisted server, then redirects @@ -25,6 +33,10 @@ if (shellConfig.sentryDsn) { * console — the WebView hosting the Console * Native business screens, when they arrive, are added beside `console` and * reached through the route rules in `lib/shell/route-owner.ts`. + * + * The app lock's overlay is drawn above the whole stack, as a sibling — not a + * route — so the Console's WebView stays mounted under it and is never + * reloaded. While it is up, the stack is hidden from screen readers. */ export default function RootLayout() { const router = useRouter(); @@ -55,16 +67,26 @@ export default function RootLayout() { ); usePushRouting(serverUrl, openRoute); + useEffect(() => startAppLock(), []); + const appLocked = useSecurityStore(isAppLocked); + return ( - - - - - + + + + + + + + diff --git a/app/app-lock-settings.tsx b/app/app-lock-settings.tsx new file mode 100644 index 0000000..66ec079 --- /dev/null +++ b/app/app-lock-settings.tsx @@ -0,0 +1,188 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Platform, Pressable, ScrollView, Text, View } from "react-native"; +import { useRouter } from "expo-router"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronLeft, ChevronRight } from "lucide-react-native"; +import { changeAppLockSettings, type ChangeOutcome } from "~/lib/app-lock"; +import { getDeviceLockLevel } from "~/lib/biometric-auth"; +import { useLayoutDirection } from "~/lib/rtl"; +import { useThemeColors } from "~/lib/theme-colors"; +import { cn } from "~/lib/utils"; +import { + APP_LOCK_TIMEOUTS, + useSecurityStore, + type AppLockSettings, + type AppLockTimeout, +} from "~/stores/security-store"; + +/** The message a change leaves behind (none for a change that went through). */ +const OUTCOME_KEYS: Partial> = { + "not-authenticated": "appLock.settings.notChanged", + unavailable: "appLock.settings.unavailable", + "save-failed": "appLock.settings.saveFailed", +}; + +/** + * The native app-lock settings screen: the one place the lock is changed. + * Reached from the switcher's "App lock" row and from the bridge's + * `openAppLockSettings()`; the bridge itself can only read the setting. + * + * Every change — on, off, another timeout — asks for Face ID / Touch ID / + * fingerprint (device passcode as fallback) through `changeAppLockSettings`, + * and nothing changes unless that succeeds and the setting is stored. + */ +export default function AppLockSettingsScreen() { + const router = useRouter(); + const { t } = useTranslation(); + const colors = useThemeColors(); + const direction = useLayoutDirection(); + const settings = useSecurityStore((s) => s.settings); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + // Turning the lock on needs a passcode or biometrics on the device. + const [deviceHasLock, setDeviceHasLock] = useState(null); + const Back = direction === "rtl" ? ChevronRight : ChevronLeft; + + useEffect(() => { + let alive = true; + getDeviceLockLevel() + .then((level) => alive && setDeviceHasLock(level !== "none")) + .catch(() => alive && setDeviceHasLock(false)); + return () => { + alive = false; + }; + }, []); + + const change = useCallback( + async (next: AppLockSettings) => { + if (busy) return; + setBusy(true); + setMessage(null); + try { + const outcome = await changeAppLockSettings(next); + if (outcome === "unavailable") setDeviceHasLock(false); + const key = OUTCOME_KEYS[outcome]; + setMessage(key ? t(key) : null); + } finally { + setBusy(false); + } + }, + [busy, t] + ); + + const leave = useCallback(() => { + if (router.canGoBack()) router.back(); + else router.replace("/"); + }, [router]); + + const canTurnOn = deviceHasLock !== false; + const toggleDisabled = busy || (!settings.enabled && !canTurnOn); + const privacyKey = + Platform.OS === "android" ? "appLock.settings.privacy.android" : "appLock.settings.privacy.ios"; + + return ( + + + + + + + {t("appLock.settings.title")} + + {busy ? : null} + + + + + {t("appLock.settings.description")} + + + void change({ ...settings, enabled: !settings.enabled })} + className={cn( + "flex-row items-center gap-4 rounded-2xl border border-border px-5 py-4", + toggleDisabled && "opacity-50" + )} + > + + {t("appLock.settings.toggle")} + + + + + + + {settings.enabled ? ( + + + {t("appLock.settings.timeoutTitle")} + + + {APP_LOCK_TIMEOUTS.map((seconds: AppLockTimeout, index) => { + const selected = settings.timeoutSeconds === seconds; + return ( + void change({ enabled: true, timeoutSeconds: seconds })} + className={cn( + "flex-row items-center px-5 py-4", + index > 0 && "border-t border-border" + )} + > + + {t(`appLock.settings.timeout.${seconds}`)} + + {selected ? ( + + + + ) : null} + + ); + })} + + + ) : null} + + {message ? ( + + {message} + + ) : !settings.enabled && deviceHasLock === false ? ( + + {t("appLock.settings.unavailable")} + + ) : null} + + + {t("appLock.settings.authNote")} + + {t(privacyKey)} + + + + + ); +} diff --git a/app/console.tsx b/app/console.tsx index fa88260..95f4e60 100644 --- a/app/console.tsx +++ b/app/console.tsx @@ -20,6 +20,10 @@ import { openFileBytes, pickImage, shareContent } from "~/lib/shell/file-handoff import { DEFAULT_ROUTE_RULES } from "~/lib/shell/route-owner"; import { AVAILABLE_NATIVE_SCREENS, NATIVE_SCREENS } from "~/lib/shell/native-screens"; import { useServerStore } from "~/stores/server-store"; +import { appLockSnapshot, isAppLocked, useSecurityStore } from "~/stores/security-store"; + +/** The native app-lock settings screen (the bridge's `openAppLockSettings()` and the switcher). */ +const APP_LOCK_SETTINGS = "/app-lock-settings"; /** * The Console screen. Receives Console routes as the `route` search param @@ -34,6 +38,10 @@ import { useServerStore } from "~/stores/server-store"; * confirm step (first launch, or a new address pushed while the app runs), the * picker takes over. While the server is locked, the managed connection * cannot be removed (switcher, `resetServer()`), and nothing can be added. + * + * The switcher's "App lock" row and the bridge's `openAppLockSettings()` open + * the native app-lock settings screen; the bridge reads the setting with + * `getAppLock()` and has no way to change it. */ export default function ConsoleScreen() { const router = useRouter(); @@ -52,6 +60,14 @@ export default function ConsoleScreen() { const webview = useRef(null); const [switcherOpen, setSwitcherOpen] = useState(false); const [removingHost, setRemovingHost] = useState(null); + const appLockEnabled = useSecurityStore((s) => s.settings.enabled); + const appLocked = useSecurityStore(isAppLocked); + + // The switcher is a modal sheet, presented above every view: the app lock's + // overlay could not cover it, so it closes when the lock engages. + useEffect(() => { + if (appLocked) setSwitcherOpen(false); + }, [appLocked]); // The first route seeds the WebView source; later ones navigate it. const initialRoute = useRef(route ?? null).current; @@ -79,6 +95,10 @@ export default function ConsoleScreen() { pickImage, share: shareContent, openFile: openFileBytes, + // Read-only: the setting changes only on the native screen, after biometrics. + getAppLock: () => appLockSnapshot(useSecurityStore.getState()), + openAppLockSettings: () => router.push(APP_LOCK_SETTINGS), + isLocked: () => isAppLocked(useSecurityStore.getState()), }), [reset, router, openSwitcher] ); @@ -101,6 +121,11 @@ export default function ConsoleScreen() { [select] ); + const openAppLockSettings = useCallback(() => { + setSwitcherOpen(false); + router.push(APP_LOCK_SETTINGS); + }, [router]); + // Back to the address step: a connection is saved only from its confirm step. const addConnection = useCallback(() => { setSwitcherOpen(false); @@ -163,6 +188,8 @@ export default function ConsoleScreen() { onRemove={(host) => void removeConnection(host)} onAdd={addConnection} onOpenSettings={() => void Linking.openSettings()} + appLockEnabled={appLockEnabled} + onOpenAppLock={openAppLockSettings} /> ); diff --git a/components/shell/AppLockOverlay.tsx b/components/shell/AppLockOverlay.tsx new file mode 100644 index 0000000..06e4a4b --- /dev/null +++ b/components/shell/AppLockOverlay.tsx @@ -0,0 +1,113 @@ +import React, { useEffect } from "react"; +import { AppState, BackHandler, Pressable, StyleSheet, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Lock } from "lucide-react-native"; +import { unlockApp } from "~/lib/app-lock"; +import { useLayoutDirection } from "~/lib/rtl"; +import { useThemeColors } from "~/lib/theme-colors"; +import { cn } from "~/lib/utils"; +import { isAppLocked, useSecurityStore } from "~/stores/security-store"; + +/** The copy under the title for each unlock failure. */ +const ERROR_KEYS = { + failed: "appLock.overlay.failed", + "no-device-lock": "appLock.overlay.noDeviceLock", +} as const; + +/** + * The app lock's overlay: an opaque native view over everything the stack + * shows, the Console's WebView included. It is a sibling drawn above the + * navigation stack in the root layout — not a route and not a reload — so the + * WebView stays mounted under it, the page keeps its state, and the Console's + * load state machine sees nothing. + * + * - `covered` (the app is in or just back from the background): the cover + * alone, while the timeout is judged, so the page does not flash on return. + * - `locked`: the title and Unlock; the system prompt starts by itself once + * per lock while the app is active, and again after each return from the + * background. A cancel leaves the overlay with Unlock. + * - `unlocking`: the prompt is on screen; Unlock waits. + * + * While it is up, Android back does nothing (the WebView's history is not + * walked behind it), and the views under it are hidden from screen readers by + * the root layout. + */ +export function AppLockOverlay() { + const { t } = useTranslation(); + const colors = useThemeColors(); + const direction = useLayoutDirection(); + const phase = useSecurityStore((s) => s.phase); + const promptPending = useSecurityStore((s) => s.promptPending); + const unlockError = useSecurityStore((s) => s.unlockError); + const up = isAppLocked({ phase }); + + useEffect(() => { + if (!up) return; + const sub = BackHandler.addEventListener("hardwareBackPress", () => true); + return () => sub.remove(); + }, [up]); + + useEffect(() => { + if (phase !== "locked" || !promptPending) return; + if (AppState.currentState === "active") { + void unlockApp(); + return; + } + const sub = AppState.addEventListener("change", (next) => { + if (next === "active") void unlockApp(); + }); + return () => sub.remove(); + }, [phase, promptPending]); + + if (!up) return null; + + const covered = phase === "covered"; + + return ( + + + + + {covered ? null : ( + <> + + {t("appLock.overlay.title")} + + {unlockError ? ( + + {t(ERROR_KEYS[unlockError])} + + ) : null} + void unlockApp()} + className={cn( + "mt-2 h-12 min-w-40 items-center justify-center rounded-xl bg-primary px-6", + phase === "unlocking" ? "opacity-50" : "active:opacity-90" + )} + > + + {t("appLock.overlay.unlock")} + + + + )} + + ); +} diff --git a/components/shell/ConnectionSwitcher.tsx b/components/shell/ConnectionSwitcher.tsx index 6ca812b..6bbb78a 100644 --- a/components/shell/ConnectionSwitcher.tsx +++ b/components/shell/ConnectionSwitcher.tsx @@ -5,7 +5,7 @@ import { GestureHandlerRootView, ScrollView } from "react-native-gesture-handler import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; -import { Check, ChevronLeft, ChevronRight, Plus } from "lucide-react-native"; +import { Check, ChevronLeft, ChevronRight, Lock, Plus } from "lucide-react-native"; import { BottomSheet } from "~/components/ui/BottomSheet"; import type { Connection } from "~/lib/connections/model"; import { useLayoutDirection } from "~/lib/rtl"; @@ -32,6 +32,10 @@ export interface ConnectionSwitcherProps { onAdd: () => void; /** App settings. */ onOpenSettings: () => void; + /** The app lock is on: the "App lock" row reads On, else Off. */ + appLockEnabled?: boolean; + /** Open the native app-lock settings screen; without it the row is not shown. */ + onOpenAppLock?: () => void; } /** Height of the scrolling list: about five rows. */ @@ -46,6 +50,10 @@ const LIST_MAX_HEIGHT = 400; * itself — it goes back to the address step and its confirm step — and app * settings. While the server is locked by the organization, the managed row * has no Remove and there is no add. + * + * "App lock" opens the native app-lock settings screen: the one place the + * lock is changed, behind biometrics. (Unrelated to the organization's server + * lock above.) */ export function ConnectionSwitcher({ open, @@ -58,6 +66,8 @@ export function ConnectionSwitcher({ onRemove, onAdd, onOpenSettings, + appLockEnabled = false, + onOpenAppLock, }: ConnectionSwitcherProps) { const { t } = useTranslation(); const colors = useThemeColors(); @@ -115,6 +125,23 @@ export function ConnectionSwitcher({ : t("switcher.lockedFootnote", { label: lockedLabel })} + {onOpenAppLock ? ( + + + {t("appLock.entry")} + + {t(appLockEnabled ? "appLock.on" : "appLock.off")} + + + + ) : null} + { if (Platform.OS !== "android") return; const sub = BackHandler.addEventListener("hardwareBackPress", () => { + if (isAppLocked(useSecurityStore.getState())) return false; if (canGoBackRef.current && !failure) { webviewRef.current?.goBack(); return true; diff --git a/docs/HYBRID-SHELL.md b/docs/HYBRID-SHELL.md index b349358..ac13f86 100644 --- a/docs/HYBRID-SHELL.md +++ b/docs/HYBRID-SHELL.md @@ -29,6 +29,7 @@ app/ connect-scan.tsx QR scanner for the server address (screen 6) bridge-scan.tsx scanner for the bridge's `scan()` (Console fields) console.tsx the Console screen (one long-lived WebView) and the switcher + app-lock-settings.tsx native app-lock settings: on/off and the timeout, behind biometrics +native-intent.tsx app links → Console routes components/shell/ ConsoleWebView.tsx WebView + navigation policy + bridge + back button + load states @@ -37,11 +38,14 @@ components/shell/ ServerConfirm.tsx "is this your server?" step of the picker QrScanner.tsx full-screen camera QR scanner (reusable; `accept` decides) OfflineFallback.tsx native page for network / 5xx failures + AppLockOverlay.tsx the app lock's cover over everything, with Unlock lib/ i18n.ts shell strings in the Console's ten locales; follows the phone language rtl.ts right-to-left layout for Arabic server-probe.ts anonymous probe of a server before it is saved server-url.ts persistence of the saved connections (and the one-time migration) + app-lock.ts app lock: the stored setting, AppState monitor, unlock, the one writer + biometric-auth.ts expo-local-authentication: prompt, device lock level lib/managed-config/ model.ts MDM managed config: serverUrl, lockServer, precedence, the lock native.ts reading it through modules/managed-config, on launch and foreground @@ -51,6 +55,7 @@ lib/connections/ sign-out.ts removing a connection: push token through the Console, then cookies stores/ server-store.ts the saved connections in memory, for the screens + security-store.ts the app lock's setting and state machine modules/managed-config/ local Expo module (iOS/Android reader) and its config plugin lib/shell/ console-url.ts server URL normalisation, Console URL ⇄ route @@ -331,6 +336,76 @@ In the shell: - `trustedHosts` is not read from MDM: the navigation policy and the bridge's trusted origins come only from the build. +## App lock + +The user can hide the app behind Face ID, Touch ID or fingerprint, with the +device passcode as fallback, when it comes back from the background. The +setting is the user's, on this device: **off** by default, or **on** with a +timeout of immediately, 1, 5 (the default), 15 or 60 minutes. + +- **Changed only on a native screen, behind biometrics.** The settings screen + (`app/app-lock-settings.tsx`) is the one place the setting changes: turning + it on, off, or choosing another timeout each asks for the same + authentication as an unlock (`changeAppLockSettings` in `lib/app-lock.ts`, + the only writer), and nothing changes unless that succeeds and the setting + is stored. Turning it on needs a screen lock on the device + (`getEnrolledLevelAsync` above `NONE`). The screen is reached from the + switcher's "App lock" row (the one native menu over the Console, next to + "App settings") and from the bridge's `openAppLockSettings()`. The bridge + reads the setting and cannot change it (see "Reading the app lock"). +- **Stored** as one versioned record in SecureStore (`objectstack_app_lock`, + `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY`) and read synchronously before the + first frame (`hydrateAppLock`). No record: off. A record that does not check + out, or a keychain read that throws, fails closed: on, at the default + timeout. A failed write changes nothing. +- **When it locks.** The AppState monitor stamps the time the app goes to + `background` and, on the return to `active`, compares it with the timeout. + `inactive` alone never locks: the Face ID prompt itself, Control Center and + call banners make the app inactive. A cold start with the lock on starts + locked: a killed app gives no time to compare. A background seen while a + prompt runs is held — Android's passcode fallback is an activity of its own + — and counts only if the app is still in the background when the prompt + ends. +- **The overlay** (`components/shell/AppLockOverlay.tsx`) is an opaque native + view drawn above the navigation stack in the root layout, as a sibling. It + is not a route and nothing reloads: the WebView stays mounted under it, the + page keeps its state, and the load state machine (see "Load states") sees no + event. Its states (`stores/security-store.ts`, `stepAppLock`): + + | State | Entered on | Shows | + | ----------- | ------------------------------------------------ | ----- | + | `covered` | going to the background with the lock on | the cover alone, while the timeout is judged | + | `locked` | a return past the timeout; a cold start | title, Unlock; the prompt starts by itself once the app is active | + | `unlocking` | the prompt is on screen | Unlock waits | + | `unlocked` | a return within the timeout; a successful prompt | nothing | + + A cancelled prompt leaves Unlock; a failed one says why (a device without a + screen lock is told to set one up). Each return from the background while + locked prompts again. While the overlay is up, Android back does nothing + (the WebView's handler steps aside and the overlay's swallows the press), + the stack under it is hidden from screen readers, the switcher sheet + closes, and on iOS the in-app browser is dismissed: both are presented above + every view. +- **The bridge while locked.** Methods that bring up native UI + (`openExternal`, `scan`, `showConnectionSwitcher`, `pickImage`, `share`, + `openFile`, `openAppLockSettings`) are answered `failed` ("The app is locked"). The others are answered as + usual, so a Console that boots under the lock on a cold start still gets + its push token. +- **The app-switcher snapshot** is protected while the lock is on, through + `expo-screen-capture`: on iOS it is blurred natively on resign-active, + before the system takes it (`enableAppSwitcherProtectionAsync`); on Android + the window is `FLAG_SECURE` (`preventScreenCaptureAsync`), which blanks the + recents thumbnail and also blocks screenshots and screen recording while the + lock is on. The module's manifest asks for photo and screen-capture + permissions for a screenshot listener the shell does not use; `app.config.ts` + blocks `READ_MEDIA_IMAGES` and `DETECT_SCREEN_CAPTURE`. Its + `READ_EXTERNAL_STORAGE` (API 32 and below) stays, because the image picker + and the file system module declare it as well. +- **Face ID** needs its usage string: `permissions.faceId` in each locale + bundle, written into `InfoPlist.strings` per language like the camera's. +- **Unrelated to the organization's server lock** (`lockServer`, see + "Managed deployment"): MDM neither sets nor reads the app lock. + ## Navigation policy Every navigation the page attempts goes through `decideNavigation` @@ -488,6 +563,8 @@ interface ObjectStackNative { }): Promise<{ base64: string; name: string; mime: string } | null>; // see "Files" share(content: { url?: string; text?: string }): Promise; // the OS share sheet openFile(file: { base64: string; name: string; mime: string }): Promise; // bytes, never a URL + getAppLock(): Promise<{ enabled: boolean; timeoutSeconds: number }>; // read-only, see "Reading the app lock" + openAppLockSettings(): Promise; // open the native app-lock settings screen ready(): void; // event: the Console has mounted (lifts the cover, releases a held route) routeChanged(route: string): void; // event: SPA route changed (the shell's last route) @@ -653,6 +730,36 @@ button calls it when `has("showConnectionSwitcher")`. next one becomes current — and opens the picker. It does not sign out: like the single-server shell before it, it clears no cookies. +### Reading the app lock + +The bridge's whole app-lock surface is read-only (`APP_LOCK_BRIDGE_METHODS`): + +- `getAppLock()` resolves the setting, `{ enabled, timeoutSeconds }` + (`timeoutSeconds` is `0` for "immediately"), copied out of the store. +- `openAppLockSettings()` opens the native settings screen and resolves + `null`; it takes no arguments. What the user does there is the shell's. + +No bridge method changes the lock. A page that could would let any page on +the server's origin — or, on an old Android WebView, any iframe in it (Known +limits) — turn off a lock the user set, silently. Both methods go through the +origin gate like every other: another page gets no answer and opens nothing, +and during a sign-in excursion nothing is answered. While the app is locked, +`openAppLockSettings()` is refused like the other methods that bring up native +UI. A test sends every method the handler dispatches on, with write-shaped +arguments, through the Console screen's real wiring and checks the setting +did not move. + +```js +if (ObjectStackNative.has("openAppLockSettings")) { + const { enabled } = await ObjectStackNative.getAppLock(); + row.label = enabled ? "On" : "Off"; + row.onclick = () => ObjectStackNative.openAppLockSettings(); +} +``` + +The Console reads the setting again when it needs it: no event tells it the +setting changed. + ### Shell → Console events When a notification tap, an app link or a bridge `navigate()` asks for a @@ -889,6 +996,8 @@ The Console needs, in `@object-ui/mobile`: fill the field with what `scan()` resolves (nothing on `null`); - when `has("showConnectionSwitcher")`, make the header's organization button call `showConnectionSwitcher()`; +- when `has("openAppLockSettings")`, offer an "App lock" entry on the profile + page that shows `getAppLock()`'s state and calls `openAppLockSettings()`; - when `has("connectionRemoved")`, listen for `objectstack-native:connectionRemoved`, call `preventDefault()`, and hand the push-token unregistration to `event.detail.waitUntil`; @@ -935,6 +1044,15 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). - Locale list, phone-language matching and bundle completeness — `__tests__/lib/i18n.test.ts`; right-to-left native screens under `ar` — `__tests__/components/RtlLayout.test.tsx`. +- The app lock: the state machine and the timeout + (`__tests__/lib/security-store.test.ts`), the stored setting, the monitor, + unlock, the writer and the snapshot protection (`app-lock.test.ts`), the + bridge methods and the lock's refusals (`bridge-handler.test.ts`), the + overlay (`AppLockOverlay.test.tsx`), the settings screen + (`AppLockSettingsScreen.test.tsx`), and, through the Console screen's real + wiring, that no bridge method changes the lock and that the WebView stays + mounted and `ready` under the overlay (`ConsoleScreen.test.tsx`, "the app + lock"). - The scanner and screen 6 — `__tests__/components/QrScanner.test.tsx`, with a mocked `expo-camera` (permission states, refused and accepted codes, torch). - The bridge's `scan()`: the handler (`bridge-handler.test.ts`), the session @@ -1008,6 +1126,11 @@ See [PUSH-NOTIFICATIONS.md](./PUSH-NOTIFICATIONS.md). - **Push taps carry no connection.** A notification is routed by its `route` to the current connection's Console; a tap on one from another saved server opens that route on the current one. +- **The app lock counts every trip out of the app.** With a short timeout, a + return from anything that sends the app to the background locks it: on + Android that includes the Custom Tab of the in-app browser, a file chooser + and the camera of a file input. On iOS those are presented in the app and + do not count. - **Swipe direction under Arabic before a restart.** The switcher's swipe actions are laid out from `I18nManager.isRTL`, which follows a language change on the next launch (see "Language"). diff --git a/lib/app-lock.ts b/lib/app-lock.ts index 773ec7a..7ad726b 100644 --- a/lib/app-lock.ts +++ b/lib/app-lock.ts @@ -1,77 +1,329 @@ /** - * App-lock controller. + * App lock — the side effects around the lock state in + * `stores/security-store.ts`. * - * Monitors the app lifecycle (foreground / background) and locks the app - * after the configured inactivity timeout. When locked the user must - * authenticate via biometrics (or device passcode) to continue. + * - The setting (on/off, timeout) is kept in SecureStore and read + * synchronously at launch ({@link hydrateAppLock}), so a launch with the + * lock on starts locked before the first frame. + * - The AppState monitor ({@link startAppLockMonitor}) reports going to the + * background and coming back from it. `inactive` alone (a Face ID prompt, + * Control Center, an incoming call banner) never locks. + * - {@link unlockApp} runs the system prompt: Face ID / Touch ID / fingerprint + * with the device passcode as fallback. + * - {@link changeAppLockSettings} is the one writer of the setting. It asks + * for the same authentication first, and only the native settings screen + * calls it. The bridge has no path to it: it reads the setting + * (`getAppLock()`) and opens that screen (`openAppLockSettings()`), and + * nothing else (docs/HYBRID-SHELL.md, "App lock"). + * - While the lock is on, the app-switcher snapshot is protected: blurred + * natively on iOS, and on Android the window is `FLAG_SECURE`, which blanks + * the recents thumbnail (and blocks screenshots and screen recording). + * + * Nothing here touches the Console's load state machine: the overlay is a + * view over the WebView, which stays mounted and is never reloaded. + */ +import { AppState, Platform, type AppStateStatus } from "react-native"; +import * as SecureStore from "expo-secure-store"; +import * as ScreenCapture from "expo-screen-capture"; +import * as WebBrowser from "expo-web-browser"; +import i18n from "~/lib/i18n"; +import { authFailureKind, authenticate, getDeviceLockLevel } from "~/lib/biometric-auth"; +import { + APP_LOCK_OFF, + DEFAULT_APP_LOCK_TIMEOUT, + isAppLockTimeout, + isAppLocked, + useSecurityStore, + type AppLockSettings, +} from "~/stores/security-store"; + +export const APP_LOCK_STORAGE_KEY = "objectstack_app_lock"; +const RECORD_VERSION = 1; + +const STORE_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, +}; + +/** What a stored record that cannot be trusted reads as: on, at the default timeout. */ +const FAIL_CLOSED: AppLockSettings = Object.freeze({ + enabled: true, + timeoutSeconds: DEFAULT_APP_LOCK_TIMEOUT, +}); + +/** Key of the screen-capture block on Android (so it never lifts another holder's). */ +const SCREEN_CAPTURE_KEY = "app-lock"; + +/** + * The setting a stored value stands for. No value: the lock is off. A value + * that does not check out (corrupt, or written by a newer build) fails closed: + * someone turned the lock on or changed it, so it stays on. + */ +export function parseAppLockRecord(raw: string | null): AppLockSettings { + if (raw === null) return APP_LOCK_OFF; + try { + const record = JSON.parse(raw) as Record | null; + if ( + typeof record === "object" && + record !== null && + record.v === RECORD_VERSION && + typeof record.enabled === "boolean" && + isAppLockTimeout(record.timeoutSeconds) + ) { + return { enabled: record.enabled, timeoutSeconds: record.timeoutSeconds }; + } + } catch { + // fall through + } + return FAIL_CLOSED; +} + +export function serializeAppLockSettings(settings: AppLockSettings): string { + return JSON.stringify({ + v: RECORD_VERSION, + enabled: settings.enabled, + timeoutSeconds: settings.timeoutSeconds, + }); +} + +/** + * The stored setting, read synchronously. A read that throws fails closed as + * well: the keychain could not answer, and it may hold "on". + */ +export function readAppLockSettings(): AppLockSettings { + try { + return parseAppLockRecord(SecureStore.getItem(APP_LOCK_STORAGE_KEY, STORE_OPTIONS)); + } catch { + return FAIL_CLOSED; + } +} + +let hydrated = false; + +/** Read the stored setting into the store, once per process. Call before the first render. */ +export function hydrateAppLock(): void { + if (hydrated) return; + hydrated = true; + useSecurityStore.getState().dispatch({ type: "hydrated", settings: readAppLockSettings() }); +} + +/* + * Authentication in flight. On Android the passcode fallback is an activity of + * its own, which sends this app to the background and back; that is not the + * user leaving. A background seen while a prompt runs is therefore held, and + * reported when the prompt ends only if the app is still in the background + * then (the user did leave: iOS and Android both cancel the prompt). */ -import { AppState, type AppStateStatus } from "react-native"; -import { useSecurityStore } from "~/stores/security-store"; -import { authenticate } from "~/lib/biometric-auth"; +let authInFlight = 0; +let heldBackgroundAt: number | null = null; +let cameFromBackground = false; + +/** {@link authenticate} that never throws: a native error is a failed attempt. */ +async function promptUser(promptKey: "appLock.prompt.unlock" | "appLock.prompt.change") { + try { + return await authenticate({ + promptMessage: i18n.t(promptKey), + cancelLabel: i18n.t("common.cancel"), + fallbackToPasscode: true, + }); + } catch { + return { success: false, error: "unknown" }; + } +} + +async function withAuthentication(run: () => Promise, after: (result: T) => void): Promise { + authInFlight += 1; + let result: T; + try { + result = await run(); + } finally { + authInFlight -= 1; + } + // The outcome first, then a background held meanwhile: an unlock that + // succeeded while the user left is covered again at once. + after(result); + releaseHeldBackground(); + return result; +} + +function releaseHeldBackground(): void { + if (authInFlight > 0 || heldBackgroundAt === null) return; + const at = heldBackgroundAt; + heldBackgroundAt = null; + if (AppState.currentState === "active") return; + cameFromBackground = true; + useSecurityStore.getState().dispatch({ type: "background", at }); +} -/** Handle returned by `startAppLockMonitor` for cleanup. */ +/** Handle returned by {@link startAppLockMonitor} for cleanup. */ export interface AppLockHandle { - /** Remove the AppState listener */ dispose: () => void; } +/** Report going to the background and coming back from it to the lock. */ +export function startAppLockMonitor(now: () => number = Date.now): AppLockHandle { + const subscription = AppState.addEventListener("change", (next: AppStateStatus) => { + const { dispatch } = useSecurityStore.getState(); + if (next === "background") { + if (authInFlight > 0) { + heldBackgroundAt ??= now(); + return; + } + cameFromBackground = true; + dispatch({ type: "background", at: now() }); + return; + } + if (next === "active") { + // Back from a prompt's own activity: nothing happened. + heldBackgroundAt = null; + if (!cameFromBackground) return; + cameFromBackground = false; + dispatch({ type: "active", at: now() }); + } + }); + return { dispose: () => subscription.remove() }; +} + /** - * Start monitoring AppState changes. - * - * - When the app goes to the background the current timestamp is - * persisted via `recordActivity()`. - * - When the app returns to the foreground we compare elapsed time - * against the configured `inactivityTimeout`. If the timeout has - * elapsed and biometric lock is enabled the app is locked. + * Run the unlock prompt for a locked app. Resolves `true` once unlocked; + * `false` when the app was not locked, or the prompt was cancelled or failed + * (the overlay stays, with the reason when there is one). */ -export function startAppLockMonitor(): AppLockHandle { - let previousState: AppStateStatus = AppState.currentState; +export async function unlockApp(): Promise { + const store = useSecurityStore.getState(); + if (store.phase !== "locked") return false; + store.dispatch({ type: "unlockStarted" }); + const result = await withAuthentication( + () => promptUser("appLock.prompt.unlock"), + (outcome) => { + const kind = outcome.success ? null : authFailureKind(outcome.error); + useSecurityStore.getState().dispatch({ + type: "unlockFinished", + ok: outcome.success, + error: kind === "cancelled" ? null : kind, + }); + } + ); + return result.success; +} - const subscription = AppState.addEventListener( - "change", - (nextState: AppStateStatus) => { - const store = useSecurityStore.getState(); +export type ChangeOutcome = + /** Authenticated, stored, and in effect. */ + | "changed" + /** Same as the current setting: nothing asked, nothing written. */ + | "unchanged" + /** The prompt was cancelled or failed: nothing changed. */ + | "not-authenticated" + /** The device has no passcode or biometrics, so the lock cannot be turned on. */ + | "unavailable" + /** Authenticated, but the setting could not be stored: nothing changed. */ + | "save-failed"; - if (previousState === "active" && nextState !== "active") { - // Going to background — stamp current time - store.recordActivity(); +/** + * Change the app-lock setting — the only writer. Asks for Face ID / Touch ID / + * fingerprint (device passcode as fallback) first, then stores the setting, + * then puts it in effect. Called by the native settings screen alone. + */ +export async function changeAppLockSettings(next: AppLockSettings): Promise { + if (!isAppLockTimeout(next.timeoutSeconds)) return "unchanged"; + const current = useSecurityStore.getState().settings; + if (current.enabled === next.enabled && current.timeoutSeconds === next.timeoutSeconds) { + return "unchanged"; + } + if (next.enabled) { + try { + if ((await getDeviceLockLevel()) === "none") return "unavailable"; + } catch { + return "unavailable"; + } + } + const settings: AppLockSettings = { enabled: next.enabled, timeoutSeconds: next.timeoutSeconds }; + let outcome: ChangeOutcome = "not-authenticated"; + await withAuthentication( + async () => { + const result = await promptUser("appLock.prompt.change"); + if (!result.success) { + return authFailureKind(result.error) === "no-device-lock" && next.enabled + ? "unavailable" + : "not-authenticated"; } - - if (previousState !== "active" && nextState === "active") { - // Returning to foreground — check timeout - if (store.biometricEnabled && store.inactivityTimeout > 0) { - const elapsed = (Date.now() - store.lastActiveAt) / 1000; - if (elapsed >= store.inactivityTimeout) { - store.setLocked(true); - } - } + try { + await SecureStore.setItemAsync( + APP_LOCK_STORAGE_KEY, + serializeAppLockSettings(settings), + STORE_OPTIONS + ); + } catch { + return "save-failed"; } - - previousState = nextState; + return "changed"; }, + (result: ChangeOutcome) => { + outcome = result; + if (result === "changed") { + useSecurityStore.getState().dispatch({ type: "settingsCommitted", settings }); + } + } ); + return outcome; +} - return { - dispose: () => subscription.remove(), - }; +/** + * Protect the app-switcher snapshot while the lock is on: iOS blurs it + * natively (on resign-active, before the snapshot); Android sets `FLAG_SECURE` + * on the window, which blanks the recents thumbnail and also blocks + * screenshots and screen recording. + */ +export async function applySnapshotProtection(enabled: boolean): Promise { + try { + if (Platform.OS === "ios") { + if (enabled) await ScreenCapture.enableAppSwitcherProtectionAsync(1); + else await ScreenCapture.disableAppSwitcherProtectionAsync(); + return; + } + if (Platform.OS === "android") { + if (enabled) await ScreenCapture.preventScreenCaptureAsync(SCREEN_CAPTURE_KEY); + else await ScreenCapture.allowScreenCaptureAsync(SCREEN_CAPTURE_KEY); + } + } catch { + // The module is missing (a build without it): the lock itself still works. + } } /** - * Attempt to unlock the app via biometric authentication. - * Returns `true` when the app is successfully unlocked. + * Start the lock for this process: the monitor, the snapshot protection + * following the setting, and, when the lock engages, the in-app browser + * closed (iOS presents it above every view, the overlay included). */ -export async function unlockApp(): Promise { - const result = await authenticate({ - promptMessage: "Unlock ObjectStack", - fallbackToPasscode: true, +export function startAppLock(): () => void { + hydrateAppLock(); + const monitor = startAppLockMonitor(); + let protectedNow = useSecurityStore.getState().settings.enabled; + void applySnapshotProtection(protectedNow); + let lockedNow = isAppLocked(useSecurityStore.getState()); + const unsubscribe = useSecurityStore.subscribe((state) => { + if (state.settings.enabled !== protectedNow) { + protectedNow = state.settings.enabled; + void applySnapshotProtection(protectedNow); + } + const locked = isAppLocked(state); + if (locked && !lockedNow && Platform.OS === "ios") { + // Rejects when no browser is open. + void Promise.resolve() + .then(() => WebBrowser.dismissBrowser()) + .catch(() => undefined); + } + lockedNow = locked; }); + return () => { + unsubscribe(); + monitor.dispose(); + }; +} - if (result.success) { - const store = useSecurityStore.getState(); - store.setLocked(false); - store.recordActivity(); - return true; - } - - return false; +/** Test hook: forget module state (the launch read, held background, prompts in flight). */ +export function _resetAppLockForTests(): void { + hydrated = false; + authInFlight = 0; + heldBackgroundAt = null; + cameFromBackground = false; } diff --git a/lib/biometric-auth.ts b/lib/biometric-auth.ts index c392f8b..9ef47e0 100644 --- a/lib/biometric-auth.ts +++ b/lib/biometric-auth.ts @@ -49,6 +49,20 @@ export async function getBiometricStatus(): Promise { return { isHardwareAvailable, isEnrolled, biometricTypes }; } +/** + * What the device can unlock with: `none` (no passcode or biometrics set up — + * nothing to authenticate with), `passcode`, or `biometric`. The app lock can + * be turned on only above `none`. + */ +export type DeviceLockLevel = "none" | "passcode" | "biometric"; + +export async function getDeviceLockLevel(): Promise { + const level = await LocalAuthentication.getEnrolledLevelAsync(); + if (level === LocalAuthentication.SecurityLevel.NONE) return "none"; + if (level === LocalAuthentication.SecurityLevel.SECRET) return "passcode"; + return "biometric"; +} + export interface AuthenticateOptions { /** User-facing reason for authentication */ promptMessage?: string; @@ -58,6 +72,23 @@ export interface AuthenticateOptions { fallbackToPasscode?: boolean; } +/** Errors that mean the device has nothing to authenticate with. */ +const NO_DEVICE_LOCK_ERRORS: ReadonlySet = new Set([ + "not_enrolled", + "passcode_not_set", + "not_available", +]); + +/** Errors that mean the prompt was dismissed rather than failed. */ +const CANCEL_ERRORS: ReadonlySet = new Set(["user_cancel", "system_cancel", "app_cancel"]); + +/** How a failed {@link authenticate} ended: dismissed, nothing to use, or failed. */ +export function authFailureKind(error: string | undefined): "cancelled" | "no-device-lock" | "failed" { + if (error !== undefined && CANCEL_ERRORS.has(error)) return "cancelled"; + if (error !== undefined && NO_DEVICE_LOCK_ERRORS.has(error)) return "no-device-lock"; + return "failed"; +} + export interface AuthenticateResult { success: boolean; error?: string; diff --git a/lib/shell/bridge/handler.ts b/lib/shell/bridge/handler.ts index 7d0cc61..b8b3457 100644 --- a/lib/shell/bridge/handler.ts +++ b/lib/shell/bridge/handler.ts @@ -14,6 +14,7 @@ import { BRIDGE_EVENTS, FILE_TOO_LARGE_MESSAGE, HAPTIC_KINDS, + LOCKED_REFUSED_METHODS, MAX_FILE_BYTES, MAX_SHARE_TEXT_LENGTH, PICK_IMAGE_DEFAULTS, @@ -21,6 +22,7 @@ import { SCAN_FORMATS, base64ByteLength, parseInbound, + type AppLockInfo, type BridgeErrorCode, type BridgeEventName, type BridgeFile, @@ -64,6 +66,15 @@ export interface BridgeDeps { * credential, so this never fetches anything (docs/HYBRID-SHELL.md, "Files"). */ openFile: (file: BridgeFile) => Promise; + /** The app-lock setting, read-only. */ + getAppLock: () => AppLockInfo; + /** Open the native app-lock settings screen, where a change asks for biometrics. */ + openAppLockSettings: () => void | Promise; + /** + * `true` while the app lock holds the screen (the overlay is up). Methods + * that bring up native UI ({@link LOCKED_REFUSED_METHODS}) are then refused. + */ + isLocked?: () => boolean; onEvent?: (name: BridgeEventName, payload: unknown) => void; /** * `true` while a sign-in excursion is on screen. The main frame is then an @@ -242,6 +253,10 @@ export function createBridgeHandler(deps: BridgeDeps): BridgeMessageHandler { const { id, method, args } = msg; + if ((LOCKED_REFUSED_METHODS as readonly string[]).includes(method) && deps.isLocked?.()) { + return fail(id, "failed", "The app is locked"); + } + try { switch (method) { case "getPushToken": @@ -319,6 +334,15 @@ export function createBridgeHandler(deps: BridgeDeps): BridgeMessageHandler { return { id, ok: true, value: null }; } + case "getAppLock": { + const { enabled, timeoutSeconds } = deps.getAppLock(); + return { id, ok: true, value: { enabled, timeoutSeconds } }; + } + + case "openAppLockSettings": + await deps.openAppLockSettings(); + return { id, ok: true, value: null }; + default: return fail(id, "unknown_method", `Unknown bridge method: ${method}`); } diff --git a/lib/shell/bridge/protocol.ts b/lib/shell/bridge/protocol.ts index 5cdf8ac..37c7d68 100644 --- a/lib/shell/bridge/protocol.ts +++ b/lib/shell/bridge/protocol.ts @@ -113,9 +113,43 @@ export const BRIDGE_METHODS = [ "pickImage", "share", "openFile", + "getAppLock", + "openAppLockSettings", ] as const; export type BridgeMethod = (typeof BRIDGE_METHODS)[number]; +/** + * The bridge's whole app-lock surface, and it is read-only: `getAppLock()` + * reads the setting, `openAppLockSettings()` opens the native settings screen, + * where a change asks for Face ID / Touch ID / fingerprint. No bridge method + * changes the lock — a page that could would let any page, or an iframe on an + * old Android WebView (Known limits), turn off a lock the user set. + */ +export const APP_LOCK_BRIDGE_METHODS = ["getAppLock", "openAppLockSettings"] as const; + +/** + * Methods that bring up native UI. While the app lock holds the screen they + * are refused (`failed`): the overlay covers the WebView, and an in-app + * browser, a sheet, a picker, a share sheet, a file preview or a scanner + * opened by the page would show above it or run unseen under it. + */ +export const LOCKED_REFUSED_METHODS: readonly BridgeMethod[] = [ + "openExternal", + "scan", + "showConnectionSwitcher", + "pickImage", + "share", + "openFile", + "openAppLockSettings", +]; + +/** What `getAppLock()` resolves: the setting, never a way to change it. */ +export interface AppLockInfo { + enabled: boolean; + /** Seconds in the background before the app locks; `0` locks as soon as it leaves. */ + timeoutSeconds: number; +} + /** * Code formats `scan()` reads, named as `expo-camera`'s `BarcodeType`. A call * may narrow the set with `formats`; without it the scanner reads all of them. diff --git a/lib/shell/bridge/scripts.ts b/lib/shell/bridge/scripts.ts index d083222..f1b18c4 100644 --- a/lib/shell/bridge/scripts.ts +++ b/lib/shell/bridge/scripts.ts @@ -107,6 +107,8 @@ export function buildBootstrapScript(info: BridgeHostInfo, serverUrl: string): s } return call("openFile", file, ${MAX_FILE_MESSAGE_LENGTH}); }, + getAppLock: function () { return call("getAppLock"); }, + openAppLockSettings: function () { return call("openAppLockSettings"); }, ready: function () { post({ type: "event", name: "ready" }); }, routeChanged: function (route) { post({ type: "event", name: "routeChanged", payload: { route: route } }); } }; diff --git a/locales/ar.json b/locales/ar.json index 92c70ff..a6558a0 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -112,8 +112,46 @@ "codeBlocked": "الوصول إلى الكاميرا متوقف لتطبيق ObjectOS. فعّله من الإعدادات لمسح الرموز." } }, + "appLock": { + "entry": "قفل التطبيق", + "on": "مفعّل", + "off": "غير مفعّل", + "prompt": { + "unlock": "فتح قفل ObjectOS", + "change": "تغيير قفل التطبيق" + }, + "overlay": { + "title": "ObjectOS مقفل", + "unlock": "فتح القفل", + "failed": "تعذّر التأكد من هويتك. حاول مرة أخرى.", + "noDeviceLock": "أعدّ قفل الشاشة في إعدادات هاتفك لفتح قفل ObjectOS." + }, + "settings": { + "title": "قفل التطبيق", + "description": "يحمي ObjectOS ببصمة الوجه أو Touch ID أو بصمة الإصبع أو رمز مرور الهاتف عند عودتك إليه.", + "toggle": "قفل التطبيق", + "timeoutTitle": "القفل بعد", + "timeout": { + "0": "فورًا", + "60": "بعد دقيقة واحدة", + "300": "بعد 5 دقائق", + "900": "بعد 15 دقيقة", + "3600": "بعد ساعة واحدة" + }, + "authNote": "يتطلب تغيير قفل التطبيق بصمة الوجه أو Touch ID أو بصمة الإصبع أو رمز مرور الهاتف.", + "unavailable": "أعدّ قفل الشاشة (رمز مرور أو بصمة الوجه أو بصمة الإصبع) في إعدادات هاتفك لاستخدام قفل التطبيق.", + "notChanged": "لم يتم التغيير: تعذّر التأكد من هويتك.", + "saveFailed": "تعذّر حفظ الإعداد. حاول مرة أخرى.", + "back": "رجوع", + "privacy": { + "ios": "أثناء تفعيل قفل التطبيق، يظهر ObjectOS ضبابيًا في مبدّل التطبيقات.", + "android": "أثناء تفعيل قفل التطبيق، يظهر ObjectOS فارغًا في شاشة التطبيقات الأخيرة، ويُمنع التقاط الشاشة وتسجيلها." + } + } + }, "permissions": { "camera": "يستخدم ObjectOS الكاميرا لمسح رموز QR والرموز الشريطية، مثل الرمز الذي يربطه بالخادم الخاص بك، ولالتقاط الصور التي ترفقها في التطبيق.", - "photos": "يفتح ObjectOS مكتبة الصور لديك لتختار الصور التي ترفقها في التطبيق." + "photos": "يفتح ObjectOS مكتبة الصور لديك لتختار الصور التي ترفقها في التطبيق.", + "faceId": "يستخدم ObjectOS بصمة الوجه لفتح قفل التطبيق عند تفعيل قفل التطبيق." } } diff --git a/locales/de.json b/locales/de.json index 9a2b932..5ba4580 100644 --- a/locales/de.json +++ b/locales/de.json @@ -112,8 +112,46 @@ "codeBlocked": "Der Kamerazugriff für ObjectOS ist deaktiviert. Aktivieren Sie ihn in den Einstellungen, um Codes zu scannen." } }, + "appLock": { + "entry": "App-Sperre", + "on": "An", + "off": "Aus", + "prompt": { + "unlock": "ObjectOS entsperren", + "change": "App-Sperre ändern" + }, + "overlay": { + "title": "ObjectOS ist gesperrt", + "unlock": "Entsperren", + "failed": "Deine Identität konnte nicht bestätigt werden. Versuche es erneut.", + "noDeviceLock": "Richte in den Einstellungen deines Telefons eine Bildschirmsperre ein, um ObjectOS zu entsperren." + }, + "settings": { + "title": "App-Sperre", + "description": "Schützt ObjectOS mit Face ID, Touch ID, Fingerabdruck oder dem Code deines Telefons, wenn du zurückkehrst.", + "toggle": "App sperren", + "timeoutTitle": "Sperren nach", + "timeout": { + "0": "Sofort", + "60": "Nach 1 Minute", + "300": "Nach 5 Minuten", + "900": "Nach 15 Minuten", + "3600": "Nach 1 Stunde" + }, + "authNote": "Zum Ändern der App-Sperre werden Face ID, Touch ID, Fingerabdruck oder der Code deines Telefons abgefragt.", + "unavailable": "Richte in den Einstellungen deines Telefons eine Bildschirmsperre (Code, Face ID oder Fingerabdruck) ein, um die App-Sperre zu nutzen.", + "notChanged": "Nicht geändert: Deine Identität konnte nicht bestätigt werden.", + "saveFailed": "Die Einstellung konnte nicht gespeichert werden. Versuche es erneut.", + "back": "Zurück", + "privacy": { + "ios": "Solange die App-Sperre an ist, zeigt der App-Umschalter ObjectOS unscharf.", + "android": "Solange die App-Sperre an ist, zeigt die Übersicht der letzten Apps ObjectOS leer, und Screenshots sowie Bildschirmaufnahmen sind gesperrt." + } + } + }, "permissions": { "camera": "ObjectOS verwendet die Kamera, um QR-Codes und Barcodes zu scannen, etwa den Code, der die App mit Ihrem Server verbindet, und um Fotos aufzunehmen, die Sie in der App anhängen.", - "photos": "ObjectOS öffnet Ihre Fotomediathek, damit Sie Fotos auswählen können, die Sie in der App anhängen." + "photos": "ObjectOS öffnet Ihre Fotomediathek, damit Sie Fotos auswählen können, die Sie in der App anhängen.", + "faceId": "ObjectOS verwendet Face ID, um die App zu entsperren, wenn die App-Sperre an ist." } } diff --git a/locales/en.json b/locales/en.json index cb6332a..fffe98e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -112,8 +112,46 @@ "codeBlocked": "Camera access is off for ObjectOS. Turn it on in Settings to scan codes." } }, + "appLock": { + "entry": "App lock", + "on": "On", + "off": "Off", + "prompt": { + "unlock": "Unlock ObjectOS", + "change": "Change the app lock" + }, + "overlay": { + "title": "ObjectOS is locked", + "unlock": "Unlock", + "failed": "Couldn't confirm it's you. Try again.", + "noDeviceLock": "Set up a screen lock in your phone's settings to unlock ObjectOS." + }, + "settings": { + "title": "App lock", + "description": "Hide ObjectOS behind Face ID, Touch ID or fingerprint, or your phone's passcode, when you come back to it.", + "toggle": "Lock the app", + "timeoutTitle": "Lock after", + "timeout": { + "0": "Immediately", + "60": "After 1 minute", + "300": "After 5 minutes", + "900": "After 15 minutes", + "3600": "After 1 hour" + }, + "authNote": "Changing the app lock asks for Face ID, Touch ID or fingerprint, or your phone's passcode.", + "unavailable": "Set up a screen lock (passcode, Face ID or fingerprint) in your phone's settings to use the app lock.", + "notChanged": "Not changed: it couldn't be confirmed that it's you.", + "saveFailed": "The setting couldn't be saved. Try again.", + "back": "Back", + "privacy": { + "ios": "While the app lock is on, the app switcher shows ObjectOS blurred.", + "android": "While the app lock is on, the recent apps screen shows ObjectOS blank, and screenshots and screen recording are blocked." + } + } + }, "permissions": { "camera": "ObjectOS uses the camera to scan QR codes and barcodes, such as the code that connects it to your server, and to take photos you attach in the app.", - "photos": "ObjectOS opens your photo library so you can choose photos to attach in the app." + "photos": "ObjectOS opens your photo library so you can choose photos to attach in the app.", + "faceId": "ObjectOS uses Face ID to unlock the app when the app lock is on." } } diff --git a/locales/es.json b/locales/es.json index bd00135..f791532 100644 --- a/locales/es.json +++ b/locales/es.json @@ -112,8 +112,46 @@ "codeBlocked": "El acceso a la cámara está desactivado para ObjectOS. Actívelo en Configuración para escanear códigos." } }, + "appLock": { + "entry": "Bloqueo de la app", + "on": "Activado", + "off": "Desactivado", + "prompt": { + "unlock": "Desbloquear ObjectOS", + "change": "Cambiar el bloqueo de la app" + }, + "overlay": { + "title": "ObjectOS está bloqueado", + "unlock": "Desbloquear", + "failed": "No se pudo confirmar que eres tú. Inténtalo de nuevo.", + "noDeviceLock": "Configura un bloqueo de pantalla en los ajustes del teléfono para desbloquear ObjectOS." + }, + "settings": { + "title": "Bloqueo de la app", + "description": "Protege ObjectOS con Face ID, Touch ID, huella digital o el código del teléfono cuando vuelves a la app.", + "toggle": "Bloquear la app", + "timeoutTitle": "Bloquear después de", + "timeout": { + "0": "Inmediatamente", + "60": "Después de 1 minuto", + "300": "Después de 5 minutos", + "900": "Después de 15 minutos", + "3600": "Después de 1 hora" + }, + "authNote": "Para cambiar el bloqueo de la app se pide Face ID, Touch ID, huella digital o el código del teléfono.", + "unavailable": "Configura un bloqueo de pantalla (código, Face ID o huella digital) en los ajustes del teléfono para usar el bloqueo de la app.", + "notChanged": "Sin cambios: no se pudo confirmar que eres tú.", + "saveFailed": "No se pudo guardar el ajuste. Inténtalo de nuevo.", + "back": "Atrás", + "privacy": { + "ios": "Mientras el bloqueo de la app está activado, el selector de apps muestra ObjectOS difuminado.", + "android": "Mientras el bloqueo de la app está activado, la pantalla de apps recientes muestra ObjectOS en blanco, y se bloquean las capturas y grabaciones de pantalla." + } + } + }, "permissions": { "camera": "ObjectOS usa la cámara para escanear códigos QR y códigos de barras, como el código que lo conecta a su servidor, y para tomar las fotos que adjunta en la app.", - "photos": "ObjectOS abre su fototeca para que elija las fotos que adjunta en la app." + "photos": "ObjectOS abre su fototeca para que elija las fotos que adjunta en la app.", + "faceId": "ObjectOS usa Face ID para desbloquear la app cuando el bloqueo de la app está activado." } } diff --git a/locales/fr.json b/locales/fr.json index 4987c6d..c6d18b2 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -112,8 +112,46 @@ "codeBlocked": "L'accès à l'appareil photo est désactivé pour ObjectOS. Activez-le dans les Paramètres pour scanner des codes." } }, + "appLock": { + "entry": "Verrouillage de l'app", + "on": "Activé", + "off": "Désactivé", + "prompt": { + "unlock": "Déverrouiller ObjectOS", + "change": "Modifier le verrouillage de l'app" + }, + "overlay": { + "title": "ObjectOS est verrouillé", + "unlock": "Déverrouiller", + "failed": "Impossible de confirmer votre identité. Réessayez.", + "noDeviceLock": "Configurez un verrouillage d'écran dans les réglages de votre téléphone pour déverrouiller ObjectOS." + }, + "settings": { + "title": "Verrouillage de l'app", + "description": "Protège ObjectOS par Face ID, Touch ID, empreinte digitale ou le code de votre téléphone quand vous y revenez.", + "toggle": "Verrouiller l'app", + "timeoutTitle": "Verrouiller après", + "timeout": { + "0": "Immédiatement", + "60": "Après 1 minute", + "300": "Après 5 minutes", + "900": "Après 15 minutes", + "3600": "Après 1 heure" + }, + "authNote": "Modifier le verrouillage de l'app demande Face ID, Touch ID, empreinte digitale ou le code de votre téléphone.", + "unavailable": "Configurez un verrouillage d'écran (code, Face ID ou empreinte digitale) dans les réglages de votre téléphone pour utiliser le verrouillage de l'app.", + "notChanged": "Non modifié : votre identité n'a pas pu être confirmée.", + "saveFailed": "Le réglage n'a pas pu être enregistré. Réessayez.", + "back": "Retour", + "privacy": { + "ios": "Tant que le verrouillage de l'app est activé, le sélecteur d'apps affiche ObjectOS flouté.", + "android": "Tant que le verrouillage de l'app est activé, l'écran des apps récentes affiche ObjectOS vide, et les captures et enregistrements d'écran sont bloqués." + } + } + }, "permissions": { "camera": "ObjectOS utilise l'appareil photo pour scanner des codes QR et des codes-barres, comme le code qui le connecte à votre serveur, et pour prendre les photos que vous joignez dans l'app.", - "photos": "ObjectOS ouvre votre photothèque pour que vous choisissiez les photos à joindre dans l'app." + "photos": "ObjectOS ouvre votre photothèque pour que vous choisissiez les photos à joindre dans l'app.", + "faceId": "ObjectOS utilise Face ID pour déverrouiller l'app quand le verrouillage de l'app est activé." } } diff --git a/locales/ja.json b/locales/ja.json index 665f64a..796a834 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -112,8 +112,46 @@ "codeBlocked": "ObjectOS のカメラへのアクセスがオフになっています。コードをスキャンするには、設定でオンにしてください。" } }, + "appLock": { + "entry": "アプリロック", + "on": "オン", + "off": "オフ", + "prompt": { + "unlock": "ObjectOS のロックを解除", + "change": "アプリロックを変更" + }, + "overlay": { + "title": "ObjectOS はロックされています", + "unlock": "ロックを解除", + "failed": "本人確認ができませんでした。もう一度お試しください。", + "noDeviceLock": "ObjectOS のロックを解除するには、スマートフォンの設定で画面ロックを設定してください。" + }, + "settings": { + "title": "アプリロック", + "description": "ObjectOS に戻ったとき、Face ID、Touch ID、指紋認証、またはスマートフォンのパスコードで保護します。", + "toggle": "アプリをロック", + "timeoutTitle": "ロックまでの時間", + "timeout": { + "0": "すぐに", + "60": "1 分後", + "300": "5 分後", + "900": "15 分後", + "3600": "1 時間後" + }, + "authNote": "アプリロックの変更には、Face ID、Touch ID、指紋認証、またはスマートフォンのパスコードが必要です。", + "unavailable": "アプリロックを使うには、スマートフォンの設定で画面ロック(パスコード、Face ID、指紋)を設定してください。", + "notChanged": "変更されていません:本人確認ができませんでした。", + "saveFailed": "設定を保存できませんでした。もう一度お試しください。", + "back": "戻る", + "privacy": { + "ios": "アプリロックがオンの間、アプリスイッチャーでは ObjectOS がぼかして表示されます。", + "android": "アプリロックがオンの間、最近使ったアプリでは ObjectOS が空白で表示され、スクリーンショットと画面録画はできません。" + } + } + }, "permissions": { "camera": "ObjectOS は、QR コードやバーコード(サーバーに接続するための QR コードなど)のスキャンと、アプリで添付する写真の撮影にカメラを使用します。", - "photos": "ObjectOS は、アプリで添付する写真を選べるように写真ライブラリを開きます。" + "photos": "ObjectOS は、アプリで添付する写真を選べるように写真ライブラリを開きます。", + "faceId": "アプリロックがオンのとき、ObjectOS は Face ID を使ってアプリのロックを解除します。" } } diff --git a/locales/ko.json b/locales/ko.json index b8477d0..c53a1d4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -112,8 +112,46 @@ "codeBlocked": "ObjectOS의 카메라 접근이 꺼져 있습니다. 코드를 스캔하려면 설정에서 켜세요." } }, + "appLock": { + "entry": "앱 잠금", + "on": "켜짐", + "off": "꺼짐", + "prompt": { + "unlock": "ObjectOS 잠금 해제", + "change": "앱 잠금 변경" + }, + "overlay": { + "title": "ObjectOS가 잠겨 있습니다", + "unlock": "잠금 해제", + "failed": "본인임을 확인하지 못했습니다. 다시 시도하세요.", + "noDeviceLock": "ObjectOS 잠금을 해제하려면 휴대폰 설정에서 화면 잠금을 설정하세요." + }, + "settings": { + "title": "앱 잠금", + "description": "ObjectOS로 돌아올 때 Face ID, Touch ID, 지문 또는 휴대폰 암호로 보호합니다.", + "toggle": "앱 잠그기", + "timeoutTitle": "잠금 시간", + "timeout": { + "0": "즉시", + "60": "1분 후", + "300": "5분 후", + "900": "15분 후", + "3600": "1시간 후" + }, + "authNote": "앱 잠금을 변경하려면 Face ID, Touch ID, 지문 또는 휴대폰 암호가 필요합니다.", + "unavailable": "앱 잠금을 사용하려면 휴대폰 설정에서 화면 잠금(암호, Face ID 또는 지문)을 설정하세요.", + "notChanged": "변경되지 않았습니다: 본인임을 확인하지 못했습니다.", + "saveFailed": "설정을 저장하지 못했습니다. 다시 시도하세요.", + "back": "뒤로", + "privacy": { + "ios": "앱 잠금이 켜져 있는 동안 앱 전환기에서 ObjectOS가 흐리게 표시됩니다.", + "android": "앱 잠금이 켜져 있는 동안 최근 앱 화면에서 ObjectOS가 빈 화면으로 표시되며, 스크린샷과 화면 녹화가 차단됩니다." + } + } + }, "permissions": { "camera": "ObjectOS는 QR 코드와 바코드(서버에 연결하기 위한 QR 코드 등)를 스캔하고 앱에서 첨부할 사진을 촬영하는 데 카메라를 사용합니다.", - "photos": "ObjectOS는 앱에서 첨부할 사진을 선택할 수 있도록 사진 보관함을 엽니다." + "photos": "ObjectOS는 앱에서 첨부할 사진을 선택할 수 있도록 사진 보관함을 엽니다.", + "faceId": "앱 잠금이 켜져 있으면 ObjectOS는 Face ID로 앱 잠금을 해제합니다." } } diff --git a/locales/pt.json b/locales/pt.json index 1de132f..10159bd 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -112,8 +112,46 @@ "codeBlocked": "O acesso à câmera está desativado para o ObjectOS. Ative-o em Configurações para escanear códigos." } }, + "appLock": { + "entry": "Bloqueio do app", + "on": "Ativado", + "off": "Desativado", + "prompt": { + "unlock": "Desbloquear o ObjectOS", + "change": "Alterar o bloqueio do app" + }, + "overlay": { + "title": "O ObjectOS está bloqueado", + "unlock": "Desbloquear", + "failed": "Não foi possível confirmar que é você. Tente novamente.", + "noDeviceLock": "Configure um bloqueio de tela nos ajustes do telefone para desbloquear o ObjectOS." + }, + "settings": { + "title": "Bloqueio do app", + "description": "Protege o ObjectOS com Face ID, Touch ID, impressão digital ou o código do telefone quando você volta ao app.", + "toggle": "Bloquear o app", + "timeoutTitle": "Bloquear depois de", + "timeout": { + "0": "Imediatamente", + "60": "Depois de 1 minuto", + "300": "Depois de 5 minutos", + "900": "Depois de 15 minutos", + "3600": "Depois de 1 hora" + }, + "authNote": "Para alterar o bloqueio do app, é pedido Face ID, Touch ID, impressão digital ou o código do telefone.", + "unavailable": "Configure um bloqueio de tela (código, Face ID ou impressão digital) nos ajustes do telefone para usar o bloqueio do app.", + "notChanged": "Não alterado: não foi possível confirmar que é você.", + "saveFailed": "Não foi possível salvar o ajuste. Tente novamente.", + "back": "Voltar", + "privacy": { + "ios": "Enquanto o bloqueio do app estiver ativado, o seletor de apps mostra o ObjectOS desfocado.", + "android": "Enquanto o bloqueio do app estiver ativado, a tela de apps recentes mostra o ObjectOS em branco, e capturas e gravações de tela ficam bloqueadas." + } + } + }, "permissions": { "camera": "O ObjectOS usa a câmera para escanear códigos QR e códigos de barras, como o código que o conecta ao seu servidor, e para tirar as fotos que você anexa no app.", - "photos": "O ObjectOS abre a sua biblioteca de fotos para que você escolha as fotos que anexa no app." + "photos": "O ObjectOS abre a sua biblioteca de fotos para que você escolha as fotos que anexa no app.", + "faceId": "O ObjectOS usa o Face ID para desbloquear o app quando o bloqueio do app está ativado." } } diff --git a/locales/ru.json b/locales/ru.json index 22bc220..308cf43 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -112,8 +112,46 @@ "codeBlocked": "Доступ к камере для ObjectOS отключён. Включите его в настройках, чтобы сканировать коды." } }, + "appLock": { + "entry": "Блокировка приложения", + "on": "Вкл.", + "off": "Выкл.", + "prompt": { + "unlock": "Разблокировать ObjectOS", + "change": "Изменить блокировку приложения" + }, + "overlay": { + "title": "ObjectOS заблокирован", + "unlock": "Разблокировать", + "failed": "Не удалось подтвердить, что это вы. Повторите попытку.", + "noDeviceLock": "Чтобы разблокировать ObjectOS, настройте блокировку экрана в настройках телефона." + }, + "settings": { + "title": "Блокировка приложения", + "description": "Защищает ObjectOS с помощью Face ID, Touch ID, отпечатка пальца или код-пароля телефона, когда вы возвращаетесь в приложение.", + "toggle": "Блокировать приложение", + "timeoutTitle": "Блокировать через", + "timeout": { + "0": "Сразу", + "60": "Через 1 минуту", + "300": "Через 5 минут", + "900": "Через 15 минут", + "3600": "Через 1 час" + }, + "authNote": "Для изменения блокировки приложения потребуется Face ID, Touch ID, отпечаток пальца или код-пароль телефона.", + "unavailable": "Чтобы пользоваться блокировкой приложения, настройте блокировку экрана (код-пароль, Face ID или отпечаток пальца) в настройках телефона.", + "notChanged": "Не изменено: не удалось подтвердить, что это вы.", + "saveFailed": "Не удалось сохранить настройку. Повторите попытку.", + "back": "Назад", + "privacy": { + "ios": "Пока блокировка приложения включена, в переключателе приложений ObjectOS отображается размытым.", + "android": "Пока блокировка приложения включена, на экране недавних приложений ObjectOS отображается пустым, а снимки и запись экрана запрещены." + } + } + }, "permissions": { "camera": "ObjectOS использует камеру, чтобы сканировать QR-коды и штрихкоды, например QR-код для подключения к вашему серверу, и снимать фото, которые вы прикрепляете в приложении.", - "photos": "ObjectOS открывает вашу медиатеку, чтобы вы могли выбрать фото для прикрепления в приложении." + "photos": "ObjectOS открывает вашу медиатеку, чтобы вы могли выбрать фото для прикрепления в приложении.", + "faceId": "ObjectOS использует Face ID для разблокировки приложения, когда включена блокировка приложения." } } diff --git a/locales/zh.json b/locales/zh.json index d5ca3e4..5fbf05f 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -112,8 +112,46 @@ "codeBlocked": "ObjectOS 的相机权限已关闭。请在设置中开启后再扫描。" } }, + "appLock": { + "entry": "应用锁", + "on": "已开启", + "off": "已关闭", + "prompt": { + "unlock": "解锁 ObjectOS", + "change": "更改应用锁" + }, + "overlay": { + "title": "ObjectOS 已锁定", + "unlock": "解锁", + "failed": "无法确认是你本人,请重试。", + "noDeviceLock": "请先在手机设置中设置锁屏,才能解锁 ObjectOS。" + }, + "settings": { + "title": "应用锁", + "description": "回到 ObjectOS 时,需要通过面容 ID、触控 ID、指纹或手机密码才能查看。", + "toggle": "锁定应用", + "timeoutTitle": "锁定时间", + "timeout": { + "0": "立即", + "60": "1 分钟后", + "300": "5 分钟后", + "900": "15 分钟后", + "3600": "1 小时后" + }, + "authNote": "更改应用锁需要验证面容 ID、触控 ID、指纹或手机密码。", + "unavailable": "请先在手机设置中设置锁屏(密码、面容 ID 或指纹),才能使用应用锁。", + "notChanged": "未更改:无法确认是你本人。", + "saveFailed": "设置未能保存,请重试。", + "back": "返回", + "privacy": { + "ios": "应用锁开启时,多任务界面中的 ObjectOS 会显示为模糊。", + "android": "应用锁开启时,最近任务中的 ObjectOS 显示为空白,并且禁止截屏和录屏。" + } + } + }, "permissions": { "camera": "ObjectOS 使用相机扫描二维码和条码(例如用于连接服务器的二维码),以及拍摄要在应用中附加的照片。", - "photos": "ObjectOS 打开你的照片图库,以便你选择要在应用中附加的照片。" + "photos": "ObjectOS 打开你的照片图库,以便你选择要在应用中附加的照片。", + "faceId": "应用锁开启时,ObjectOS 使用面容 ID 解锁应用。" } } diff --git a/package.json b/package.json index 45b149d..6944360 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "expo-network": "~55.0.14", "expo-notifications": "~55.0.23", "expo-router": "~55.0.16", + "expo-screen-capture": "~55.0.18", "expo-secure-store": "~55.0.14", "expo-sharing": "~55.0.25", "expo-status-bar": "~55.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2c2b4a..472fed1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: expo-router: specifier: ~55.0.16 version: 55.0.16(8353c0f062ad909fe85fda344f27d482) + expo-screen-capture: + specifier: ~55.0.18 + version: 55.0.18(expo@55.0.26)(react@19.2.0) expo-secure-store: specifier: ~55.0.14 version: 55.0.14(expo@55.0.26) @@ -2964,6 +2967,12 @@ packages: react-server-dom-webpack: optional: true + expo-screen-capture@55.0.18: + resolution: {integrity: sha512-AXDEcJwPRj/kcaoSEfY0eLsrL3yjInyxwIdMMjlBiMqB/wZGuMkLA432JONm9gT8aBohSshskZ1LruZ70E2qcw==} + peerDependencies: + expo: '*' + react: '*' + expo-secure-store@55.0.14: resolution: {integrity: sha512-OKp9pDiTa4kgChop8+pTRJGBPhkJUcAxP5c6JbivNr4bmx3I+gKmAj1ov4KOXkY95TpWdHO+GQ4+0BgSY2P3JQ==} peerDependencies: @@ -9149,6 +9158,11 @@ snapshots: - expo-font - supports-color + expo-screen-capture@55.0.18(expo@55.0.26)(react@19.2.0): + dependencies: + expo: 55.0.26(@babel/core@7.29.7)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.16)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.7)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react: 19.2.0 + expo-secure-store@55.0.14(expo@55.0.26): dependencies: expo: 55.0.26(@babel/core@7.29.7)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.16)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.7)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) diff --git a/stores/security-store.ts b/stores/security-store.ts index f2037e7..9a95b47 100644 --- a/stores/security-store.ts +++ b/stores/security-store.ts @@ -1,33 +1,147 @@ +/** + * App lock state: the user's setting and where the lock is right now. + * + * The transitions are one pure function, {@link stepAppLock}; the store only + * holds its result. Everything with a side effect — reading and writing the + * setting, the AppState monitor, the biometric prompt, the app-switcher + * protection — lives in `lib/app-lock.ts`. + * + * unlocked ──background──▶ covered ──active, within the timeout──▶ unlocked + * └──────active, past the timeout──▶ locked + * locked ──unlockStarted──▶ unlocking ──ok──▶ unlocked + * └──────fail──▶ locked + * + * - `covered`: the app went to the background with the lock on. The overlay + * is up (no prompt yet), so the page does not show for a frame on return + * while the timeout is judged. + * - `locked`: the overlay is up and asks for Face ID / Touch ID / fingerprint, + * with the device passcode as fallback. A cold start with the lock on starts + * here. + * - `unlocking`: the system prompt is on screen. + * + * The lock never changes the page under it: the overlay is a native view over + * the WebView, not a route, and nothing here touches the Console's load state. + */ import { create } from "zustand"; -export interface SecurityState { - /** Whether biometric lock is enabled by the user */ - biometricEnabled: boolean; - /** Whether the app is currently locked */ - isLocked: boolean; - /** Inactivity timeout in seconds (0 = disabled) */ - inactivityTimeout: number; - /** Timestamp when app was last active (foreground interaction) */ - lastActiveAt: number; - - /** Toggle biometric lock */ - setBiometricEnabled: (enabled: boolean) => void; - /** Lock / unlock the app */ - setLocked: (locked: boolean) => void; - /** Set inactivity timeout in seconds */ - setInactivityTimeout: (seconds: number) => void; - /** Record user activity (reset inactivity timer) */ - recordActivity: () => void; +/** The timeouts the settings screen offers, in seconds (`0`: as soon as the app leaves). */ +export const APP_LOCK_TIMEOUTS = [0, 60, 300, 900, 3600] as const; +export type AppLockTimeout = (typeof APP_LOCK_TIMEOUTS)[number]; +export const DEFAULT_APP_LOCK_TIMEOUT: AppLockTimeout = 300; + +export function isAppLockTimeout(value: unknown): value is AppLockTimeout { + return (APP_LOCK_TIMEOUTS as readonly unknown[]).includes(value); +} + +export interface AppLockSettings { + enabled: boolean; + /** How long the app may stay in the background before it locks. */ + timeoutSeconds: AppLockTimeout; +} + +export const APP_LOCK_OFF: AppLockSettings = Object.freeze({ + enabled: false, + timeoutSeconds: DEFAULT_APP_LOCK_TIMEOUT, +}); + +export type AppLockPhase = "unlocked" | "covered" | "locked" | "unlocking"; + +/** Why the last unlock attempt did not unlock (a cancel leaves no reason). */ +export type UnlockError = "failed" | "no-device-lock"; + +export interface AppLockState { + settings: AppLockSettings; + phase: AppLockPhase; + /** When the app went to the background while unlocked (ms), for the timeout. */ + backgroundAt: number | null; + /** The overlay starts the prompt by itself once the app is active. */ + promptPending: boolean; + unlockError: UnlockError | null; +} + +export type AppLockEvent = + /** The stored setting was read at launch: with the lock on, the app starts locked. */ + | { type: "hydrated"; settings: AppLockSettings } + /** The app went to the background (not merely inactive). */ + | { type: "background"; at: number } + /** The app came back to the foreground from the background. */ + | { type: "active"; at: number } + | { type: "unlockStarted" } + | { type: "unlockFinished"; ok: boolean; error?: UnlockError | null } + /** + * A new setting was authenticated and stored (`changeAppLockSettings` in + * `lib/app-lock.ts` is the only producer). + */ + | { type: "settingsCommitted"; settings: AppLockSettings }; + +export const INITIAL_APP_LOCK_STATE: AppLockState = { + settings: APP_LOCK_OFF, + phase: "unlocked", + backgroundAt: null, + promptPending: false, + unlockError: null, +}; + +const LOCKED = { phase: "locked", backgroundAt: null, promptPending: true } as const; + +export function stepAppLock(state: AppLockState, event: AppLockEvent): AppLockState { + switch (event.type) { + case "hydrated": + return event.settings.enabled + ? { ...state, ...LOCKED, settings: event.settings, unlockError: null } + : { ...INITIAL_APP_LOCK_STATE, settings: event.settings }; + + case "background": + if (!state.settings.enabled || state.phase !== "unlocked") return state; + return { ...state, phase: "covered", backgroundAt: event.at }; + + case "active": { + if (state.phase === "locked") { + // Back from the background still locked: ask again. + return { ...state, promptPending: true, unlockError: null }; + } + if (state.phase !== "covered") return state; + const elapsedMs = event.at - (state.backgroundAt ?? event.at); + if (elapsedMs >= state.settings.timeoutSeconds * 1000) { + return { ...state, ...LOCKED, unlockError: null }; + } + return { ...state, phase: "unlocked", backgroundAt: null }; + } + + case "unlockStarted": + if (state.phase !== "locked") return state; + return { ...state, phase: "unlocking", promptPending: false, unlockError: null }; + + case "unlockFinished": + if (state.phase !== "unlocking") return state; + return event.ok + ? { ...state, phase: "unlocked", backgroundAt: null, unlockError: null } + : { ...state, phase: "locked", unlockError: event.error ?? null }; + + case "settingsCommitted": + // Turning the lock off lifts anything it holds; turning it on, or a new + // timeout, takes effect at the next background. + return event.settings.enabled + ? { ...state, settings: event.settings } + : { ...INITIAL_APP_LOCK_STATE, settings: event.settings }; + } +} + +/** The lock holds the screen: the overlay is up. */ +export function isAppLocked(state: Pick): boolean { + return state.phase !== "unlocked"; +} + +/** What the bridge's read-only `getAppLock()` answers: a copy, never the store's object. */ +export function appLockSnapshot(state: Pick): AppLockSettings { + return { enabled: state.settings.enabled, timeoutSeconds: state.settings.timeoutSeconds }; +} + +export interface SecurityState extends AppLockState { + dispatch: (event: AppLockEvent) => void; } export const useSecurityStore = create((set) => ({ - biometricEnabled: false, - isLocked: false, - inactivityTimeout: 300, // default 5 minutes - lastActiveAt: Date.now(), - - setBiometricEnabled: (enabled) => set({ biometricEnabled: enabled }), - setLocked: (locked) => set({ isLocked: locked }), - setInactivityTimeout: (seconds) => set({ inactivityTimeout: seconds }), - recordActivity: () => set({ lastActiveAt: Date.now() }), + ...INITIAL_APP_LOCK_STATE, + dispatch: (event) => set((state) => stepAppLock(state, event)), }));