/** * Loose device-reconnect matching. * * A checkpoint reports a selected device "gone" by exact deviceId. That's the * right rule for the DISCONNECT edge. But on RECONNECT it's too strict: * Bluetooth devices (AirPods and friends) routinely come back under a NEW * deviceId — and sometimes a new groupId — so an exact-id check would keep * reporting a freshly-reconnected headset as still-missing, and the recovery * gate would never clear. * * So the recovery re-check matches loosely: exact id first, then a non-empty * label match (trimmed, case-insensitive). Audio identity matters less for * proctoring integrity than the camera's does, so a returning device with the * same human label is an acceptable "it's back" signal. This module is pure so * it's deterministically testable and shared between the SDK and the wrapper's * resume re-check. */ /** A device the session depends on, as captured in the checkpoint baseline. */ export interface DeviceRef { id: string; label?: string | undefined; } /** A currently-enumerated device (subset of MediaDeviceInfo we compare on). */ export interface EnumeratedDevice { deviceId: string; label: string; } /** * Is a depended-on device present again, tolerating a Bluetooth deviceId * change? Exact-id match wins; failing that, a non-empty label match counts. * An empty/unknown baseline label falls back to exact-id only (never matches a * blank label — a censored enumeration must not read as "reconnected"). */ export function isDevicePresentLoose( want: DeviceRef, available: readonly EnumeratedDevice[], ): boolean { if (available.some((d) => d.deviceId === want.id)) return true; const label = want.label?.trim().toLowerCase(); if (!label) return false; return available.some((d) => d.label.trim().toLowerCase() === label); }