Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions docs/ble-connection-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 16 additions & 13 deletions src/connection/ConnectionContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,22 @@ import { requestBluetoothPermission } from './permissions';
const Context = createContext<ConnectionManager | null>(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(); });
Expand Down
18 changes: 18 additions & 0 deletions src/diagnostics/DiagnosticLog.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 22 additions & 1 deletion src/diagnostics/DiagnosticLog.ts
Original file line number Diff line number Diff line change
@@ -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.',
Expand Down Expand Up @@ -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()); }
Expand Down
144 changes: 144 additions & 0 deletions src/transport/ReactNativeBleTransport.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -29,6 +30,149 @@ function manager(overrides: Record<string, unknown> = {}): 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<Device>(() => 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<Device>((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);
Expand Down
Loading