diff --git a/mobile/jest.setup.js b/mobile/jest.setup.js index c0fbe8496f..341c20c070 100644 --- a/mobile/jest.setup.js +++ b/mobile/jest.setup.js @@ -261,6 +261,10 @@ jest.mock("@mentra/island", () => { const realOtaService = jest.requireActual("./modules/island/src/services/OtaService") const realAudioCloudUplink = jest.requireActual("./modules/island/src/services/AudioCloudUplink") const realDeviceEventRouter = jest.requireActual("./modules/island/src/services/DeviceEventRouter") + // Pairing-identity lifecycle (projection + the JS-owned identity writes) — real + // implementation (pure: settings store + types only) so host screens/tests + // exercise the actual three-state read-model, not a parallel stub. + const realPairingIdentity = jest.requireActual("./modules/island/src/services/PairingIdentity") // Clock-skew utils moved into island; the host gallery sync + OTA checker import them // from @mentra/island, so expose the real (pure) implementations through the mock. const realGlassesClockSync = jest.requireActual("./modules/island/src/services/glassesClockSync") @@ -376,6 +380,7 @@ jest.mock("@mentra/island", () => { }), glasses: { connectDefault: jest.fn(() => Promise.resolve()), + hasDefaultDevice: jest.fn(() => Promise.resolve(true)), disconnect: jest.fn(() => Promise.resolve()), forget: jest.fn(() => Promise.resolve()), connect: jest.fn(() => Promise.resolve()), @@ -502,12 +507,19 @@ jest.mock("@mentra/island", () => { cb(readiness) }) }), + // Identity lifecycle: real projection/writes over the real settings store, + // so tests observe the same none/pending/paired snapshots the app does. + identity: jest.fn(() => realPairingIdentity.projectPairingIdentity()), + onIdentity: jest.fn((cb) => realPairingIdentity.subscribePairingIdentity(cb)), + markPendingSelection: jest.fn((model) => realPairingIdentity.markPendingSelection(model)), scan: jest.fn(), scanning: jest.fn(() => false), searchResults: jest.fn(() => []), onFound: jest.fn(() => () => {}), pair: jest.fn(() => Promise.resolve()), setDefault: jest.fn(() => Promise.resolve()), + setBluetoothClassicTarget: jest.fn(() => Promise.resolve()), + abandonAttempt: jest.fn(() => Promise.resolve()), onPairFailure: subscribeVia("pair_failure"), onGlassesNotReady: subscribeVia("glasses_not_ready"), waitForReady: jest.fn(() => Promise.resolve(false)), @@ -918,4 +930,17 @@ global.__reanimatedWorkletInit = jest.fn() // after every test (a no-op when not attached) so each test starts clean. afterEach(() => { jest.requireActual("./modules/island/src/services/OtaInstallCoordinator").otaInstallCoordinator.detach() + // The pairing mocks above delegate identity reads/writes to the REAL + // PairingIdentity over the shared settings store; scrub the identity keys so + // a test that marked a pending selection can't leak a stale identity into + // the next test's identity()/onIdentity() reads. + const {useSettingsStore: realSettingsStore, PAIRING_IDENTITY_KEYS: realIdentityKeys} = jest.requireActual( + "./modules/island/src/stores/settings", + ) + const currentSettings = realSettingsStore.getState().settings + if (realIdentityKeys.some((key) => currentSettings[key])) { + const cleared = {...currentSettings} + for (const key of realIdentityKeys) cleared[key] = "" + realSettingsStore.setState({settings: cleared}) + } }) diff --git a/mobile/modules/island/src/facades/glasses.ts b/mobile/modules/island/src/facades/glasses.ts index 4efd316456..14e9b25269 100644 --- a/mobile/modules/island/src/facades/glasses.ts +++ b/mobile/modules/island/src/facades/glasses.ts @@ -14,6 +14,8 @@ import BluetoothSdk from "@mentra/bluetooth-sdk/internal" import type {ButtonPressEvent, TouchEvent} from "@mentra/bluetooth-sdk/internal" import {useGlassesStore} from "../stores/glasses" import {useSettingsStore, SETTINGS} from "../stores/settings" +import {hasDefaultDevice} from "../services/DeviceStoreHydration" +import {projectPairingIdentity} from "../services/PairingIdentity" import {isGlassesConnected, isGlassesReady} from "../services/GlassesReadiness" import {pushAllBluetoothSettings} from "../services/GlassesSettingsSync" import {getModelCapabilities, type DeviceTypes} from "../types" @@ -113,9 +115,23 @@ export const glasses = { } return true }, - /** True when no glasses has ever been paired (no saved default wearable) — e.g. to - * route a first-run host into the pairing/onboarding flow. */ - isFirstPairing: (): boolean => !useSettingsStore.getState().getSetting(SETTINGS.default_wearable.key), + /** True when no glasses has ever been paired NOR selected (the pairing-identity + * lifecycle is in its `none` state) — e.g. to route a first-run host into the + * pairing/onboarding flow. A pending selection is not "first" — the host should + * offer finish-pairing (see `toolkit.pairing.identity()`). */ + isFirstPairing: (): boolean => projectPairingIdentity().kind === "none", + /** + * True when the SDK has an actual default DEVICE to reconnect to. Distinct from + * `default_wearable` (a model string that a pending or orphaned selection can + * hold without any paired device): connectDefault() throws without a device, so + * hosts should route to the pairing flow when this is false. + * + * Hydration-aware: awaits the device-store seed internally, so a cold-start + * read can't see a false "absent" for genuinely-paired users. Rejects if + * hydration failed — callers guarding destructive or routing decisions must + * fail open (treat as "has a device"). + */ + hasDefaultDevice: (): Promise => hasDefaultDevice(), // --- read-model (projected from the island-owned glasses store) --- status: (): GlassesStatusSnapshot => projectStatus(), diff --git a/mobile/modules/island/src/facades/pairing.ts b/mobile/modules/island/src/facades/pairing.ts index 393361c57d..bfebdf59d4 100644 --- a/mobile/modules/island/src/facades/pairing.ts +++ b/mobile/modules/island/src/facades/pairing.ts @@ -6,11 +6,26 @@ * (Connect-the-already-paired-default is `toolkit.glasses.connectDefault()`; this * facade is the first-time discovery + pair flow.) */ -import BluetoothSdk from "@mentra/bluetooth-sdk" -import type {PairFailureEvent, GlassesNotReadyEvent} from "@mentra/bluetooth-sdk" +// Internal btsdk surface — updateBluetoothSettings (the Bluetooth Classic +// target hint) lives on the full surface, not the public entry (same reason +// the glasses facade imports internal). +import BluetoothSdk from "@mentra/bluetooth-sdk/internal" +import type {ConnectOptions, Device, PairFailureEvent, GlassesNotReadyEvent} from "@mentra/bluetooth-sdk" import {useCoreStore} from "../stores/core" import {useGlassesStore} from "../stores/glasses" -import {isGlassesLinkLayerBusy, isGlassesReady, waitForGlassesReady} from "../services/GlassesReadiness" +import {hasDefaultDevice} from "../services/DeviceStoreHydration" +import { + markPendingSelection, + projectPairingIdentity, + subscribePairingIdentity, + type IdentitySnapshot, +} from "../services/PairingIdentity" +import { + isGlassesConnected, + isGlassesLinkLayerBusy, + isGlassesReady, + waitForGlassesReady, +} from "../services/GlassesReadiness" import { logAutomaticReportSubmissionStatus, toAutomaticReportSubmissionStatus, @@ -19,6 +34,8 @@ import { import {pushAllBluetoothSettings} from "../services/GlassesSettingsSync" import {submitAutomaticReport} from "./reports" +export type {IdentitySnapshot} from "../services/PairingIdentity" + export interface PairingReadyWaitOptions { deviceModel?: string deviceName?: string @@ -177,13 +194,23 @@ export const pairing = { }) }, + // --- pairing-identity lifecycle (the PairingIdentity read-model + the JS-owned + // identity writes; promotion to `paired` only ever happens natively) --- + /** The identity lifecycle snapshot: none | pending (chosen, never paired) | paired. */ + identity: (): IdentitySnapshot => projectPairingIdentity(), + /** Subscribe to identity changes; fires only when the projected snapshot changes. + * Returns an unsubscribe. */ + onIdentity: (cb: (identity: IdentitySnapshot) => void): (() => void) => subscribePairingIdentity(cb), + /** Mark the chosen model as the pending selection (the scan-entry write); the + * host renders it as a finish-pairing affordance until pairing succeeds. */ + markPendingSelection: (model: string) => markPendingSelection(model), + /** Start scanning for nearby glasses. Results land on `searchResults()`/`onFound()`. */ scan: (...args: Parameters) => BluetoothSdk.startScan(...args), /** Whether a scan is currently in progress. */ scanning: (): boolean => useCoreStore.getState().searching, /** Subscribe to scan-in-progress changes; fires only when it changes. Returns an unsubscribe. */ - onScanning: (cb: (scanning: boolean) => void): (() => void) => - useCoreStore.subscribe((s) => s.searching, cb), + onScanning: (cb: (scanning: boolean) => void): (() => void) => useCoreStore.subscribe((s) => s.searching, cb), /** Whether a controller scan is currently in progress. */ scanningController: (): boolean => useCoreStore.getState().searchingController, /** Subscribe to controller-scan-in-progress changes; fires only when it changes. Returns an unsubscribe. */ @@ -211,13 +238,79 @@ export const pairing = { }) }, - /** Pair with (connect to) a discovered device. */ - pair: async (...args: Parameters): Promise => { + /** + * Pair with (connect to) a discovered device. Two-phase identity: the connect + * attempt only marks the device as pending (`saveAsDefault: false` — no eager + * default-device write); the native layer promotes it to the default wearable + * when pairing actually succeeds (handleDeviceReady), so an abandoned or + * failed attempt can't leave a default identity with no paired device behind. + */ + pair: async (device: Device, options?: ConnectOptions): Promise => { await pushAllBluetoothSettings() - return BluetoothSdk.connect(...args) + return BluetoothSdk.connect(device, {...options, saveAsDefault: false}) }, /** Set a device as the default for subsequent `glasses.connectDefault()`. */ setDefault: (...args: Parameters) => BluetoothSdk.setDefaultDevice(...args), + /** + * Prime the native Bluetooth Classic audio watcher with the picked device. + * The iOS Mentra Live flow pairs Classic audio BEFORE any BLE connect + * exists, and native detects the pairing by matching the connected audio + * route against its device_name — which two-phase identity no longer sets + * at selection time. This is native-only routing state (device_name has no + * native→JS echo): the phone's persisted identity is untouched, and + * promotion still happens only at pairing success. + */ + setBluetoothClassicTarget: (device: Device): Promise => + BluetoothSdk.updateBluetoothSettings({device_name: device.name}), + /** + * Abandon an in-flight pairing attempt (back-out, failure retry, conflict + * retry) without destroying an existing pairing. The decision comes from the + * LIVE hydrated default-device read — never from flow-entry state, because a + * pairing can PROMOTE while the flow is open (the glasses finish pairing + * even as the user backs out of the UI), and an entry snapshot would forget + * that brand-new pairing: + * - Default device exists and glasses are connected (nothing in flight — an + * attempt drops the link first): stop the scan, touch nothing else. + * - Default device exists with an attempt in flight: cancel it, then re-seed + * the native identity from the phone's persisted settings — the attempt's + * connect-by-name overwrote the native device_name, so a later + * connectDefault() would otherwise target the abandoned device. + * - No default device (genuinely unpaired attempt): also forget, clearing + * the partial native pairing state. + * The read fails OPEN to "preserve" — a transient failure must never wipe a + * real pairing. The `pending_wearable` marker is deliberately left alone — + * the host renders it as a finish-pairing affordance. + */ + abandonAttempt: async (): Promise => { + const nativeHasDefault = await hasDefaultDevice().catch(() => true) + if (nativeHasDefault && isGlassesConnected(useGlassesStore.getState().connection)) { + // Still connected means no connect attempt is in flight (an attempt drops + // the existing link first): the user browsed the scan and backed out. + // Stop the scan and leave the live pairing untouched. + console.log("PairingIdentity: abandonAttempt — pairing intact and connected; stopping scan only") + await BluetoothSdk.stopScan() + return + } + await BluetoothSdk.disconnect() + if (projectPairingIdentity().kind === "paired") { + // The persisted settings describe a COMPLETE pairing: restore it to + // native (the attempt's connect-by-name overwrote the native + // device_name; and if native somehow lost its default entirely, this + // repairs the divergence instead of forgetting a real pairing). + console.log("PairingIdentity: abandonAttempt — preserving pairing; attempt cancelled, native identity re-seeded") + await pushAllBluetoothSettings() + } else if (nativeHasDefault) { + // Mid-relay: native promoted and its echoes are still landing — the + // incomplete JS snapshot must not be pushed over the fresher native + // identity (the on-connect replay's race). Native holds the truth. + console.log("PairingIdentity: abandonAttempt — preserving pairing; JS identity mid-relay, native kept as-is") + } else { + // Forgetting requires CONSENSUS: no native default AND no complete + // persisted pairing — a genuinely partial attempt. + console.log("PairingIdentity: abandonAttempt — no pairing on either layer; forgetting the partial attempt") + await BluetoothSdk.forget() + } + }, /** Subscribe to pairing failures; returns an unsubscribe. */ onPairFailure: (cb: (event: PairFailureEvent) => void): (() => void) => { diff --git a/mobile/modules/island/src/index.ts b/mobile/modules/island/src/index.ts index 2aa1054948..62f605ce53 100644 --- a/mobile/modules/island/src/index.ts +++ b/mobile/modules/island/src/index.ts @@ -167,6 +167,7 @@ export type { } from "./facades/reports" export type {IslandNotification, IslandNotificationKind} from "./facades/notifications" export type {WifiSearchResult} from "./facades/glassesWifi" +export type {IdentitySnapshot} from "./services/PairingIdentity" export type {DisplayMirrorEvent} from "./facades/displayMirror" export type { IslandConfigureOptions, diff --git a/mobile/modules/island/src/island.ts b/mobile/modules/island/src/island.ts index 0aab7bcf3a..37705423fb 100644 --- a/mobile/modules/island/src/island.ts +++ b/mobile/modules/island/src/island.ts @@ -10,6 +10,7 @@ */ import {configure, start as bootstrapStart, stop as bootstrapStop} from "./runtime/bootstrap" import {cloudClientService} from "./services/CloudClientService" +import {hydrateDeviceStore, demoteOrphanedDefaultWearable} from "./services/DeviceStoreHydration" import {startGlassesSettingsSync, stopGlassesSettingsSync} from "./services/GlassesSettingsSync" import {startGlassesStatusProjection, stopGlassesStatusProjection} from "./services/GlassesStatusProjection" import {startOtaService, stopOtaService} from "./services/OtaService" @@ -17,7 +18,10 @@ import {startAudioCloudUplink, stopAudioCloudUplink} from "./services/AudioCloud import {startDeviceEventRouter, stopDeviceEventRouter} from "./services/DeviceEventRouter" import {startPhoneNotificationsSync, stopPhoneNotificationsSync} from "./services/PhoneNotificationsSync" import {startCaptionsTesterReportService, stopCaptionsTesterReportService} from "./services/CaptionsTesterReportService" -import {startMentraJSCrashloopReportService, stopMentraJSCrashloopReportService} from "./services/MentraJSCrashloopReportService" +import { + startMentraJSCrashloopReportService, + stopMentraJSCrashloopReportService, +} from "./services/MentraJSCrashloopReportService" import {ensureMiniappEngine, stopMiniappEngine} from "./services/MiniappEngine" import localMiniappRuntime from "./services/LocalMiniappRuntime" import displayProcessor from "./services/DisplayProcessor" @@ -66,6 +70,24 @@ export const toolkit = { // store, miniapp_selected -> launcher) so a bare OEM gets device data, not just the // Mentra app's MantleManager. startDeviceEventRouter() + // Hydration contract: the native DeviceStore is in-memory and starts empty + // every launch; seed it from the persisted settings BEFORE start() resolves + // so every post-start getDefaultDevice() read is a trustworthy two-state + // answer (no false "absent" for genuinely-paired users at cold start). + // Ordered before startGlassesSettingsSync so the settings load can't + // double-push through the change subscription. A failed hydration must not + // brick the runtime: start() continues, and the guards that consume + // hasDefaultDevice() see the rejection and fail open. + try { + await hydrateDeviceStore() + // Boot repair: a persisted default_wearable without a native default + // device (identity abandoned mid-pairing, or resurrected from the server + // before identity stopped syncing) demotes to pending_wearable so hosts + // render finish-pairing instead of a connect that would throw. + await demoteOrphanedDefaultWearable() + } catch (error) { + console.warn("toolkit.start: device-store hydration failed:", error instanceof Error ? error.message : error) + } // Project the glasses' OTA events into the store for the toolkit.ota read surface. startOtaService() // Forward glasses mic_lc3 frames to the v2 cloud session so cloud transcription @@ -74,7 +96,10 @@ export const toolkit = { try { await cloudClientService.syncCoreTokenToBluetooth() } catch (error) { - console.warn("toolkit.start: initial Cloud V2 core token sync failed:", error instanceof Error ? error.message : error) + console.warn( + "toolkit.start: initial Cloud V2 core token sync failed:", + error instanceof Error ? error.message : error, + ) } // Push device-setting changes to the glasses for ANY host, so // toolkit.glasses.settings.set() reaches the device (not just the Mentra app). diff --git a/mobile/modules/island/src/services/ConnectionCoordinator.ts b/mobile/modules/island/src/services/ConnectionCoordinator.ts index 6262e79e27..74f4625cc6 100644 --- a/mobile/modules/island/src/services/ConnectionCoordinator.ts +++ b/mobile/modules/island/src/services/ConnectionCoordinator.ts @@ -20,6 +20,8 @@ export interface ReconnectDecisionInput { connected: boolean /** True while the native link layer is mid scan / connect / bond. */ nativeLinkBusy: boolean + /** True when the SDK holds an actual default device (connectDefault target). */ + hasDefaultDevice: boolean /** True when a scan is already in progress. */ searching: boolean } @@ -36,6 +38,11 @@ export function decideReconnect(input: ReconnectDecisionInput): ReconnectDecisio if (!input.reconnectOnForeground) return {kind: "skip", result: true} // No real wearable paired (or the simulated device): nothing to reconnect to. if (!input.defaultWearable || input.isSimulated) return {kind: "skip", result: false} + // Model chosen but never actually paired (or the native default was cleared): + // connectDefault() would throw — skip quietly; pairing is a user-driven flow. + // (Safe to trust at cold start now: hasDefaultDevice reads are gated on the + // device-store hydration completing, and callers fail open on rejection.) + if (!input.hasDefaultDevice) return {kind: "skip", result: false} // Already connected, or a scan/connect/bond is already in flight: starting a // second connectDefault() on top of the busy link layer would collide with it // (same busy rule as decideConnectButtonAction). diff --git a/mobile/modules/island/src/services/DeviceEventRouter.ts b/mobile/modules/island/src/services/DeviceEventRouter.ts index bcef6b8256..3af465316b 100644 --- a/mobile/modules/island/src/services/DeviceEventRouter.ts +++ b/mobile/modules/island/src/services/DeviceEventRouter.ts @@ -27,6 +27,7 @@ import {isGlassesConnected} from "./GlassesReadiness" import {useGlassesStore} from "../stores/glasses" import {useSettingsStore} from "../stores/settings" import {useAppStatusStore} from "../stores/apps" +import {retirePendingSelectionOnPromotion} from "./PairingIdentity" import GlobalEventEmitter from "../utils/GlobalEventEmitter" import {asgCameraApi} from "./asg/asgCameraApi" @@ -117,7 +118,16 @@ export function startDeviceEventRouter(): void { // The inbound complement to GlassesSettingsSync (which only pushes store→device). subs.push( BluetoothSdk.addListener("save_setting", async (event) => { - await useSettingsStore.getState().setSetting(event.key, event.value) + const settings = useSettingsStore.getState() + // Damp the relay: native re-echoes some settings unconditionally (e.g. + // the identity block at every handleDeviceReady) — a same-value echo + // must not become a store write (persistence churn + change-push noise). + if (settings.getSetting(event.key) !== event.value) { + await settings.setSetting(event.key, event.value) + } + // Two-phase identity: a promoted default retires the pending selection + // marker. The rule lives with PairingIdentity (a no-op for other keys). + await retirePendingSelectionOnPromotion(event.key, event.value) }), ) diff --git a/mobile/modules/island/src/services/DeviceStoreHydration.ts b/mobile/modules/island/src/services/DeviceStoreHydration.ts new file mode 100644 index 0000000000..20b15e6db5 --- /dev/null +++ b/mobile/modules/island/src/services/DeviceStoreHydration.ts @@ -0,0 +1,130 @@ +/** + * DeviceStoreHydration — the cold-start contract for the native DeviceStore. + * + * The Bluetooth SDK's DeviceStore is in-memory on BOTH platforms: every key + * (including the pairing identity `default_wearable` / `device_name` / + * `device_address`) initializes to "" at process start and only becomes real + * once JS pushes the persisted settings over `updateBluetoothSettings`. Until + * that seed lands, `BluetoothSdk.getDefaultDevice()` returns null for + * genuinely-paired users — the false "absent" that made the first + * hasDefaultDevice guard misfire at cold start (see the revert of the + * hasDefaultDevice pairing-route guards). + * + * This service makes that seed an explicit, awaited initialization step: + * 1. load the persisted settings store (idempotent), + * 2. push the full Bluetooth settings set (identity included) to native. + * `toolkit.start()` awaits it, so every post-start `getDefaultDevice()` read is + * a trustworthy two-state answer. `hasDefaultDevice()` also awaits it + * internally (belt-and-suspenders for reads that race start()). + * + * Failure surfaces as a REJECTION — never a false "absent". Callers guarding + * destructive or routing decisions must fail open (treat as "has a device"), + * matching the foreground-reconnect pattern. + */ +import BluetoothSdk from "@mentra/bluetooth-sdk/internal" +import {useSettingsStore, SETTINGS} from "../stores/settings" +import {useGlassesStore} from "../stores/glasses" +import {pushAllBluetoothSettings} from "./GlassesSettingsSync" +import {demoteDefaultToPending} from "./PairingIdentity" +import {DeviceTypes} from "../types" + +let hydrationPromise: Promise | null = null + +/** + * Seed the native DeviceStore from the persisted settings. Single-flight: the + * first caller runs it, everyone else awaits the same promise. On failure the + * current awaiters see the rejection (and fail open — the pre-guard behavior, + * where the connect calls' own error handling was the last line of defense), + * and the memo clears so a LATER read can retry and heal. + */ +export function hydrateDeviceStore(): Promise { + if (!hydrationPromise) { + const startedAt = Date.now() + hydrationPromise = (async () => { + const loadResult = await useSettingsStore.getState().loadAllSettings() + if (loadResult.is_error()) { + throw new Error(`DeviceStoreHydration: settings load failed: ${loadResult.error}`) + } + await pushAllBluetoothSettings() + const settings = useSettingsStore.getState() + const model = settings.getSetting(SETTINGS.default_wearable.key) + const hasName = !!settings.getSetting(SETTINGS.device_name.key) + // Cold-start timing evidence: this line must appear BEFORE any + // RECONNECT/guard log for the hydration contract to hold on device. + console.log( + `DeviceStoreHydration: native seed complete in ${Date.now() - startedAt}ms ` + + `(default_wearable=${model || ""}, device_name present=${hasName})`, + ) + })().catch((error) => { + hydrationPromise = null + throw error + }) + } + return hydrationPromise +} + +/** + * Test-only: clear the hydration memo so each test starts from an unhydrated + * store instead of inheriting whichever hydration a previous test ran. + */ +export function resetDeviceStoreHydrationForTests(): void { + hydrationPromise = null +} + +/** + * Whether the SDK holds an actual default DEVICE to reconnect to — the + * hydration-aware two-state read. Distinct from the `default_wearable` + * setting (a model string that pending/orphaned states can hold without any + * paired device): `connectDefault()` throws without a device. + * Rejects if hydration failed; callers fail open. + */ +export async function hasDefaultDevice(): Promise { + await hydrateDeviceStore() + return !!(await BluetoothSdk.getDefaultDevice()) +} + +/** + * Boot repair for the orphaned-identity population: a persisted + * `default_wearable` whose native default device does not exist after a + * completed hydration. Two-phase writes stop NEW orphans from forming + * (identity is only promoted at pairing success); this demotes the ones + * already in the field — selection-time writes abandoned before pairing + * succeeded, and identity resurrected from the server before it stopped + * syncing — down to `pending_wearable`, which the host renders as a + * finish-pairing card instead of a connect button that would throw + * "Set a default glasses device before calling connectDefault". + * + * Runs after hydration on every start; converges because a demoted default + * can't re-trip the condition. Skips while the link layer is busy or + * connected (an in-flight pairing owns the identity right now). + */ +export async function demoteOrphanedDefaultWearable(): Promise { + // Never judge "native default absent" against an unseeded store: a valid + // pairing identity would read as an orphan and be demoted. Awaiting the + // (memoized) hydration makes the check safe regardless of call ordering; + // a failed hydration rejects here instead of demoting blind. + await hydrateDeviceStore() + + const settings = useSettingsStore.getState() + const model = settings.getSetting(SETTINGS.default_wearable.key) as string | undefined + if (!model) return + // The simulated device is its own identity (name mirrors the model); it + // reconstructs a native default from settings alone and never orphans. + if (model.includes(DeviceTypes.SIMULATED)) return + + // "disconnected" is the only idle state — anything else (scanning / + // connecting / bonding / connected) means a pairing or link owns the + // identity right now. + if (useGlassesStore.getState().connection.state !== "disconnected") return + + if (await BluetoothSdk.getDefaultDevice()) return + + console.log(`DeviceStoreHydration: demoting orphaned default_wearable "${model}" to pending_wearable`) + // The identity write set (latest-selection-wins backfill + identity-triplet + // clear) lives with PairingIdentity, the single writer of the JS-side + // identity keys; this boot repair owns only the guards above. + await demoteDefaultToPending(model) + // The hydration seed already landed the pre-demotion values in the native + // store; re-push so native mirrors the demoted identity too. + await pushAllBluetoothSettings() +} diff --git a/mobile/modules/island/src/services/GlassesSettingsSync.ts b/mobile/modules/island/src/services/GlassesSettingsSync.ts index 270d8cc3ec..53cab8d2c9 100644 --- a/mobile/modules/island/src/services/GlassesSettingsSync.ts +++ b/mobile/modules/island/src/services/GlassesSettingsSync.ts @@ -15,18 +15,51 @@ import {shallow} from "zustand/shallow" import BluetoothSdk from "@mentra/bluetooth-sdk/internal" -import {useSettingsStore} from "../stores/settings" +import {useSettingsStore, PAIRING_IDENTITY_KEYS} from "../stores/settings" import {useGlassesStore} from "../stores/glasses" import {isGlassesConnected} from "./GlassesReadiness" +/** + * The changed-keys diff for the change-push, MINUS the pairing-identity keys. + * Identity is native-authoritative (promoted on pairing success, cleared on + * forget, echoed down via save_setting) and reaches native only through the + * explicit full seeds. Pushing identity from the change subscription would + * close a feedback loop: native echo → store setSetting → change-push → + * native apply → echo…, which self-sustains the moment two different values + * are in flight (e.g. a boot demotion crossing a native promotion) and flaps + * the pairing UI. + */ +export function diffBluetoothSettingsForPush( + settings: Record, + previous: Record, +): Record { + const changed: Record = {} + for (const key in settings) { + if (PAIRING_IDENTITY_KEYS.includes(key)) continue + if (settings[key] !== previous[key]) changed[key] = settings[key] + } + return changed +} + +/** A copy of the settings record without the pairing-identity keys. */ +export function stripPairingIdentity(settings: Record): Record { + const stripped: Record = {} + for (const key in settings) { + if (PAIRING_IDENTITY_KEYS.includes(key)) continue + stripped[key] = settings[key] + } + return stripped +} + let unsubChange: (() => void) | null = null let unsubConnect: (() => void) | null = null /** - * Push the FULL current device-settings set to native over the bluetooth-sdk. - * Used both by the on-connect transition below and as a pre-connect seed by - * `toolkit.glasses.connectDefault()`, so native has the phone's settings primed - * before the connect handshake replays them to the glasses. + * Push the FULL current device-settings set — identity included — to native. + * This is the explicit identity SEED, for the flows where native genuinely + * needs the phone's persisted identity BEFORE it can act: the device-store + * hydration at start, the pre-connect seed (connectDefault targets the seeded + * identity), the post-demotion re-push, and the abandon re-seed. */ export async function pushAllBluetoothSettings(): Promise { // Returns the native write promise so callers can await the seed before the @@ -35,17 +68,27 @@ export async function pushAllBluetoothSettings(): Promise { await BluetoothSdk.updateBluetoothSettings(useSettingsStore.getState().getBluetoothSettings()) } +/** + * Push the device-settings set MINUS the pairing identity. The on-connect + * replay uses this: while connected, NATIVE owns the identity (it promotes at + * device-ready and echoes down), and the JS snapshot can be mid-relay stale — + * on Mentra Live "connected" lands together with device-ready, and a full + * replay raced the promotion echoes and overwrote the just-promoted identity + * with pre-promotion empties (wiping the pairing it had just made). + */ +export async function pushDeviceSettingsOnConnect(): Promise { + await BluetoothSdk.updateBluetoothSettings(stripPairingIdentity(useSettingsStore.getState().getBluetoothSettings())) +} + export function startGlassesSettingsSync(): void { if (unsubChange || unsubConnect) return // 1. Push only the keys that changed (matching the prior MantleManager sync). + // Pairing identity is excluded — see diffBluetoothSettingsForPush. unsubChange = useSettingsStore.subscribe( (state) => state.getBluetoothSettings(), (settings: Record, previous: Record) => { - const changed: Record = {} - for (const key in settings) { - if (settings[key] !== previous[key]) changed[key] = settings[key] - } + const changed = diffBluetoothSettingsForPush(settings, previous) if (Object.keys(changed).length > 0) { // Settings can change while the glasses are disconnected — a native // rejection must not surface as an unhandled promise rejection. @@ -57,13 +100,15 @@ export function startGlassesSettingsSync(): void { {equalityFn: shallow}, ) - // 2. Push the full set whenever the glasses transition to connected. + // 2. Push the device settings whenever the glasses transition to connected — + // identity EXCLUDED: native owns the identity it just promoted, and a full + // replay here raced the promotion echoes (see pushDeviceSettingsOnConnect). let wasConnected = isGlassesConnected(useGlassesStore.getState().connection) unsubConnect = useGlassesStore.subscribe(() => { const connected = isGlassesConnected(useGlassesStore.getState().connection) if (connected && !wasConnected) { // Background sync: log-and-continue if the device drops right after connect. - void pushAllBluetoothSettings().catch((error) => { + void pushDeviceSettingsOnConnect().catch((error) => { console.warn("GlassesSettingsSync: on-connect settings push failed:", error) }) } diff --git a/mobile/modules/island/src/services/PairingIdentity.ts b/mobile/modules/island/src/services/PairingIdentity.ts new file mode 100644 index 0000000000..2339da7ba0 --- /dev/null +++ b/mobile/modules/island/src/services/PairingIdentity.ts @@ -0,0 +1,154 @@ +/** + * PairingIdentity — the pairing-identity lifecycle, reified. The two-phase + * identity rules (see the pending/default group in stores/settings.ts) exist + * as a three-state machine over the identity settings keys: + * + * none ──markPendingSelection()──▶ pending ──native promotion──▶ paired + * ▲ │ + * └───────boot demotion────────┘ + * + * - `none` — no model ever selected: hosts route to model selection. + * - `pending` — a model was CHOSEN (the one JS-owned identity write, made at + * scan entry) but pairing never completed: hosts render + * finish-pairing affordances. + * - `paired` — the native layer promoted the identity at pairing success + * (handleDeviceReady) and echoed it down via save_setting: + * hosts render the device card / connect surfaces. + * + * Projection law, mirroring native currentDefaultDevice() on both platforms: + * `paired` requires default_wearable AND device_name; device_address is + * optional. Two read-time alignments with the demotion's own write rules: + * - The simulated device is its own identity (its name mirrors the model at + * connectSimulated, and the boot demotion never touches it), so a simulated + * default_wearable projects as `paired` even without a device_name echo. + * - A non-simulated default_wearable WITHOUT a device_name is the orphan + * population `demoteOrphanedDefaultWearable` repairs into pending_wearable + * at boot; the projection already reads it as `pending` (a fresher + * pending_wearable wins, matching the demotion's latest-selection-wins + * backfill). + * + * JS-initiated identity writes live here and only here — the scan screen + * (markPendingSelection), the device-event router (promotion retires the + * pending marker), and the boot demotion (demoteDefaultToPending) invoke + * these methods instead of writing the raw keys. The router's generic + * save_setting persistence is the inbound native→JS echo leg, not a + * JS-initiated write, and stays with the router. + */ +import {result as Res, type AsyncResult} from "typesafe-ts" + +import {useSettingsStore, SETTINGS} from "../stores/settings" +import {DeviceTypes} from "../types" + +export type IdentitySnapshot = + | {kind: "none"} + | {kind: "pending"; model: string} + | {kind: "paired"; model: string; name: string; address?: string} + +// Identity keys hold model/name strings; anything else (legacy null, a stray +// non-string) reads as absent, matching native currentDefaultDevice()'s +// blank checks. +function readIdentityString(key: string): string { + const value = useSettingsStore.getState().getSetting(key) + return typeof value === "string" ? value : "" +} + +/** Project the identity settings keys into the lifecycle snapshot. */ +export function projectPairingIdentity(): IdentitySnapshot { + const model = readIdentityString(SETTINGS.default_wearable.key) + const name = readIdentityString(SETTINGS.device_name.key) + + if (model && name) { + const address = readIdentityString(SETTINGS.device_address.key) + return address ? {kind: "paired", model, name, address} : {kind: "paired", model, name} + } + if (model && model.includes(DeviceTypes.SIMULATED)) { + return {kind: "paired", model, name: model} + } + const pending = readIdentityString(SETTINGS.pending_wearable.key) + if (pending) return {kind: "pending", model: pending} + if (model) return {kind: "pending", model} + return {kind: "none"} +} + +/** + * Subscribe to identity changes, deduped on the projected snapshot (same + * style as glasses.onStatus): the settings store updates on many keys; the + * callback fires only when the identity snapshot actually changes. + */ +export function subscribePairingIdentity(cb: (identity: IdentitySnapshot) => void): () => void { + let last = JSON.stringify(projectPairingIdentity()) + return useSettingsStore.subscribe(() => { + const snap = projectPairingIdentity() + const key = JSON.stringify(snap) + if (key === last) return + last = key + cb(snap) + }) +} + +/** + * Selection: mark the model the user chose as the PENDING wearable (written + * at scan entry). Promotion to `paired` only ever happens natively at pairing + * success. The marker persists so an abandoned selection still renders its + * finish-pairing card after an app restart. + */ +export function markPendingSelection(model: string): AsyncResult { + // Guard the public write-path: an empty/whitespace model would persist a + // pending identity that renders a blank finish-pairing card. + const trimmed = typeof model === "string" ? model.trim() : "" + if (!trimmed) { + console.warn(`PairingIdentity: ignoring pending selection with empty model (got ${JSON.stringify(model)})`) + return Res.try_async(async () => {}) + } + console.log(`PairingIdentity: pending selection -> "${trimmed}"`) + const result = useSettingsStore.getState().setSetting(SETTINGS.pending_wearable.key, trimmed) + // setSetting resolves to a Result (never rejects); a persist failure would + // silently drop the finish-pairing affordance on restart — surface it. + void Promise.resolve(result).then((r) => { + if (r.is_error()) console.warn("PairingIdentity: pending selection failed to persist:", r.error) + }) + return result +} + +/** + * Promotion retires the selection: when the native layer promotes the default + * wearable at pairing success and its save_setting echo lands, the pending + * marker has served its purpose. Invoked by DeviceEventRouter for every echo; + * a no-op for other keys, empty promotions, and an already-clear marker. + */ +export async function retirePendingSelectionOnPromotion(echoedKey: string, echoedValue: unknown): Promise { + if (echoedKey !== SETTINGS.default_wearable.key || !echoedValue) return + const settings = useSettingsStore.getState() + const pending = settings.getSetting(SETTINGS.pending_wearable.key) + if (pending) { + console.log(`PairingIdentity: promotion "${String(echoedValue)}" retires pending "${pending}"`) + await settings.setSetting(SETTINGS.pending_wearable.key, "") + } +} + +/** + * Demotion: turn an orphaned default identity back into a pending selection. + * The guards (hydration, the native default-device read, link-layer state) + * live with the boot repair in demoteOrphanedDefaultWearable — this is only + * the identity write set: backfill the pending marker unless a fresher + * selection already holds it (the user's LATEST selection wins), then clear + * the promoted identity triplet. + */ +export async function demoteDefaultToPending(model: string): Promise { + const settings = useSettingsStore.getState() + // readIdentityString, not raw getSetting: a truthy non-string legacy value + // must read as absent here too, or the orphaned model is never backfilled + // while the projection reads the same junk as no pending at all. + const fresher = readIdentityString(SETTINGS.pending_wearable.key) + console.log( + fresher + ? `PairingIdentity: demote "${model}" — fresher pending "${fresher}" kept` + : `PairingIdentity: demote "${model}" -> pending`, + ) + if (!fresher) { + await settings.setSetting(SETTINGS.pending_wearable.key, model) + } + await settings.setSetting(SETTINGS.default_wearable.key, "") + await settings.setSetting(SETTINGS.device_name.key, "") + await settings.setSetting(SETTINGS.device_address.key, "") +} diff --git a/mobile/modules/island/src/services/__tests__/DeviceStoreHydration.test.ts b/mobile/modules/island/src/services/__tests__/DeviceStoreHydration.test.ts new file mode 100644 index 0000000000..b6edca8fbd --- /dev/null +++ b/mobile/modules/island/src/services/__tests__/DeviceStoreHydration.test.ts @@ -0,0 +1,183 @@ +/// + +import {beforeEach, describe, expect, mock, test} from "bun:test" + +const mockGetDefaultDevice = mock((): Promise | null> => Promise.resolve(null)) +mock.module("@mentra/bluetooth-sdk/internal", () => ({ + __esModule: true, + default: { + getDefaultDevice: mockGetDefaultDevice, + }, +})) + +const okResult = {is_error: () => false} as const +const settingsValues: Record = {} +const mockLoadAllSettings = mock(() => Promise.resolve(okResult as {is_error: () => boolean; error?: unknown})) +const mockSetSetting = mock((key: string, value: unknown) => { + settingsValues[key] = value + return Promise.resolve(okResult) +}) +mock.module("../../stores/settings", () => ({ + SETTINGS: { + default_wearable: {key: "default_wearable"}, + pending_wearable: {key: "pending_wearable"}, + device_name: {key: "device_name"}, + device_address: {key: "device_address"}, + }, + useSettingsStore: { + getState: () => ({ + loadAllSettings: mockLoadAllSettings, + getSetting: (key: string) => settingsValues[key], + setSetting: mockSetSetting, + }), + }, +})) + +let connection: {state: string} = {state: "disconnected"} +mock.module("../../stores/glasses", () => ({ + useGlassesStore: { + getState: () => ({connection}), + }, +})) + +const mockPushAllBluetoothSettings = mock(() => Promise.resolve()) +mock.module("../GlassesSettingsSync", () => ({ + pushAllBluetoothSettings: mockPushAllBluetoothSettings, +})) + +// Import AFTER the mocks are registered +const { + hydrateDeviceStore, + hasDefaultDevice, + demoteOrphanedDefaultWearable, + resetDeviceStoreHydrationForTests, +} = require("../DeviceStoreHydration") + +function resetSettings(values: Record) { + for (const key of Object.keys(settingsValues)) delete settingsValues[key] + Object.assign(settingsValues, values) +} + +describe("DeviceStoreHydration", () => { + beforeEach(() => { + resetDeviceStoreHydrationForTests() + mockGetDefaultDevice.mockClear() + mockLoadAllSettings.mockClear() + mockSetSetting.mockClear() + mockPushAllBluetoothSettings.mockClear() + mockLoadAllSettings.mockImplementation(() => Promise.resolve(okResult)) + mockGetDefaultDevice.mockImplementation(() => Promise.resolve(null)) + connection = {state: "disconnected"} + resetSettings({}) + }) + + test("hydration retries after a failed settings load, then single-flights", async () => { + mockLoadAllSettings.mockImplementation(() => + Promise.resolve({is_error: () => true, error: new Error("disk unavailable")}), + ) + // The failure surfaces as a REJECTION (never a false "absent"), and + // hasDefaultDevice propagates it so callers can fail open. + await expect(hasDefaultDevice()).rejects.toThrow("settings load failed") + expect(mockPushAllBluetoothSettings).not.toHaveBeenCalled() + + // The memo cleared on failure: a later call retries and heals. + mockLoadAllSettings.mockImplementation(() => Promise.resolve(okResult)) + await hydrateDeviceStore() + expect(mockPushAllBluetoothSettings).toHaveBeenCalledTimes(1) + + // Completed hydration is cached: further calls run nothing again. + await hydrateDeviceStore() + await hydrateDeviceStore() + expect(mockPushAllBluetoothSettings).toHaveBeenCalledTimes(1) + }) + + test("hasDefaultDevice awaits hydration and reports the native two-state answer", async () => { + mockGetDefaultDevice.mockImplementation(() => Promise.resolve(null)) + expect(await hasDefaultDevice()).toBe(false) + + mockGetDefaultDevice.mockImplementation(() => + Promise.resolve({model: "Even Realities G1", name: "G1_123"}), + ) + expect(await hasDefaultDevice()).toBe(true) + }) + + test("demotes an orphaned default_wearable to pending and clears the identity", async () => { + resetSettings({default_wearable: "Mentra Live", device_name: "LIVE_1", device_address: "aa:bb"}) + mockGetDefaultDevice.mockImplementation(() => Promise.resolve(null)) + + await demoteOrphanedDefaultWearable() + + expect(settingsValues.pending_wearable).toBe("Mentra Live") + expect(settingsValues.default_wearable).toBe("") + expect(settingsValues.device_name).toBe("") + expect(settingsValues.device_address).toBe("") + // Two pushes: the hydration seed (pre-demotion values), then the re-push + // so native mirrors the demoted identity. + expect(mockPushAllBluetoothSettings).toHaveBeenCalledTimes(2) + }) + + test("demotion backfills pending only when empty — a fresher selection wins", async () => { + resetSettings({default_wearable: "Mentra Live", pending_wearable: "Even Realities G1"}) + mockGetDefaultDevice.mockImplementation(() => Promise.resolve(null)) + + await demoteOrphanedDefaultWearable() + + // The user's later selection (G1) survives; the stale orphan is only cleared. + expect(settingsValues.pending_wearable).toBe("Even Realities G1") + expect(settingsValues.default_wearable).toBe("") + }) + + test("does not demote when the native default device exists", async () => { + resetSettings({default_wearable: "Mentra Live", device_name: "LIVE_1"}) + mockGetDefaultDevice.mockImplementation(() => + Promise.resolve({model: "Mentra Live", name: "LIVE_1"}), + ) + + await demoteOrphanedDefaultWearable() + + expect(mockSetSetting).not.toHaveBeenCalled() + }) + + test("does not demote the simulated wearable", async () => { + resetSettings({default_wearable: "Simulated Glasses"}) + + await demoteOrphanedDefaultWearable() + + expect(mockSetSetting).not.toHaveBeenCalled() + // Hydration runs (the internal await), but the native default is never read. + expect(mockGetDefaultDevice).not.toHaveBeenCalled() + }) + + test("demotion refuses to run against an unhydrated store", async () => { + resetSettings({default_wearable: "Mentra Live", device_name: "LIVE_1"}) + mockLoadAllSettings.mockImplementation(() => + Promise.resolve({is_error: () => true, error: new Error("disk unavailable")}), + ) + + await expect(demoteOrphanedDefaultWearable()).rejects.toThrow("settings load failed") + + // Nothing was demoted on the unseeded store. + expect(mockSetSetting).not.toHaveBeenCalled() + expect(settingsValues.default_wearable).toBe("Mentra Live") + }) + + test("does not demote while the link layer is busy or connected (a pairing owns the identity)", async () => { + resetSettings({default_wearable: "Mentra Live"}) + + connection = {state: "connecting"} + await demoteOrphanedDefaultWearable() + expect(mockSetSetting).not.toHaveBeenCalled() + + connection = {state: "connected"} + await demoteOrphanedDefaultWearable() + expect(mockSetSetting).not.toHaveBeenCalled() + }) + + test("does nothing when no default_wearable is set", async () => { + resetSettings({pending_wearable: "Mentra Live"}) + + await demoteOrphanedDefaultWearable() + + expect(mockSetSetting).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/modules/island/src/services/__tests__/PairingIdentity.test.ts b/mobile/modules/island/src/services/__tests__/PairingIdentity.test.ts new file mode 100644 index 0000000000..cbd1740e15 --- /dev/null +++ b/mobile/modules/island/src/services/__tests__/PairingIdentity.test.ts @@ -0,0 +1,171 @@ +/// + +import {beforeEach, describe, expect, mock, test} from "bun:test" + +const settingsValues: Record = {} +const listeners = new Set<() => void>() +const okResult = {is_error: () => false} as const +const mockSetSetting = mock((key: string, value: unknown) => { + settingsValues[key] = value + for (const listener of [...listeners]) listener() + return Promise.resolve(okResult) +}) + +mock.module("../../stores/settings", () => ({ + SETTINGS: { + default_wearable: {key: "default_wearable"}, + pending_wearable: {key: "pending_wearable"}, + device_name: {key: "device_name"}, + device_address: {key: "device_address"}, + }, + useSettingsStore: { + getState: () => ({ + getSetting: (key: string) => settingsValues[key], + setSetting: mockSetSetting, + }), + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + }, +})) + +// Import AFTER the mocks are registered +const { + projectPairingIdentity, + subscribePairingIdentity, + markPendingSelection, + retirePendingSelectionOnPromotion, + demoteDefaultToPending, +} = require("../PairingIdentity") +// Real enum: the simulated special case must track the actual model string. +const {DeviceTypes} = require("../../types") + +function resetSettings(values: Record) { + for (const key of Object.keys(settingsValues)) delete settingsValues[key] + Object.assign(settingsValues, values) +} + +describe("PairingIdentity", () => { + beforeEach(() => { + mockSetSetting.mockClear() + listeners.clear() + resetSettings({}) + }) + + describe("projectPairingIdentity", () => { + test("no identity keys → none", () => { + expect(projectPairingIdentity()).toEqual({kind: "none"}) + }) + + test("a pending selection → pending", () => { + resetSettings({pending_wearable: "Even Realities G1"}) + expect(projectPairingIdentity()).toEqual({kind: "pending", model: "Even Realities G1"}) + }) + + test("paired requires default_wearable AND device_name (native currentDefaultDevice)", () => { + resetSettings({default_wearable: "Mentra Live", device_name: "LIVE_1", device_address: "aa:bb"}) + expect(projectPairingIdentity()).toEqual({ + kind: "paired", + model: "Mentra Live", + name: "LIVE_1", + address: "aa:bb", + }) + }) + + test("address is optional on a paired identity", () => { + resetSettings({default_wearable: "Mentra Live", device_name: "LIVE_1"}) + expect(projectPairingIdentity()).toEqual({kind: "paired", model: "Mentra Live", name: "LIVE_1"}) + }) + + test("an orphaned default (no device_name) reads as pending — the demotion's target state", () => { + resetSettings({default_wearable: "Mentra Live"}) + expect(projectPairingIdentity()).toEqual({kind: "pending", model: "Mentra Live"}) + }) + + test("a fresher pending selection wins over an orphaned default", () => { + resetSettings({default_wearable: "Mentra Live", pending_wearable: "Even Realities G1"}) + expect(projectPairingIdentity()).toEqual({kind: "pending", model: "Even Realities G1"}) + }) + + test("a completed pairing wins over a stale pending marker (retire echo still in flight)", () => { + resetSettings({default_wearable: "Mentra Live", device_name: "LIVE_1", pending_wearable: "Mentra Live"}) + expect(projectPairingIdentity()).toEqual({kind: "paired", model: "Mentra Live", name: "LIVE_1"}) + }) + + test("the simulated device is its own identity: paired from the model alone, name mirrors it", () => { + resetSettings({default_wearable: DeviceTypes.SIMULATED}) + expect(projectPairingIdentity()).toEqual({ + kind: "paired", + model: DeviceTypes.SIMULATED, + name: DeviceTypes.SIMULATED, + }) + }) + + test("non-string identity values read as absent", () => { + resetSettings({default_wearable: null, pending_wearable: undefined, device_name: 42}) + expect(projectPairingIdentity()).toEqual({kind: "none"}) + }) + }) + + describe("subscribePairingIdentity", () => { + test("fires with the new snapshot when the identity changes, dedupes when it does not", async () => { + const seen: unknown[] = [] + const unsubscribe = subscribePairingIdentity((identity: unknown) => seen.push(identity)) + + await markPendingSelection("Even Realities G1") + expect(seen).toEqual([{kind: "pending", model: "Even Realities G1"}]) + + // A settings-store update that leaves the projection unchanged must not fire. + await mockSetSetting("brightness", 80) + await markPendingSelection("Even Realities G1") + expect(seen).toHaveLength(1) + + unsubscribe() + await markPendingSelection("Mentra Live") + expect(seen).toHaveLength(1) + }) + }) + + describe("identity writes", () => { + test("markPendingSelection writes the pending marker", async () => { + await markPendingSelection("Even Realities G1") + expect(settingsValues.pending_wearable).toBe("Even Realities G1") + }) + + test("a promoted default retires the pending selection marker", async () => { + resetSettings({pending_wearable: "Mentra Live"}) + await retirePendingSelectionOnPromotion("default_wearable", "Mentra Live") + expect(settingsValues.pending_wearable).toBe("") + }) + + test("retire is a no-op for other keys, empty promotions, and an already-clear marker", async () => { + resetSettings({pending_wearable: "Mentra Live"}) + await retirePendingSelectionOnPromotion("device_name", "LIVE_1") + await retirePendingSelectionOnPromotion("default_wearable", "") + expect(settingsValues.pending_wearable).toBe("Mentra Live") + + resetSettings({}) + await retirePendingSelectionOnPromotion("default_wearable", "Mentra Live") + expect(mockSetSetting).not.toHaveBeenCalled() + }) + + test("demoteDefaultToPending backfills the marker and clears the identity triplet", async () => { + resetSettings({default_wearable: "Mentra Live", device_name: "LIVE_1", device_address: "aa:bb"}) + await demoteDefaultToPending("Mentra Live") + expect(settingsValues).toEqual({ + pending_wearable: "Mentra Live", + default_wearable: "", + device_name: "", + device_address: "", + }) + }) + + test("demoteDefaultToPending never overwrites a fresher pending selection", async () => { + resetSettings({default_wearable: "Mentra Live", pending_wearable: "Even Realities G1"}) + await demoteDefaultToPending("Mentra Live") + expect(settingsValues.pending_wearable).toBe("Even Realities G1") + expect(settingsValues.default_wearable).toBe("") + }) + }) +}) diff --git a/mobile/modules/island/src/stores/settings.ts b/mobile/modules/island/src/stores/settings.ts index 4b8295867f..89af6e662e 100644 --- a/mobile/modules/island/src/stores/settings.ts +++ b/mobile/modules/island/src/stores/settings.ts @@ -18,14 +18,28 @@ export interface Setting { override?: () => any // onWrite?: () => void persist: boolean + // Pairing-identity keys are NATIVE-authoritative: the native layer writes + // them on pairing success (handleDeviceReady) / forget and echoes them down + // via save_setting; JS→native they travel only in the explicit SEEDS + // (hydration, pre-connect, post-demotion, abandon re-seed) — never the + // change-push and never the on-connect replay, whose mid-relay snapshot can + // overwrite a just-promoted identity. PAIRING_IDENTITY_KEYS is derived from + // this flag so the sync exclusions can't drift from the descriptors. + nativeAuthoritative?: true } export const SETTINGS: Record = { // feature flags / mantle settings: - dev_mode: {key: "dev_mode", defaultValue: () => __DEV__, writable: true, saveOnServer: true, persist: true},// deprecated + dev_mode: {key: "dev_mode", defaultValue: () => __DEV__, writable: true, saveOnServer: true, persist: true}, // deprecated debug_mode: {key: "debug_mode", defaultValue: () => __DEV__, writable: true, saveOnServer: true, persist: true}, super_mode: {key: "super_mode", defaultValue: () => false, writable: true, saveOnServer: true, persist: true}, - appearance_menu_enabled: {key: "appearance_menu_enabled", defaultValue: () => false, writable: true, saveOnServer: true, persist: true}, + appearance_menu_enabled: { + key: "appearance_menu_enabled", + defaultValue: () => false, + writable: true, + saveOnServer: true, + persist: true, + }, app_boot_extra_info: { key: "app_boot_extra_info", defaultValue: () => false, @@ -33,7 +47,13 @@ export const SETTINGS: Record = { saveOnServer: true, persist: true, }, - miniapp_dev_mode: {key: "miniapp_dev_mode", defaultValue: () => false, writable: true, saveOnServer: true, persist: true}, + miniapp_dev_mode: { + key: "miniapp_dev_mode", + defaultValue: () => false, + writable: true, + saveOnServer: true, + persist: true, + }, enable_squircles: { key: "enable_squircles", defaultValue: () => true, @@ -208,34 +228,56 @@ export const SETTINGS: Record = { // from or saved to the legacy Cloud V1 settings store. core_token: {key: "core_token", defaultValue: () => "", writable: true, saveOnServer: false, persist: false}, auth_email: {key: "auth_email", defaultValue: () => "", writable: true, saveOnServer: false, persist: true}, + // Pairing identity is per-phone, not per-account: two phones on one account + // can be paired to different glasses, so none of these keys may sync to the + // server (saveOnServer: false — a stale server copy resurrecting a local + // pairing identity is exactly the desync this group's flags prevent). + // + // Two-phase identity: pending_wearable is the model the user last STARTED + // pairing (written at selection, rendered as a finish-pairing card); + // default_wearable + device_name + device_address are only promoted by the + // native layer when pairing actually succeeds (handleDeviceReady). + // pending_wearable persists so an abandoned selection still shows its + // finish-pairing card after an app restart. pending_wearable: { key: "pending_wearable", defaultValue: () => "", writable: true, saveOnServer: false, - persist: false, + persist: true, + nativeAuthoritative: true, }, default_wearable: { key: "default_wearable", defaultValue: () => "", writable: true, - saveOnServer: true, + saveOnServer: false, + persist: true, + nativeAuthoritative: true, + }, + device_name: { + key: "device_name", + defaultValue: () => "", + writable: true, + saveOnServer: false, persist: true, + nativeAuthoritative: true, }, - device_name: {key: "device_name", defaultValue: () => "", writable: true, saveOnServer: true, persist: true}, device_address: { key: "device_address", defaultValue: () => "", writable: true, - saveOnServer: true, + saveOnServer: false, persist: true, + nativeAuthoritative: true, }, default_controller: { key: "default_controller", defaultValue: () => "", writable: true, - saveOnServer: true, + saveOnServer: false, persist: true, + nativeAuthoritative: true, }, pending_controller: { key: "pending_controller", @@ -243,20 +285,23 @@ export const SETTINGS: Record = { writable: true, saveOnServer: false, persist: true, + nativeAuthoritative: true, }, controller_device_name: { key: "controller_device_name", defaultValue: () => "", writable: true, - saveOnServer: true, + saveOnServer: false, persist: true, + nativeAuthoritative: true, }, controller_address: { key: "controller_address", defaultValue: () => "", writable: true, - saveOnServer: true, + saveOnServer: false, persist: true, + nativeAuthoritative: true, }, // ui state: home_background: { @@ -698,6 +743,23 @@ export const BLUETOOTH_SETTING_KEYS: string[] = [ SETTINGS.nex_lc3_audio_playback.key, ] +// Pairing identity is NATIVE-authoritative: the native layer writes it on +// pairing success (handleDeviceReady) / forget and echoes it down via +// save_setting; JS persists those echoes. JS→native, identity travels ONLY in +// the explicit full seeds (device-store hydration, pre-connect push, +// post-demotion re-push) — never in the change-push subscription. Relaying an +// echoed identity change back up would make the sync bidirectional with loop +// gain 1: two identity values in flight (e.g. a boot demotion crossing a +// native promotion) then chase each other through push→apply→echo→push +// forever, flapping the UI. +// +// Derived from the `nativeAuthoritative` descriptor flag (not maintained as a +// parallel list) so the change-push exclusion in GlassesSettingsSync can't +// drift from the setting declarations. +export const PAIRING_IDENTITY_KEYS: string[] = Object.values(SETTINGS) + .filter((setting) => setting.nativeAuthoritative) + .map((setting) => setting.key) + // const PER_GLASSES_SETTINGS_KEYS: string[] = [SETTINGS.preferred_mic.key] export interface SettingsState { @@ -719,10 +781,19 @@ export interface SettingsState { } const getDefaultSettings = () => - Object.keys(SETTINGS).reduce((acc, key) => { - acc[key] = SETTINGS[key].defaultValue() - return acc - }, {} as Record) + Object.keys(SETTINGS).reduce( + (acc, key) => { + acc[key] = SETTINGS[key].defaultValue() + return acc + }, + {} as Record, + ) + +// Single-flight for loadAllSettings: the host fires it at module load and +// toolkit.start()'s device-store hydration awaits it — without the memo the +// second caller runs a duplicate full disk load while the first is still in +// flight. Cleared on failure so a later call can retry. +let loadAllSettingsInFlight: AsyncResult | null = null export const useSettingsStore = create()( subscribeWithSelector((set, get) => ({ @@ -829,7 +900,10 @@ export const useSettingsStore = create()( // loads any preferences that have been changed from the default and saved to DISK! loadAllSettings: (): AsyncResult => { console.log("SETTINGS: loadAllSettings()") - return Res.try_async(async () => { + if (loadAllSettingsInFlight) { + return loadAllSettingsInFlight + } + const inFlight = Res.try_async(async () => { const state = get() let loadedSettings: Record = {} @@ -914,6 +988,13 @@ export const useSettingsStore = create()( storage.save(MIGRATION_KEY, true) } }) + loadAllSettingsInFlight = inFlight + void Promise.resolve(inFlight).then((result) => { + if (result.is_error()) { + loadAllSettingsInFlight = null + } + }) + return inFlight }, getRestUrl: () => { const serverUrl = get().getSetting(SETTINGS.backend_url.key) diff --git a/mobile/src/__tests__/app/pairing/loading.test.tsx b/mobile/src/__tests__/app/pairing/loading.test.tsx index b96010d012..17baa7237b 100644 --- a/mobile/src/__tests__/app/pairing/loading.test.tsx +++ b/mobile/src/__tests__/app/pairing/loading.test.tsx @@ -119,7 +119,9 @@ describe("pairing loading screen", () => { }) await waitFor(() => { - expect(toolkit.glasses.forget).toHaveBeenCalled() + // Attempt cleanup preserves a pre-existing pairing (re-pair) instead of + // an unconditional forget. + expect(toolkit.pairing.abandonAttempt).toHaveBeenCalled() expect(replace).toHaveBeenCalledWith("/pairing/failure", { error: "pairing:failed", deviceModel: "Mentra Live", diff --git a/mobile/src/__tests__/app/pairing/scan.test.tsx b/mobile/src/__tests__/app/pairing/scan.test.tsx index 8668d8b1b5..1d541b1cab 100644 --- a/mobile/src/__tests__/app/pairing/scan.test.tsx +++ b/mobile/src/__tests__/app/pairing/scan.test.tsx @@ -141,7 +141,7 @@ import {Platform} from "react-native" import {toolkit} from "@mentra/island" import {useLocalSearchParams} from "expo-router" -import {usePushUnder} from "@/contexts/NavigationHistoryContext" +import {focusEffectPreventBack, usePushUnder} from "@/contexts/NavigationHistoryContext" import {useNavigationStore} from "@/stores/navigation" import {requestFeaturePermissions} from "@/utils/PermissionsUtils" import SelectGlassesBluetoothScreen from "@/app/pairing/scan" @@ -176,6 +176,7 @@ describe("pairing scan screen", () => { ;(toolkit.pairing.onFound as jest.Mock).mockImplementation((cb: (results: unknown) => void) => useCoreStore.subscribe((s) => s.searchResults, cb), ) + ;(toolkit.glasses.hasDefaultDevice as jest.Mock).mockResolvedValue(true) ;(useLocalSearchParams as jest.Mock).mockReturnValue({deviceModel: "Mentra Live"}) ;(useNavigationStore.getState as jest.Mock).mockReturnValue({replace, push, goBack}) ;(usePushUnder as jest.Mock).mockReturnValue(pushUnder) @@ -204,20 +205,54 @@ describe("pairing scan screen", () => { fireEvent.press(getByText("001")) await waitFor(() => { - expect(replace).toHaveBeenCalledWith("/pairing/btclassic") + expect(replace).toHaveBeenCalledWith("/pairing/btclassic", { + device: JSON.stringify({id: "a", model: "Mentra Live", name: "MENTRA_LIVE_BLE_001", address: "a"}), + }) expect(pushUnder).toHaveBeenCalledWith("/pairing/loading", { deviceModel: "Mentra Live", deviceName: "MENTRA_LIVE_BLE_001", }) }) - expect(toolkit.pairing.setDefault).toHaveBeenCalledWith({ - id: "a", - model: "Mentra Live", - name: "MENTRA_LIVE_BLE_001", - address: "a", + // Two-phase identity: picking a device must NOT write the default identity — + // the scan marks the model pending and the native layer promotes on success. + expect(toolkit.pairing.setDefault).not.toHaveBeenCalled() + expect(useSettingsStore.getState().getSetting(SETTINGS.device_name.key)).toBe("") + expect(useSettingsStore.getState().getSetting(SETTINGS.pending_wearable.key)).toBe("Mentra Live") + }) + + it("marks the chosen model pending on entry", async () => { + render() + + await waitFor(() => { + expect(useSettingsStore.getState().getSetting(SETTINGS.pending_wearable.key)).toBe("Mentra Live") + }) + }) + + it("back-out delegates cleanup to the live abandonAttempt and keeps the pending marker", async () => { + // No entry snapshot: the abandon decision must come from the LIVE + // default-device read, because a pairing can promote while the flow is + // open — an entry snapshot would forget that brand-new pairing. + render() + + const backHandler = (focusEffectPreventBack as jest.Mock).mock.calls[0][0] + backHandler({actionType: "GO_BACK"}) + + await waitFor(() => { + expect(toolkit.pairing.abandonAttempt).toHaveBeenCalledWith() }) - expect(useSettingsStore.getState().getSetting(SETTINGS.device_name.key)).toBe("MENTRA_LIVE_BLE_001") + expect(goBack).toHaveBeenCalled() + expect(useSettingsStore.getState().getSetting(SETTINGS.pending_wearable.key)).toBe("Mentra Live") + }) + + it("forward navigation (replace to btclassic) skips the back-out cleanup", async () => { + render() + + const backHandler = (focusEffectPreventBack as jest.Mock).mock.calls[0][0] + backHandler({actionType: "REPLACE"}) + + expect(toolkit.pairing.abandonAttempt).not.toHaveBeenCalled() + expect(goBack).not.toHaveBeenCalled() }) it("auto-skips directly into pairing when NOTREQUIREDSKIP is discovered", async () => { diff --git a/mobile/src/__tests__/connectionCoordinator.test.ts b/mobile/src/__tests__/connectionCoordinator.test.ts index f1f53c9879..7669032cd5 100644 --- a/mobile/src/__tests__/connectionCoordinator.test.ts +++ b/mobile/src/__tests__/connectionCoordinator.test.ts @@ -10,6 +10,7 @@ describe("decideReconnect", () => { connected: false, searching: false, nativeLinkBusy: false, + hasDefaultDevice: true, } it("skips as a no-op success when reconnect-on-foreground is off", () => { @@ -36,6 +37,10 @@ describe("decideReconnect", () => { expect(decideReconnect({...base, nativeLinkBusy: true})).toEqual({kind: "skip", result: true}) }) + it("skips quietly when a model is chosen but no device was ever paired", () => { + expect(decideReconnect({...base, hasDefaultDevice: false})).toEqual({kind: "skip", result: false}) + }) + it("connects when paired, idle, and disconnected", () => { expect(decideReconnect(base)).toEqual({kind: "connect"}) }) diff --git a/mobile/src/__tests__/glassesSettingsSyncDiff.test.ts b/mobile/src/__tests__/glassesSettingsSyncDiff.test.ts new file mode 100644 index 0000000000..996df4e06e --- /dev/null +++ b/mobile/src/__tests__/glassesSettingsSyncDiff.test.ts @@ -0,0 +1,63 @@ +// Imports the real GlassesSettingsSync by path (not via "@mentra/island", +// which jest mocks) so the actual diff logic runs under the mobile jest CI +// runner. +import {diffBluetoothSettingsForPush, stripPairingIdentity} from "../../modules/island/src/services/GlassesSettingsSync" +import {PAIRING_IDENTITY_KEYS, SETTINGS} from "../../modules/island/src/stores/settings" + +describe("diffBluetoothSettingsForPush", () => { + it("pushes changed non-identity keys only", () => { + const previous = {brightness: 50, sensing_enabled: true} + const next = {brightness: 80, sensing_enabled: true} + expect(diffBluetoothSettingsForPush(next, previous)).toEqual({brightness: 80}) + }) + + it("returns empty when nothing changed", () => { + const settings = {brightness: 50} + expect(diffBluetoothSettingsForPush(settings, {...settings})).toEqual({}) + }) + + it("never pushes pairing-identity keys, even when they changed", () => { + // Identity is native-authoritative and reaches native only via the explicit + // seeds. Relaying an echoed identity change back through the change-push + // closes a feedback loop: two values in flight (boot demotion crossing a + // native promotion) chase each other through push→apply→echo→push forever + // and flap the home pairing card. + const previous = Object.fromEntries(PAIRING_IDENTITY_KEYS.map((key) => [key, ""])) + const next = Object.fromEntries(PAIRING_IDENTITY_KEYS.map((key) => [key, "Even Realities G2"])) + expect(diffBluetoothSettingsForPush(next, previous)).toEqual({}) + }) + + it("covers the full identity group", () => { + // The oscillation fix only holds if every identity key is in the list. + expect(PAIRING_IDENTITY_KEYS.sort()).toEqual( + [ + SETTINGS.pending_wearable.key, + SETTINGS.default_wearable.key, + SETTINGS.device_name.key, + SETTINGS.device_address.key, + SETTINGS.default_controller.key, + SETTINGS.pending_controller.key, + SETTINGS.controller_device_name.key, + SETTINGS.controller_address.key, + ].sort(), + ) + }) + + it("still pushes a mixed diff minus the identity part", () => { + const previous = {brightness: 50, default_wearable: ""} + const next = {brightness: 80, default_wearable: "Even Realities G2"} + expect(diffBluetoothSettingsForPush(next, previous)).toEqual({brightness: 80}) + }) +}) + +describe("stripPairingIdentity", () => { + it("removes every identity key and keeps the rest — the on-connect replay set", () => { + // While connected, NATIVE owns the identity it promoted at device-ready; + // the on-connect replay carrying a mid-relay JS snapshot overwrote a + // just-promoted identity with pre-promotion empties (Mentra Live pairing + // wiped itself right after succeeding). + const settings: Record = {brightness: 80, gallery_mode: true} + for (const key of PAIRING_IDENTITY_KEYS) settings[key] = "stale" + expect(stripPairingIdentity(settings)).toEqual({brightness: 80, gallery_mode: true}) + }) +}) diff --git a/mobile/src/app/home.tsx b/mobile/src/app/home.tsx index b85a28ba9d..712b355c0c 100644 --- a/mobile/src/app/home.tsx +++ b/mobile/src/app/home.tsx @@ -24,7 +24,9 @@ import {BlurTargetView, BlurView} from "expo-blur" export default function Homepage() { const refreshApps = useRefresh() - const [defaultWearable] = useSetting(SETTINGS.default_wearable.key) + // Pairing-identity read-model: none | pending (chosen, never paired) | paired. + const identity = useToolkitSnapshot(toolkit.pairing.identity, (onChange) => toolkit.pairing.onIdentity(onChange)) + const pairedModel = identity.kind === "paired" ? identity.model : "" const glassesConnected = useToolkitSnapshot(toolkit.glasses.status, (onChange) => toolkit.glasses.onStatus(onChange)).state === "connected" const isSearching = useToolkitSnapshot(toolkit.pairing.scanning, (onChange) => toolkit.pairing.onScanning(onChange)) @@ -56,10 +58,12 @@ export default function Homepage() { } attemptInitialConnect() - }, [glassesConnected, isSearching, defaultWearable]) + }, [glassesConnected, isSearching, pairedModel]) const renderContent = () => { - if (!defaultWearable) { + // A pending selection (model chosen, pairing never completed) renders the + // glasses card in its finish-pairing state rather than the first-run card. + if (identity.kind === "none") { return ( <> diff --git a/mobile/src/app/pairing/btclassic.tsx b/mobile/src/app/pairing/btclassic.tsx index e845bffe82..0208fb707b 100644 --- a/mobile/src/app/pairing/btclassic.tsx +++ b/mobile/src/app/pairing/btclassic.tsx @@ -1,10 +1,12 @@ -import {useEffect} from "react" +import {useRoute} from "@react-navigation/native" +import {useEffect, useMemo} from "react" import {Button, Screen} from "@/components/ignite" import {OnboardingGuide, OnboardingStep} from "@/components/onboarding/OnboardingGuide" import {useToolkitSnapshot} from "@/hooks/useToolkitSnapshot" import {translate} from "@/i18n" import {focusEffectPreventBack, usePushPrevious} from "@/contexts/NavigationHistoryContext" import {toolkit} from "@mentra/island" +import type {Device} from "@mentra/bluetooth-sdk" import {SETTINGS, useSetting} from "@/stores/settings" import {SettingsNavigationUtils} from "@/utils/SettingsNavigationUtils" import {View} from "react-native" @@ -15,21 +17,48 @@ import CrustModule from "@mentra/crust" export default function BtClassicPairingScreen() { const {goBack} = useNavigationStore.getState() const pushPrevious = usePushPrevious() + const route = useRoute() + // The device the user picked on the scan screen, threaded through the route. + // Two-phase identity: it is NOT the default device yet — the connect below + // marks it pending, and the native layer promotes it on pairing success. + // + // This screen is ALSO entered without a device from paired contexts — the + // pairing-success step stack and the home-screen "Bluetooth Classic + // disconnected" recovery alert — where the saved default identity is the + // device to (re)connect. + const device = useMemo((): Device | null => { + const {device: deviceJson} = (route.params ?? {}) as {device?: string} + if (!deviceJson) return null + try { + return JSON.parse(deviceJson) as Device + } catch { + return null + } + }, [route.params]) const bluetoothClassicConnected = useToolkitSnapshot(toolkit.pairing.readiness, (onChange) => toolkit.pairing.onReadiness(onChange), ).bluetoothClassicConnected const otherBtConnected = useToolkitSnapshot(toolkit.pairing.otherBtConnected, (onChange) => toolkit.pairing.onOtherBtConnected(onChange), ) - const [deviceName] = useSetting(SETTINGS.device_name.key) + const [savedDeviceName] = useSetting(SETTINGS.device_name.key) + const deviceName = device?.name || savedDeviceName || "" const {theme} = useAppTheme() focusEffectPreventBack() const handleSuccess = () => { - toolkit.glasses.connectDefault().catch((error) => { - console.error("Failed to connect default glasses after Bluetooth Classic pairing:", error) - }) + if (device) { + toolkit.glasses.connect(device, {saveAsDefault: false}).catch((error) => { + console.error("Failed to connect glasses after Bluetooth Classic pairing:", error) + }) + } else { + // Paired contexts (success step stack / recovery alert): reconnect the + // saved default device. + toolkit.glasses.connectDefault().catch((error) => { + console.error("Failed to connect default glasses after Bluetooth Classic pairing:", error) + }) + } pushPrevious() } @@ -44,6 +73,18 @@ export default function BtClassicPairingScreen() { } } + useEffect(() => { + if (!device) return + // The Pair Audio step advances when native detects the picked device's + // Classic audio route — prime the native watcher with that device (it no + // longer learns it from a selection-time identity write). If the audio + // device is already paired, the resulting immediate check advances the + // screen right away. + void toolkit.pairing.setBluetoothClassicTarget(device).catch((error) => { + console.warn("BTCLASSIC: failed to set the Bluetooth Classic target:", error) + }) + }, [device]) + useEffect(() => { console.log("BTCLASSIC: check bluetoothClassicConnected", bluetoothClassicConnected) if (bluetoothClassicConnected) { @@ -52,13 +93,24 @@ export default function BtClassicPairingScreen() { }, [bluetoothClassicConnected]) useEffect(() => { - console.log("BTCLASSIC: check deviceName", deviceName) - if (deviceName == "" || deviceName == null) { - console.log("BTCLASSIC: deviceName is empty, cannot continue") - handleBack() - return + if (device) return + let cancelled = false + // Paired-context entry (no scan device): stay only when a default device + // actually exists — the same precondition connectDefault() has, read + // hydration-aware. Fail open (stay) on a read error; connectDefault()'s + // catch is the fallback for a genuinely missing device. + toolkit.glasses + .hasDefaultDevice() + .catch(() => true) + .then((hasDefault) => { + if (cancelled || hasDefault) return + console.log("BTCLASSIC: no device threaded from scan and no default device, cannot continue") + handleBack() + }) + return () => { + cancelled = true } - }, [deviceName]) + }, [device]) let steps: OnboardingStep[] = [ { diff --git a/mobile/src/app/pairing/failure.tsx b/mobile/src/app/pairing/failure.tsx index 058d040932..b2fb6ce1f7 100644 --- a/mobile/src/app/pairing/failure.tsx +++ b/mobile/src/app/pairing/failure.tsx @@ -30,7 +30,10 @@ export default function PairingFailureScreen() { }, []) const handleRetry = () => { - toolkit.glasses.forget() + // Clears the failed attempt; a pre-existing pairing (re-pair) is preserved. + void toolkit.pairing.abandonAttempt().catch((error) => { + console.warn("Pairing retry cleanup failed:", error) + }) clearHistoryAndGoHome() push("/pairing/select-glasses-model") } diff --git a/mobile/src/app/pairing/loading.tsx b/mobile/src/app/pairing/loading.tsx index 8915659120..079879ea83 100644 --- a/mobile/src/app/pairing/loading.tsx +++ b/mobile/src/app/pairing/loading.tsx @@ -41,7 +41,11 @@ export default function GlassesPairingLoadingScreen() { const handlePairFailure = useCallback( (error: string) => { - toolkit.glasses.forget() + // Clears the failed attempt; when a real pairing predates this attempt + // (re-pair), it is preserved instead of forgotten. + void toolkit.pairing.abandonAttempt().catch((cleanupError) => { + console.warn("Pairing failure cleanup failed:", cleanupError) + }) if (error === "errors:pairNeedDisconnect") { replace("/pairing/unpair-even", {deviceModel: deviceModel}) return diff --git a/mobile/src/app/pairing/scan.tsx b/mobile/src/app/pairing/scan.tsx index d1abd8abf5..b0276e34ad 100644 --- a/mobile/src/app/pairing/scan.tsx +++ b/mobile/src/app/pairing/scan.tsx @@ -1,7 +1,7 @@ import {type Device, type DeviceModel} from "@mentra/bluetooth-sdk" import {toolkit} from "@mentra/island" import {useLocalSearchParams} from "expo-router" -import {useEffect, useState} from "react" +import {useEffect, useRef, useState} from "react" import {ActivityIndicator, Image, Platform, ScrollView, TouchableOpacity, View} from "react-native" import {DeviceTypes} from "@/../../cloud/packages/types/src" @@ -18,7 +18,6 @@ import {useNavigationStore} from "@/stores/navigation" import showAlert from "@/utils/AlertUtils" import {PermissionFeatures, requestFeaturePermissions} from "@/utils/PermissionsUtils" import {getGlassesOpenImage} from "@/utils/getGlassesImage" -import {SETTINGS, useSetting} from "@/stores/settings" import GlassView from "@/components/ui/GlassView" export default function SelectGlassesBluetoothScreen() { @@ -30,29 +29,63 @@ export default function SelectGlassesBluetoothScreen() { const bluetoothClassicConnected = useToolkitSnapshot(toolkit.pairing.readiness, (onChange) => toolkit.pairing.onReadiness(onChange), ).bluetoothClassicConnected - const [_deviceName, setDeviceName] = useSetting(SETTINGS.device_name.key) const searchResults = useToolkitSnapshot(toolkit.pairing.searchResults, (onChange) => toolkit.pairing.onFound(onChange), ) const [rememberedSearchResults, setRememberedSearchResults] = useState(searchResults) + useEffect(() => { + // Two-phase identity: reaching the scan screen marks the chosen model as the + // PENDING selection. Promotion to `paired` only happens natively when pairing + // succeeds; until then the home card renders a finish-pairing affordance. + toolkit.pairing.markPendingSelection(deviceModel) + }, [deviceModel]) + // useFocusEffect( // useCallback(() => { // setRememberedSearchResults([]) // }, [setRememberedSearchResults]), // ) + // One back-out per mount, whichever surface triggers it — the header chevron + // and Cancel button (handleBackOut), Android hardware back (the preventBack + // handler), or the iOS pop gesture (the beforeRemove listener). The ref + // dedupes the overlap: an explicit back-out's goBack() also fires + // beforeRemove on iOS, which must not run the cleanup (or pop) again. + const backOutRanRef = useRef(false) + const runBackOutCleanup = () => { + if (backOutRanRef.current) return false + backOutRanRef.current = true + // Non-destructive back-out: abandonAttempt decides from the LIVE hydrated + // default-device read — a re-pair's existing pairing survives, and so does + // a pairing that PROMOTED while this flow was open (glasses can finish + // pairing even when the user backs out of the UI). Only a genuinely + // unpaired attempt forgets. The pending marker survives either way. + void toolkit.pairing.abandonAttempt().catch((error) => { + console.warn("Pairing scan back-out cleanup failed:", error) + }) + return true + } + focusEffectPreventBack((event) => { // Skip cleanup when navigating forward (e.g. replace() to btclassic) — // only run on actual back navigation. if (event && event.actionType !== "GO_BACK" && event.actionType !== "POP") { return } - toolkit.glasses.disconnect() - toolkit.glasses.forget() - goBack() + if (runBackOutCleanup()) { + goBack() + } }, true) + const handleBackOut = () => { + // Guard the pop like the preventBack handler does: a rapid double-tap on + // Cancel/the chevron must not pop a second route while cleanup ran once. + if (runBackOutCleanup()) { + goBack() + } + } + useEffect(() => { const skipDevice = searchResults.find((result) => result.name === "NOTREQUIREDSKIP") if (skipDevice) { @@ -71,7 +104,7 @@ export default function SelectGlassesBluetoothScreen() { } void initializeAndSearchForDevices() - }, []) + }, [deviceModel]) const triggerGlassesPairingGuide = async (device: Device) => { if (Platform.OS === "android") { @@ -118,10 +151,9 @@ export default function SelectGlassesBluetoothScreen() { return } - await toolkit.pairing.setDefault(device) - setDeviceName(device.name) - // pair bt classic first: - replace("/pairing/btclassic") + // pair bt classic first — thread the device through the route; the connect + // (and the on-success default promotion) happens after BT Classic links. + replace("/pairing/btclassic", {device: JSON.stringify(device)}) pushUnder("/pairing/loading", {deviceModel: device.model, deviceName: device.name}) } @@ -155,7 +187,7 @@ export default function SelectGlassesBluetoothScreen() { return ( -
} /> +
} /> -