diff --git a/docs/ble-connection-diagnostics.md b/docs/ble-connection-diagnostics.md new file mode 100644 index 0000000..0ed5106 --- /dev/null +++ b/docs/ble-connection-diagnostics.md @@ -0,0 +1,33 @@ +# Bluetooth connection-stage diagnostics + +Settings → Diagnostics records fixed stage names in the existing local, bounded log. Clear it before an attempt, connect once, then return to Diagnostics and copy/export the result. Entries are newest first; no log is sent automatically. The generic connection-failure UI remains unchanged. + +An updated native/development app build from this branch is needed; the installed store build does not gain these diagnostics automatically. This change does not enable Linux controls, change authentication or bypass notification readiness. + +| Code prefix | Operation | +| --- | --- | +| `ble_probe_connect` | Temporary GATT connection used during discovery | +| `ble_probe_services` | Service discovery for a status probe | +| `ble_status_read` | Discovery status-characteristic read | +| `ble_connect` | Connection to the selected device | +| `ble_priority` | Optional Android priority request; failure is nonfatal | +| `ble_mtu` | Android MTU negotiation | +| `ble_services` | Service discovery for the control connection | +| `ble_notifications` | Local notification listener registration, or a listener error | +| `ble_notification_ready` | Android CCCD read and enabled-value check | + +Each stage has `_started`, `_succeeded` and `_failed` outcomes. Listener registration success does not prove that the peripheral enabled notifications; Android checks its descriptor separately. Status-read success records a completed GATT read, not successful parsing. Scan probes can interleave; no device identifiers are included. Stop discovery and make one selected-device attempt for diagnosis. + +Cancelled operations cannot later append success/failure into a newer operation. Unsubscribed notification callbacks cannot append stage diagnostics. Cancellation may leave a started entry without an outcome; that is not proof of Bluetooth failure. Native rejection and timeout both count as failure of the relevant stage. Raw errors/codes, addresses, PC names, descriptor values, payloads and credentials are never added to stage diagnostics. + +## S26/Linux probe retest (pending) + +Follow [physical-smoke-test.md](physical-smoke-test.md). Record exact Remote commit/build, phone model/Android version, probe commit and BlueZ version. Use the opt-in transport probe, which cannot approve pairing or inject input. + +1. Install/run an authorized build containing these diagnostics. Keep the probe stopped until the phone is ready. +2. Clear Diagnostics, start the probe, scan, and confirm it appears. Discovery requires a valid status read. +3. Select it once. After failure, open Settings → Diagnostics and report the first failed connection stage in chronological order and preceding successes. Optional priority failure alone is not terminal. +4. Compare with probe RX counters and notification events. No RX/TX evidence plus `ble_notification_ready_failed` points to subscription/readiness, but does not distinguish a failed CCCD read from a disabled value. `ble_mtu_failed` points to negotiation instead. +5. Verify cancellation/back/background does not append stale outcomes. Check readable labels and copy/export with TalkBack and large text. No new automatic stage announcements or scan stops are added. + +Do not claim a root cause or successful pairing from labels alone. Pairing failure is expected for the probe, but connection setup must be observed independently. Physical S26 validation remains pending until the updated app is installed and tested. diff --git a/src/connection/ConnectionContext.tsx b/src/connection/ConnectionContext.tsx index 56a6a39..87b1845 100644 --- a/src/connection/ConnectionContext.tsx +++ b/src/connection/ConnectionContext.tsx @@ -12,19 +12,22 @@ import { requestBluetoothPermission } from './permissions'; const Context = createContext(null); export function ConnectionProvider({ children }: PropsWithChildren) { - const manager = useMemo(() => new ConnectionManager( - new ReactNativeBleTransport(), - new PairingStore(), - new DiagnosticLog(), - requestBluetoothPermission, - Date.now, - undefined, - undefined, - async () => { - await preferencesStore.load(); - return resolveRemoteName(preferencesStore.snapshot().remoteName); - }, - ), []); + const manager = useMemo(() => { + const diagnostics = new DiagnosticLog(); + return new ConnectionManager( + new ReactNativeBleTransport(null, undefined, undefined, undefined, diagnostics), + new PairingStore(), + diagnostics, + requestBluetoothPermission, + Date.now, + undefined, + undefined, + async () => { + await preferencesStore.load(); + return resolveRemoteName(preferencesStore.snapshot().remoteName); + }, + ); + }, []); useEffect(() => { void manager.load(); const subscription = AppState.addEventListener('change', (state) => { if (state !== 'active') void manager.disconnect(); }); diff --git a/src/diagnostics/DiagnosticLog.test.ts b/src/diagnostics/DiagnosticLog.test.ts new file mode 100644 index 0000000..7b3163b --- /dev/null +++ b/src/diagnostics/DiagnosticLog.test.ts @@ -0,0 +1,18 @@ +import { DiagnosticLog } from './DiagnosticLog'; + +describe('connection-stage diagnostics', () => { + it('uses the existing bounded, observable and clearable local log', () => { + const log = new DiagnosticLog(); + const listener = jest.fn(); + const remove = log.subscribe(listener); + for (let index = 0; index < 205; index++) log.addConnectionStage('connect', 'started'); + expect(log.snapshot()).toHaveLength(200); + expect(listener).toHaveBeenCalledTimes(205); + expect(log.export()).toContain('ble_connect_started: Connect to the selected PC: started.'); + log.addConnectionStage('notification_ready', 'failed'); + expect(log.snapshot()[0]).toMatchObject({ level: 'warning', code: 'ble_notification_ready_failed' }); + log.clear(); + expect(log.export()).toBe(''); + remove(); + }); +}); diff --git a/src/diagnostics/DiagnosticLog.ts b/src/diagnostics/DiagnosticLog.ts index ce4e35d..1899a70 100644 --- a/src/diagnostics/DiagnosticLog.ts +++ b/src/diagnostics/DiagnosticLog.ts @@ -1,6 +1,20 @@ export type DiagnosticLevel = 'info' | 'warning' | 'error'; export type DiagnosticEntry = { id: number; timestamp: number; level: DiagnosticLevel; code: string; message: string }; +const connectionStages = { + connect: 'Connect to the selected PC', + priority: 'Request Android connection priority (optional)', + mtu: 'Negotiate Bluetooth MTU', + services: 'Discover connection services', + probe_connect: 'Connect for discovery status', + probe_services: 'Discover status services', + status_read: 'Read discovery status', + notifications: 'Register notification listener', + notification_ready: 'Verify Android notification descriptor', +} as const; +export type ConnectionStage = keyof typeof connectionStages; +export type ConnectionStageOutcome = 'started' | 'succeeded' | 'failed'; + const messages = { scan_started: 'Looking for nearby PCs.', scan_failed: 'Bluetooth discovery could not start.', @@ -28,7 +42,14 @@ export class DiagnosticLog { subscribe = (listener: () => void) => { this.#listeners.add(listener); return () => this.#listeners.delete(listener); }; snapshot = () => this.#entries; add(code: keyof typeof messages, level: DiagnosticLevel = 'info'): void { - this.#entries = [{ id: this.#nextId++, timestamp: Date.now(), level, code, message: messages[code]! }, ...this.#entries].slice(0, 200); + this.#append(code, messages[code], level); + } + addConnectionStage(stage: ConnectionStage, outcome: ConnectionStageOutcome): void { + // Only fixed vocabulary crosses this boundary: no native error, address or payload. + this.#append(`ble_${stage}_${outcome}`, `${connectionStages[stage]}: ${outcome}.`, outcome === 'failed' ? 'warning' : 'info'); + } + #append(code: string, message: string, level: DiagnosticLevel): void { + this.#entries = [{ id: this.#nextId++, timestamp: Date.now(), level, code, message }, ...this.#entries].slice(0, 200); this.#listeners.forEach((listener) => listener()); } clear(): void { this.#entries = []; this.#listeners.forEach((listener) => listener()); } diff --git a/src/transport/ReactNativeBleTransport.test.ts b/src/transport/ReactNativeBleTransport.test.ts index 8a249cf..44c17aa 100644 --- a/src/transport/ReactNativeBleTransport.test.ts +++ b/src/transport/ReactNativeBleTransport.test.ts @@ -1,6 +1,7 @@ import { fromByteArray } from 'base64-js'; import { ConnectionPriority, type BleManager, type Characteristic, type Descriptor, type Device } from 'react-native-ble-plx'; import { ReactNativeBleTransport } from './ReactNativeBleTransport'; +import { DiagnosticLog } from '@/diagnostics/DiagnosticLog'; const descriptor = (value: string): Descriptor => ({ value } as Descriptor); @@ -29,6 +30,149 @@ function manager(overrides: Record = {}): BleManager { } describe('ReactNativeBleTransport', () => { + it('records discovery status separately from the selected-PC connection', async () => { + const log = new DiagnosticLog(); + let scanCallback!: (error: Error | null, value: Device | null) => void; + const found = jest.fn(); + const candidate = device({ isConnected: jest.fn(async () => false) }); + const transport = new ReactNativeBleTransport(manager({ startDeviceScan: jest.fn((_uuids, _options, callback) => { scanCallback = callback; }) }), 'android', 100, undefined, log); + const stop = transport.scan(found, jest.fn()); + scanCallback(null, candidate); + await waitFor(() => found.mock.calls.length === 1); + expect(log.snapshot().map((entry) => entry.code).reverse()).toEqual([ + 'ble_probe_connect_started', 'ble_probe_connect_succeeded', + 'ble_probe_services_started', 'ble_probe_services_succeeded', + 'ble_status_read_started', 'ble_status_read_succeeded', + ]); + stop(); + }); + + it('records synchronous listener registration failure', async () => { + const log = new DiagnosticLog(); + const connected = device({ monitorCharacteristicForService: jest.fn(() => { throw new Error('private'); }) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios', 100, undefined, log); + await transport.connect('ble-1'); + expect(() => transport.subscribe(jest.fn(), jest.fn())).toThrow(); + expect(log.snapshot()[0]?.code).toBe('ble_notifications_failed'); + expect(log.export()).not.toContain('private'); + await transport.disconnect(); + }); + + it('records the Android connection stages in order through descriptor readiness', async () => { + const log = new DiagnosticLog(); + const connected = device(); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'android', 100, undefined, log); + await transport.connect('private-address'); + const remove = transport.subscribe(jest.fn(), jest.fn()); + await transport.notificationsReady(); + expect(log.snapshot().map((entry) => entry.code).reverse()).toEqual([ + 'ble_connect_started', 'ble_connect_succeeded', + 'ble_priority_started', 'ble_priority_succeeded', + 'ble_mtu_started', 'ble_mtu_succeeded', + 'ble_services_started', 'ble_services_succeeded', + 'ble_notifications_started', 'ble_notifications_succeeded', + 'ble_notification_ready_started', 'ble_notification_ready_succeeded', + ]); + expect(log.export()).not.toContain('private-address'); + remove(); + await transport.disconnect(); + }); + + it.each([ + ['connect', 'connectToDevice'], ['mtu', 'requestMTU'], ['services', 'discoverAllServicesAndCharacteristics'], + ] as const)('identifies a %s failure without exporting native error details', async (stage, method) => { + const log = new DiagnosticLog(); + const failure = jest.fn(async () => { throw new Error('private native payload and address'); }); + const connected = device(stage === 'connect' ? {} : { [method]: failure }); + const native = manager({ connectToDevice: stage === 'connect' ? failure : jest.fn(async () => connected) }); + const transport = new ReactNativeBleTransport(native, 'android', 100, undefined, log); + await expect(transport.connect('private-address')).rejects.toThrow(); + expect(log.snapshot()[0]).toMatchObject({ code: `ble_${stage}_failed`, level: 'warning' }); + expect(log.export()).not.toContain('private'); + await transport.disconnect(); + }); + + it('records a bounded MTU timeout as an MTU failure', async () => { + const log = new DiagnosticLog(); + const connected = device({ requestMTU: jest.fn(() => new Promise(() => undefined)) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'android', 1, undefined, log); + await expect(transport.connect('ble-1')).rejects.toThrow('timed out'); + expect(log.snapshot()[0]?.code).toBe('ble_mtu_failed'); + await transport.disconnect(); + }); + + it('does not report a cancelled or late connection as a failure or success', async () => { + const log = new DiagnosticLog(); + let resolve!: (value: Device) => void; + const connected = device(); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(() => new Promise((done) => { resolve = done; })) }), 'android', 100, undefined, log); + const result = transport.connect('ble-1'); + const rejected = expect(result).rejects.toThrow(); + while (!resolve) await Promise.resolve(); + await transport.disconnect(); + resolve(connected); + await rejected; + expect(log.snapshot().map((entry) => entry.code)).toEqual(['ble_connect_started']); + }); + + it('records optional priority failure but continues, and skips Android-only stages on iOS', async () => { + for (const platform of ['android', 'ios'] as const) { + const log = new DiagnosticLog(); + const connected = device({ requestConnectionPriority: jest.fn(async () => { throw new Error('private'); }) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), platform, 100, undefined, log); + await transport.connect('ble-1'); + await transport.notificationsReady(); + const codes = log.snapshot().map((entry) => entry.code); + expect(codes).toContain('ble_services_succeeded'); + if (platform === 'android') expect(codes).toContain('ble_priority_failed'); + else expect(codes.some((code) => /priority|mtu|notification_ready/.test(code))).toBe(false); + await transport.disconnect(); + } + }); + + it.each(['rejected read', 'disabled descriptor'] as const)('records notification readiness failure for %s', async (reason) => { + const log = new DiagnosticLog(); + const connected = device({ readDescriptorForService: jest.fn(async () => { + if (reason === 'rejected read') throw new Error('private descriptor error'); + return descriptor('AAA='); + }) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'android', 100, undefined, log); + await transport.connect('ble-1'); + await expect(transport.notificationsReady()).rejects.toThrow(); + expect(log.snapshot()[0]?.code).toBe('ble_notification_ready_failed'); + expect(log.export()).not.toContain('private'); + await transport.disconnect(); + }); + + it('ignores stale notification diagnostic callbacks after unsubscribe', async () => { + const log = new DiagnosticLog(); + let fail!: () => void; + const connected = device({ monitorCharacteristicForService: jest.fn((_service, _characteristic, listener) => { + fail = () => listener(Object.assign(new Error('private'), { + errorCode: 0 as const, attErrorCode: null, iosErrorCode: null, androidErrorCode: null, reason: 'private', + }), null); + return { remove: jest.fn() }; + }) }); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios', 100, undefined, log); + await transport.connect('ble-1'); + const remove = transport.subscribe(jest.fn(), jest.fn()); + fail(); + expect(log.snapshot()[0]?.code).toBe('ble_notifications_failed'); + const count = log.snapshot().length; + remove(); + fail(); + expect(log.snapshot()).toHaveLength(count); + expect(log.export()).not.toContain('private'); + await transport.disconnect(); + }); + + it('does not let diagnostic observer failures affect connection', async () => { + const connected = device(); + const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios', 100, undefined, { addConnectionStage: () => { throw new Error('observer'); } }); + await expect(transport.connect('ble-1')).resolves.toBeUndefined(); + await transport.disconnect(); + }); + it('does not construct the native manager during launch or pre-initialization cleanup', async () => { const factory = jest.fn(() => manager()); const transport = new ReactNativeBleTransport(null, 'ios', 10_000, factory); diff --git a/src/transport/ReactNativeBleTransport.ts b/src/transport/ReactNativeBleTransport.ts index 061cc59..e0dffa4 100644 --- a/src/transport/ReactNativeBleTransport.ts +++ b/src/transport/ReactNativeBleTransport.ts @@ -4,6 +4,7 @@ import { toByteArray } from 'base64-js'; import { BLE_DESCRIPTORS, BLE_UUIDS } from '@/domain/protocol/constants'; import { parseStatus } from '@/domain/protocol/responses'; +import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog } from '@/diagnostics/DiagnosticLog'; import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from './BleTransport'; import { bluetoothDeviceDisplayName, desktopDisplayName } from './desktopDisplayName'; @@ -27,6 +28,7 @@ export class ReactNativeBleTransport implements BleTransport { private readonly platform = Platform.OS, private readonly nativeTimeoutMs = 10_000, private readonly managerFactory = () => new BleManager(), + private readonly diagnostics?: Pick, ) { this.#manager = manager; } async availability(): Promise { @@ -148,11 +150,11 @@ export class ReactNativeBleTransport implements BleTransport { connected = await this.#requestHighPriority(connected); if (!active || operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; - connected = await this.#bounded(connected.requestMTU(517)); + connected = await this.#stage('mtu', () => this.#bounded(connected!.requestMTU(517)), operation); if (!active || operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; } - connected = await this.#bounded(connected.discoverAllServicesAndCharacteristics()); + connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation); if (!active || operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; this.#writePoisoned = false; @@ -184,22 +186,24 @@ export class ReactNativeBleTransport implements BleTransport { this.#connectingPeripheralId = peripheralId; try { if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); - const nativeConnect = this.#managerOrCreate().connectToDevice(peripheralId); - void nativeConnect.then((device) => { - if (connectCancelled || operation !== this.#operation) void device.cancelConnection().catch(() => undefined); - }, () => undefined); - connected = await this.#bounded(nativeConnect); + connected = await this.#stage('connect', () => { + const nativeConnect = this.#managerOrCreate().connectToDevice(peripheralId); + void nativeConnect.then((device) => { + if (connectCancelled || operation !== this.#operation) void device.cancelConnection().catch(() => undefined); + }, () => undefined); + return this.#bounded(nativeConnect); + }, operation); if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; if (this.platform === 'android') { connected = await this.#requestHighPriority(connected); if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; - connected = await this.#bounded(connected.requestMTU(517)); + connected = await this.#stage('mtu', () => this.#bounded(connected!.requestMTU(517)), operation); if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; } - connected = await this.#bounded(connected.discoverAllServicesAndCharacteristics()); + connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation); if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.'); this.#device = connected; this.#writePoisoned = false; @@ -286,21 +290,37 @@ export class ReactNativeBleTransport implements BleTransport { } subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe { - const subscription: Subscription = this.#requireDevice().monitorCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.transmit, (error, characteristic) => { - if (error) onError(error); - else if (characteristic?.value) onFrame(characteristic.value); - }); - return () => subscription.remove(); + const operation = this.#operation; + let active = true; + let failed = false; + const recordFailure = () => { + if (active && !failed) this.#recordStage('notifications', 'failed', operation); + failed = true; + }; + this.#recordStage('notifications', 'started', operation); + try { + const subscription: Subscription = this.#requireDevice().monitorCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.transmit, (error, characteristic) => { + if (error) { recordFailure(); onError(error); } + else if (characteristic?.value) onFrame(characteristic.value); + }); + if (!failed) this.#recordStage('notifications', 'succeeded', operation); + return () => { active = false; subscription.remove(); }; + } catch (error) { + recordFailure(); + throw error; + } } async notificationsReady(): Promise { if (this.platform !== 'android') return; - const descriptor = await this.#bounded(this.#requireDevice().readDescriptorForService( - BLE_UUIDS.service, - BLE_UUIDS.transmit, - BLE_DESCRIPTORS.clientCharacteristicConfiguration, - )); - if (descriptor.value !== 'AQA=') throw new Error('Bluetooth notifications could not be enabled.'); + await this.#stage('notification_ready', async () => { + const descriptor = await this.#bounded(this.#requireDevice().readDescriptorForService( + BLE_UUIDS.service, + BLE_UUIDS.transmit, + BLE_DESCRIPTORS.clientCharacteristicConfiguration, + )); + if (descriptor.value !== 'AQA=') throw new Error('Bluetooth notifications could not be enabled.'); + }); } subscribeDisconnect(onDisconnect: () => void): Unsubscribe { @@ -310,20 +330,23 @@ export class ReactNativeBleTransport implements BleTransport { } async #readStatus(device: Device, retain = (_desktop: DiscoveredDesktop) => false): Promise { + const operation = this.#operation; let connectedHere = false; let target = device; let probeFinished = false; try { connectedHere = !(await this.#bounded(device.isConnected())); if (connectedHere) { - const nativeConnect = device.connect(); - void nativeConnect.then((connected) => { - if (probeFinished) void connected.cancelConnection().catch(() => undefined); - }, () => undefined); - target = await this.#bounded(nativeConnect); + target = await this.#stage('probe_connect', () => { + const nativeConnect = device.connect(); + void nativeConnect.then((connected) => { + if (probeFinished || operation !== this.#operation) void connected.cancelConnection().catch(() => undefined); + }, () => undefined); + return this.#bounded(nativeConnect); + }, operation); } - await this.#bounded(target.discoverAllServicesAndCharacteristics()); - const characteristic = await this.#bounded(target.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.status)); + await this.#stage('probe_services', () => this.#bounded(target.discoverAllServicesAndCharacteristics()), operation); + const characteristic = await this.#stage('status_read', () => this.#bounded(target.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.status)), operation); if (!characteristic.value) return null; const raw = new TextDecoder().decode(toByteArray(characteristic.value)); const status = parseStatus(raw); @@ -354,6 +377,24 @@ export class ReactNativeBleTransport implements BleTransport { return this.#manager; } + #recordStage(stage: ConnectionStage, outcome: ConnectionStageOutcome, operation: number): void { + if (operation !== this.#operation) return; + // Diagnostics must never change Bluetooth control flow, even if a UI listener throws. + try { this.diagnostics?.addConnectionStage(stage, outcome); } catch { /* best effort */ } + } + + async #stage(stage: ConnectionStage, action: () => Promise, operation = this.#operation): Promise { + this.#recordStage(stage, 'started', operation); + try { + const result = await action(); + this.#recordStage(stage, 'succeeded', operation); + return result; + } catch (error) { + this.#recordStage(stage, 'failed', operation); + throw error; + } + } + #settledManagerState(manager: BleManager, initial: State): Promise { if (initial !== 'Unknown' && initial !== 'Resetting') return Promise.resolve(initial); return new Promise((resolve) => { @@ -383,7 +424,7 @@ export class ReactNativeBleTransport implements BleTransport { async #requestHighPriority(device: Device): Promise { try { - return await this.#bounded(device.requestConnectionPriority(ConnectionPriority.High)); + return await this.#stage('priority', () => this.#bounded(device.requestConnectionPriority(ConnectionPriority.High))); } catch { return device; }