Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4d1fac6
Hydrate the native DeviceStore at toolkit.start and demote orphaned i…
Jul 4, 2026
4b2ef78
Defer default promotion to pairing success and make back-outs non-des…
Jul 4, 2026
18bd89f
Reintroduce the hasDefaultDevice pairing-route guards and render pend…
Jul 4, 2026
51b4199
Address review findings: btclassic paired entries, demotion ordering …
Jul 5, 2026
fafc89f
Gate paired-context btclassic entry on hasDefaultDevice, not the name…
Jul 5, 2026
0bb2df0
Merge branch 'integration/toolkit-boundary' into pairing-two-phase-id…
Jul 6, 2026
5404c52
Make pairing identity native-authoritative in the settings sync
Jul 6, 2026
a52d382
Prime the native Bluetooth Classic watcher from the Pair Audio screen
Jul 6, 2026
6c12d9d
Use the internal btsdk surface in the pairing facade
Jul 6, 2026
4aac1bf
Decide scan back-out from the live default-device read, not an entry …
Jul 6, 2026
b5c250c
Reify pairing identity as an island read-model with a single writer
Jul 6, 2026
e3e8125
Log identity transitions and abandon branches at the single-writer ch…
Jul 6, 2026
ae2cac9
Exclude pairing identity from the on-connect settings replay
Jul 6, 2026
8b4fdf7
Address review findings: mid-relay re-seed gate, scan deps, identity …
Jul 6, 2026
1d6955e
Return a real AsyncResult from the empty-model guard
Jul 6, 2026
be7017b
Run the scan back-out cleanup from every back surface, once
Jul 6, 2026
89a270f
Guard the explicit back-out pop behind the cleanup dedupe
Jul 6, 2026
c0b85c0
Render the connected card during the post-promotion echo window
Jul 6, 2026
a839611
Route the pending identity's Connect fallback to its scan, not model …
Jul 6, 2026
a90d918
Require both-layer consensus before abandonAttempt forgets
Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions mobile/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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)),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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)),
Expand Down Expand Up @@ -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})
}
})
22 changes: 19 additions & 3 deletions mobile/modules/island/src/facades/glasses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<boolean> => hasDefaultDevice(),

// --- read-model (projected from the island-owned glasses store) ---
status: (): GlassesStatusSnapshot => projectStatus(),
Expand Down
109 changes: 101 additions & 8 deletions mobile/modules/island/src/facades/pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The markPendingSelection facade exposes a public write-path that persists a raw model string directly to the pairing identity state without any validation. If callers pass an empty string, whitespace-only value, or an unexpected model identifier, the app will store an invalid pending identity and may render a broken or confusing finish-pairing affordance. Consider adding a guard at the facade or service boundary to reject empty/whitespace values, trim the input, and optionally validate against the known device-model set before persisting.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mobile/modules/island/src/facades/pairing.ts, line 206:

<comment>The `markPendingSelection` facade exposes a public write-path that persists a raw `model` string directly to the pairing identity state without any validation. If callers pass an empty string, whitespace-only value, or an unexpected model identifier, the app will store an invalid pending identity and may render a broken or confusing finish-pairing affordance. Consider adding a guard at the facade or service boundary to reject empty/whitespace values, trim the input, and optionally validate against the known device-model set before persisting.</comment>

<file context>
@@ -186,6 +194,17 @@ export const pairing = {
+  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()`. */
</file context>


/** Start scanning for nearby glasses. Results land on `searchResults()`/`onFound()`. */
scan: (...args: Parameters<typeof BluetoothSdk.startScan>) => 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. */
Expand Down Expand Up @@ -211,13 +238,79 @@ export const pairing = {
})
},

/** Pair with (connect to) a discovered device. */
pair: async (...args: Parameters<typeof BluetoothSdk.connect>): Promise<void> => {
/**
* 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<void> => {
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<typeof BluetoothSdk.setDefaultDevice>) => 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<void> =>
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<void> => {
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()
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
},

/** Subscribe to pairing failures; returns an unsubscribe. */
onPairFailure: (cb: (event: PairFailureEvent) => void): (() => void) => {
Expand Down
1 change: 1 addition & 0 deletions mobile/modules/island/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 27 additions & 2 deletions mobile/modules/island/src/island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@
*/
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"
import {startAudioCloudUplink, stopAudioCloudUplink} from "./services/AudioCloudUplink"
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"
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions mobile/modules/island/src/services/ConnectionCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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).
Expand Down
Loading
Loading