diff --git a/docs/bluetooth-audio-integration.md b/docs/bluetooth-audio-integration.md index a49a88e5..fa9485fc 100644 --- a/docs/bluetooth-audio-integration.md +++ b/docs/bluetooth-audio-integration.md @@ -212,11 +212,13 @@ The system identifies audio devices using: ### Button Control Support -Button events are monitored on these characteristic UUIDs: +Button events are routed by characteristic: -- `0000FE59-0000-1000-8000-00805F9B34FB` (Common button control) -- `0000180F-0000-1000-8000-00805F9B34FB` (Battery Service) -- `00001812-0000-1000-8000-00805F9B34FB` (HID Service) +- Vendor button characteristics for AINA, B01 Inrico and HYS headsets are parsed with each vendor's protocol. Once one is subscribed, every other characteristic on that headset is ignored for button input (e.g. the AINA event counter). +- `0000FE59-0000-1000-8000-00805F9B34FB` (Common button control) is parsed with the generic parser. +- Standard GATT telemetry is never treated as a button: Battery Level (`2A19`) and the HID characteristics (`2A4A`–`2A4D`). A battery level of 100% would otherwise decode as a mute press. + +Characteristics whose notifications never arrive are read-polled as a fallback. Polling stops for a characteristic once it delivers a notification, and after three consecutive failed reads (e.g. write-only characteristics). **Note**: Button control implementation varies by manufacturer and may require device-specific customization. @@ -289,8 +291,8 @@ try { ### Android -- Requires `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, and `ACCESS_FINE_LOCATION` permissions -- Permissions are requested automatically by the service +- Android 12+ requires `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT`; Android 11 and lower require `ACCESS_FINE_LOCATION` for BLE scanning +- Permissions are requested automatically by the service, only when not already granted - Some devices may require location services to be enabled ### iOS diff --git a/plugins/__tests__/register-android-package.test.ts b/plugins/__tests__/register-android-package.test.ts new file mode 100644 index 00000000..a0351e29 --- /dev/null +++ b/plugins/__tests__/register-android-package.test.ts @@ -0,0 +1,103 @@ +import { registerAndroidPackage } from '../utils/register-android-package'; + +// Expo SDK 57 template (what `expo prebuild` generates today) +const applyTemplate = `package com.resgrid.unit.development + +import android.app.Application + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + ExpoReactHostFactory.getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + ) + } +} +`; + +// Older template that declares the package list as a local +const valTemplate = `package com.example + +import com.facebook.react.PackageList + +class MainApplication : Application(), ReactApplication { + override fun getPackages(): List { + val packages = PackageList(this).packages + return packages + } +} +`; + +const countOf = (contents: string, text: string) => contents.split(text).length - 1; + +describe('registerAndroidPackage', () => { + it('adds the package inside the packages.apply block of the current Expo template', () => { + const result = registerAndroidPackage(applyTemplate, 'com.resgridunit', 'MediaButtonPackage'); + const lines = result.split('\n'); + + expect(lines[1]).toBe('import com.resgridunit.MediaButtonPackage'); + const applyLine = lines.findIndex((line) => line.includes('PackageList(this).packages.apply {')); + expect(lines[applyLine + 1]).toBe(' add(MediaButtonPackage())'); + }); + + it('adds the package to a val packages list, making it mutable', () => { + const result = registerAndroidPackage(valTemplate, 'com.example', 'MediaButtonPackage'); + + expect(result).toContain('import com.example.MediaButtonPackage'); + expect(result).toContain(' val packages = PackageList(this).packages.toMutableList()\n packages.add(MediaButtonPackage())'); + }); + + it('registers several packages once each', () => { + for (const template of [applyTemplate, valTemplate]) { + const result = registerAndroidPackage(registerAndroidPackage(template, 'com.resgridunit', 'InCallAudioPackage'), 'com.resgridunit', 'MediaButtonPackage'); + + expect(countOf(result, 'add(InCallAudioPackage())')).toBe(1); + expect(countOf(result, 'add(MediaButtonPackage())')).toBe(1); + expect(countOf(result, 'toMutableList()')).toBeLessThanOrEqual(1); + } + }); + + it('leaves an already registered package unchanged across repeated prebuilds', () => { + const once = registerAndroidPackage(applyTemplate, 'com.resgridunit', 'MediaButtonPackage'); + const twice = registerAndroidPackage(once, 'com.resgridunit', 'MediaButtonPackage'); + + expect(twice).toBe(once); + }); + + it('registers a package that is imported but not yet registered', () => { + const importedOnly = applyTemplate.replace('package com.resgrid.unit.development\n', 'package com.resgrid.unit.development\nimport com.resgridunit.InCallAudioPackage\n'); + + const result = registerAndroidPackage(importedOnly, 'com.resgridunit', 'InCallAudioPackage'); + + expect(countOf(result, 'import com.resgridunit.InCallAudioPackage')).toBe(1); + expect(result).toContain('add(InCallAudioPackage())'); + }); + + it('registers a package whose import and registration are only present as comments', () => { + const commentedOut = applyTemplate + .replace('package com.resgrid.unit.development\n', 'package com.resgrid.unit.development\n// import com.resgridunit.MediaButtonPackage\n') + .replace('// add(MyReactNativePackage())', '// add(MediaButtonPackage())'); + + const result = registerAndroidPackage(commentedOut, 'com.resgridunit', 'MediaButtonPackage'); + const lines = result.split('\n'); + + expect(lines[1]).toBe('import com.resgridunit.MediaButtonPackage'); + const applyLine = lines.findIndex((line) => line.includes('PackageList(this).packages.apply {')); + expect(lines[applyLine + 1]).toBe(' add(MediaButtonPackage())'); + expect(countOf(result, '// add(MediaButtonPackage())')).toBe(1); + }); + + it('throws when MainApplication.kt has no recognised package list', () => { + const unknown = 'package com.example\n\nclass MainApplication : Application()\n'; + + expect(() => registerAndroidPackage(unknown, 'com.example', 'MediaButtonPackage')).toThrow('Could not find where to register MediaButtonPackage in MainApplication.kt'); + }); +}); diff --git a/plugins/utils/register-android-package.js b/plugins/utils/register-android-package.js new file mode 100644 index 00000000..387a64ad --- /dev/null +++ b/plugins/utils/register-android-package.js @@ -0,0 +1,50 @@ +/* eslint-env node */ + +/** + * Registers a manually linked ReactPackage in MainApplication.kt. + * + * The import uses the BASE package name (where our plugins write the native module files), not the + * variant-specific package of MainApplication.kt (e.g. `.development`). + * + * Handles both MainApplication shapes: + * - Current Expo templates: `PackageList(this).packages.apply { ... }` gets `add(Package())` inside the block + * - Older templates: `val packages = PackageList(this).packages` gets `packages.add(Package())` + * + * Throws when neither shape is found, so a template change fails the prebuild instead of shipping a + * build where the native module is silently missing. + * + * @param {string} contents - MainApplication.kt source + * @param {string} basePackageName - Package the native module files are written to + * @param {string} packageClass - ReactPackage class name, e.g. `MediaButtonPackage` + * @returns {string} Updated MainApplication.kt source + */ +function registerAndroidPackage(contents, basePackageName, packageClass) { + let result = contents; + // Line comments are stripped so a commented-out `// add(Package())` (like the template's example) doesn't count + const activeLines = () => result.split('\n').map((line) => line.replace(/\/\/.*$/, '').trim()); + + const importStatement = `import ${basePackageName}.${packageClass}`; + if (!activeLines().includes(importStatement)) { + result = result.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`); + } + + // Checked separately from the import: a MainApplication.kt that already imports the package + // without registering it must still get the registration + if (activeLines().some((line) => line.includes(`add(${packageClass}())`))) { + return result; + } + + const applyPattern = /^([ \t]*)(.*PackageList\(this\)\.packages\.apply\s*\{)/m; + if (applyPattern.test(result)) { + return result.replace(applyPattern, (_match, indent, line) => `${indent}${line}\n${indent} add(${packageClass}())`); + } + + const packagesPattern = /^([ \t]*)val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/m; + if (packagesPattern.test(result)) { + return result.replace(packagesPattern, (_match, indent) => `${indent}val packages = PackageList(this).packages.toMutableList()\n${indent}packages.add(${packageClass}())`); + } + + throw new Error(`Could not find where to register ${packageClass} in MainApplication.kt`); +} + +module.exports = { registerAndroidPackage }; diff --git a/plugins/withInCallAudioModule.js b/plugins/withInCallAudioModule.js index eab2b92e..668caaf2 100644 --- a/plugins/withInCallAudioModule.js +++ b/plugins/withInCallAudioModule.js @@ -2,6 +2,8 @@ const { withDangerousMod, withMainApplication } = require('expo/config-plugins') const fs = require('fs'); const path = require('path'); +const { registerAndroidPackage } = require('./utils/register-android-package'); + /** * Android InCallAudioModule.kt content * Uses SoundPool to play sounds on the VOICE_COMMUNICATION stream. @@ -277,34 +279,12 @@ const withInCallAudioModule = (config) => { // 3. Register Package in MainApplication.kt config = withMainApplication(config, (config) => { - const mainApplication = config.modResults; - const projectRoot = config.modRequest.projectRoot; - - if (!mainApplication.contents.includes('InCallAudioPackage')) { - const basePackageName = resolveBasePackageName(projectRoot); - const importStatement = `import ${basePackageName}.InCallAudioPackage`; - - if (!mainApplication.contents.includes(importStatement)) { - mainApplication.contents = mainApplication.contents.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`); - } - - const packagesPattern = /val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/; - const packagesMatch = mainApplication.contents.match(packagesPattern); + const basePackageName = resolveBasePackageName(config.modRequest.projectRoot); + const contents = config.modResults.contents; - if (packagesMatch) { - // Using the simplest replacement that ensures toMutableList() - const replacement = `val packages = PackageList(this).packages.toMutableList()\n packages.add(InCallAudioPackage())`; - - // Avoid double adding if MediaButtonPackage logic already changed it to mutable - if (mainApplication.contents.includes('packages.add(MediaButtonPackage()')) { - // Add ours after MediaButtonPackage - mainApplication.contents = mainApplication.contents.replace('packages.add(MediaButtonPackage())', 'packages.add(MediaButtonPackage())\n packages.add(InCallAudioPackage())'); - } else { - // Standard replacement - mainApplication.contents = mainApplication.contents.replace(packagesPattern, replacement); - } - console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt'); - } + config.modResults.contents = registerAndroidPackage(contents, basePackageName, 'InCallAudioPackage'); + if (config.modResults.contents !== contents) { + console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt'); } return config; diff --git a/plugins/withMediaButtonModule.js b/plugins/withMediaButtonModule.js index aec6d498..abda2960 100644 --- a/plugins/withMediaButtonModule.js +++ b/plugins/withMediaButtonModule.js @@ -2,6 +2,8 @@ const { withDangerousMod, withMainApplication } = require('expo/config-plugins') const fs = require('fs'); const path = require('path'); +const { registerAndroidPackage } = require('./utils/register-android-package'); + /** * iOS MediaButtonModule.swift content */ @@ -505,34 +507,14 @@ const withMediaButtonModule = (config) => { // Update MainApplication.kt to register the package config = withMainApplication(config, (config) => { - const mainApplication = config.modResults; - const projectRoot = config.modRequest.projectRoot; - - // Check if MediaButtonPackage is already imported/added - if (!mainApplication.contents.includes('MediaButtonPackage')) { - // Resolve the BASE package name from build.gradle or build.gradle.kts - // This is where the native module files are actually created - const basePackageName = resolveBasePackageName(projectRoot); - - // Add import statement using the BASE package name (not the variant-specific package) - // The native module files are created in the base package, not in variant packages like 'development' - const importStatement = `import ${basePackageName}.MediaButtonPackage`; - if (!mainApplication.contents.includes(importStatement)) { - // Add import after the package declaration line - mainApplication.contents = mainApplication.contents.replace(/^(package\s+[^\n]+\n)/, `$1${importStatement}\n`); - console.log(`[withMediaButtonModule] Added MediaButtonPackage import from base package: ${basePackageName}`); - } - - // Add the package to getPackages() - // Find the packages list and add our package - const packagesPattern = /val packages = PackageList\(this\)\.packages(\.toMutableList\(\))?/; - const packagesMatch = mainApplication.contents.match(packagesPattern); - - if (packagesMatch) { - // Replace the packages declaration, ensuring toMutableList() is present so we can add our package - mainApplication.contents = mainApplication.contents.replace(packagesPattern, `val packages = PackageList(this).packages.toMutableList()\n packages.add(MediaButtonPackage())`); - console.log('[withMediaButtonModule] Registered MediaButtonPackage in MainApplication.kt'); - } + // Resolve the BASE package name from build.gradle or build.gradle.kts + // This is where the native module files are actually created + const basePackageName = resolveBasePackageName(config.modRequest.projectRoot); + const contents = config.modResults.contents; + + config.modResults.contents = registerAndroidPackage(contents, basePackageName, 'MediaButtonPackage'); + if (config.modResults.contents !== contents) { + console.log(`[withMediaButtonModule] Registered MediaButtonPackage from base package ${basePackageName} in MainApplication.kt`); } return config; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index a596878c..9bc20f6f 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -35,9 +35,8 @@ import { loadKeepAliveState } from '@/lib/hooks/use-keep-alive'; import { loadSelectedTheme, useSelectedTheme } from '@/lib/hooks/use-selected-theme'; import { logger } from '@/lib/logging'; import { registerNavigationReadyCheck } from '@/lib/navigation'; -import { getDeviceUuid, setDeviceUuid } from '@/lib/storage/app'; +import { getOrCreateDeviceUuid } from '@/lib/storage/app'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; -import { uuidv4 } from '@/lib/utils'; import { appInitializationService } from '@/services/app-initialization.service'; // A web sign-in popup returns to the page its redirect names (/auth/callback), and a production web build loads a route's @@ -117,10 +116,7 @@ SplashScreen.setOptions({ fade: true, }); -const deviceUuid = getDeviceUuid(); -if (!deviceUuid) { - setDeviceUuid(uuidv4()); -} +getOrCreateDeviceUuid(); LogBox.ignoreLogs([ //Mapbox errors diff --git a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx index 023d0161..9461b536 100644 --- a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx @@ -385,6 +385,31 @@ describe('ServerUrlBottomSheet', () => { unmount(); }); + it('ends the session on the old server before the new url applies', async () => { + // Sign-out still talks to the server the session belongs to (taking the device's push token off it). + const order: string[] = []; + mockOnUrlChanged.mockImplementationOnce(async () => { + order.push('signOut'); + }); + mockSetUrl.mockImplementationOnce(async () => { + order.push('setUrl'); + }); + + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByTestId('select-item-EU-Central')).toBeTruthy(); + }); + + fireEvent.press(screen.getByTestId('select-item-EU-Central')); + fireEvent.press(screen.getByText('Save')); + + await waitFor(() => { + expect(order).toEqual(['signOut', 'setUrl']); + }); + unmount(); + }); + it('does not report a change when the same server is saved again', async () => { mockGetUrl.mockResolvedValue('https://api.resgrid.com/api/v4'); diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx index 82d5b25c..e63df737 100644 --- a/src/components/settings/server-url-bottom-sheet.tsx +++ b/src/components/settings/server-url-bottom-sheet.tsx @@ -28,7 +28,7 @@ interface ServerUrlForm { interface ServerUrlBottomSheetProps { isOpen: boolean; onClose: () => void; - /** Called after a save that actually switched servers (e.g. to log out of the old one). */ + /** Called when a save is about to switch servers, before the new URL applies (e.g. to log out of the old one). */ onUrlChanged?: () => Promise; } @@ -142,12 +142,15 @@ export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUr const nextApiUrl = buildApiUrl(resolvedBaseUrl); const previousApiUrl = buildApiUrl(await getUrl()); - await setUrl(nextApiUrl); - + // End the session on the server it belongs to before pointing the app at the new one. Signing out still talks to + // the old server (taking this device's web push token off it); with the URL switched first, those calls went to + // the new server carrying the old server's token. if (nextApiUrl !== previousApiUrl && onUrlChanged) { await onUrlChanged(); } + await setUrl(nextApiUrl); + logger.info({ message: 'Server URL updated successfully', context: { url: nextApiUrl }, diff --git a/src/lib/storage/__tests__/app.test.ts b/src/lib/storage/__tests__/app.test.ts index 9105f17f..b03ecfa2 100644 --- a/src/lib/storage/__tests__/app.test.ts +++ b/src/lib/storage/__tests__/app.test.ts @@ -11,7 +11,7 @@ jest.mock('@/lib/storage', () => ({ }), })); -import { getActiveCallId, getActiveUnitId, removeActiveCallId, removeActiveUnitId, setActiveCallId, setActiveUnitId } from '../app'; +import { getActiveCallId, getActiveUnitId, getDeviceUuid, getOrCreateDeviceUuid, removeActiveCallId, removeActiveUnitId, removeDeviceUuid, setActiveCallId, setActiveUnitId, setDeviceUuid } from '../app'; // The core store's init() restores the crew's unit and call from these on every launch. describe('active unit and call persistence', () => { @@ -49,3 +49,28 @@ describe('active unit and call persistence', () => { expect(getActiveCallId()).toBeNull(); }); }); + +// Every push registration carries this id. Sign-out wipes it, and signing in again does not restart the app. +describe('device uuid', () => { + beforeEach(() => { + mockStore.clear(); + }); + + it('keeps the id the device already has', async () => { + await setDeviceUuid('existing-uuid'); + + expect(getOrCreateDeviceUuid()).toBe('existing-uuid'); + }); + + it('mints and saves a new id once sign-out has removed it', async () => { + await setDeviceUuid('previous-session-uuid'); + await removeDeviceUuid(); + + const uuid = getOrCreateDeviceUuid(); + + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(uuid).not.toBe('previous-session-uuid'); + expect(getDeviceUuid()).toBe(uuid); + expect(getOrCreateDeviceUuid()).toBe(uuid); + }); +}); diff --git a/src/lib/storage/app.tsx b/src/lib/storage/app.tsx index 2522aab3..20e20988 100644 --- a/src/lib/storage/app.tsx +++ b/src/lib/storage/app.tsx @@ -1,4 +1,5 @@ import { Env } from '@env'; +import { randomUUID } from 'expo-crypto'; import { getItem, removeItem, setItem } from '@/lib/storage'; @@ -37,3 +38,19 @@ export const getDeviceUuid = () => { const uuid = getItem(DEVICE_UUID); return uuid; }; + +/** + * The device id sent with every push registration, minted on first use. Sign-out wipes it so the next session + * gets a fresh one, but sign-in does not restart the app: reading it with getDeviceUuid() after an in-app sign-out + * (which every server switch from Settings is) registered the device with an empty id. + */ +export const getOrCreateDeviceUuid = (): string => { + const existing = getDeviceUuid(); + if (existing) { + return existing; + } + + const uuid = randomUUID(); + setDeviceUuid(uuid); + return uuid; +}; diff --git a/src/services/__tests__/bluetooth-audio-aina.test.ts b/src/services/__tests__/bluetooth-audio-aina.test.ts new file mode 100644 index 00000000..d32bbb92 --- /dev/null +++ b/src/services/__tests__/bluetooth-audio-aina.test.ts @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import 'react-native'; + +jest.mock('@livekit/react-native-webrtc', () => ({ + RTCAudioSession: { + audioSessionDidActivate: jest.fn(), + audioSessionDidDeactivate: jest.fn(), + }, +})); + +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + +jest.mock('react-native-ble-manager', () => ({ + __esModule: true, + default: { + start: jest.fn(), + checkState: jest.fn(), + scan: jest.fn(), + stopScan: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + getConnectedPeripherals: jest.fn().mockResolvedValue([]), + removeAllListeners: jest.fn(), + startNotification: jest.fn().mockResolvedValue(undefined), + read: jest.fn(), + }, +})); + +jest.mock('@/lib/storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@/services/audio.service', () => ({ + audioService: { + playConnectedDeviceSound: jest.fn(), + playStartTransmittingSound: jest.fn(), + playStopTransmittingSound: jest.fn(), + }, +})); + +jest.mock('@/stores/app/livekit-store', () => { + const actions = { + toggleMicrophone: jest.fn(), + setMicrophoneEnabled: jest.fn(), + }; + return { + useLiveKitStore: { + getState: jest.fn(() => actions), + }, + }; +}); + +import BleManager from 'react-native-ble-manager'; + +import { logger } from '@/lib/logging'; + +import { bluetoothAudioService } from '../bluetooth-audio.service'; + +const AINA_SERVICE = '127FACE1-CB21-11E5-93D0-0002A5D5C51B'; +const AINA_BUTTON_CHAR = '127FBEEF-CB21-11E5-93D0-0002A5D5C51B'; +// Another characteristic in the AINA service that emits an incrementing counter (03, 04, 05, ...) +const AINA_COUNTER_CHAR = '127FC0FF-CB21-11E5-93D0-0002A5D5C51B'; +const IMMEDIATE_ALERT_SERVICE = '1802'; +const ALERT_LEVEL_CHAR = '2a06'; +const BLE_BUTTON_CONTROL_CHAR = '0000FE59-0000-1000-8000-00805F9B34FB'; +const DEVICE_ID = '673dffb6-1287-a157-6fea-1e36117cfebb'; + +const ainaDevice = { + id: DEVICE_ID, + name: 'APTT', + rssi: -50, + advertising: { serviceUUIDs: [AINA_SERVICE] }, +}; + +const notify = (service: any, serviceUuid: string, characteristicUuid: string, byte: number) => { + service.handleCharacteristicValueUpdate({ + peripheral: DEVICE_ID, + service: serviceUuid, + characteristic: characteristicUuid, + value: [byte], + }); +}; + +describe('BluetoothAudioService - AINA headset button handling', () => { + let service: any; + let processButtonEventSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + service = bluetoothAudioService as any; + service.connectedDevice = ainaDevice; + service.monitoredReadCharacteristics = []; + service.hasVendorButtonCharacteristic = false; + service.pttPressActive = false; + processButtonEventSpy = jest.spyOn(service, 'processButtonEvent').mockImplementation(() => undefined); + }); + + afterEach(() => { + processButtonEventSpy.mockRestore(); + service.stopReadPollingFallback(); + service.connectedDevice = null; + service.monitoredReadCharacteristics = []; + service.hasVendorButtonCharacteristic = false; + }); + + const dispatchedButtons = () => processButtonEventSpy.mock.calls.map(([event]) => event.button); + + describe('startNotificationsForButtonControls', () => { + it('flags the vendor characteristic and only read-polls readable characteristics', async () => { + await service.startNotificationsForButtonControls(DEVICE_ID, { + services: [{ uuid: AINA_SERVICE }, { uuid: IMMEDIATE_ALERT_SERVICE }], + characteristics: [ + { service: AINA_SERVICE, characteristic: AINA_BUTTON_CHAR, properties: { Read: 'Read', Notify: 'Notify' } }, + { service: AINA_SERVICE, characteristic: AINA_COUNTER_CHAR, properties: { Read: 'Read', Notify: 'Notify' } }, + { service: IMMEDIATE_ALERT_SERVICE, characteristic: ALERT_LEVEL_CHAR, properties: { WriteWithoutResponse: 'WriteWithoutResponse' } }, + ], + }); + + expect(service.hasVendorButtonCharacteristic).toBe(true); + const polled = service.monitoredReadCharacteristics.map((entry: any) => entry.characteristicUuid); + expect(polled).toEqual([AINA_BUTTON_CHAR, AINA_COUNTER_CHAR]); + expect(polled).not.toContain(ALERT_LEVEL_CHAR); + }); + }); + + describe('characteristic routing', () => { + it('ignores the AINA counter characteristic so it cannot toggle mute while PTT is held', () => { + service.hasVendorButtonCharacteristic = true; + + // Sequence captured from a real AINA PTT press-and-hold + notify(service, AINA_SERVICE, AINA_BUTTON_CHAR, 0x01); + notify(service, AINA_SERVICE, AINA_COUNTER_CHAR, 0x03); + notify(service, AINA_SERVICE, AINA_BUTTON_CHAR, 0x81); + notify(service, AINA_SERVICE, AINA_COUNTER_CHAR, 0x04); + notify(service, AINA_SERVICE, AINA_BUTTON_CHAR, 0x01); + notify(service, AINA_SERVICE, AINA_COUNTER_CHAR, 0x05); + notify(service, AINA_SERVICE, AINA_COUNTER_CHAR, 0x06); + notify(service, AINA_SERVICE, AINA_BUTTON_CHAR, 0x00); + + expect(dispatchedButtons()).toEqual(['ptt_start', 'unknown', 'ptt_start', 'ptt_stop']); + expect(dispatchedButtons()).not.toContain('mute'); + }); + + it('still uses the generic parser when no vendor button characteristic is active', () => { + service.connectedDevice = { ...ainaDevice, name: 'Generic Headset', advertising: { serviceUUIDs: [] } }; + + notify(service, '0000FE59-0000-1000-8000-00805F9B34FB', BLE_BUTTON_CONTROL_CHAR, 0x04); + + expect(dispatchedButtons()).toEqual(['mute']); + }); + }); + + describe('read polling', () => { + it('stops polling a characteristic once it delivers a notification', () => { + service.registerReadPollingCharacteristic(AINA_SERVICE, AINA_BUTTON_CHAR); + service.registerReadPollingCharacteristic(AINA_SERVICE, AINA_COUNTER_CHAR); + + notify(service, AINA_SERVICE, AINA_BUTTON_CHAR, 0x01); + + const confirmed = service.monitoredReadCharacteristics.filter((entry: any) => entry.notificationConfirmed).map((entry: any) => entry.characteristicUuid); + expect(confirmed).toEqual([AINA_BUTTON_CHAR]); + }); + + it('discards an in-flight read when a notification arrives for the same characteristic', async () => { + service.registerReadPollingCharacteristic(AINA_SERVICE, AINA_BUTTON_CHAR); + service.monitoredReadCharacteristics[0].lastHexValue = '00'; + + let resolveRead: (value: number[]) => void = () => undefined; + (BleManager.read as jest.Mock).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRead = resolve; + }) + ); + + const poll = service.pollReadCharacteristics(DEVICE_ID); + + // Press and release are notified while the read is still pending + notify(service, AINA_SERVICE, AINA_BUTTON_CHAR, 0x01); + notify(service, AINA_SERVICE, AINA_BUTTON_CHAR, 0x00); + + // The stale read resolves with the "pressed" value + resolveRead([0x01]); + await poll; + + expect(dispatchedButtons()).toEqual(['ptt_start', 'ptt_stop']); + }); + + it('stops polling a characteristic after repeated read failures', async () => { + service.registerReadPollingCharacteristic(IMMEDIATE_ALERT_SERVICE, ALERT_LEVEL_CHAR); + (BleManager.read as jest.Mock).mockRejectedValue('Reading is not permitted.'); + + await service.pollReadCharacteristics(DEVICE_ID); + await service.pollReadCharacteristics(DEVICE_ID); + expect(service.monitoredReadCharacteristics).toHaveLength(1); + + await service.pollReadCharacteristics(DEVICE_ID); + expect(service.monitoredReadCharacteristics).toHaveLength(0); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: 'Stopped read polling for characteristic after repeated failures' })); + + await service.pollReadCharacteristics(DEVICE_ID); + expect(BleManager.read).toHaveBeenCalledTimes(3); + }); + + it('resets the failure count after a successful read', async () => { + service.registerReadPollingCharacteristic(AINA_SERVICE, AINA_BUTTON_CHAR); + (BleManager.read as jest.Mock).mockRejectedValueOnce('busy').mockRejectedValueOnce('busy').mockResolvedValueOnce([0x00]).mockRejectedValueOnce('busy').mockRejectedValueOnce('busy'); + + for (let i = 0; i < 5; i++) { + await service.pollReadCharacteristics(DEVICE_ID); + } + + expect(service.monitoredReadCharacteristics).toHaveLength(1); + expect(service.monitoredReadCharacteristics[0].consecutiveFailures).toBe(2); + }); + }); +}); diff --git a/src/services/__tests__/bluetooth-audio-edge-cases.test.ts b/src/services/__tests__/bluetooth-audio-edge-cases.test.ts new file mode 100644 index 00000000..9ea3bffb --- /dev/null +++ b/src/services/__tests__/bluetooth-audio-edge-cases.test.ts @@ -0,0 +1,508 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import 'react-native'; + +jest.mock('@livekit/react-native-webrtc', () => ({ + RTCAudioSession: { + audioSessionDidActivate: jest.fn(), + audioSessionDidDeactivate: jest.fn(), + }, +})); + +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + +jest.mock('react-native-ble-manager', () => { + const listener = () => ({ remove: jest.fn() }); + return { + __esModule: true, + BleScanCallbackType: { AllMatches: 1 }, + BleScanMatchMode: { Sticky: 2 }, + BleScanMode: { LowLatency: 2 }, + default: { + start: jest.fn().mockResolvedValue(undefined), + checkState: jest.fn().mockResolvedValue('on'), + scan: jest.fn().mockResolvedValue(undefined), + stopScan: jest.fn().mockResolvedValue(undefined), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + getConnectedPeripherals: jest.fn().mockResolvedValue([]), + retrieveServices: jest.fn().mockResolvedValue({ services: [], characteristics: [] }), + startNotification: jest.fn().mockResolvedValue(undefined), + read: jest.fn(), + onDidUpdateState: jest.fn(listener), + onDisconnectPeripheral: jest.fn(listener), + onDiscoverPeripheral: jest.fn(listener), + onDidUpdateValueForCharacteristic: jest.fn(listener), + onStopScan: jest.fn(listener), + }, + }; +}); + +jest.mock('@/lib/storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@/services/audio.service', () => ({ + audioService: { + playConnectedDeviceSound: jest.fn(), + playStartTransmittingSound: jest.fn(), + playStopTransmittingSound: jest.fn(), + }, +})); + +jest.mock('@/stores/app/livekit-store', () => { + const actions = { + toggleMicrophone: jest.fn(), + setMicrophoneEnabled: jest.fn(), + }; + return { + useLiveKitStore: { + getState: jest.fn(() => actions), + }, + }; +}); + +import { PermissionsAndroid, Platform } from 'react-native'; +import BleManager from 'react-native-ble-manager'; + +import { removeItem } from '@/lib/storage'; +import { callKeepService } from '@/services/callkeep.service'; +import { State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; +import { useLiveKitStore } from '@/stores/app/livekit-store'; + +import { bluetoothAudioService } from '../bluetooth-audio.service'; + +const DEVICE_ID = 'device-1'; +const HFP_SERVICE_UUID = '0000111E-0000-1000-8000-00805F9B34FB'; +const BATTERY_SERVICE = '0000180F-0000-1000-8000-00805F9B34FB'; +const BATTERY_LEVEL_CHAR = '2a19'; +const HID_SERVICE = '1812'; +const HID_REPORT_CHAR = '2a4d'; +const HYS_SERVICE = '0000FFE0-0000-1000-8000-00805F9B34FB'; + +const hysDevice = { + id: DEVICE_ID, + name: 'HYS PTT', + rssi: -50, + advertising: { serviceUUIDs: [HYS_SERVICE] }, +}; + +describe('BluetoothAudioService - edge cases', () => { + let service: any; + + beforeEach(() => { + jest.clearAllMocks(); + service = bluetoothAudioService as any; + service.connectedDevice = null; + service.isConnecting = false; + service.monitoredReadCharacteristics = []; + service.hasVendorButtonCharacteristic = false; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + service.stopReadPollingFallback(); + service.clearConnectionTimeout(); + service.connectedDevice = null; + service.isConnecting = false; + }); + + describe('standard GATT characteristics', () => { + it('never treats a battery level of 100% (0x64) as a mute press', () => { + const processButtonEventSpy = jest.spyOn(service, 'processButtonEvent').mockImplementation(() => undefined); + service.connectedDevice = hysDevice; + + service.handleCharacteristicValueUpdate({ peripheral: DEVICE_ID, service: BATTERY_SERVICE, characteristic: BATTERY_LEVEL_CHAR, value: [0x64] }); + service.handleCharacteristicValueUpdate({ peripheral: DEVICE_ID, service: HID_SERVICE, characteristic: HID_REPORT_CHAR, value: [0x04] }); + + expect(processButtonEventSpy).not.toHaveBeenCalled(); + }); + + it('does not subscribe to battery or HID characteristics', async () => { + service.connectedDevice = hysDevice; + + await service.startNotificationsForButtonControls(DEVICE_ID, { + services: [{ uuid: BATTERY_SERVICE }, { uuid: HID_SERVICE }, { uuid: HYS_SERVICE }], + characteristics: [ + { service: BATTERY_SERVICE, characteristic: BATTERY_LEVEL_CHAR, properties: { Read: 'Read', Notify: 'Notify' } }, + { service: HID_SERVICE, characteristic: HID_REPORT_CHAR, properties: { Read: 'Read', Notify: 'Notify' } }, + { service: HYS_SERVICE, characteristic: 'FFE1', properties: { Read: 'Read', Notify: 'Notify' } }, + ], + }); + + const subscribed = (BleManager.startNotification as jest.Mock).mock.calls.map(([, , characteristic]) => characteristic); + expect(subscribed).toEqual(['FFE1']); + }); + }); + + describe('UUID normalization', () => { + it('normalizes 16-bit, 32-bit and unhyphenated UUIDs to the 128-bit form', () => { + expect(service.normalizeUuid('111e')).toBe(HFP_SERVICE_UUID); + expect(service.normalizeUuid('0000111E')).toBe(HFP_SERVICE_UUID); + expect(service.normalizeUuid('0000111e00001000800000805f9b34fb')).toBe(HFP_SERVICE_UUID); + expect(service.normalizeUuid(HFP_SERVICE_UUID.toLowerCase())).toBe(HFP_SERVICE_UUID); + }); + + it('matches short advertised UUIDs against the 128-bit constants', () => { + const device = { id: 'headset', name: 'Unnamed', advertising: { serviceUUIDs: ['111E'] } }; + + expect(service.areUuidsEqual('111E', HFP_SERVICE_UUID)).toBe(true); + expect(service.isAudioDevice(device)).toBe(true); + expect(service.supportsMicrophoneControl(device)).toBe(true); + }); + }); + + it('detects ASCII vendor identifiers in service data', () => { + expect(service.analyzeServiceDataForAudio(Buffer.from('AINA-PTT', 'ascii'))).toBe(true); + }); + + describe('requestPermissions', () => { + // Assigned directly (not jest.replaceProperty) because some apps' jest setup mocks Platform without Version + const platform = Platform as unknown as { OS: string; Version: number | string | undefined }; + const originalPlatform = { OS: platform.OS, Version: platform.Version }; + + beforeAll(() => { + // Some apps' jest setup mocks react-native without PermissionsAndroid + const RN = require('react-native'); + if (!RN.PermissionsAndroid) { + RN.PermissionsAndroid = { + PERMISSIONS: { + BLUETOOTH_SCAN: 'android.permission.BLUETOOTH_SCAN', + BLUETOOTH_CONNECT: 'android.permission.BLUETOOTH_CONNECT', + ACCESS_FINE_LOCATION: 'android.permission.ACCESS_FINE_LOCATION', + }, + RESULTS: { GRANTED: 'granted', DENIED: 'denied' }, + check: jest.fn(), + requestMultiple: jest.fn(), + }; + } + }); + + afterEach(() => { + platform.OS = originalPlatform.OS; + platform.Version = originalPlatform.Version; + }); + + const setAndroidVersion = (version: number) => { + platform.OS = 'android'; + platform.Version = version; + }; + + it('requests only BLUETOOTH_SCAN/CONNECT on Android 12+', async () => { + setAndroidVersion(31); + jest.spyOn(PermissionsAndroid, 'check').mockResolvedValue(false); + const requestSpy = jest.spyOn(PermissionsAndroid, 'requestMultiple').mockResolvedValue({ + [PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN]: PermissionsAndroid.RESULTS.GRANTED, + [PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT]: PermissionsAndroid.RESULTS.GRANTED, + } as any); + + await expect(service.requestPermissions()).resolves.toBe(true); + expect(requestSpy).toHaveBeenCalledWith([PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT]); + }); + + it('requests location for BLE scanning on Android 11 and lower', async () => { + setAndroidVersion(30); + jest.spyOn(PermissionsAndroid, 'check').mockResolvedValue(false); + const requestSpy = jest.spyOn(PermissionsAndroid, 'requestMultiple').mockResolvedValue({ + [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION]: PermissionsAndroid.RESULTS.GRANTED, + } as any); + + await expect(service.requestPermissions()).resolves.toBe(true); + expect(requestSpy).toHaveBeenCalledWith([PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION]); + }); + + it('does not prompt when permissions are already granted', async () => { + setAndroidVersion(34); + jest.spyOn(PermissionsAndroid, 'check').mockResolvedValue(true); + const requestSpy = jest.spyOn(PermissionsAndroid, 'requestMultiple'); + + await expect(service.requestPermissions()).resolves.toBe(true); + expect(requestSpy).not.toHaveBeenCalled(); + }); + }); + + describe('connectToDevice', () => { + it('ignores a connect request while another one is in flight', async () => { + service.isConnecting = true; + + await service.connectToDevice(DEVICE_ID); + + expect(BleManager.connect).not.toHaveBeenCalled(); + }); + + it('times out a connect that never completes and cancels it', async () => { + jest.useFakeTimers(); + (BleManager.connect as jest.Mock).mockReturnValueOnce(new Promise(() => undefined)); + + const connectAttempt = service.connectToDevice(DEVICE_ID); + const assertion = expect(connectAttempt).rejects.toThrow('Timed out connecting to Bluetooth device'); + + await jest.advanceTimersByTimeAsync(500 + 15000); + await assertion; + + expect(BleManager.disconnect).toHaveBeenCalledWith(DEVICE_ID); + expect(useBluetoothAudioStore.getState().connectionError).toContain('Timed out'); + expect(service.isConnecting).toBe(false); + }); + }); + + describe('Bluetooth state changes', () => { + let reconnectSpy: jest.SpyInstance; + + beforeEach(() => { + service.isInitialized = true; + reconnectSpy = jest.spyOn(service, 'attemptReconnectToPreferredDevice').mockResolvedValue(undefined); + }); + + afterEach(() => { + service.isInitialized = false; + service.hasAttemptedPreferredDeviceConnection = false; + useBluetoothAudioStore.getState().setBluetoothState(State.Unknown); + }); + + it('reconnects when Bluetooth is turned back on after an earlier attempt', () => { + service.hasAttemptedPreferredDeviceConnection = true; + useBluetoothAudioStore.getState().setBluetoothState(State.PoweredOff); + + service.handleBluetoothStateChange({ state: 'on' }); + + expect(reconnectSpy).toHaveBeenCalledTimes(1); + }); + + it('retries when startup skipped the preferred device because the state was not yet known', () => { + service.hasAttemptedPreferredDeviceConnection = false; + + service.handleBluetoothStateChange({ state: 'on' }); + + expect(reconnectSpy).toHaveBeenCalledTimes(1); + }); + + it('does not reconnect on the first power-on report when startup already attempted it', () => { + service.hasAttemptedPreferredDeviceConnection = true; + + service.handleBluetoothStateChange({ state: 'on' }); + + expect(reconnectSpy).not.toHaveBeenCalled(); + }); + + it('does not reconnect while a device is connected', () => { + service.hasAttemptedPreferredDeviceConnection = true; + service.connectedDevice = hysDevice; + useBluetoothAudioStore.getState().setBluetoothState(State.PoweredOff); + + service.handleBluetoothStateChange({ state: 'on' }); + + expect(reconnectSpy).not.toHaveBeenCalled(); + }); + }); + + describe('forgetPreferredDevice', () => { + it('clears the stored preference and moves the audio selection back to the built-in devices', async () => { + const store = useBluetoothAudioStore.getState(); + const headset = { id: DEVICE_ID, name: 'Headset', type: 'bluetooth' as const, isAvailable: true }; + store.setAvailableAudioDevices([ + { id: 'default-mic', name: 'Default Microphone', type: 'microphone', isAvailable: true }, + { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true }, + headset, + ]); + store.setPreferredDevice({ id: DEVICE_ID, name: 'Headset' }); + store.setSelectedMicrophone(headset); + store.setSelectedSpeaker(headset); + + await service.forgetPreferredDevice(DEVICE_ID); + + const { preferredDevice, selectedAudioDevices } = useBluetoothAudioStore.getState(); + expect(removeItem).toHaveBeenCalledWith('preferredBluetoothDevice'); + expect(preferredDevice).toBeNull(); + expect(selectedAudioDevices.microphone?.id).toBe('default-mic'); + expect(selectedAudioDevices.speaker?.id).toBe('default-speaker'); + }); + + it('leaves a different preferred device alone', async () => { + useBluetoothAudioStore.getState().setPreferredDevice({ id: 'other', name: 'Other' }); + + await service.forgetPreferredDevice(DEVICE_ID); + + expect(removeItem).not.toHaveBeenCalled(); + expect(useBluetoothAudioStore.getState().preferredDevice?.id).toBe('other'); + useBluetoothAudioStore.getState().setPreferredDevice(null); + }); + }); + + it('clears the pending scan timeout when the scan stops early', () => { + jest.useFakeTimers(); + const timeoutSpy = jest.fn(); + service.scanTimeout = setTimeout(timeoutSpy, 1000); + + service.handleScanStopped(); + jest.advanceTimersByTime(1000); + + expect(timeoutSpy).not.toHaveBeenCalled(); + expect(service.scanTimeout).toBeNull(); + }); + + describe('PTT cleanup', () => { + const setMicrophoneEnabled = () => useLiveKitStore.getState().setMicrophoneEnabled as jest.Mock; + const micCalls = () => setMicrophoneEnabled().mock.calls.map(([enabled]) => enabled); + const flushMicQueue = () => new Promise((resolve) => setImmediate(resolve)); + const pttEvent = (button: 'ptt_start' | 'ptt_stop') => ({ type: 'press', button, timestamp: Date.now() }); + + beforeEach(() => { + // jest-setup enables fake timers globally; flushMicQueue needs a real setImmediate + jest.useRealTimers(); + service.connectedDevice = hysDevice; + service.pttPressActive = false; + }); + + it('releases the microphone when the headset is disconnected while PTT is held', async () => { + service.processButtonEvent(pttEvent('ptt_start')); + await flushMicQueue(); + + await service.disconnectDevice(); + await flushMicQueue(); + + expect(micCalls()).toEqual([true, false]); + expect(service.pttPressActive).toBe(false); + expect(callKeepService.ignoreMuteEvents).toHaveBeenCalledTimes(2); + }); + + it('releases a held press whose unmute is still being applied when the headset drops', async () => { + service.processButtonEvent(pttEvent('ptt_start')); + + service.handleDeviceDisconnected({ peripheral: DEVICE_ID }); + await flushMicQueue(); + + expect(micCalls()).toEqual([true, false]); + }); + + it('keeps a queued release when the headset drops before it is applied', async () => { + service.processButtonEvent(pttEvent('ptt_start')); + service.processButtonEvent(pttEvent('ptt_stop')); + + service.handleDeviceDisconnected({ peripheral: DEVICE_ID }); + await flushMicQueue(); + + expect(micCalls()).toEqual([true, false]); + }); + + it('drops a queued unmute when the headset drops', async () => { + service.processButtonEvent(pttEvent('ptt_start')); + service.processButtonEvent(pttEvent('ptt_stop')); + service.processButtonEvent(pttEvent('ptt_start')); + + service.handleDeviceDisconnected({ peripheral: DEVICE_ID }); + await flushMicQueue(); + + expect(micCalls()).toEqual([true, false]); + }); + + it('releases the microphone on destroy while PTT is held', async () => { + service.processButtonEvent(pttEvent('ptt_start')); + await flushMicQueue(); + + await service.destroy(); + await flushMicQueue(); + + expect(micCalls()).toEqual([true, false]); + }); + + it('leaves the microphone alone on disconnect when no PTT press is active', async () => { + await service.disconnectDevice(); + await flushMicQueue(); + + expect(setMicrophoneEnabled()).not.toHaveBeenCalled(); + expect(callKeepService.ignoreMuteEvents).not.toHaveBeenCalled(); + }); + }); + + describe('BleManager startup', () => { + // Holds BleManager.start() pending until the returned function is called + const deferredStart = () => { + let resolveStart: () => void = () => undefined; + const pending = new Promise((resolve) => { + resolveStart = resolve; + }); + (BleManager.start as jest.Mock).mockReturnValueOnce(pending); + return () => resolveStart(); + }; + + beforeEach(() => { + service.isBleManagerStarted = false; + service.bleManagerStartPromise = null; + }); + + afterEach(() => { + service.eventListeners = []; + service.isBleManagerStarted = false; + service.bleManagerStartPromise = null; + }); + + it('starts BleManager and registers its listeners once for overlapping callers', async () => { + const resolveStart = deferredStart(); + + const first = service.ensureBleManagerStarted(); + const second = service.ensureBleManagerStarted(); + resolveStart(); + await Promise.all([first, second]); + await service.ensureBleManagerStarted(); + + expect(BleManager.start).toHaveBeenCalledTimes(1); + expect(BleManager.onDidUpdateValueForCharacteristic).toHaveBeenCalledTimes(1); + expect(service.isBleManagerStarted).toBe(true); + }); + + it('does not register listeners when destroyed while BleManager is starting', async () => { + const resolveStart = deferredStart(); + + const starting = service.ensureBleManagerStarted(); + const assertion = expect(starting).rejects.toThrow('destroyed while BleManager was starting'); + const destroying = service.destroy(); + resolveStart(); + await assertion; + await destroying; + + expect(BleManager.onDidUpdateValueForCharacteristic).not.toHaveBeenCalled(); + expect(service.isBleManagerStarted).toBe(false); + expect(service.bleManagerStartPromise).toBeNull(); + }); + + it('allows a retry after BleManager fails to start', async () => { + (BleManager.start as jest.Mock).mockRejectedValueOnce(new Error('start failed')); + + await expect(service.ensureBleManagerStarted()).rejects.toThrow('start failed'); + await service.ensureBleManagerStarted(); + + expect(BleManager.start).toHaveBeenCalledTimes(2); + expect(service.isBleManagerStarted).toBe(true); + }); + }); + + it('resets its state synchronously on destroy so it can be initialized again', () => { + service.isInitialized = true; + service.isBleManagerStarted = true; + service.hasAttemptedPreferredDeviceConnection = true; + + void service.destroy(); + + expect(service.isInitialized).toBe(false); + expect(service.isBleManagerStarted).toBe(false); + expect(service.hasAttemptedPreferredDeviceConnection).toBe(false); + }); +}); diff --git a/src/services/__tests__/push-notification-chat-deeplink.test.ts b/src/services/__tests__/push-notification-chat-deeplink.test.ts index 10c26e68..bf80a3a0 100644 --- a/src/services/__tests__/push-notification-chat-deeplink.test.ts +++ b/src/services/__tests__/push-notification-chat-deeplink.test.ts @@ -64,7 +64,7 @@ jest.mock('@/lib/logging', () => ({ })); jest.mock('@/lib/storage/app', () => ({ - getDeviceUuid: jest.fn(() => 'test-uuid'), + getOrCreateDeviceUuid: jest.fn(() => 'test-uuid'), getBaseApiUrl: jest.fn(() => ''), })); diff --git a/src/services/__tests__/push-notification-hook.test.ts b/src/services/__tests__/push-notification-hook.test.ts index 4f58dfbb..b8961ee8 100644 --- a/src/services/__tests__/push-notification-hook.test.ts +++ b/src/services/__tests__/push-notification-hook.test.ts @@ -58,8 +58,10 @@ jest.mock('@/lib/logging', () => ({ }, })); +let mockBaseApiUrl = 'https://api.resgrid.com/api/v4'; jest.mock('@/lib/storage/app', () => ({ - getDeviceUuid: jest.fn(() => 'test-device-uuid'), + getOrCreateDeviceUuid: jest.fn(() => 'test-device-uuid'), + getBaseApiUrl: jest.fn(() => mockBaseApiUrl), })); jest.mock('@/lib/storage/notification-prefs', () => ({ @@ -119,7 +121,7 @@ jest.mock('@/stores/auth/store', () => { return { __esModule: true, default: store }; }); -import { renderHook, waitFor } from '@testing-library/react-native'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; import { logger } from '@/lib/logging'; import { useCoreStore } from '@/stores/app/core-store'; @@ -140,6 +142,7 @@ describe('usePushNotifications', () => { setAuthState({ status: 'signedIn', accessToken: 'test-access-token' }); setCoreState({ activeUnitId: 'test-unit' }); setSecurityState({ rights: { DepartmentCode: 'TEST' } }); + mockBaseApiUrl = 'https://api.resgrid.com/api/v4'; registerSpy = jest.spyOn(pushNotificationService, 'registerForPushNotifications').mockResolvedValue('test-device-token'); }); @@ -254,4 +257,135 @@ describe('usePushNotifications', () => { unmount(); }); + + it('does not register without a department code', async () => { + // Core files the token under a subscriber named from the department code; a blank one is accepted and silently dropped. + setSecurityState({ rights: { DepartmentCode: '' } }); + + const { unmount } = renderHook(() => usePushNotifications()); + + await waitFor(() => { + expect(registerSpy).not.toHaveBeenCalled(); + }); + + unmount(); + }); + + it('registers the same unit again when the department code changes', async () => { + // Persisted rights can carry a stale department until getRights() refreshes them; a token filed under the old code + // never receives the unit's pushes. + setSecurityState({ rights: { DepartmentCode: 'OLD' } }); + + const { rerender, unmount } = renderHook(() => usePushNotifications()); + + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledWith('test-unit', 'OLD'); + }); + + setSecurityState({ rights: { DepartmentCode: 'NEW' } }); + rerender({}); + + await waitFor(() => { + expect(registerSpy).toHaveBeenLastCalledWith('test-unit', 'NEW'); + }); + expect(registerSpy).toHaveBeenCalledTimes(2); + + unmount(); + }); + + it('registers the same unit again against a different server', async () => { + const { rerender, unmount } = renderHook(() => usePushNotifications()); + + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledTimes(1); + }); + + mockBaseApiUrl = 'https://api-eu-central.resgrid.com/api/v4'; + setSecurityState({ rights: { DepartmentCode: 'TEST' } }); + rerender({}); + + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledTimes(2); + }); + + unmount(); + }); + + it('registers the same unit again after signing out and back in', async () => { + const { rerender, unmount } = renderHook(() => usePushNotifications()); + + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledTimes(1); + }); + + setAuthState({ status: 'signedOut', accessToken: null }); + rerender({}); + setAuthState({ status: 'signedIn', accessToken: 'next-access-token' }); + rerender({}); + + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledTimes(2); + }); + + unmount(); + }); + + it('does not start a second registration while one for the same unit is in flight', async () => { + let resolveRegistration: (token: string | null) => void = () => {}; + registerSpy.mockReturnValueOnce( + new Promise((resolve) => { + resolveRegistration = resolve; + }) + ); + + const { rerender, unmount } = renderHook(() => usePushNotifications()); + + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledTimes(1); + }); + + setSecurityState({ rights: { DepartmentCode: 'TEST' } }); + rerender({}); + expect(registerSpy).toHaveBeenCalledTimes(1); + + resolveRegistration('test-device-token'); + await waitFor(() => { + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: 'Successfully registered for push notifications' })); + }); + + unmount(); + }); + + it('does not count a registration that finished after the session ended', async () => { + let resolveRegistration: (token: string | null) => void = () => {}; + registerSpy.mockReturnValueOnce( + new Promise((resolve) => { + resolveRegistration = resolve; + }) + ); + + const { rerender, unmount } = renderHook(() => usePushNotifications()); + + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledTimes(1); + }); + + // Signed out (as every server switch from Settings does) while the registration was still in flight. + setAuthState({ status: 'signedOut', accessToken: null }); + rerender({}); + await act(async () => { + resolveRegistration('test-device-token'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + setAuthState({ status: 'signedIn', accessToken: 'next-access-token' }); + rerender({}); + + // The late answer was not taken as this session's registration, so signing in registers again. + await waitFor(() => { + expect(registerSpy).toHaveBeenCalledTimes(2); + }); + + unmount(); + }); }); diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index c86755b9..503ea47a 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -49,7 +49,8 @@ jest.mock('@/lib/logging', () => ({ })); jest.mock('@/lib/storage/app', () => ({ - getDeviceUuid: jest.fn(() => 'test-device-uuid'), + getOrCreateDeviceUuid: jest.fn(() => 'test-device-uuid'), + getBaseApiUrl: jest.fn(() => 'https://api.test/api/v4'), })); jest.mock('@/api/devices/push', () => ({ diff --git a/src/services/__tests__/push-notification.web.test.ts b/src/services/__tests__/push-notification.web.test.ts index 061f8543..a53112bf 100644 --- a/src/services/__tests__/push-notification.web.test.ts +++ b/src/services/__tests__/push-notification.web.test.ts @@ -23,7 +23,8 @@ jest.mock('@/api/devices/push', () => ({ unRegisterWebPush: (...args: unknown[]) => mockUnRegisterWebPush(...(args as [])), })); jest.mock('@/lib/navigation', () => ({ routerPushWithRetry: (...args: unknown[]) => mockRouterPushWithRetry(...(args as [])) })); -jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: () => 'https://api.test/api/v4', getDeviceUuid: () => 'device-uuid' })); +let mockBaseApiUrl = 'https://api.test/api/v4'; +jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: () => mockBaseApiUrl, getOrCreateDeviceUuid: () => 'device-uuid' })); jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() } })); jest.mock('@/lib/mfa/client-app', () => ({ CLIENT_HEADER: 'X-Resgrid-Client', RESGRID_CLIENT: 'unit' })); jest.mock('@/stores/push-notification/store', () => { @@ -139,6 +140,7 @@ beforeEach(() => { mockState.auth = { status: 'signedIn', accessToken: 'access-token' }; mockState.core = { activeUnitId: '12', config: { ...firebaseConfig } }; mockState.security = { rights: { DepartmentCode: 'DEPT' } }; + mockBaseApiUrl = 'https://api.test/api/v4'; installBrowser(); globals.fetch = jest.fn(async () => ({ ok: true })); }); @@ -299,6 +301,23 @@ describe('browser', () => { expect(push.pushNotificationService.getPushToken()).toBeNull(); }); + it('unregisters from the server the session belonged to when a server switch lands while the cleanup waits', async () => { + permission = 'granted'; + const { push, hooks } = load(); + const reachedMint = holdNextMint(); + + const sync = push.syncWebPush(); + const releaseToken = await reachedMint(); + const signOut = hooks.runSignOutHooks('access-token'); + // Settings applies the new server once sign-out returns; this cleanup is still queued behind the sync. + mockBaseApiUrl = 'https://api-eu-central.resgrid.com/api/v4'; + releaseToken('browser-token'); + await Promise.all([sync, signOut]); + + expect(globals.fetch).toHaveBeenCalledWith('https://api.test/api/v4/Devices/UnRegisterWebPush', expect.anything()); + expect(globals.fetch).not.toHaveBeenCalledWith(expect.stringContaining('api-eu-central'), expect.anything()); + }); + it('registers nothing for a session that ended while its token was minted', async () => { permission = 'granted'; const { push } = load(); diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index 4405bc8d..738eae42 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -6,7 +6,7 @@ import BleManager, { type BleManagerDidUpdateValueForCharacteristicEvent, BleSca import { logger } from '@/lib/logging'; import { audioService } from '@/services/audio.service'; import { callKeepService } from '@/services/callkeep.service'; -import { type AudioButtonEvent, type BluetoothAudioDevice, type Device, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; +import { type AudioButtonEvent, type AudioDeviceInfo, type BluetoothAudioDevice, type Device, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; // Lazy getter to avoid a circular dependency with livekit-store // eslint-disable-next-line @typescript-eslint/no-explicit-any const getLiveKitStore = (): any => { @@ -38,19 +38,39 @@ const HYS_HEADSET_SERVICE = '0000FFE0-0000-1000-8000-00805F9B34FB'; const HYS_HEADSET_SERVICE_CHAR = '00002902-0000-1000-8000-00805F9B34FB'; // Common button control characteristic UUIDs (varies by manufacturer) -const BUTTON_CONTROL_UUIDS = [ - '0000FE59-0000-1000-8000-00805F9B34FB', // Common button control - '0000180F-0000-1000-8000-00805F9B34FB', // Battery Service (often includes button data) - '00001812-0000-1000-8000-00805F9B34FB', // Human Interface Device Service +const BUTTON_CONTROL_CHARACTERISTICS = [ + '0000FE59-0000-1000-8000-00805F9B34FB', // Common button control characteristic ]; -class BluetoothAudioService { +// Standard GATT characteristics that must never be fed to the generic button parser. +// Their payloads are telemetry/descriptors, not button frames: a headset reporting +// battery 100% (0x64) decodes as `0x64 & 0x0f === 0x04` -> 'mute', which would toggle +// the LiveKit microphone mid-transmission. Battery levels 4/20/36/52/68/84/100 all hit it. +const EXCLUDED_STANDARD_CHARACTERISTICS = [ + '00002A19-0000-1000-8000-00805F9B34FB', // Battery Level + '00002A4A-0000-1000-8000-00805F9B34FB', // HID Information + '00002A4B-0000-1000-8000-00805F9B34FB', // HID Report Map + '00002A4C-0000-1000-8000-00805F9B34FB', // HID Control Point + '00002A4D-0000-1000-8000-00805F9B34FB', // HID Report +]; + +// Upper bound on a single BleManager.connect attempt (Android can hang indefinitely). +const CONNECTION_TIMEOUT_MS = 15000; + +// Stop read-polling a characteristic after this many consecutive read failures (e.g. "Reading is not permitted") +const READ_POLL_MAX_CONSECUTIVE_FAILURES = 3; + +export class BluetoothAudioService { private static instance: BluetoothAudioService; private connectedDevice: Device | null = null; private scanTimeout: ReturnType | null = null; + private connectionTimeout: ReturnType | null = null; // Guards connectToDevice against overlapping runs — discovery events can fire // repeatedly for the preferred device while a connection is still in flight. private isConnecting: boolean = false; + private isBleManagerStarted: boolean = false; + // Shared by overlapping initialize()/scan calls so BleManager listeners are only registered once + private bleManagerStartPromise: Promise | null = null; private isInitialized: boolean = false; private hasAttemptedPreferredDeviceConnection: boolean = false; private eventListeners: { remove: () => void }[] = []; @@ -59,7 +79,8 @@ class BluetoothAudioService { private monitoringWatchdogInterval: ReturnType | null = null; private readPollingInterval: ReturnType | null = null; private isReadPollingInFlight: boolean = false; - private monitoredReadCharacteristics: { serviceUuid: string; characteristicUuid: string; lastHexValue: string | null; notificationConfirmed: boolean }[] = []; + private monitoredReadCharacteristics: { serviceUuid: string; characteristicUuid: string; lastHexValue: string | null; notificationConfirmed: boolean; consecutiveFailures: number }[] = []; + private hasVendorButtonCharacteristic: boolean = false; private mediaButtonEventListener: { remove: () => void } | null = null; private mediaButtonListeningActive: boolean = false; private pttPressActive: boolean = false; @@ -94,13 +115,7 @@ class BluetoothAudioService { } try { - // Initialize BLE Manager - await BleManager.start({ showAlert: false }); - this.setupEventListeners(); - - this.isInitialized = true; - - // Check if we have permissions + // Permissions first: if they are denied, initialize() stays retryable instead of being marked done const hasPermissions = await this.requestPermissions(); if (!hasPermissions) { logger.warn({ @@ -109,6 +124,9 @@ class BluetoothAudioService { return; } + await this.ensureBleManagerStarted(); + this.isInitialized = true; + // Check Bluetooth state const state = await this.checkBluetoothState(); if (state !== State.PoweredOn) { @@ -181,7 +199,8 @@ class BluetoothAudioService { }); // If direct connection fails, start scanning to find the device - this.startScanning(5000); // 5 second scan + // Await so scan failures (BT off, permission denied) stay inside this try/catch + await this.startScanning(5000); // 5 second scan } } else { logger.info({ @@ -196,6 +215,36 @@ class BluetoothAudioService { } } + /** + * Start BleManager and register its listeners once. Called from initialize() and from scanning so a user who + * denied permissions at startup and grants them later from settings can still scan. + */ + private ensureBleManagerStarted(): Promise { + if (this.isBleManagerStarted) { + return Promise.resolve(); + } + + if (!this.bleManagerStartPromise) { + const startPromise = BleManager.start({ showAlert: false }) + .then(() => { + // destroy() ran while start was pending: don't restore listeners on a torn-down service + if (this.bleManagerStartPromise !== startPromise) { + throw new Error('Bluetooth Audio Service was destroyed while BleManager was starting'); + } + this.setupEventListeners(); + this.isBleManagerStarted = true; + }) + .finally(() => { + if (this.bleManagerStartPromise === startPromise) { + this.bleManagerStartPromise = null; + } + }); + this.bleManagerStartPromise = startPromise; + } + + return this.bleManagerStartPromise; + } + private addEventListener(listener: { remove: () => void }): void { if (!this.eventListeners.includes(listener)) { this.eventListeners.push(listener); @@ -234,19 +283,24 @@ class BluetoothAudioService { private handleBluetoothStateChange(args: { state: BleState }): void { const state = this.mapBleStateToState(args.state); + const previousState = useBluetoothAudioStore.getState().bluetoothState; logger.info({ message: 'Bluetooth state changed', - context: { state }, + context: { state, previousState }, }); useBluetoothAudioStore.getState().setBluetoothState(state); if (state === State.PoweredOff || state === State.Unauthorized) { this.handleBluetoothDisabled(); - } else if (state === State.PoweredOn && this.isInitialized && !this.hasAttemptedPreferredDeviceConnection) { - // If Bluetooth is turned back on, try to reconnect to preferred device - this.attemptReconnectToPreferredDevice(); + } else if (state === State.PoweredOn && this.isInitialized && !this.connectedDevice && !this.isConnecting) { + // Retry the preferred device when no attempt has run yet (on iOS checkState can still report 'unknown' right + // after start, so initialize() skips it) or when Bluetooth comes back on after being off/unauthorized. + const turnedBackOn = previousState !== State.PoweredOn && previousState !== State.Unknown; + if (turnedBackOn || !this.hasAttemptedPreferredDeviceConnection) { + void this.attemptReconnectToPreferredDevice(); + } } } @@ -320,11 +374,12 @@ class BluetoothAudioService { * Record that a GATT notification was actually delivered for a characteristic. * * The read-polling fallback exists for devices whose notifications silently - * never fire (screen-off PTT must keep working), but polling every monitored - * characteristic every 700ms forever wastes battery/radio when notifications - * do work. Once a characteristic has delivered a notification it is excluded - * from polling; a re-subscribe (reconnect) rebuilds the entries unconfirmed, - * which resumes polling until notifications prove themselves again. + * never fire (screen-off PTT must keep working), but polling a characteristic + * whose notifications do work wastes battery/radio and can replay stale values + * after the notification (duplicate PTT/mute events). Once a characteristic has + * delivered a notification it is excluded from polling; a re-subscribe + * (reconnect) rebuilds the entries unconfirmed, which resumes polling until + * notifications prove themselves again. */ private markNotificationConfirmed(serviceUuid: string, characteristicUuid: string): void { for (const entry of this.monitoredReadCharacteristics) { @@ -339,6 +394,12 @@ class BluetoothAudioService { } private handleScanStopped(): void { + // Clear the pending scan timeout to avoid double-calls + if (this.scanTimeout) { + clearTimeout(this.scanTimeout); + this.scanTimeout = null; + } + useBluetoothAudioStore.getState().setIsScanning(false); logger.info({ message: 'Bluetooth scan stopped', @@ -346,6 +407,15 @@ class BluetoothAudioService { } private handleButtonEventFromCharacteristic(peripheralId: string, serviceUuid: string, characteristicUuid: string, value: string): void { + // Standard GATT telemetry/descriptor characteristics are never button input. + if (this.isExcludedStandardCharacteristic(characteristicUuid)) { + logger.debug({ + message: 'Ignoring standard GATT characteristic for button routing', + context: { peripheralId, serviceUuid, characteristicUuid }, + }); + return; + } + // Route to appropriate handler based on service/characteristic if (this.areUuidsEqual(serviceUuid, AINA_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, AINA_HEADSET_SVC_PROP)) { this.handleAinaButtonEvent(value); @@ -353,7 +423,18 @@ class BluetoothAudioService { this.handleB01InricoButtonEvent(value); } else if (this.areUuidsEqual(serviceUuid, HYS_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, HYS_HEADSET_SERVICE_CHAR)) { this.handleHYSButtonEvent(value); - } else if (BUTTON_CONTROL_UUIDS.some((uuid) => this.areUuidsEqual(characteristicUuid, uuid))) { + } else if (this.hasVendorButtonCharacteristic) { + // The headset reports buttons on its vendor characteristic. Its other characteristics (e.g. the AINA + // event counter) must not go through the generic parser, which would misread them as mute/PTT presses. + logger.debug({ + message: 'Ignoring characteristic update (vendor button characteristic is active)', + context: { + peripheralId, + serviceUuid, + characteristicUuid, + }, + }); + } else if (BUTTON_CONTROL_CHARACTERISTICS.some((uuid) => this.areUuidsEqual(characteristicUuid, uuid))) { this.handleGenericButtonEvent(value); } else if (this.connectedDevice && this.connectedDevice.id === peripheralId && this.getDeviceType(this.connectedDevice) === 'specialized' && this.isLikelyButtonCharacteristic(serviceUuid, characteristicUuid)) { this.handleGenericButtonEvent(value); @@ -391,7 +472,17 @@ class BluetoothAudioService { if (this.isWeb) return true; if (Platform.OS === 'android') { try { - const permissions = [PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION]; + // Android 12+ (API 31) scans with BLUETOOTH_SCAN/CONNECT; Android 11 and lower need location for BLE scans. + // Requesting location on 12+ is unnecessary and collides with the app's own location prompt. + const permissions = Number(Platform.Version) >= 31 ? [PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] : [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION]; + + const alreadyGranted = await Promise.all(permissions.map((permission) => PermissionsAndroid.check(permission))); + if (alreadyGranted.every(Boolean)) { + return true; + } + + // Android drops a permission request made while another one is showing, so give startup prompts a moment + await new Promise((resolve) => setTimeout(resolve, 500)); const results = await PermissionsAndroid.requestMultiple(permissions); @@ -435,6 +526,8 @@ class BluetoothAudioService { throw new Error('Bluetooth permissions not granted'); } + await this.ensureBleManagerStarted(); + const state = await this.checkBluetoothState(); if (state !== State.PoweredOn) { throw new Error(`Bluetooth is ${state}. Please enable Bluetooth.`); @@ -494,6 +587,8 @@ class BluetoothAudioService { throw new Error('Bluetooth permissions not granted'); } + await this.ensureBleManagerStarted(); + const state = await this.checkBluetoothState(); if (state !== State.PoweredOn) { throw new Error(`Bluetooth is ${state}. Please enable Bluetooth.`); @@ -543,13 +638,10 @@ class BluetoothAudioService { // Check if device name contains audio-related keywords const hasAudioKeyword = audioKeywords.some((keyword) => name.includes(keyword)); - // Check if device has audio service UUIDs - use advertising data + // Check if device has audio service UUIDs - use advertising data (iOS reports 16-bit UUIDs in short form, e.g. '111E') const advertisingData = device.advertising; - const hasAudioService = - advertisingData?.serviceUUIDs?.some((uuid: string) => { - const upperUuid = uuid.toUpperCase(); - return [AUDIO_SERVICE_UUID, HFP_SERVICE_UUID, HSP_SERVICE_UUID, AINA_HEADSET_SERVICE, B01INRICO_HEADSET_SERVICE, HYS_HEADSET_SERVICE].includes(upperUuid); - }) || false; + const expectedAudioUuids = [AUDIO_SERVICE_UUID, HFP_SERVICE_UUID, HSP_SERVICE_UUID, AINA_HEADSET_SERVICE, B01INRICO_HEADSET_SERVICE, HYS_HEADSET_SERVICE]; + const hasAudioService = advertisingData?.serviceUUIDs?.some((uuid: string) => expectedAudioUuids.some((expected) => this.areUuidsEqual(uuid, expected))) || false; // Check manufacturer data for known audio device manufacturers const hasAudioManufacturerData = advertisingData?.manufacturerData ? this.hasAudioManufacturerData(advertisingData.manufacturerData) : false; @@ -614,7 +706,7 @@ class BluetoothAudioService { return false; // Skip non-string data } - const upperServiceUuid = serviceUuid.toUpperCase(); + const normalizedServiceUuid = this.normalizeUuid(serviceUuid); // Check if the service UUID itself indicates audio capability const isAudioServiceUuid = [ @@ -626,13 +718,13 @@ class BluetoothAudioService { HYS_HEADSET_SERVICE, '0000FE59-0000-1000-8000-00805F9B34FB', // Common audio service '0000180F-0000-1000-8000-00805F9B34FB', // Battery service (often used by audio devices) - ].some((uuid) => uuid.toUpperCase() === upperServiceUuid); + ].some((uuid) => this.normalizeUuid(uuid) === normalizedServiceUuid); if (isAudioServiceUuid) { logger.debug({ message: 'Found audio service UUID in service data', context: { - serviceUuid: upperServiceUuid, + serviceUuid: normalizedServiceUuid, data: data, }, }); @@ -711,12 +803,15 @@ class BluetoothAudioService { } try { - // Convert to hex string for pattern matching + // Convert to hex string for hex pattern matching const hexData = data.toString('hex').toLowerCase(); - // Look for common audio device indicators in service data - const audioPatterns = [ - // Common audio capability flags (these are example patterns) + // Convert to ASCII string for text pattern matching + const asciiData = data.toString('ascii').toLowerCase(); + + // Look for common audio device indicators in hex service data + const hexAudioPatterns = [ + // Common audio capability flags (these are hex patterns) '0001', // Audio sink capability '0002', // Audio source capability '0004', // Headset capability @@ -726,13 +821,17 @@ class BluetoothAudioService { '110b', // A2DP source service class '111e', // HFP service class '1203', // Audio/Video Remote Control Profile - // Known manufacturer-specific patterns + ]; + + // Look for manufacturer-specific ASCII identifiers in service data (these can never appear in the hex string) + const asciiAudioPatterns = [ 'aina', // AINA device identifier 'inrico', // Inrico device identifier 'hys', // HYS device identifier ]; - const hasAudioPattern = audioPatterns.some((pattern) => hexData.includes(pattern)); + const hasHexAudioPattern = hexAudioPatterns.some((pattern) => hexData.includes(pattern)); + const hasAsciiAudioPattern = asciiAudioPatterns.some((pattern) => asciiData.includes(pattern)); // Check for specific byte patterns that indicate audio capabilities const hasAudioCapabilityBytes = this.checkAudioCapabilityBytes(data); @@ -744,14 +843,16 @@ class BluetoothAudioService { message: 'Service data audio analysis', context: { hexData, - hasAudioPattern, + asciiData: asciiData.replace(/[^\x20-\x7E]/g, '?'), // Replace non-printable chars for logging + hasHexAudioPattern, + hasAsciiAudioPattern, hasAudioCapabilityBytes, hasAudioDeviceClass, dataLength: data.length, }, }); - return hasAudioPattern || hasAudioCapabilityBytes || hasAudioDeviceClass; + return hasHexAudioPattern || hasAsciiAudioPattern || hasAudioCapabilityBytes || hasAudioDeviceClass; } catch (error) { logger.debug({ message: 'Error in service data audio analysis', @@ -775,9 +876,9 @@ class BluetoothAudioService { const byte2 = data[i + 1]; // Check for audio device class patterns - if ((byte1 & 0x1f) === 0x04) { + if (byte1 !== undefined && (byte1 & 0x1f) === 0x04) { // Major class: Audio/Video - const minorClass = (byte2 >> 2) & 0x3f; + const minorClass = byte2 !== undefined ? (byte2 >> 2) & 0x3f : 0; if ([0x01, 0x02, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a].includes(minorClass)) { logger.debug({ message: 'Found audio device class in service data', @@ -815,7 +916,15 @@ class BluetoothAudioService { try { // Device class is typically 3 bytes: service classes (2 bytes) + device class (1 byte) for (let i = 0; i <= data.length - 3; i++) { - const cod = (data[i + 2] << 16) | (data[i + 1] << 8) | data[i]; + const byte0 = data[i]; + const byte1 = data[i + 1]; + const byte2 = data[i + 2]; + + if (byte0 === undefined || byte1 === undefined || byte2 === undefined) { + continue; + } + + const cod = (byte2 << 16) | (byte1 << 8) | byte0; // Extract major and minor device class const majorDeviceClass = (cod >> 8) & 0x1f; @@ -921,7 +1030,7 @@ class BluetoothAudioService { // Check if device likely supports microphone control based on service UUIDs const advertisingData = device.advertising; const serviceUUIDs = advertisingData?.serviceUUIDs || []; - return serviceUUIDs.some((uuid: string) => [HFP_SERVICE_UUID, HSP_SERVICE_UUID].includes(uuid.toUpperCase())); + return serviceUUIDs.some((uuid: string) => [HFP_SERVICE_UUID, HSP_SERVICE_UUID].some((expected) => this.areUuidsEqual(uuid, expected))); } async stopScanning(): Promise { @@ -973,7 +1082,7 @@ class BluetoothAudioService { message: 'Attempting to connect to device via BleManager', context: { deviceId }, }); - await BleManager.connect(deviceId); + await this.withConnectionTimeout(Promise.resolve(BleManager.connect(deviceId)), deviceId); logger.info({ message: 'Connected to Bluetooth audio device', @@ -1074,6 +1183,39 @@ class BluetoothAudioService { } } + private withConnectionTimeout(operation: Promise, deviceId: string): Promise { + return new Promise((resolve, reject) => { + this.clearConnectionTimeout(); + + this.connectionTimeout = setTimeout(() => { + this.connectionTimeout = null; + // Cancel the pending native connect so it cannot complete later without being tracked + Promise.resolve() + .then(() => BleManager.disconnect(deviceId)) + .catch(() => undefined); + reject(new Error(`Timed out connecting to Bluetooth device ${deviceId}`)); + }, CONNECTION_TIMEOUT_MS); + + operation.then( + (value) => { + this.clearConnectionTimeout(); + resolve(value); + }, + (error: unknown) => { + this.clearConnectionTimeout(); + reject(error); + } + ); + }); + } + + private clearConnectionTimeout(): void { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } + } + async connectToSystemAudio(): Promise { if (this.isWeb) return; try { @@ -1125,6 +1267,7 @@ class BluetoothAudioService { // Only handle if this is our connected device if (this.connectedDevice && this.connectedDevice.id === args.peripheral) { this.connectedDevice = null; + this.hasVendorButtonCharacteristic = false; useBluetoothAudioStore.getState().setConnectedDevice(null); useBluetoothAudioStore.getState().clearConnectionError(); @@ -1334,39 +1477,47 @@ class BluetoothAudioService { } /** - * Compare two UUIDs handling 16-bit and 128-bit formats + * Normalizes a UUID to its full 128-bit form. + * Converts 16-bit UUIDs (e.g., '110A') and 32-bit UUIDs to their full 128-bit equivalent + * using the Bluetooth base UUID (0000xxxx-0000-1000-8000-00805F9B34FB). + * @param uuid - The UUID to normalize (16-bit, 32-bit, or 128-bit) + * @returns The normalized 128-bit UUID in uppercase */ - private areUuidsEqual(uuid1: string, uuid2: string): boolean { - if (!uuid1 || !uuid2) return false; - - const u1 = uuid1.replace(/-/g, '').toUpperCase(); - const u2 = uuid2.replace(/-/g, '').toUpperCase(); - - // If lengths match, just compare - if (u1.length === u2.length) { - return u1 === u2; + private normalizeUuid(uuid: string): string { + const cleanUuid = uuid.replace(/[-\s]/g, '').toUpperCase(); + + if (cleanUuid.length === 4) { + // 16-bit UUID: convert to 128-bit using Bluetooth base UUID + return `0000${cleanUuid}-0000-1000-8000-00805F9B34FB`; + } else if (cleanUuid.length === 8) { + // 32-bit UUID: convert to 128-bit using Bluetooth base UUID + return `${cleanUuid}-0000-1000-8000-00805F9B34FB`; + } else if (cleanUuid.length === 32) { + // 128-bit UUID: add hyphens if missing + return `${cleanUuid.substring(0, 8)}-${cleanUuid.substring(8, 12)}-${cleanUuid.substring(12, 16)}-${cleanUuid.substring(16, 20)}-${cleanUuid.substring(20)}`; + } else { + // Already formatted or unknown format: return as uppercase + return uuid.toUpperCase(); } + } - // Handle 16-bit vs 128-bit comparison - // 16-bit UUIDs are often returned as 4 characters, while 128-bit are 32 chars - // Standard base UUID: 0000xxxx-0000-1000-8000-00805F9B34FB - - const longUuid = u1.length > u2.length ? u1 : u2; - const shortUuid = u1.length > u2.length ? u2 : u1; + /** + * Compare two UUIDs regardless of 16/32/128-bit form, case or hyphenation + */ + private areUuidsEqual(uuid1: string, uuid2: string): boolean { + if (!uuid1 || !uuid2) return false; - // If short UUID is 4 chars (16-bit), check if it matches the 128-bit pattern - if (shortUuid.length === 4 && longUuid.length === 32) { - // Construct expected 128-bit UUID from 16-bit UUID - const expectedLong = `0000${shortUuid}00001000800000805F9B34FB`; - return longUuid === expectedLong; - } + return this.normalizeUuid(uuid1) === this.normalizeUuid(uuid2); + } - return false; + private isExcludedStandardCharacteristic(characteristicUuid: string): boolean { + return EXCLUDED_STANDARD_CHARACTERISTICS.some((uuid) => this.areUuidsEqual(characteristicUuid, uuid)); } private async startNotificationsForButtonControls(deviceId: string, peripheralInfo: PeripheralInfo): Promise { const successfullySubscribed = new Set(); this.monitoredReadCharacteristics = []; + this.hasVendorButtonCharacteristic = false; const isSpecializedDevice = Boolean(this.connectedDevice && this.connectedDevice.id === deviceId && this.getDeviceType(this.connectedDevice) === 'specialized'); // Try to start notifications for known button control service/characteristic combinations @@ -1397,7 +1548,8 @@ class BluetoothAudioService { } await BleManager.startNotification(deviceId, config.service, config.characteristic); - successfullySubscribed.add(`${config.service.toUpperCase()}::${config.characteristic.toUpperCase()}`); + successfullySubscribed.add(this.getCharacteristicKey(config.service, config.characteristic)); + this.hasVendorButtonCharacteristic = true; this.registerReadPollingCharacteristic(config.service, config.characteristic); logger.info({ message: 'Started notifications for button control', @@ -1430,7 +1582,13 @@ class BluetoothAudioService { continue; } - const pairKey = `${String(serviceUuid).toUpperCase()}::${String(characteristicUuid).toUpperCase()}`; + // Never subscribe standard GATT telemetry/descriptor characteristics: they are + // not button frames and would be misread as presses (e.g. Battery Level -> 'mute'). + if (this.isExcludedStandardCharacteristic(characteristicUuid)) { + continue; + } + + const pairKey = this.getCharacteristicKey(serviceUuid, characteristicUuid); const shouldSubscribe = isSpecializedDevice ? true : this.isLikelyButtonCharacteristic(serviceUuid, characteristicUuid) || this.hasNotificationOrReadCapability(characteristic?.properties); if (!shouldSubscribe || successfullySubscribed.has(pairKey)) { @@ -1441,7 +1599,7 @@ class BluetoothAudioService { await BleManager.startNotification(deviceId, serviceUuid, characteristicUuid); successfullySubscribed.add(pairKey); - if (isSpecializedDevice || this.hasReadCapability(characteristic?.properties)) { + if (this.hasReadCapability(characteristic?.properties)) { this.registerReadPollingCharacteristic(serviceUuid, characteristicUuid); } @@ -1468,24 +1626,6 @@ class BluetoothAudioService { } } - if (isSpecializedDevice && peripheralInfo?.characteristics?.length) { - for (const characteristic of peripheralInfo.characteristics) { - const serviceUuid = characteristic?.service; - const characteristicUuid = characteristic?.characteristic; - - if (!serviceUuid || !characteristicUuid) { - continue; - } - - const pairKey = `${String(serviceUuid).toUpperCase()}::${String(characteristicUuid).toUpperCase()}`; - if (!successfullySubscribed.has(pairKey)) { - continue; - } - - this.registerReadPollingCharacteristic(serviceUuid, characteristicUuid); - } - } - if (successfullySubscribed.size > 0) { useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(true); this.startReadPollingFallback(deviceId); @@ -1506,9 +1646,18 @@ class BluetoothAudioService { characteristicUuid, lastHexValue: null, notificationConfirmed: false, + consecutiveFailures: 0, }); } + private getCharacteristicKey(serviceUuid: string, characteristicUuid: string): string { + return `${this.normalizeUuid(serviceUuid)}::${this.normalizeUuid(characteristicUuid)}`; + } + + private unregisterReadPollingCharacteristic(serviceUuid: string, characteristicUuid: string): void { + this.monitoredReadCharacteristics = this.monitoredReadCharacteristics.filter((entry) => !(this.areUuidsEqual(entry.serviceUuid, serviceUuid) && this.areUuidsEqual(entry.characteristicUuid, characteristicUuid))); + } + private startReadPollingFallback(deviceId: string): void { this.stopReadPollingFallback(); @@ -1528,8 +1677,8 @@ class BluetoothAudioService { return; } - // Every subscription has delivered a real notification — nothing left - // that needs the polling fallback. + // Every remaining subscription has delivered a real notification (or + // none are left) — nothing still needs the polling fallback. if (this.monitoredReadCharacteristics.every((entry) => entry.notificationConfirmed)) { this.stopReadPollingFallback(); return; @@ -1547,7 +1696,8 @@ class BluetoothAudioService { } private async pollReadCharacteristics(deviceId: string): Promise { - for (const entry of this.monitoredReadCharacteristics) { + // Iterate a snapshot: entries can be unregistered while a read is awaiting + for (const entry of [...this.monitoredReadCharacteristics]) { // Notifications are proven to work for this characteristic — reading it // on a timer would only duplicate events and burn battery. if (entry.notificationConfirmed) { @@ -1555,6 +1705,13 @@ class BluetoothAudioService { } try { const readValue = await BleManager.read(deviceId, entry.serviceUuid, entry.characteristicUuid); + + // A notification arrived (or the device re-subscribed) while the read was in flight; this value may already be stale + if (entry.notificationConfirmed || !this.monitoredReadCharacteristics.includes(entry)) { + continue; + } + + entry.consecutiveFailures = 0; const nextHexValue = Buffer.from(readValue).toString('hex'); if (!nextHexValue || nextHexValue.length === 0) { @@ -1575,6 +1732,23 @@ class BluetoothAudioService { const valueBase64 = Buffer.from(readValue).toString('base64'); this.handleButtonEventFromCharacteristic(deviceId, entry.serviceUuid, entry.characteristicUuid, valueBase64); } catch (error) { + entry.consecutiveFailures += 1; + + if (entry.consecutiveFailures >= READ_POLL_MAX_CONSECUTIVE_FAILURES) { + this.unregisterReadPollingCharacteristic(entry.serviceUuid, entry.characteristicUuid); + logger.info({ + message: 'Stopped read polling for characteristic after repeated failures', + context: { + deviceId, + serviceUuid: entry.serviceUuid, + characteristicUuid: entry.characteristicUuid, + failures: entry.consecutiveFailures, + error, + }, + }); + continue; + } + logger.debug({ message: 'Read polling failed for characteristic', context: { @@ -1665,13 +1839,14 @@ class BluetoothAudioService { const normalizedCharacteristic = characteristicUuid.replace(/-/g, '').toUpperCase(); const knownServiceMatch = [AINA_HEADSET_SERVICE, B01INRICO_HEADSET_SERVICE, HYS_HEADSET_SERVICE].some((uuid) => this.areUuidsEqual(serviceUuid, uuid)); - const knownCharacteristicMatch = [AINA_HEADSET_SVC_PROP, B01INRICO_HEADSET_SERVICE_CHAR, HYS_HEADSET_SERVICE_CHAR, ...BUTTON_CONTROL_UUIDS].some((uuid) => this.areUuidsEqual(characteristicUuid, uuid)); + const knownCharacteristicMatch = [AINA_HEADSET_SVC_PROP, B01INRICO_HEADSET_SERVICE_CHAR, HYS_HEADSET_SERVICE_CHAR, ...BUTTON_CONTROL_CHARACTERISTICS].some((uuid) => this.areUuidsEqual(characteristicUuid, uuid)); if (knownServiceMatch || knownCharacteristicMatch) { return true; } - const heuristicFragments = ['2A4D', '2A4E', '2A4B', 'FFE0', 'FFE1', 'FFE2', '8888', 'BEEF', 'FE59']; + // HID characteristics are deliberately absent: their reports are not single-byte button frames (see EXCLUDED_STANDARD_CHARACTERISTICS) + const heuristicFragments = ['FFE0', 'FFE1', 'FFE2', '8888', 'BEEF', 'FE59']; return heuristicFragments.some((fragment) => normalizedService.includes(fragment) || normalizedCharacteristic.includes(fragment)); } @@ -2152,6 +2327,28 @@ class BluetoothAudioService { } } + /** + * Drop PTT state when its input source goes away (disconnect, reset, destroy). A queued unmute is + * discarded, but a held press is released and a queued release is kept, so the microphone is + * never left transmitting after the headset that keyed it is gone. + */ + private resetPttState(): void { + const wasPttActive = this.pttPressActive; + + this.pttPressActive = false; + this.clearPttReleaseFallback(); + this.clearMicApplyRetry(); + + if (this.pendingMicEnabled === true) { + this.pendingMicEnabled = null; + } + + if (wasPttActive) { + callKeepService.ignoreMuteEvents(1000); + this.requestMicrophoneState(false); + } + } + private scheduleMicApplyRetry(enabled: boolean): void { this.retryMicEnabled = enabled; @@ -2295,9 +2492,7 @@ class BluetoothAudioService { bluetoothStore.setAvailableAudioDevices(nonBluetoothDevices); // Revert to default audio devices - const defaultMic = nonBluetoothDevices.find((d) => d.type === 'microphone' || d.id.toLowerCase().includes('mic')) || nonBluetoothDevices.find((d) => d.type === 'default'); - - const defaultSpeaker = nonBluetoothDevices.find((d) => d.type === 'speaker' || d.id.toLowerCase().includes('speaker')) || nonBluetoothDevices.find((d) => d.type === 'default'); + const { microphone: defaultMic, speaker: defaultSpeaker } = this.findDefaultAudioDevices(nonBluetoothDevices); if (defaultMic) { bluetoothStore.setSelectedMicrophone(defaultMic); @@ -2307,11 +2502,7 @@ class BluetoothAudioService { } bluetoothStore.setIsHeadsetButtonMonitoring(false); - this.pttPressActive = false; - this.clearPttReleaseFallback(); - this.clearMicApplyRetry(); - this.retryMicEnabled = null; - this.pendingMicEnabled = null; + this.resetPttState(); this.stopMonitoringWatchdog(); this.stopReadPollingFallback(); this.stopMediaButtonFallbackMonitoring(); @@ -2323,14 +2514,67 @@ class BluetoothAudioService { } } + /** + * Find the phone's built-in microphone/speaker among the non-Bluetooth audio devices + */ + private findDefaultAudioDevices(devices: AudioDeviceInfo[]): { microphone: AudioDeviceInfo | undefined; speaker: AudioDeviceInfo | undefined } { + const nonBluetoothDevices = devices.filter((d) => d.type !== 'bluetooth'); + + return { + microphone: nonBluetoothDevices.find((d) => d.type === 'microphone' || d.id.toLowerCase().includes('mic')) || nonBluetoothDevices.find((d) => d.type === 'default'), + speaker: nonBluetoothDevices.find((d) => d.type === 'speaker' || d.id.toLowerCase().includes('speaker')) || nonBluetoothDevices.find((d) => d.type === 'default'), + }; + } + + /** + * Forget a device: drop it as the preferred device (storage + store), disconnect it if it is + * connected, and move any audio selection that pointed at it back to the built-in devices. + */ + async forgetPreferredDevice(deviceId: string): Promise { + try { + logger.info({ + message: 'Forgetting preferred Bluetooth device', + context: { deviceId }, + }); + + const store = useBluetoothAudioStore.getState(); + + if (store.preferredDevice?.id === deviceId) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { removeItem } = require('@/lib/storage'); + await removeItem('preferredBluetoothDevice'); + store.setPreferredDevice(null); + } + + if (store.connectedDevice?.id === deviceId) { + logger.info({ message: 'Disconnecting device being forgotten', context: { deviceId } }); + await this.disconnectDevice(); + } + + const { selectedAudioDevices, availableAudioDevices } = useBluetoothAudioStore.getState(); + const defaults = this.findDefaultAudioDevices(availableAudioDevices ?? []); + + if (selectedAudioDevices.microphone?.id === deviceId && defaults.microphone) { + logger.info({ message: 'Resetting microphone selection as device is forgotten' }); + store.setSelectedMicrophone(defaults.microphone); + } + + if (selectedAudioDevices.speaker?.id === deviceId && defaults.speaker) { + logger.info({ message: 'Resetting speaker selection as device is forgotten' }); + store.setSelectedSpeaker(defaults.speaker); + } + } catch (error) { + logger.error({ + message: 'Failed to forget preferred Bluetooth device', + context: { error, deviceId }, + }); + } + } + async disconnectDevice(): Promise { if (this.isWeb) return; useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); - this.pttPressActive = false; - this.clearPttReleaseFallback(); - this.clearMicApplyRetry(); - this.retryMicEnabled = null; - this.pendingMicEnabled = null; + this.resetPttState(); this.stopMonitoringWatchdog(); this.stopReadPollingFallback(); this.stopMediaButtonFallbackMonitoring(); @@ -2407,6 +2651,26 @@ class BluetoothAudioService { } async destroy(): Promise { + // Synchronous cleanup first so callers that don't await still get a fully reset service + useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); + this.resetPttState(); + this.stopMonitoringWatchdog(); + this.stopReadPollingFallback(); + this.stopMediaButtonFallbackMonitoring(); + this.clearConnectionTimeout(); + + // Remove all event listeners + this.eventListeners.forEach((listener) => { + listener.remove(); + }); + this.eventListeners = []; + + // Reset initialization flags + this.isInitialized = false; + this.isBleManagerStarted = false; + this.bleManagerStartPromise = null; + this.hasAttemptedPreferredDeviceConnection = false; + try { await this.stopScanning(); } catch (error) { @@ -2423,24 +2687,6 @@ class BluetoothAudioService { context: { error }, }); } - useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); - this.clearPttReleaseFallback(); - this.clearMicApplyRetry(); - this.retryMicEnabled = null; - this.pendingMicEnabled = null; - this.stopMonitoringWatchdog(); - this.stopReadPollingFallback(); - this.stopMediaButtonFallbackMonitoring(); - - // Remove all event listeners - this.eventListeners.forEach((listener) => { - listener.remove(); - }); - this.eventListeners = []; - - // Reset initialization flags - this.isInitialized = false; - this.hasAttemptedPreferredDeviceConnection = false; } /** @@ -2471,10 +2717,7 @@ class BluetoothAudioService { store.setIsConnecting(false); store.setIsScanning(false); store.setIsHeadsetButtonMonitoring(false); - this.clearPttReleaseFallback(); - this.clearMicApplyRetry(); - this.retryMicEnabled = null; - this.pendingMicEnabled = null; + this.resetPttState(); this.stopMonitoringWatchdog(); this.stopReadPollingFallback(); this.stopMediaButtonFallbackMonitoring(); diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index de2bd8fd..63056e60 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -8,7 +8,7 @@ import { Platform } from 'react-native'; import { registerUnitDevice } from '@/api/devices/push'; import { logger } from '@/lib/logging'; import { type RouterPushRetryOptions, routerPushWithRetry } from '@/lib/navigation'; -import { getDeviceUuid } from '@/lib/storage/app'; +import { getBaseApiUrl, getOrCreateDeviceUuid } from '@/lib/storage/app'; import { getAppliedNotificationSoundMode, getModernNotificationSoundsEnabled, setAppliedNotificationSoundMode } from '@/lib/storage/notification-prefs'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; @@ -584,7 +584,7 @@ class PushNotificationService { UnitId: unitId, Token: this.pushToken || '', Platform: Platform.OS === 'ios' ? 1 : 2, - DeviceUuid: getDeviceUuid() || '', + DeviceUuid: getOrCreateDeviceUuid(), Prefix: departmentCode, }); @@ -653,43 +653,66 @@ export const usePushNotifications = () => { const activeUnitId = useCoreStore((state) => state.activeUnitId); const rights = securityStore((state) => state.rights); const authStatus = useAuthStore((state) => state.status); - const previousUnitIdRef = useRef(null); + // Registrations as "server|department|unit". Core files the token under a subscriber named from the department + // code and unit id, and both are only unique within one server, so a change in any of the three needs a new + // registration. Keying on the unit alone skipped it when persisted rights carried a stale department code, and + // after signing back in to the same unit. + const registeredKeyRef = useRef(null); + const inFlightKeyRef = useRef(null); + const currentKeyRef = useRef(null); useEffect(() => { // Push notifications are native-only; skip on web if (Platform.OS === 'web') return; + if (authStatus === 'signedOut') { + // Whatever was registered belonged to the session that just ended (a server switch from Settings always ends one). + registeredKeyRef.current = null; + } + // Don't register until auth has settled. On a cold start (especially a // background wake) the persisted activeUnitId and rights hydrate before // token validity is known, so registering here sends a request with a // stale token: the 401 forces a refresh and, if the refresh token has // expired, logs the user out. - if (authStatus !== 'signedIn' || !useAuthStore.getState().accessToken) return; - - // Only register if we have an active unit ID and it's different from the previous one - if (rights && activeUnitId && activeUnitId !== previousUnitIdRef.current) { - pushNotificationService - .registerForPushNotifications(activeUnitId, rights.DepartmentCode) - .then((token) => { - if (token) { - // Only mark the unit as registered on success. Marking it - // unconditionally meant a single transient failure (401 during - // hydration, offline, 5xx) permanently disabled push for the rest - // of the app session, since the ref then matched forever. - previousUnitIdRef.current = activeUnitId; - logger.info({ - message: 'Successfully registered for push notifications', - context: { unitId: activeUnitId }, - }); - } - }) - .catch((error) => { - logger.error({ - message: 'Error in push notification registration hook', - context: { error }, + const departmentCode = rights?.DepartmentCode; + if (authStatus !== 'signedIn' || !useAuthStore.getState().accessToken || !activeUnitId || !departmentCode) { + currentKeyRef.current = null; + return; + } + + const key = `${getBaseApiUrl()}|${departmentCode}|${activeUnitId}`; + currentKeyRef.current = key; + if (key === registeredKeyRef.current || key === inFlightKeyRef.current) return; + + inFlightKeyRef.current = key; + pushNotificationService + .registerForPushNotifications(activeUnitId, departmentCode) + .then((token) => { + // Only mark it registered on success. Marking it unconditionally meant a + // single transient failure (401 during hydration, offline, 5xx) + // permanently disabled push for the rest of the app session. A + // registration the session moved on from while it was in flight (signed + // out, switched server, picked another unit) is not current either. + if (token && currentKeyRef.current === key) { + registeredKeyRef.current = key; + logger.info({ + message: 'Successfully registered for push notifications', + context: { unitId: activeUnitId }, }); + } + }) + .catch((error) => { + logger.error({ + message: 'Error in push notification registration hook', + context: { error }, }); - } + }) + .finally(() => { + if (inFlightKeyRef.current === key) { + inFlightKeyRef.current = null; + } + }); // Cleanup function return () => { diff --git a/src/services/push-notification.web.ts b/src/services/push-notification.web.ts index 558509cc..ed24f95f 100644 --- a/src/services/push-notification.web.ts +++ b/src/services/push-notification.web.ts @@ -28,7 +28,7 @@ import { registerSignOutHook } from '@/lib/auth/sign-out-hooks'; import { logger } from '@/lib/logging'; import { CLIENT_HEADER, RESGRID_CLIENT } from '@/lib/mfa/client-app'; import { routerPushWithRetry } from '@/lib/navigation'; -import { getBaseApiUrl, getDeviceUuid } from '@/lib/storage/app'; +import { getBaseApiUrl, getOrCreateDeviceUuid } from '@/lib/storage/app'; import { useCoreStore } from '@/stores/app/core-store'; import useAuthStore from '@/stores/auth/store'; import { isSafeRouteId, parseNotificationData, usePushNotificationModalStore } from '@/stores/push-notification/store'; @@ -290,6 +290,8 @@ async function runSync(): Promise { const identity = currentIdentity(); const config = getWebPushConfig(); const accessToken = useAuthStore.getState().accessToken; + // The server this session (and so this registration) belongs to, before anything can switch it. + const apiUrl = getBaseApiUrl(); if (!identity || !config) { return; } @@ -333,7 +335,7 @@ async function runSync(): Promise { const registration = { key: keyOf(identity), unitId: identity.unitId, prefix: identity.prefix, token, registeredAt: Date.now() }; try { - await withPushTimeout('register unit device', (signal) => registerUnitDevice({ UnitId: identity.unitId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getDeviceUuid() || '', Prefix: identity.prefix }, signal)); + await withPushTimeout('register unit device', (signal) => registerUnitDevice({ UnitId: identity.unitId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getOrCreateDeviceUuid(), Prefix: identity.prefix }, signal)); } catch (error) { // A timed-out request may have reached Core. Keep the token available for the queued sign-out cleanup. writeRegistration({ ...registration, registeredAt: 0 }); @@ -341,7 +343,7 @@ async function runSync(): Promise { } const registeredIdentity = currentIdentity(); if (!registeredIdentity || keyOf(registeredIdentity) !== keyOf(identity)) { - await Promise.all([accessToken ? unregisterAtSignOut(registration, accessToken) : undefined, deleteLocalToken(config)]); + await Promise.all([accessToken ? unregisterAtSignOut(registration, accessToken, apiUrl) : undefined, deleteLocalToken(config)]); return; } writeRegistration(registration); @@ -397,12 +399,12 @@ export function askForPermissionOnce(): void { document.addEventListener('click', ask, true); } -async function unregisterAtSignOut(registration: StoredRegistration, accessToken: string): Promise { +async function unregisterAtSignOut(registration: StoredRegistration, accessToken: string, apiUrl: string): Promise { // Straight to the server, not through the api client: its 401 handling would re-enter logout. Awaited (sign-out // waits on it), so no keepalive: some browsers refuse keepalive on a cross-origin call that needs a preflight. try { await withPushTimeout('unregister at sign-out', (signal) => - fetch(`${getBaseApiUrl()}/Devices/UnRegisterWebPush`, { + fetch(`${apiUrl}/Devices/UnRegisterWebPush`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, [CLIENT_HEADER]: RESGRID_CLIENT }, body: JSON.stringify({ Token: registration.token, Prefix: registration.prefix, UnitId: registration.unitId }), @@ -414,23 +416,25 @@ async function unregisterAtSignOut(registration: StoredRegistration, accessToken } } -async function signOutCleanup(accessToken: string | null, config: WebPushFirebaseConfig | null): Promise { +async function signOutCleanup(accessToken: string | null, config: WebPushFirebaseConfig | null, apiUrl: string): Promise { const stored = readRegistration(); writeRegistration(null); // Killing the token here runs beside the server call, not after it: sign-out only waits so long, and this device has // to stop showing the session's pushes even when the server is slow to answer. - await Promise.all([deleteLocalToken(config), stored && accessToken ? unregisterAtSignOut(stored, accessToken) : undefined]); + await Promise.all([deleteLocalToken(config), stored && accessToken ? unregisterAtSignOut(stored, accessToken, apiUrl) : undefined]); notify(); } registerSignOutHook((accessToken) => { - // Read while the session's config is still loaded: the cleanup can run after sign-out has moved on. + // Read while the session's config and server are still current: the cleanup can run after sign-out has moved on, and + // a server switch applies the new URL once sign-out returns. const config = getWebPushConfig(); + const apiUrl = getBaseApiUrl(); // Queued behind any sync in flight, so the registration that sync is still writing is the one taken off. The next // sign-in's sync queues behind this in turn: a cleanup that outlasts sign-out's wait never tears down that session's token. - return enqueue(() => signOutCleanup(accessToken, config)).catch((error) => { + return enqueue(() => signOutCleanup(accessToken, config, apiUrl)).catch((error) => { logger.warn({ message: 'Web push sign-out cleanup failed', operation: 'signOutCleanup', context: { error } }); }); }); diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index 93d7d8e2..2d35f438 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -37,9 +37,9 @@ jest.mock('../../../services/callkeep.service.ios', () => ({ import { Platform } from 'react-native'; -import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; +import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio'; -import { useLiveKitStore } from '../livekit-store'; +import { applyAudioRouting, useLiveKitStore } from '../livekit-store'; import { logger } from '../../../lib/logging'; // Mock livekit-client @@ -418,3 +418,29 @@ describe('LiveKit Store - Permission Management', () => { }); }); }); + +describe('LiveKit Store - iOS audio routing', () => { + const mockSetAudioModeAsync = setAudioModeAsync as jest.MockedFunction; + const platform = Platform as unknown as { OS: typeof Platform.OS }; + let originalOS: typeof Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + originalOS = platform.OS; + platform.OS = 'ios'; + }); + + afterEach(() => { + platform.OS = originalOS; + }); + + it.each([ + ['speaker', false], + ['bluetooth', false], + ['earpiece', true], + ] as const)('routes %s output with shouldRouteThroughEarpiece=%s so chimes are not sent to the receiver', async (deviceType, expectedEarpiece) => { + await applyAudioRouting(deviceType); + + expect(mockSetAudioModeAsync).toHaveBeenCalledWith(expect.objectContaining({ allowsRecording: true, shouldRouteThroughEarpiece: expectedEarpiece })); + }); +}); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index ff91d9ea..66efd79f 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -88,13 +88,16 @@ export const applyAudioRouting = async (deviceType: 'bluetooth' | 'speaker' | 'e }); } else { // iOS handling (expo-audio configures the mode while CallKeep handles the session) - // Just ensure the mode is correct + // On iOS shouldRouteThroughEarpiece drops `defaultToSpeaker` from the playAndRecord session, which sends all + // app audio (PTT/room/device chimes included) to the earpiece receiver. Only do that when the earpiece is the + // target: a connected Bluetooth headset still takes the route, and without one the speaker is the fallback + // (expo-av always set defaultToSpeaker here before the expo-audio migration). await setAudioModeAsync({ allowsRecording: true, shouldPlayInBackground: true, playsInSilentMode: true, interruptionMode: 'mixWithOthers', - shouldRouteThroughEarpiece: true, + shouldRouteThroughEarpiece: normalizedDeviceType === 'earpiece', }); } } catch (error) {