diff --git a/app.config.ts b/app.config.ts index 493d3411..aa7100d5 100644 --- a/app.config.ts +++ b/app.config.ts @@ -370,6 +370,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ '@config-plugins/react-native-callkeep', './customGradle.plugin.js', './customManifest.plugin.js', + './plugins/withMediaButtonModule.js', './plugins/withInCallAudioModule.js', './plugins/withResourceBundleDeploymentTarget.js', [ diff --git a/plugins/__tests__/register-android-package.test.ts b/plugins/__tests__/register-android-package.test.ts new file mode 100644 index 00000000..03b8bbc1 --- /dev/null +++ b/plugins/__tests__/register-android-package.test.ts @@ -0,0 +1,107 @@ +import { registerAndroidPackage } from '../utils/register-android-package'; + +// Expo SDK 57 template (what `expo prebuild` generates today) +const applyTemplate = `package wtdt.resgrid.andriod.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.resgridresponder', 'MediaButtonPackage'); + const lines = result.split('\n'); + + expect(lines[1]).toBe('import com.resgridresponder.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.resgridresponder', 'InCallAudioPackage'), 'com.resgridresponder', '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.resgridresponder', 'MediaButtonPackage'); + const twice = registerAndroidPackage(once, 'com.resgridresponder', 'MediaButtonPackage'); + + expect(twice).toBe(once); + }); + + it('registers a package that is imported but not yet registered', () => { + const importedOnly = applyTemplate.replace('package wtdt.resgrid.andriod.development\n', 'package wtdt.resgrid.andriod.development\nimport com.resgridresponder.InCallAudioPackage\n'); + + const result = registerAndroidPackage(importedOnly, 'com.resgridresponder', 'InCallAudioPackage'); + + expect(countOf(result, 'import com.resgridresponder.InCallAudioPackage')).toBe(1); + expect(result).toContain('add(InCallAudioPackage())'); + }); + + it('registers a package whose only registration is commented out', () => { + const commentedOut = applyTemplate + .replace('// add(MyReactNativePackage())', '// add(MediaButtonPackage())') + .replace(' }\n )', ' /* add(MediaButtonPackage()) */\n }\n )'); + + const result = registerAndroidPackage(commentedOut, 'com.resgridresponder', 'MediaButtonPackage'); + + expect(result.split('\n')).toContain(' add(MediaButtonPackage())'); + }); + + it('adds the import when the only one is commented out', () => { + const commentedImport = applyTemplate.replace('package wtdt.resgrid.andriod.development\n', 'package wtdt.resgrid.andriod.development\n// import com.resgridresponder.MediaButtonPackage\n'); + + const result = registerAndroidPackage(commentedImport, 'com.resgridresponder', 'MediaButtonPackage'); + + expect(result.split('\n')).toContain('import com.resgridresponder.MediaButtonPackage'); + }); + + 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..606901ea --- /dev/null +++ b/plugins/utils/register-android-package.js @@ -0,0 +1,51 @@ +/* eslint-env node */ + +/** Kotlin source without its comments, so a commented-out import or `add(...)` is not taken for a real one. */ +const withoutComments = (source) => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); + +/** + * 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; + + const importStatement = `import ${basePackageName}.${packageClass}`; + if (!withoutComments(result).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 (withoutComments(result).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 e6e3c45e..b36d001f 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. @@ -222,34 +224,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 new file mode 100644 index 00000000..fc2259bd --- /dev/null +++ b/plugins/withMediaButtonModule.js @@ -0,0 +1,526 @@ +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 + */ +const IOS_SWIFT_MODULE = `import Foundation +import MediaPlayer +import React + +@objc(MediaButtonModule) +class MediaButtonModule: RCTEventEmitter { + + private var hasListeners = false + private var commandCenter: MPRemoteCommandCenter? + + override init() { + super.init() + commandCenter = MPRemoteCommandCenter.shared() + } + + override static func moduleName() -> String! { + return "MediaButtonModule" + } + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func supportedEvents() -> [String]! { + return [ + "onMediaButtonPlayPause", + "onMediaButtonPlay", + "onMediaButtonPause", + "onMediaButtonToggle", + "onMediaButtonNext", + "onMediaButtonPrevious" + ] + } + + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } + + @objc + func startListening() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Setup play/pause toggle command (primary PTT trigger) + self.commandCenter?.togglePlayPauseCommand.isEnabled = true + self.commandCenter?.togglePlayPauseCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonToggle", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup play command + self.commandCenter?.playCommand.isEnabled = true + self.commandCenter?.playCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonPlay", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup pause command + self.commandCenter?.pauseCommand.isEnabled = true + self.commandCenter?.pauseCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonPause", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup next track command (optional - can be used for other PTT actions) + self.commandCenter?.nextTrackCommand.isEnabled = true + self.commandCenter?.nextTrackCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonNext", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup previous track command (optional) + self.commandCenter?.previousTrackCommand.isEnabled = true + self.commandCenter?.previousTrackCommand.addTarget { [weak self] event in + self?.sendEventIfListening("onMediaButtonPrevious", body: ["timestamp": Date().timeIntervalSince1970 * 1000]) + return .success + } + + // Setup now playing info to enable media controls + var nowPlayingInfo = [String: Any]() + nowPlayingInfo[MPMediaItemPropertyTitle] = "PTT Active" + nowPlayingInfo[MPMediaItemPropertyArtist] = "Resgrid" + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0 + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + + print("[MediaButtonModule] Started listening for media button events") + } + } + + @objc + func stopListening() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.commandCenter?.togglePlayPauseCommand.removeTarget(nil) + self.commandCenter?.playCommand.removeTarget(nil) + self.commandCenter?.pauseCommand.removeTarget(nil) + self.commandCenter?.nextTrackCommand.removeTarget(nil) + self.commandCenter?.previousTrackCommand.removeTarget(nil) + + self.commandCenter?.togglePlayPauseCommand.isEnabled = false + self.commandCenter?.playCommand.isEnabled = false + self.commandCenter?.pauseCommand.isEnabled = false + self.commandCenter?.nextTrackCommand.isEnabled = false + self.commandCenter?.previousTrackCommand.isEnabled = false + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + + print("[MediaButtonModule] Stopped listening for media button events") + } + } + + private func sendEventIfListening(_ eventName: String, body: [String: Any]?) { + guard hasListeners else { return } + sendEvent(withName: eventName, body: body) + } +} +`; + +/** + * iOS MediaButtonModule.m (Objective-C bridge) content + */ +const IOS_OBJC_BRIDGE = `#import +#import + +@interface RCT_EXTERN_MODULE(MediaButtonModule, RCTEventEmitter) + +RCT_EXTERN_METHOD(startListening) +RCT_EXTERN_METHOD(stopListening) + +@end +`; + +/** + * Android MediaButtonModule.kt content + */ +const ANDROID_MODULE = `package {{PACKAGE_NAME}} + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Build +import android.util.Log +import android.view.KeyEvent +import com.facebook.react.bridge.* +import com.facebook.react.modules.core.DeviceEventManagerModule + +class MediaButtonModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext), LifecycleEventListener { + + companion object { + private const val TAG = "MediaButtonModule" + } + + private var mediaSession: MediaSession? = null + private var isListening = false + private var mediaButtonReceiver: BroadcastReceiver? = null + + init { + reactContext.addLifecycleEventListener(this) + } + + override fun getName(): String { + return "MediaButtonModule" + } + + @ReactMethod + fun startListening() { + if (isListening) return + + val context = reactApplicationContext ?: return + + // Create media session for capturing media button events + mediaSession = MediaSession(context, "ResgridPTT").apply { + // Set the media button callback + setCallback(object : MediaSession.Callback() { + override fun onPlay() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_PLAY, "ACTION_DOWN")) + } + + override fun onPause() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_PAUSE, "ACTION_DOWN")) + } + + override fun onStop() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_STOP, "ACTION_DOWN")) + } + + override fun onSkipToNext() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_NEXT, "ACTION_DOWN")) + } + + override fun onSkipToPrevious() { + sendEvent("onMediaButtonEvent", createParams(KeyEvent.KEYCODE_MEDIA_PREVIOUS, "ACTION_DOWN")) + } + + override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { + val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + } + + keyEvent?.let { event -> + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> "ACTION_DOWN" + KeyEvent.ACTION_UP -> "ACTION_UP" + else -> "UNKNOWN" + } + + // Handle play/pause toggle and headset hook (primary PTT triggers) + when (event.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_HEADSETHOOK, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + sendEvent("onMediaButtonEvent", createParams(event.keyCode, action)) + return true + } + } + } + return super.onMediaButtonEvent(mediaButtonEvent) + } + }) + + // Set playback state to enable media button handling + val playbackState = PlaybackState.Builder() + .setActions( + PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SKIP_TO_NEXT or + PlaybackState.ACTION_SKIP_TO_PREVIOUS + ) + .setState(PlaybackState.STATE_PLAYING, 0, 1.0f) + .build() + setPlaybackState(playbackState) + + // Activate the session + isActive = true + } + + // Register a broadcast receiver for media button events (fallback) + mediaButtonReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (Intent.ACTION_MEDIA_BUTTON == intent?.action) { + val keyEvent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + } + + keyEvent?.let { event -> + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> "ACTION_DOWN" + KeyEvent.ACTION_UP -> "ACTION_UP" + else -> "UNKNOWN" + } + sendEvent("onMediaButtonEvent", createParams(event.keyCode, action)) + } + } + } + } + + val filter = IntentFilter(Intent.ACTION_MEDIA_BUTTON) + filter.priority = IntentFilter.SYSTEM_HIGH_PRIORITY + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(mediaButtonReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(mediaButtonReceiver, filter) + } + + isListening = true + } + + @ReactMethod + fun stopListening() { + if (!isListening) return + + mediaSession?.apply { + isActive = false + release() + } + mediaSession = null + + mediaButtonReceiver?.let { + try { + reactApplicationContext.unregisterReceiver(it) + } catch (e: Exception) { + Log.d(TAG, "Failed to unregister media button receiver: \${e.message}") + } + } + mediaButtonReceiver = null + + isListening = false + } + + private fun createParams(keyCode: Int, action: String): WritableMap { + return Arguments.createMap().apply { + putInt("keyCode", keyCode) + putString("action", action) + putDouble("timestamp", System.currentTimeMillis().toDouble()) + } + } + + private fun sendEvent(eventName: String, params: WritableMap?) { + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit(eventName, params) + } + + override fun onHostResume() { + // App is in foreground - ensure media session is active + mediaSession?.isActive = true + } + + override fun onHostPause() { + // App is in background - keep media session active for background audio + } + + override fun onHostDestroy() { + stopListening() + } + + @ReactMethod + fun addListener(eventName: String) { + // Required for RN built-in Event Emitter Support + } + + @ReactMethod + fun removeListeners(count: Int) { + // Required for RN built-in Event Emitter Support + } +} +`; + +/** + * Android MediaButtonPackage.kt content + */ +const ANDROID_PACKAGE = `package {{PACKAGE_NAME}} + +import android.view.View +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ReactShadowNode +import com.facebook.react.uimanager.ViewManager + +class MediaButtonPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(MediaButtonModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List>> { + return emptyList() + } +} +`; + +/** + * Resolves the Android namespace/package name from build.gradle or build.gradle.kts. + * Probes both Groovy and Kotlin DSL files and uses a regex that handles both syntaxes: + * - Groovy: namespace 'com.example.app' or namespace "com.example.app" + * - Kotlin DSL: namespace = "com.example.app" + * + * @param {string} projectRoot - The project root directory + * @param {string} fallback - Fallback package name if not found (default: 'wtdt.resgrid.andriod') + * @returns {string} The resolved namespace or fallback + */ +function resolveBasePackageName(projectRoot, fallback = 'wtdt.resgrid.andriod') { + // Regex that accepts optional equals sign for both Groovy and Kotlin DSL + const namespaceRegex = /namespace\s*(?:=)?\s*['"]([^'"]+)['"]/; + + // Probe build.gradle (Groovy DSL) first + const groovyPath = path.join(projectRoot, 'android', 'app', 'build.gradle'); + if (fs.existsSync(groovyPath)) { + const content = fs.readFileSync(groovyPath, 'utf-8'); + const match = content.match(namespaceRegex); + if (match) { + return match[1]; + } + } + + // Probe build.gradle.kts (Kotlin DSL) as fallback + const ktsPath = path.join(projectRoot, 'android', 'app', 'build.gradle.kts'); + if (fs.existsSync(ktsPath)) { + const content = fs.readFileSync(ktsPath, 'utf-8'); + const match = content.match(namespaceRegex); + if (match) { + return match[1]; + } + } + + return fallback; +} + +/** + * Expo config plugin to add MediaButtonModule for AirPods/earbuds PTT support. + * + * This plugin: + * 1. Creates the MediaButtonModule.swift and MediaButtonModule.m files in the iOS project + * 2. Updates the bridging header to include necessary imports + * 3. Creates MediaButtonModule.kt and MediaButtonPackage.kt for Android + * 4. Registers the package in MainApplication.kt + */ +const withMediaButtonModule = (config) => { + // Add iOS native module files + config = withDangerousMod(config, [ + 'ios', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const projectName = config.modRequest.projectName; + const iosProjectPath = path.join(projectRoot, 'ios', projectName); + + // Ensure the directory exists + if (!fs.existsSync(iosProjectPath)) { + fs.mkdirSync(iosProjectPath, { recursive: true }); + } + + // Write MediaButtonModule.swift + const swiftPath = path.join(iosProjectPath, 'MediaButtonModule.swift'); + fs.writeFileSync(swiftPath, IOS_SWIFT_MODULE); + console.log('[withMediaButtonModule] Created MediaButtonModule.swift'); + + // Write MediaButtonModule.m (Objective-C bridge) + const objcPath = path.join(iosProjectPath, 'MediaButtonModule.m'); + fs.writeFileSync(objcPath, IOS_OBJC_BRIDGE); + console.log('[withMediaButtonModule] Created MediaButtonModule.m'); + + // Update bridging header + const bridgingHeaderPath = path.join(iosProjectPath, `${projectName}-Bridging-Header.h`); + if (fs.existsSync(bridgingHeaderPath)) { + let bridgingHeaderContents = fs.readFileSync(bridgingHeaderPath, 'utf-8'); + + const requiredImports = ['#import ', '#import ']; + + let modified = false; + for (const importLine of requiredImports) { + if (!bridgingHeaderContents.includes(importLine)) { + bridgingHeaderContents += `\n${importLine}`; + modified = true; + } + } + + if (modified) { + fs.writeFileSync(bridgingHeaderPath, bridgingHeaderContents); + console.log('[withMediaButtonModule] Updated bridging header with React Native imports'); + } + } + + return config; + }, + ]); + + // Add Android native module files + config = withDangerousMod(config, [ + 'android', + async (config) => { + const projectRoot = config.modRequest.projectRoot; + + // Resolve the package name from build.gradle or build.gradle.kts + const packageName = resolveBasePackageName(projectRoot); + + const packagePath = packageName.replace(/\./g, '/'); + const androidSrcPath = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', packagePath); + + // Ensure the directory exists + if (!fs.existsSync(androidSrcPath)) { + fs.mkdirSync(androidSrcPath, { recursive: true }); + } + + // Write MediaButtonModule.kt + const modulePath = path.join(androidSrcPath, 'MediaButtonModule.kt'); + const moduleContent = ANDROID_MODULE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(modulePath, moduleContent); + console.log('[withMediaButtonModule] Created MediaButtonModule.kt'); + + // Write MediaButtonPackage.kt + const packageFilePath = path.join(androidSrcPath, 'MediaButtonPackage.kt'); + const packageContent = ANDROID_PACKAGE.replace(/\{\{PACKAGE_NAME\}\}/g, packageName); + fs.writeFileSync(packageFilePath, packageContent); + console.log('[withMediaButtonModule] Created MediaButtonPackage.kt'); + + return config; + }, + ]); + + // Update MainApplication.kt to register the package + config = withMainApplication(config, (config) => { + // 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; + }); + + return config; +}; + +module.exports = withMediaButtonModule; diff --git a/src/app/(app)/__tests__/settings.test.tsx b/src/app/(app)/__tests__/settings.test.tsx index 7c0fbe97..e707641c 100644 --- a/src/app/(app)/__tests__/settings.test.tsx +++ b/src/app/(app)/__tests__/settings.test.tsx @@ -1,7 +1,7 @@ import { useFocusEffect } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { Alert } from 'react-native'; import Settings from '../settings'; @@ -139,8 +139,10 @@ jest.mock('@/components/settings/login-info-bottom-sheet', () => ({ }, })); +// What the sheet's last submit is waiting on before it saves the new URL +let mockBeforeUrlChangeResult: Promise | undefined; jest.mock('@/components/settings/server-url-bottom-sheet', () => ({ - ServerUrlBottomSheet: ({ isOpen, onClose, onUrlChanged }: any) => { + ServerUrlBottomSheet: ({ isOpen, onClose, onBeforeUrlChange }: any) => { const { View, TouchableOpacity, Text } = require('react-native'); return ( @@ -152,7 +154,7 @@ jest.mock('@/components/settings/server-url-bottom-sheet', () => ({ { - void onUrlChanged?.(); + mockBeforeUrlChangeResult = onBeforeUrlChange?.(); }} > Save Server @@ -449,6 +451,44 @@ describe('Settings Screen', () => { expect(mockLogout).not.toHaveBeenCalled(); }); + it('waits for a sign-out already in progress before the new server url is saved', async () => { + let finishCleanup: () => void = () => undefined; + mockClearAllAppData.mockImplementationOnce( + () => + new Promise((resolve) => { + finishCleanup = resolve; + }) + ); + render(); + + fireEvent.press(screen.getByTestId('item-settings.logout')); + const buttons = (Alert.alert as jest.Mock).mock.calls[0]?.[2] as Array<{ text: string; onPress?: () => void }>; + act(() => { + buttons.find((button) => button.text === 'settings.logout_confirm_yes')?.onPress?.(); + }); + + fireEvent.press(screen.getByTestId('item-settings.server')); + fireEvent.press(screen.getByTestId('submit-server-url-change')); + + let urlChangeReleased = false; + void mockBeforeUrlChangeResult?.then(() => { + urlChangeReleased = true; + }); + await act(async () => { + await Promise.resolve(); + }); + expect(urlChangeReleased).toBe(false); + + await act(async () => { + finishCleanup(); + await mockBeforeUrlChangeResult; + }); + + expect(urlChangeReleased).toBe(true); + expect(mockClearAllAppData).toHaveBeenCalledTimes(1); + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + it('logs out after server url changes while authenticated', async () => { render(); diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 9c27c4ba..1433673c 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -2,7 +2,7 @@ import { Env } from '@env'; import { type Href, useFocusEffect, useRouter } from 'expo-router'; import { useColorScheme } from 'nativewind'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert } from 'react-native'; @@ -29,6 +29,38 @@ import { clearAllAppData, LOGOUT_PRESERVED_STORAGE_KEYS } from '@/lib/storage/cl import { openLinkInBrowser } from '@/lib/utils'; import { useUnitsStore } from '@/stores/units/store'; +/** Clears the session's app data, then signs out. Signs out even when the cleanup fails. */ +const clearDataAndSignOut = async (signOut: () => Promise): Promise => { + logger.info({ + message: 'User confirmed logout, clearing all app data', + }); + + try { + // Clear all app data (stores, storage, cached values) + await clearAllAppData({ + resetStores: true, + clearStorage: true, + clearFilters: true, + clearSecure: false, // Keep secure keys for re-login + preserveStorageKeys: [...LOGOUT_PRESERVED_STORAGE_KEYS], + }); + + // Sign out the user + await signOut(); + + logger.info({ + message: 'Logout completed successfully, all data cleared', + }); + } catch (error) { + logger.error({ + message: 'Error during logout data cleanup', + context: { error }, + }); + // Still sign out even if cleanup fails + await signOut(); + } +}; + export default function Settings() { const { t } = useTranslation(); const router = useRouter(); @@ -39,7 +71,8 @@ export default function Settings() { const { login, status, isAuthenticated } = useAuth(); const [showServerUrl, setShowServerUrl] = React.useState(false); const [showUnitSelection, setShowUnitSelection] = React.useState(false); - const [isLoggingOut, setIsLoggingOut] = useState(false); + // The sign-out in progress, so a second request joins it instead of starting another or racing past it + const logoutInFlightRef = useRef | null>(null); const units = useUnitsStore((state) => state.units); // Track analytics when view becomes visible @@ -83,39 +116,14 @@ export default function Settings() { setShowLoginInfo(true); }, [trackEvent]); - const performLogout = useCallback(async () => { - setIsLoggingOut(true); - - logger.info({ - message: 'User confirmed logout, clearing all app data', - }); - - try { - // Clear all app data (stores, storage, cached values) - await clearAllAppData({ - resetStores: true, - clearStorage: true, - clearFilters: true, - clearSecure: false, // Keep secure keys for re-login - preserveStorageKeys: [...LOGOUT_PRESERVED_STORAGE_KEYS], - }); - - // Sign out the user - await signOut(); - - logger.info({ - message: 'Logout completed successfully, all data cleared', + const performLogout = useCallback((): Promise => { + if (!logoutInFlightRef.current) { + logoutInFlightRef.current = clearDataAndSignOut(signOut).finally(() => { + logoutInFlightRef.current = null; }); - } catch (error) { - logger.error({ - message: 'Error during logout data cleanup', - context: { error }, - }); - // Still sign out even if cleanup fails - await signOut(); - } finally { - setIsLoggingOut(false); } + + return logoutInFlightRef.current; }, [signOut]); const handleLogoutConfirm = useCallback(async () => { @@ -154,7 +162,13 @@ export default function Settings() { }, [handleLogoutCancel, handleLogoutConfirm, t, trackEvent]); const handleServerUrlChanged = useCallback(async () => { - if (!isAuthenticated || isLoggingOut) { + // Already signing out: wait for it, so the new URL is only saved once the old session's requests are done + if (logoutInFlightRef.current) { + await logoutInFlightRef.current; + return; + } + + if (!isAuthenticated) { return; } @@ -163,7 +177,7 @@ export default function Settings() { }); await performLogout(); - }, [isAuthenticated, isLoggingOut, performLogout, trackEvent]); + }, [isAuthenticated, performLogout, trackEvent]); const handleSupportLinkPress = useCallback( (linkType: string, url: string) => { @@ -239,7 +253,7 @@ export default function Settings() { setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} /> - setShowServerUrl(false)} onUrlChanged={handleServerUrlChanged} /> + setShowServerUrl(false)} onBeforeUrlChange={handleServerUrlChanged} /> ); } 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 f6a215ea..9b4d3ea0 100644 --- a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx +++ b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx @@ -24,9 +24,10 @@ const mockGetSystemConfig = jest.fn().mockResolvedValue({ }, }); const mockFormSetValue = jest.fn(); +const mockFormSetError = jest.fn(); let mockFormValues: { url: string } = { url: 'https://test.com' }; let mockSelectOnValueChange: ((value: string) => void) | undefined; -const mockOnUrlChanged = jest.fn().mockResolvedValue(undefined); +const mockOnBeforeUrlChange = jest.fn().mockResolvedValue(undefined); const mockIsAuthenticated = jest.fn(() => false); // Mock all dependencies @@ -81,6 +82,7 @@ jest.mock('react-hook-form', () => ({ control: {}, handleSubmit: (fn: Function) => () => fn({ ...mockFormValues }), setValue, + setError: mockFormSetError, formState: { errors: {} }, }; }, @@ -224,7 +226,7 @@ describe('ServerUrlBottomSheet', () => { const defaultProps = { isOpen: true, onClose: mockOnClose, - onUrlChanged: mockOnUrlChanged, + onBeforeUrlChange: mockOnBeforeUrlChange, }; beforeEach(() => { @@ -233,7 +235,7 @@ describe('ServerUrlBottomSheet', () => { mockGetUrl.mockClear(); mockGetSystemConfig.mockClear(); mockFormSetValue.mockClear(); - mockOnUrlChanged.mockClear(); + mockOnBeforeUrlChange.mockClear(); mockIsAuthenticated.mockReset(); mockIsAuthenticated.mockReturnValue(false); mockTrackEvent.mockReset(); @@ -416,7 +418,7 @@ describe('ServerUrlBottomSheet', () => { expect(mockSetUrl).toHaveBeenCalledWith('https://test.com/api/v4'); }); - expect(mockOnUrlChanged).not.toHaveBeenCalled(); + expect(mockOnBeforeUrlChange).not.toHaveBeenCalled(); }); it('disables text input when current url matches a configured location', async () => { @@ -466,7 +468,7 @@ describe('ServerUrlBottomSheet', () => { expect(screen.getByTestId('input-field').props.editable).toBe(true); }); - it('logs out through callback after saving when authenticated', async () => { + it('signs out against the old server before saving the new url when authenticated', async () => { mockIsAuthenticated.mockReturnValue(true); render(); @@ -477,9 +479,25 @@ describe('ServerUrlBottomSheet', () => { expect(mockSetUrl).toHaveBeenCalledWith('https://test.com/api/v4'); }); + expect(mockOnBeforeUrlChange).toHaveBeenCalledTimes(1); + // Sign-out still has requests for the old server (taking a push token off it), so the new url waits for it. + expect(mockOnBeforeUrlChange.mock.invocationCallOrder[0]).toBeLessThan(mockSetUrl.mock.invocationCallOrder[0]); + }); + + it('keeps the old url when signing out fails', async () => { + mockIsAuthenticated.mockReturnValue(true); + mockOnBeforeUrlChange.mockRejectedValueOnce(new Error('sign-out failed')); + + render(); + + fireEvent.press(screen.getByText('Save')); + await waitFor(() => { - expect(mockOnUrlChanged).toHaveBeenCalledTimes(1); + expect(mockOnBeforeUrlChange).toHaveBeenCalledTimes(1); }); + + expect(mockSetUrl).not.toHaveBeenCalled(); + expect(mockFormSetError).toHaveBeenCalledWith('root', { message: 'sign-out failed' }); }); }); diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx index 8848b764..a9594a9f 100644 --- a/src/components/settings/server-url-bottom-sheet.tsx +++ b/src/components/settings/server-url-bottom-sheet.tsx @@ -31,7 +31,8 @@ interface ServerUrlForm { interface ServerUrlBottomSheetProps { isOpen: boolean; onClose: () => void; - onUrlChanged?: () => Promise; + /** Signs the current session out. Runs before the new URL is saved, while requests still reach the session's server. */ + onBeforeUrlChange?: () => Promise; } const URL_PATTERN = /^https?:\/\/.+/; @@ -54,7 +55,7 @@ const normalizeCustomDisplayUrl = (url: string) => normalizeBaseUrl(url).replace const buildApiUrl = (url: string) => `${normalizeBaseUrl(url)}${API_PATH_SUFFIX}`; -export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUrlBottomSheetProps) { +export function ServerUrlBottomSheet({ isOpen, onClose, onBeforeUrlChange }: ServerUrlBottomSheetProps) { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); const keyboardHeight = useKeyboardHeight(); @@ -163,12 +164,14 @@ export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUr console.warn('Failed to track server URL form submission analytics:', error); } - await setUrl(buildApiUrl(normalizedResolvedBaseUrl)); - - if (isAuthenticated && onUrlChanged) { - await onUrlChanged(); + // Sign out first: sign-out still has work for the old server (taking this browser's push token off it), and + // switching before it sent that, and anything else the old session had in flight, to the new server instead. + if (isAuthenticated && onBeforeUrlChange) { + await onBeforeUrlChange(); } + await setUrl(buildApiUrl(normalizedResolvedBaseUrl)); + // Track successful submission try { trackEvent('server_url_form_success', { diff --git a/src/lib/offline-queue-owner.ts b/src/lib/offline-queue-owner.ts new file mode 100644 index 00000000..4a31d5d6 --- /dev/null +++ b/src/lib/offline-queue-owner.ts @@ -0,0 +1,53 @@ +import { getBaseApiUrl } from '@/lib/storage/app'; +import { type QueueOwner } from '@/models/offline-queue/queued-event'; +import useAuthStore from '@/stores/auth/store'; + +/** + * Who an offline action belongs to. + * + * Queued actions outlive sign-out (the queue has its own storage, which sign-out does not clear) and are replayed + * through the api client, which sends to whichever server and account are signed in at that moment. Without an owner, + * a status or check-in queued on one server went to the next server signed in to, under the next account's token, and + * one replayed while signed out drew a 401 that dropped it for good. + * + * The department is not part of the owner: it cannot be changed from the app, and checklist actions carry their own + * department scope. + */ + +/** An action held for someone else (another user, or this user on another server) is dropped once this old. */ +export const FOREIGN_QUEUE_ITEM_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +const sameOwner = (a: QueueOwner, b: QueueOwner): boolean => a.serverUrl === b.serverUrl && a.userId === b.userId; + +/** The signed-in session, or null while nobody is signed in. Stamped on every action as it is queued. */ +export const currentQueueOwner = (): QueueOwner | null => { + const { status, userId } = useAuthStore.getState(); + return status === 'signedIn' && userId ? { serverUrl: getBaseApiUrl(), userId } : null; +}; + +/** + * Whether an action may be sent now: only by its own session. Held back while signed out and while anyone else is + * signed in, it goes out when its owner signs in to its server again. An action queued before owners were recorded has + * none and is taken to be the signed-in session's, which is who queued it in all but a rare case. + */ +export const isOwnedByCurrentSession = (owner: QueueOwner | null | undefined): boolean => { + const current = currentQueueOwner(); + if (!current) { + return false; + } + + return owner === undefined || (owner !== null && sameOwner(owner, current)); +}; + +/** + * An action someone else owns that has waited too long for them to come back. Decided only while signed in: a device + * nobody is signed in to keeps everything for whoever signs in next. + */ +export const isAbandonedQueueItem = (owner: QueueOwner | null | undefined, queuedAt: number | undefined, now = Date.now()): boolean => { + if (owner === undefined || queuedAt === undefined || now - queuedAt <= FOREIGN_QUEUE_ITEM_TTL_MS) { + return false; + } + + const current = currentQueueOwner(); + return current !== null && (owner === null || !sameOwner(owner, current)); +}; diff --git a/src/lib/storage/__tests__/clear-all-data.test.ts b/src/lib/storage/__tests__/clear-all-data.test.ts index 610d496d..792a53cd 100644 --- a/src/lib/storage/__tests__/clear-all-data.test.ts +++ b/src/lib/storage/__tests__/clear-all-data.test.ts @@ -46,7 +46,7 @@ jest.mock('@/lib/mapbox-token', () => ({ clearMapboxToken: () => mockClearMapboxToken(), })); -import { clearAllAppData, getRegisteredStoreCount, getRegisteredStoreNames, registerStoreReset, unregisterStoreReset } from '../clear-all-data'; +import { clearAllAppData, getRegisteredStoreCount, getRegisteredStoreNames, LOGOUT_PRESERVED_STORAGE_KEYS, registerStoreReset, unregisterStoreReset } from '../clear-all-data'; describe('clearAllAppData', () => { beforeEach(() => { @@ -139,6 +139,11 @@ describe('clearAllAppData', () => { }); }); + it('should keep the server url and this install\'s device id across sign-out', () => { + // A server switch signs out; the push registration of the next sign-in still needs both. + expect(LOGOUT_PRESERVED_STORAGE_KEYS).toEqual(expect.arrayContaining(['baseUrl', 'IS_FIRST_TIME', 'unitDeviceUuid'])); + }); + it('should forget the server Mapbox token so maps fall back to the built-in token', async () => { await clearAllAppData(); diff --git a/src/lib/storage/app.tsx b/src/lib/storage/app.tsx index 24e11216..f0b58bd9 100644 --- a/src/lib/storage/app.tsx +++ b/src/lib/storage/app.tsx @@ -32,6 +32,7 @@ export const getActiveCallId = (): string => { return activeCallId; }; +export const DEVICE_UUID_STORAGE_KEY = DEVICE_UUID; export const removeDeviceUuid = () => removeItem(DEVICE_UUID); export const setDeviceUuid = (value: string) => setItem(DEVICE_UUID, value); diff --git a/src/lib/storage/clear-all-data.ts b/src/lib/storage/clear-all-data.ts index ba00ce9e..d5bdf155 100644 --- a/src/lib/storage/clear-all-data.ts +++ b/src/lib/storage/clear-all-data.ts @@ -13,7 +13,7 @@ import { logger } from '@/lib/logging'; import { clearMapboxToken } from '@/lib/mapbox-token'; import { IS_FIRST_TIME, storage } from '@/lib/storage'; -import { BASE_API_URL_STORAGE_KEY } from '@/lib/storage/app'; +import { BASE_API_URL_STORAGE_KEY, DEVICE_UUID_STORAGE_KEY } from '@/lib/storage/app'; import { clearPersonnelFilterOptions } from './personnel-filter'; import { clearSecureKeys } from './secure-storage'; @@ -211,7 +211,9 @@ export const clearAllAppData = async ( } }; -export const LOGOUT_PRESERVED_STORAGE_KEYS = [BASE_API_URL_STORAGE_KEY, IS_FIRST_TIME] as const; +// The device id names this install, not the session: it is only minted at app start, so clearing it here sent every +// push registration after a sign-out (and so after every server switch) without one until the app was restarted. +export const LOGOUT_PRESERVED_STORAGE_KEYS = [BASE_API_URL_STORAGE_KEY, IS_FIRST_TIME, DEVICE_UUID_STORAGE_KEY] as const; /** * Get the count of registered stores diff --git a/src/models/offline-queue/queued-event.ts b/src/models/offline-queue/queued-event.ts index 20b70755..8b2f1d59 100644 --- a/src/models/offline-queue/queued-event.ts +++ b/src/models/offline-queue/queued-event.ts @@ -12,6 +12,12 @@ export enum QueuedEventStatus { COMPLETED = 'completed', } +/** The server and user a queued action was made for. It is only ever sent while that user is signed in to that server. */ +export interface QueueOwner { + serverUrl: string; + userId: string; +} + export interface QueuedEvent { id: string; type: QueuedEventType; @@ -23,6 +29,8 @@ export interface QueuedEvent { lastAttemptAt?: number | undefined; nextRetryAt?: number | undefined; error?: string | undefined; + /** Null when nobody was signed in to queue it for; absent on actions queued before owners were recorded. */ + owner?: QueueOwner | null | undefined; } export interface QueuedPersonnelStatusEvent extends Omit { 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-b01inrico.test.ts b/src/services/__tests__/bluetooth-audio-b01inrico.test.ts index e3ad4df3..b4f3a8e0 100644 --- a/src/services/__tests__/bluetooth-audio-b01inrico.test.ts +++ b/src/services/__tests__/bluetooth-audio-b01inrico.test.ts @@ -2,6 +2,14 @@ import { Buffer } from 'buffer'; import { bluetoothAudioService } from '../bluetooth-audio.service'; // Mock the dependencies +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), diff --git a/src/services/__tests__/bluetooth-audio-button-controls.test.ts b/src/services/__tests__/bluetooth-audio-button-controls.test.ts index 19318e93..b167741f 100644 --- a/src/services/__tests__/bluetooth-audio-button-controls.test.ts +++ b/src/services/__tests__/bluetooth-audio-button-controls.test.ts @@ -1,6 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // Mock dependencies +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + jest.mock('react-native', () => ({ Platform: { OS: 'android' }, PermissionsAndroid: { @@ -153,6 +161,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { // Test AINA button event service.handleButtonEventFromCharacteristic( + 'test-device', '127FACE1-CB21-11E5-93D0-0002A5D5C51B', '127FBEEF-CB21-11E5-93D0-0002A5D5C51B', 'testvalue1' @@ -161,6 +170,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { // Test B01 Inrico button event service.handleButtonEventFromCharacteristic( + 'test-device', '00006666-0000-1000-8000-00805F9B34FB', '00008888-0000-1000-8000-00805F9B34FB', 'testvalue2' @@ -169,6 +179,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { // Test generic button event (using button control characteristic) service.handleButtonEventFromCharacteristic( + 'test-device', 'SOME-UNKNOWN-SERVICE-UUID', '0000FE59-0000-1000-8000-00805F9B34FB', // Common button control characteristic 'testvalue3' @@ -186,6 +197,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { // Test with 16-bit characteristic UUID that should be normalized service.handleButtonEventFromCharacteristic( + 'test-device', 'SOME-UNKNOWN-SERVICE-UUID', 'FE59', // 16-bit version of common button control characteristic 'testvalue' @@ -201,6 +213,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { // Test that service UUIDs are NOT treated as characteristic UUIDs // Battery Service UUID should not trigger button event (it's a service, not a characteristic) service.handleButtonEventFromCharacteristic( + 'test-device', 'SOME-SERVICE-UUID', '0000180F-0000-1000-8000-00805F9B34FB', // Battery Service UUID (was incorrectly used as characteristic before fix) 'testvalue' @@ -211,6 +224,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { // HID Service UUID should not trigger button event (it's a service, not a characteristic) service.handleButtonEventFromCharacteristic( + 'test-device', 'SOME-SERVICE-UUID', '00001812-0000-1000-8000-00805F9B34FB', // HID Service UUID (was incorrectly used as characteristic before fix) 'testvalue2' @@ -221,6 +235,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { // But actual button control characteristic should work service.handleButtonEventFromCharacteristic( + 'test-device', 'SOME-SERVICE-UUID', '0000FE59-0000-1000-8000-00805F9B34FB', // Common button control characteristic 'testvalue3' @@ -244,7 +259,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { ]; standardCharacteristics.forEach((characteristic) => { - service.handleButtonEventFromCharacteristic('00001812-0000-1000-8000-00805F9B34FB', characteristic, 'ZA=='); + service.handleButtonEventFromCharacteristic('test-device', '00001812-0000-1000-8000-00805F9B34FB', characteristic, 'ZA=='); }); expect(mockHandleGenericButtonEvent).not.toHaveBeenCalled(); @@ -269,7 +284,7 @@ describe('BluetoothAudioService Button Control Fixes', () => { expect(mockProcessButtonEvent).not.toHaveBeenCalled(); // The vendor characteristic still works with the same payload. - service.handleButtonEventFromCharacteristic('SOME-SERVICE-UUID', '0000FE59-0000-1000-8000-00805F9B34FB', batteryFullBase64); + service.handleButtonEventFromCharacteristic('test-device', 'SOME-SERVICE-UUID', '0000FE59-0000-1000-8000-00805F9B34FB', batteryFullBase64); expect(mockProcessButtonEvent).toHaveBeenCalledWith(expect.objectContaining({ button: 'mute' })); 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..a94990fb --- /dev/null +++ b/src/services/__tests__/bluetooth-audio-edge-cases.test.ts @@ -0,0 +1,441 @@ +/* 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.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(); + }); + }); + + 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__/bluetooth-audio-forget.test.ts b/src/services/__tests__/bluetooth-audio-forget.test.ts index cc29ee46..78fbdfc8 100644 --- a/src/services/__tests__/bluetooth-audio-forget.test.ts +++ b/src/services/__tests__/bluetooth-audio-forget.test.ts @@ -3,6 +3,14 @@ import { useBluetoothAudioStore } from '../../stores/app/bluetooth-audio-store'; import { removeItem } from '../../lib/storage'; // Mock dependencies +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + jest.mock('../../lib/logging', () => ({ logger: { info: jest.fn(), @@ -62,10 +70,15 @@ describe('BluetoothAudioService - forgetPreferredDevice', () => { microphone: { id: 'test-device-id', name: 'Test Device', type: 'bluetooth' }, speaker: { id: 'test-device-id', name: 'Test Device', type: 'bluetooth' }, }, + availableAudioDevices: [ + { id: 'default-mic', name: 'Default Microphone', type: 'default', isAvailable: true }, + { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true }, + ], setPreferredDevice: jest.fn(), setConnectedDevice: jest.fn(), setSelectedMicrophone: jest.fn(), setSelectedSpeaker: jest.fn(), + setIsHeadsetButtonMonitoring: jest.fn(), updateDevice: jest.fn(), availableDevices: [], }; diff --git a/src/services/__tests__/bluetooth-audio-service-data.test.ts b/src/services/__tests__/bluetooth-audio-service-data.test.ts index 40603f2d..30e7a080 100644 --- a/src/services/__tests__/bluetooth-audio-service-data.test.ts +++ b/src/services/__tests__/bluetooth-audio-service-data.test.ts @@ -1,4 +1,12 @@ // Mock audio.service to avoid loading expo modules +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + jest.mock('@/services/audio.service', () => ({ audioService: {}, })); diff --git a/src/services/__tests__/bluetooth-audio-uuid-normalization.test.ts b/src/services/__tests__/bluetooth-audio-uuid-normalization.test.ts index 4865c470..0ce8d813 100644 --- a/src/services/__tests__/bluetooth-audio-uuid-normalization.test.ts +++ b/src/services/__tests__/bluetooth-audio-uuid-normalization.test.ts @@ -1,6 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // Mock dependencies to prevent import errors +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + jest.mock('react-native', () => ({ Platform: { OS: 'android' }, PermissionsAndroid: { diff --git a/src/services/__tests__/bluetooth-audio.service.test.ts b/src/services/__tests__/bluetooth-audio.service.test.ts index d762fa23..562952a3 100644 --- a/src/services/__tests__/bluetooth-audio.service.test.ts +++ b/src/services/__tests__/bluetooth-audio.service.test.ts @@ -24,11 +24,13 @@ 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(), }; @@ -40,6 +42,14 @@ if (!RN.DeviceEventEmitter) { } // Mock other dependencies +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + ignoreMuteEvents: jest.fn(), + removeMuteListener: jest.fn(), + restoreMuteListener: jest.fn(), + }, +})); + jest.mock('react-native-ble-manager', () => ({ __esModule: true, default: { @@ -106,6 +116,8 @@ jest.mock('@/stores/app/bluetooth-audio-store', () => ({ setSelectedMicrophone: jest.fn(), setSelectedSpeaker: jest.fn(), setAudioRoutingActive: jest.fn(), + setIsHeadsetButtonMonitoring: jest.fn(), + isHeadsetButtonMonitoring: false, availableDevices: [], connectedDevice: null, preferredDevice: null, @@ -133,14 +145,17 @@ describe('BluetoothAudioService Refactoring', () => { // Reset all mock implementations RN.PermissionsAndroid.requestMultiple.mockReset(); + RN.PermissionsAndroid.check.mockReset(); + RN.PermissionsAndroid.check.mockResolvedValue(false); mockStart.mockReset(); mockCheckState.mockReset(); mockScan.mockReset(); mockStopScan.mockReset(); mockConnect.mockReset(); - // Reset Platform.OS to Android for each test + // Reset Platform to Android 12+ for each test (RN.Platform as any).OS = 'android'; + (RN.Platform as any).Version = 31; }); afterEach(() => { @@ -176,7 +191,7 @@ describe('BluetoothAudioService Refactoring', () => { }); describe('Permission Requests', () => { - it('should add 500ms delay before requesting permissions', async () => { + it('should wait 500ms before prompting for permissions that are not yet granted', async () => { // Ensure Android platform (RN.Platform as any).OS = 'android'; @@ -190,8 +205,10 @@ describe('BluetoothAudioService Refactoring', () => { // Start the permission request const permissionPromise = service.requestPermissions(); - // Fast-forward time by 500ms - jest.advanceTimersByTime(500); + // Android drops a prompt raised while another one is showing, so nothing is requested before the delay + await jest.advanceTimersByTimeAsync(499); + expect(RN.PermissionsAndroid.requestMultiple).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1); // Wait for the promise to resolve const result = await permissionPromise; @@ -214,7 +231,7 @@ describe('BluetoothAudioService Refactoring', () => { const permissionPromise = service.requestPermissions(); // Fast-forward time by 500ms to handle the delay - jest.advanceTimersByTime(500); + await jest.advanceTimersByTimeAsync(500); const result = await permissionPromise; @@ -237,7 +254,7 @@ describe('BluetoothAudioService Refactoring', () => { const permissionPromise = service.requestPermissions(); // Fast-forward time by 500ms - jest.advanceTimersByTime(500); + await jest.advanceTimersByTimeAsync(500); const result = await permissionPromise; @@ -260,7 +277,7 @@ describe('BluetoothAudioService Refactoring', () => { const permissionPromise = service.requestPermissions(); // Fast-forward time by 500ms - jest.advanceTimersByTime(500); + await jest.advanceTimersByTimeAsync(500); const result = await permissionPromise; diff --git a/src/services/__tests__/callkeep-mute-handling.test.ts b/src/services/__tests__/callkeep-mute-handling.test.ts new file mode 100644 index 00000000..86b3905a --- /dev/null +++ b/src/services/__tests__/callkeep-mute-handling.test.ts @@ -0,0 +1,91 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +jest.mock('@livekit/react-native-webrtc', () => ({ + RTCAudioSession: { + audioSessionDidActivate: jest.fn(), + audioSessionDidDeactivate: jest.fn(), + }, +})); + +jest.mock('react-native-callkeep'); + +jest.mock('../../lib/logging', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { CallKeepService as AndroidCallKeepService } from '../callkeep.service.android'; +import { CallKeepService as IosCallKeepService } from '../callkeep.service.ios'; + +describe.each([ + ['iOS', IosCallKeepService], + ['Android', AndroidCallKeepService], +])('CallKeep mute handling (%s)', (_platform, ServiceClass) => { + let service: any; + let muteCallback: jest.Mock; + + const muteEvent = (muted: boolean) => service.handleMutedCallAction({ muted, callUUID: 'call-1' }); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-01T00:00:00Z')); + (ServiceClass as any).instance = null; + service = (ServiceClass as any).getInstance(); + muteCallback = jest.fn(); + service.setMuteStateCallback(muteCallback); + }); + + afterEach(() => { + jest.useRealTimers(); + (ServiceClass as any).instance = null; + }); + + it('forwards a mute change to the registered callback', () => { + muteEvent(true); + + expect(muteCallback).toHaveBeenCalledWith(true); + }); + + it('ignores mute changes while a PTT press holds the external lock', () => { + service.ignoreMuteEvents(1000); + + muteEvent(true); + expect(muteCallback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1001); + muteEvent(false); + expect(muteCallback).toHaveBeenCalledWith(false); + }); + + it('drops a storm of rapid mute changes until it settles', () => { + muteEvent(true); + expect(muteCallback).toHaveBeenCalledTimes(1); + + // Faulty headset / HFP conflict: events less than 500ms apart + jest.advanceTimersByTime(100); + muteEvent(false); + jest.advanceTimersByTime(100); + muteEvent(true); + expect(muteCallback).toHaveBeenCalledTimes(1); + + // Quiet for longer than the storm window: events flow again + jest.advanceTimersByTime(900); + muteEvent(false); + expect(muteCallback).toHaveBeenCalledTimes(2); + expect(muteCallback).toHaveBeenLastCalledWith(false); + }); + + it('stops forwarding while a specialized PTT device has removed the listener', () => { + service.removeMuteListener(); + muteEvent(true); + expect(muteCallback).not.toHaveBeenCalled(); + + service.restoreMuteListener(); + jest.advanceTimersByTime(600); + muteEvent(false); + expect(muteCallback).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts index 20c73583..dc980acb 100644 --- a/src/services/__tests__/offline-event-manager.service.test.ts +++ b/src/services/__tests__/offline-event-manager.service.test.ts @@ -73,6 +73,7 @@ describe('OfflineEventManager', () => { initializeNetworkListener: jest.fn(), retryAllFailedEvents: jest.fn(), clearCompletedEvents: jest.fn(), + purgeStaleFailedEvents: jest.fn(), _setProcessing: jest.fn(), totalEvents: 0, completedEvents: 0, @@ -243,6 +244,16 @@ describe('OfflineEventManager', () => { // So we just verify the interval was set up }); + it('purges events held too long for another session on every pass, even offline', () => { + mockStoreState.isConnected = false; + mockStoreState.isNetworkReachable = false; + + offlineEventManager.startProcessing(); + + expect(mockStoreState.purgeStaleFailedEvents).toHaveBeenCalledTimes(1); + expect(mockStoreState.getPendingEvents).not.toHaveBeenCalled(); + }); + it('should set up processing interval when online', () => { const mockEvent = { id: 'test-event', diff --git a/src/services/__tests__/offline-queue-processor.test.ts b/src/services/__tests__/offline-queue-processor.test.ts index b9db249b..547a339d 100644 --- a/src/services/__tests__/offline-queue-processor.test.ts +++ b/src/services/__tests__/offline-queue-processor.test.ts @@ -26,6 +26,14 @@ jest.mock('@/api/personnel/personnelStatuses', () => ({ savePersonnelStatus: jest.fn().mockResolvedValue(undefined), })); +// The session queued items belong to: user-1 signed in to the US server, unless a test changes it. +const US_SERVER = 'https://api.us.example/api/v4'; +const EU_SERVER = 'https://api.eu.example/api/v4'; +const mockSession: { status: string; userId: string | null } = { status: 'signedIn', userId: 'user-1' }; +let mockServerUrl = US_SERVER; +jest.mock('@/stores/auth/store', () => ({ __esModule: true, default: { getState: () => mockSession } })); +jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: () => mockServerUrl })); + // Require modules after mocks are set up const { offlineQueueProcessor, OfflineQueueProcessor, RealOfflineQueueProcessor } = require('@/services/offline-queue-processor'); const { savePersonnelStatus } = require('@/api/personnel/personnelStatuses'); @@ -87,11 +95,15 @@ describe('RealOfflineQueueProcessor', () => { beforeEach(async () => { jest.clearAllMocks(); + mockSession.status = 'signedIn'; + mockSession.userId = 'user-1'; + mockServerUrl = US_SERVER; storage = await getOfflineQueueStorage(); storage.getString.mockReturnValue('[]'); storage.set.mockResolvedValue(undefined); savePersonnelStatus.mockResolvedValue(undefined); processor.processing = false; + processor.rerunRequested = false; processor.queueMutationChain = Promise.resolve(); processor.cleanup(); }); @@ -392,4 +404,161 @@ describe('RealOfflineQueueProcessor', () => { expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Dropping offline queue item after max retries' })); }); }); + + describe('owner', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const usOwner = { serverUrl: US_SERVER, userId: 'user-1' }; + const status = { UserId: 'user-1', Type: '3', RespondingTo: '321', Timestamp: '2026-09-23T10:00:00.000Z' }; + const persistedQueue = () => JSON.parse(storage.set.mock.calls[storage.set.mock.calls.length - 1][1]); + + const queueItem = (extra: Record) => { + storage.getString.mockReturnValue(JSON.stringify([{ id: 'item-1', type: 'personnelStatus', payload: status, retries: 0, attempts: 0, ...extra }])); + }; + + it('records who an item was queued for', async () => { + const now = 100000; + jest.spyOn(Date, 'now').mockReturnValue(now); + + processor.addPersonnelStatusToQueue(status); + await processor.queueMutationChain; + + expect(persistedQueue()).toEqual([expect.objectContaining({ payload: status, owner: usOwner, queuedAt: now })]); + }); + + it('holds an item queued on another server instead of sending it to this one', async () => { + mockServerUrl = EU_SERVER; + queueItem({ owner: usOwner, queuedAt: Date.now() }); + + await processor.processQueue(); + + expect(savePersonnelStatus).not.toHaveBeenCalled(); + expect(persistedQueue()).toEqual([expect.objectContaining({ id: 'item-1', owner: usOwner })]); + }); + + it('holds an item queued by another user', async () => { + mockSession.userId = 'user-2'; + queueItem({ owner: usOwner, queuedAt: Date.now() }); + + await processor.processQueue(); + + expect(savePersonnelStatus).not.toHaveBeenCalled(); + expect(persistedQueue()).toHaveLength(1); + }); + + it('holds items while signed out rather than replaying them into a 401 that drops them', async () => { + mockSession.status = 'signedOut'; + mockSession.userId = null; + queueItem({ owner: usOwner, queuedAt: Date.now() }); + + await processor.processQueue(); + + expect(savePersonnelStatus).not.toHaveBeenCalled(); + expect(persistedQueue()).toHaveLength(1); + }); + + it('sends a held item once its owner is signed in to its server again', async () => { + queueItem({ owner: usOwner, queuedAt: Date.now() }); + + await processor.processQueue(); + + expect(savePersonnelStatus).toHaveBeenCalledWith(status); + expect(persistedQueue()).toEqual([]); + }); + + it('runs again after the active pass when an owner signs in during it, so the item it held is not stranded', async () => { + const otherOwner = { serverUrl: US_SERVER, userId: 'user-2' }; + const otherStatus = { ...status, UserId: 'user-2' }; + let storedQueue = JSON.stringify([ + { id: 'held', type: 'personnelStatus', payload: otherStatus, retries: 0, attempts: 0, owner: otherOwner, queuedAt: Date.now() }, + { id: 'sending', type: 'personnelStatus', payload: status, retries: 0, attempts: 0, owner: usOwner, queuedAt: Date.now() }, + ]); + storage.getString.mockImplementation(() => storedQueue); + storage.set.mockImplementation((_key: string, value: string) => { + storedQueue = value; + return Promise.resolve(); + }); + + let signalSendStarted: () => void; + const sendStarted = new Promise((resolve) => { + signalSendStarted = resolve; + }); + let finishSend: () => void; + const sendBlocked = new Promise((resolve) => { + finishSend = resolve; + }); + savePersonnelStatus.mockImplementationOnce(() => { + signalSendStarted!(); + return sendBlocked; + }); + + const processing = processor.processQueue(); + await sendStarted; + + // user-2 signs in while user-1's status is still going out; the pass already held user-2's item + mockSession.userId = 'user-2'; + await processor.processQueue(); + finishSend!(); + await processing; + + expect(savePersonnelStatus).toHaveBeenCalledTimes(2); + expect(savePersonnelStatus).toHaveBeenLastCalledWith(otherStatus); + expect(JSON.parse(storedQueue)).toEqual([]); + }); + + it('never sends an item queued while nobody was signed in', async () => { + queueItem({ owner: null, queuedAt: Date.now() }); + + await processor.processQueue(); + + expect(savePersonnelStatus).not.toHaveBeenCalled(); + expect(persistedQueue()).toHaveLength(1); + }); + + it('sends an item queued before owners were recorded as the signed-in session\'s', async () => { + queueItem({}); + + await processor.processQueue(); + + expect(savePersonnelStatus).toHaveBeenCalledWith(status); + }); + + it('drops an item held for another session for more than 7 days', async () => { + const now = 10 * DAY_MS; + jest.spyOn(Date, 'now').mockReturnValue(now); + mockServerUrl = EU_SERVER; + queueItem({ owner: usOwner, queuedAt: now - 8 * DAY_MS }); + + await processor.processQueue(); + + expect(savePersonnelStatus).not.toHaveBeenCalled(); + expect(persistedQueue()).toEqual([]); + }); + + it('keeps an item for another session while nobody is signed in, however old', async () => { + const now = 10 * DAY_MS; + jest.spyOn(Date, 'now').mockReturnValue(now); + mockSession.status = 'signedOut'; + mockSession.userId = null; + queueItem({ owner: usOwner, queuedAt: now - 8 * DAY_MS }); + + await processor.processQueue(); + + expect(persistedQueue()).toHaveLength(1); + }); + + it('does not set a retry timer for a held item, which would fire again at once', async () => { + jest.useFakeTimers(); + try { + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + mockServerUrl = EU_SERVER; + queueItem({ owner: usOwner, queuedAt: Date.now(), nextRetryAt: Date.now() - 1000 }); + + await processor.processQueue(); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + }); }); diff --git a/src/services/__tests__/offline-queue.service.test.ts b/src/services/__tests__/offline-queue.service.test.ts new file mode 100644 index 00000000..3e359af9 --- /dev/null +++ b/src/services/__tests__/offline-queue.service.test.ts @@ -0,0 +1,54 @@ +const mockInitializeNetworkListener = jest.fn(); +const mockStartProcessing = jest.fn(() => Promise.resolve()); +const mockProcessQueue = jest.fn(() => Promise.resolve()); + +jest.mock('@/stores/offline-queue/store', () => ({ + useOfflineQueueStore: { getState: () => ({ initializeNetworkListener: mockInitializeNetworkListener, teardownNetworkListener: jest.fn() }) }, +})); + +jest.mock('../offline-queue-processor', () => ({ + offlineQueueProcessor: { + startProcessing: () => mockStartProcessing(), + processQueue: () => mockProcessQueue(), + cleanup: jest.fn(), + }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), debug: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +import { offlineQueueService } from '../offline-queue.service'; + +describe('offlineQueueService', () => { + beforeEach(() => { + jest.clearAllMocks(); + offlineQueueService.cleanup(); + }); + + it('starts the queue on the first sign-in', async () => { + await offlineQueueService.initialize(); + + expect(mockInitializeNetworkListener).toHaveBeenCalledTimes(1); + expect(mockStartProcessing).toHaveBeenCalledTimes(1); + }); + + it('sends what was held for the session on every later sign-in', async () => { + await offlineQueueService.initialize(); + jest.clearAllMocks(); + + await offlineQueueService.initialize(); + + // Held while signed out, or while another account or server was, until this session came back. + expect(mockProcessQueue).toHaveBeenCalledTimes(1); + expect(mockInitializeNetworkListener).not.toHaveBeenCalled(); + expect(mockStartProcessing).not.toHaveBeenCalled(); + }); + + it('does not fail sign-in when that pass fails', async () => { + await offlineQueueService.initialize(); + mockProcessQueue.mockRejectedValueOnce(new Error('storage unavailable')); + + await expect(offlineQueueService.initialize()).resolves.toBeUndefined(); + }); +}); diff --git a/src/services/__tests__/push-notification-registration.test.ts b/src/services/__tests__/push-notification-registration.test.ts new file mode 100644 index 00000000..fec732fa --- /dev/null +++ b/src/services/__tests__/push-notification-registration.test.ts @@ -0,0 +1,306 @@ +import { act, renderHook } from '@testing-library/react-native'; +import * as Notifications from 'expo-notifications'; + +import { registerDevice } from '@/api/devices/push'; + +jest.mock('expo-device', () => ({ + isDevice: true, +})); + +jest.mock('expo-notifications', () => ({ + addNotificationReceivedListener: jest.fn(() => ({ remove: jest.fn() })), + addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })), + setNotificationHandler: jest.fn(), + setNotificationChannelAsync: jest.fn(), + getLastNotificationResponseAsync: jest.fn(() => Promise.resolve(null)), + getPermissionsAsync: jest.fn(), + requestPermissionsAsync: jest.fn(), + getDevicePushTokenAsync: jest.fn(), + AndroidImportance: { MAX: 5 }, + AndroidNotificationVisibility: { PUBLIC: 1 }, +})); + +// Session state the service and hook read; each test sets what it needs. +const mockAuthState: { status: string; userId: string | null } = { status: 'signedIn', userId: 'user-1' }; +const mockSecurityState: { rights: { DepartmentCode: string } | null } = { rights: { DepartmentCode: 'EU1' } }; +const mockLifecycleState = { isActive: true }; +let mockBaseUrl = 'https://api.eu.example/api/v4'; + +// A zustand-style hook (callable with a selector, plus getState) over state the test mutates. +function mockSelectorHook(getState: () => object) { + const hook: any = jest.fn((selector?: (s: object) => unknown) => (selector ? selector(getState()) : getState())); + hook.getState = getState; + return hook; +} + +jest.mock('@/lib/auth', () => ({ useAuthStore: mockSelectorHook(() => mockAuthState) })); +jest.mock('@/stores/security/store', () => ({ securityStore: mockSelectorHook(() => mockSecurityState) })); +jest.mock('@/stores/app/app-lifecycle', () => ({ useAppLifecycleStore: mockSelectorHook(() => mockLifecycleState) })); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: () => mockBaseUrl, + getDeviceUuid: () => 'device-uuid', +})); + +const mockStoreResets = new Map void>(); +jest.mock('@/lib/storage/clear-all-data', () => ({ + registerStoreReset: (name: string, reset: () => void) => mockStoreResets.set(name, reset), +})); + +jest.mock('@/api/devices/push', () => ({ + registerDevice: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/lib/navigation', () => ({ + routerPushWithRetry: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@/components/weather-alerts/weather-alert-navigation', () => ({ + openWeatherAlertDetail: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@/stores/push-notification/store', () => ({ + ...jest.requireActual('@/stores/push-notification/store'), + usePushNotificationModalStore: { getState: jest.fn(() => ({ showNotificationModal: jest.fn() })) }, +})); + +// Required, not imported: the module registers its sign-out reset as it loads, which needs the map above to exist. +const { pushNotificationService, usePushNotifications } = require('../push-notification') as typeof import('../push-notification'); + +const mockRegisterDevice = registerDevice as jest.Mock; +const mockGetPermissions = Notifications.getPermissionsAsync as jest.Mock; +const mockRequestPermissions = Notifications.requestPermissionsAsync as jest.Mock; +const mockGetDevicePushToken = Notifications.getDevicePushTokenAsync as jest.Mock; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const signOut = () => mockStoreResets.get('push-notification')?.(); + +describe('push registration', () => { + beforeEach(() => { + jest.clearAllMocks(); + signOut(); + mockAuthState.userId = 'user-1'; + mockSecurityState.rights = { DepartmentCode: 'EU1' }; + mockLifecycleState.isActive = true; + mockBaseUrl = 'https://api.eu.example/api/v4'; + mockGetPermissions.mockResolvedValue({ status: 'granted' }); + mockRequestPermissions.mockResolvedValue({ status: 'granted' }); + mockGetDevicePushToken.mockResolvedValue({ data: 'apns-token-1' }); + mockRegisterDevice.mockResolvedValue({ Status: 'Queued' }); + }); + + describe('registerForPushNotifications', () => { + it('registers the token under the department code at the server the app points at', async () => { + await expect(pushNotificationService.registerForPushNotifications('user-1', 'EU1')).resolves.toBe('apns-token-1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + expect(mockRegisterDevice).toHaveBeenCalledWith({ UserId: 'user-1', Token: 'apns-token-1', Platform: 1, DeviceUuid: 'device-uuid', Prefix: 'EU1' }); + }); + + it('does not register the same server, user, department and token twice', async () => { + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + }); + + it('registers again when the department code changes', async () => { + mockSecurityState.rights = { DepartmentCode: 'OLD' }; + await pushNotificationService.registerForPushNotifications('user-1', 'OLD'); + + mockSecurityState.rights = { DepartmentCode: 'EU1' }; + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + expect(mockRegisterDevice).toHaveBeenLastCalledWith(expect.objectContaining({ Prefix: 'EU1' })); + }); + + it('registers again when the server changes', async () => { + mockBaseUrl = 'https://api.us.example/api/v4'; + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + mockBaseUrl = 'https://api.eu.example/api/v4'; + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + }); + + it('registers again when the token rotates', async () => { + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + mockGetDevicePushToken.mockResolvedValue({ data: 'apns-token-2' }); + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + expect(mockRegisterDevice).toHaveBeenLastCalledWith(expect.objectContaining({ Token: 'apns-token-2' })); + }); + + it('does not register a department code that is no longer the session\'s', async () => { + // Queued with the previous session's code; the session's own rights have since loaded. + mockSecurityState.rights = { DepartmentCode: 'EU1' }; + + await expect(pushNotificationService.registerForPushNotifications('user-1', 'US1')).resolves.toBeNull(); + + expect(mockRegisterDevice).not.toHaveBeenCalled(); + }); + + it('does not register a user who has signed out', async () => { + mockAuthState.userId = null; + + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).not.toHaveBeenCalled(); + }); + + it('retries a registration the server did not accept', async () => { + mockRegisterDevice.mockResolvedValueOnce({ Status: 'Failure' }); + + await expect(pushNotificationService.registerForPushNotifications('user-1', 'EU1')).resolves.toBeNull(); + await expect(pushNotificationService.registerForPushNotifications('user-1', 'EU1')).resolves.toBe('apns-token-1'); + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + }); + + it('retries a registration that failed to reach the server', async () => { + mockRegisterDevice.mockRejectedValueOnce(new Error('timeout of 15000ms exceeded')); + + await expect(pushNotificationService.registerForPushNotifications('user-1', 'EU1')).resolves.toBeNull(); + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + }); + + it('asks for permission by default', async () => { + mockGetPermissions.mockResolvedValue({ status: 'undetermined' }); + + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRequestPermissions).toHaveBeenCalledTimes(1); + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + }); + + it('never prompts when asked not to', async () => { + mockGetPermissions.mockResolvedValue({ status: 'denied' }); + + await expect(pushNotificationService.registerForPushNotifications('user-1', 'EU1', { requestPermission: false })).resolves.toBeNull(); + + expect(mockRequestPermissions).not.toHaveBeenCalled(); + expect(mockRegisterDevice).not.toHaveBeenCalled(); + }); + + it('registers again after sign-out', async () => { + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + signOut(); + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + }); + + it('does not let a registration in flight at sign-out count for the next session', async () => { + const response = deferred<{ Status: string }>(); + mockRegisterDevice.mockReturnValueOnce(response.promise); + + const inFlight = pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + await act(async () => {}); + signOut(); + response.resolve({ Status: 'Queued' }); + await inFlight; + + await pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + }); + + it('runs registrations one at a time', async () => { + const response = deferred<{ Status: string }>(); + mockRegisterDevice.mockReturnValueOnce(response.promise); + + const first = pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + const second = pushNotificationService.registerForPushNotifications('user-1', 'EU1'); + await act(async () => {}); + + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + + response.resolve({ Status: 'Queued' }); + await Promise.all([first, second]); + + // The second call waited for the first and found the server already had this token. + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + }); + }); + + describe('usePushNotifications', () => { + let registerSpy: jest.SpyInstance; + + beforeEach(() => { + registerSpy = jest.spyOn(pushNotificationService, 'registerForPushNotifications').mockResolvedValue(null); + }); + + afterEach(() => { + registerSpy.mockRestore(); + }); + + it('waits for the session\'s department code, then registers under it', () => { + mockSecurityState.rights = null; + const { rerender } = renderHook(() => usePushNotifications()); + + expect(registerSpy).not.toHaveBeenCalled(); + + mockSecurityState.rights = { DepartmentCode: 'EU1' }; + rerender({}); + + expect(registerSpy).toHaveBeenCalledTimes(1); + expect(registerSpy).toHaveBeenCalledWith('user-1', 'EU1', { requestPermission: true }); + }); + + it('registers again when the department code changes for the same user', () => { + mockSecurityState.rights = { DepartmentCode: 'OLD' }; + const { rerender } = renderHook(() => usePushNotifications()); + + mockSecurityState.rights = { DepartmentCode: 'EU1' }; + rerender({}); + + expect(registerSpy).toHaveBeenCalledTimes(2); + expect(registerSpy).toHaveBeenLastCalledWith('user-1', 'EU1', { requestPermission: true }); + }); + + it('checks again without prompting when the app comes back to the foreground', () => { + const { rerender } = renderHook(() => usePushNotifications()); + expect(registerSpy).toHaveBeenCalledTimes(1); + + mockLifecycleState.isActive = false; + rerender({}); + expect(registerSpy).toHaveBeenCalledTimes(1); + + mockLifecycleState.isActive = true; + rerender({}); + + expect(registerSpy).toHaveBeenCalledTimes(2); + expect(registerSpy).toHaveBeenLastCalledWith('user-1', 'EU1', { requestPermission: false }); + }); + + it('does nothing while signed out', () => { + mockAuthState.userId = null; + const { rerender } = renderHook(() => usePushNotifications()); + + mockLifecycleState.isActive = false; + rerender({}); + mockLifecycleState.isActive = true; + rerender({}); + + expect(registerSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index 0b7bcb71..1aa8a0e1 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -1,19 +1,19 @@ import { Buffer } from 'buffer'; -import { Alert, DeviceEventEmitter, PermissionsAndroid, Platform } from 'react-native'; -import BleManager, { type BleManagerDidUpdateValueForCharacteristicEvent, BleScanCallbackType, BleScanMatchMode, BleScanMode, type BleState, type Peripheral } from 'react-native-ble-manager'; +// @ts-ignore - callkeep service might not be resolvable in all contexts without barrel file updates +import { Alert, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native'; +import BleManager, { type BleManagerDidUpdateValueForCharacteristicEvent, BleScanCallbackType, BleScanMatchMode, BleScanMode, type BleState, type Peripheral, type PeripheralInfo } from 'react-native-ble-manager'; import { logger } from '@/lib/logging'; -import { getItem, removeItem } from '@/lib/storage'; -import { type AudioButtonEvent, type BluetoothAudioDevice, type Device, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; -import { useLiveKitStore } from '@/stores/app/livekit-store'; -// Import audioService dynamically to avoid expo module import errors in tests -let audioService: any; -try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - audioService = require('@/services/audio.service').audioService; -} catch { - audioService = {}; -} +import { audioService } from '@/services/audio.service'; +import { callKeepService } from '@/services/callkeep.service'; +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 => { + // Using import() for lazy loading to avoid circular dependencies + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('@/stores/app/livekit-store').useLiveKitStore; +}; // Standard Bluetooth UUIDs for audio services const AUDIO_SERVICE_UUID = '0000110A-0000-1000-8000-00805F9B34FB'; // Advanced Audio Distribution Profile @@ -37,12 +37,7 @@ const HYS_HEADSET_SERVICE = '0000FFE0-0000-1000-8000-00805F9B34FB'; //const HYS_HEADSET_SERVICE_CHAR = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E'; const HYS_HEADSET_SERVICE_CHAR = '00002902-0000-1000-8000-00805F9B34FB'; -// Common button control service and characteristic UUIDs (varies by manufacturer) -const BUTTON_CONTROL_SERVICES = [ - '0000180F-0000-1000-8000-00805F9B34FB', // Battery Service (often includes button data) - '00001812-0000-1000-8000-00805F9B34FB', // Human Interface Device Service -]; - +// Common button control characteristic UUIDs (varies by manufacturer) const BUTTON_CONTROL_CHARACTERISTICS = [ '0000FE59-0000-1000-8000-00805F9B34FB', // Common button control characteristic ]; @@ -62,38 +57,36 @@ const EXCLUDED_STANDARD_CHARACTERISTICS = [ // 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; - - /** - * Normalizes a UUID to its full 128-bit form. - * Converts 16-bit UUIDs (e.g., '110A') to their full 128-bit equivalent. - * @param uuid - The UUID to normalize (16-bit, 32-bit, or 128-bit) - * @returns The normalized 128-bit UUID in uppercase - */ - 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(); - } - } + // 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; private isInitialized: boolean = false; private hasAttemptedPreferredDeviceConnection: boolean = false; private eventListeners: { remove: () => void }[] = []; + private readonly isWeb = Platform.OS === 'web'; + private monitoringStartedAt: number | null = null; + 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; consecutiveFailures: number }[] = []; + private hasVendorButtonCharacteristic: boolean = false; + private mediaButtonEventListener: { remove: () => void } | null = null; + private mediaButtonListeningActive: boolean = false; + private pttPressActive: boolean = false; + private pttReleaseFallbackTimeout: ReturnType | null = null; + private micApplyRetryTimeout: ReturnType | null = null; + private retryMicEnabled: boolean | null = null; + private pendingMicEnabled: boolean | null = null; + private isApplyingMicState: boolean = false; static getInstance(): BluetoothAudioService { if (!BluetoothAudioService.instance) { @@ -110,12 +103,17 @@ export class BluetoothAudioService { return; } - try { + // BLE is not available on web — skip initialization entirely + if (Platform.OS === 'web') { logger.info({ - message: 'Initializing Bluetooth Audio Service', + message: 'Bluetooth Audio Service not available on web, skipping initialization', }); + this.isInitialized = true; + return; + } - // Check if we have permissions + try { + // Permissions first: if they are denied, initialize() stays retryable instead of being marked done const hasPermissions = await this.requestPermissions(); if (!hasPermissions) { logger.warn({ @@ -124,10 +122,7 @@ export class BluetoothAudioService { return; } - // Initialize BLE Manager - await BleManager.start({ showAlert: false }); - this.setupEventListeners(); - + await this.ensureBleManagerStarted(); this.isInitialized = true; // Check Bluetooth state @@ -167,6 +162,8 @@ export class BluetoothAudioService { try { // Load preferred device from storage + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getItem } = require('@/lib/storage'); const preferredDevice: { id: string; name: string } | null = getItem('preferredBluetoothDevice'); if (preferredDevice) { @@ -178,6 +175,14 @@ export class BluetoothAudioService { // Set the preferred device in the store useBluetoothAudioStore.getState().setPreferredDevice(preferredDevice); + if (preferredDevice.id === 'system-audio') { + logger.info({ + message: 'Preferred device is System Audio, ensuring no specialized device is connected', + }); + // We are already in system audio mode by default if no device is connected + return; + } + // Try to connect directly to the preferred device try { await this.connectToDevice(preferredDevice.id); @@ -192,7 +197,8 @@ export 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({ @@ -207,50 +213,76 @@ export 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 async ensureBleManagerStarted(): Promise { + if (this.isBleManagerStarted) { + return; + } + + await BleManager.start({ showAlert: false }); + this.setupEventListeners(); + this.isBleManagerStarted = true; + } + + private addEventListener(listener: { remove: () => void }): void { + if (!this.eventListeners.includes(listener)) { + this.eventListeners.push(listener); + } + } + + private removeEventListener(listener: { remove: () => void } | null): void { + if (!listener) { + return; + } + + this.eventListeners = this.eventListeners.filter((registeredListener) => registeredListener !== listener); + } + private setupEventListeners(): void { // Bluetooth state change listener - //const stateListener = DeviceEventEmitter.addListener('BleManagerDidUpdateState', this.handleBluetoothStateChange.bind(this)); const stateListener = BleManager.onDidUpdateState(this.handleBluetoothStateChange.bind(this)); - this.eventListeners.push(stateListener); + this.addEventListener(stateListener); // Device disconnection listener - //const disconnectListener = DeviceEventEmitter.addListener('BleManagerDisconnectPeripheral', this.handleDeviceDisconnected.bind(this)); const disconnectListener = BleManager.onDisconnectPeripheral(this.handleDeviceDisconnected.bind(this)); - this.eventListeners.push(disconnectListener); + this.addEventListener(disconnectListener); // Device discovered listener - //const discoverListener = DeviceEventEmitter.addListener('BleManagerDiscoverPeripheral', this.handleDeviceDiscovered.bind(this)); const discoverListener = BleManager.onDiscoverPeripheral(this.handleDeviceDiscovered.bind(this)); - this.eventListeners.push(discoverListener); + this.addEventListener(discoverListener); // Characteristic value update listener - //const valueUpdateListener = DeviceEventEmitter.addListener('BleManagerDidUpdateValueForCharacteristic', this.handleCharacteristicValueUpdate.bind(this)); const valueUpdateListener = BleManager.onDidUpdateValueForCharacteristic(this.handleCharacteristicValueUpdate.bind(this)); - this.eventListeners.push(valueUpdateListener); + this.addEventListener(valueUpdateListener); // Stop scan listener - //const stopScanListener = DeviceEventEmitter.addListener('BleManagerStopScan', this.handleScanStopped.bind(this)); const stopScanListener = BleManager.onStopScan(this.handleScanStopped.bind(this)); - this.eventListeners.push(stopScanListener); + this.addEventListener(stopScanListener); } 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) { - // If Bluetooth is turned back on, try to reconnect to preferred device. - // attemptReconnectToPreferredDevice() resets hasAttemptedPreferredDeviceConnection - // itself, so gating on that flag here would make this branch unreachable after init. - 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(); + } } } @@ -274,18 +306,21 @@ export class BluetoothAudioService { return; } - // Define RSSI floor (typical range: -100 to -20 dBm). -60 dBm only reached headsets - // within a couple of meters, so pocket/belt-worn devices presented as "not found". - const MIN_RSSI_THRESHOLD = -85; + // Define RSSI threshold for strong signals (typical range: -100 to -20 dBm) + const STRONG_RSSI_THRESHOLD = -95; // Relaxed threshold to improve discovery - // Check RSSI signal strength - drop only devices that are effectively out of range - if (!device.rssi || device.rssi < MIN_RSSI_THRESHOLD) { + // Check RSSI signal strength - only proceed with strong signals + if (!device.rssi || device.rssi < STRONG_RSSI_THRESHOLD) { + logger.debug({ + message: 'Device ignored due to weak RSSI', + context: { deviceId: device.id, rssi: device.rssi, threshold: STRONG_RSSI_THRESHOLD }, + }); return; } // Log discovered device for debugging logger.debug({ - message: 'Device discovered during scan with usable RSSI', + message: 'Device discovered during scan with strong RSSI', context: { deviceId: device.id, deviceName: device.name, @@ -304,18 +339,40 @@ export class BluetoothAudioService { // Convert the value array to a base64 string to match the old API const value = Buffer.from(data.value).toString('base64'); - logger.debug({ - message: 'Characteristic value updated', - context: { - peripheral: data.peripheral, - service: data.service, - characteristic: data.characteristic, - value: Buffer.from(data.value).toString('hex'), - }, - }); + if (this.connectedDevice && data.peripheral !== this.connectedDevice.id) { + return; + } + + // A real GATT notification arrived for this characteristic — the + // subscription is proven to work, so the read-polling fallback can stop + // polling it. + this.markNotificationConfirmed(data.service, data.characteristic); // Handle button events based on service and characteristic UUIDs - this.handleButtonEventFromCharacteristic(data.service, data.characteristic, value); + this.handleButtonEventFromCharacteristic(data.peripheral, data.service, data.characteristic, value); + } + + /** + * 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 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) { + if (!entry.notificationConfirmed && this.areUuidsEqual(entry.serviceUuid, serviceUuid) && this.areUuidsEqual(entry.characteristicUuid, characteristicUuid)) { + entry.notificationConfirmed = true; + logger.info({ + message: 'GATT notifications confirmed for characteristic; read-polling fallback no longer needed for it', + context: { serviceUuid, characteristicUuid }, + }); + } + } } private handleScanStopped(): void { @@ -331,28 +388,47 @@ export class BluetoothAudioService { }); } - private handleButtonEventFromCharacteristic(serviceUuid: string, characteristicUuid: string, value: string): void { - const normalizedServiceUuid = this.normalizeUuid(serviceUuid); - const normalizedCharUuid = this.normalizeUuid(characteristicUuid); - + private handleButtonEventFromCharacteristic(peripheralId: string, serviceUuid: string, characteristicUuid: string, value: string): void { // Standard GATT telemetry/descriptor characteristics are never button input. - if (EXCLUDED_STANDARD_CHARACTERISTICS.some((uuid) => uuid.toUpperCase() === normalizedCharUuid)) { + if (this.isExcludedStandardCharacteristic(characteristicUuid)) { logger.debug({ message: 'Ignoring standard GATT characteristic for button routing', - context: { service: normalizedServiceUuid, characteristic: normalizedCharUuid }, + context: { peripheralId, serviceUuid, characteristicUuid }, }); return; } // Route to appropriate handler based on service/characteristic - if (normalizedServiceUuid === AINA_HEADSET_SERVICE.toUpperCase() && normalizedCharUuid === AINA_HEADSET_SVC_PROP.toUpperCase()) { + if (this.areUuidsEqual(serviceUuid, AINA_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, AINA_HEADSET_SVC_PROP)) { this.handleAinaButtonEvent(value); - } else if (normalizedServiceUuid === B01INRICO_HEADSET_SERVICE.toUpperCase() && normalizedCharUuid === B01INRICO_HEADSET_SERVICE_CHAR.toUpperCase()) { + } else if (this.areUuidsEqual(serviceUuid, B01INRICO_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, B01INRICO_HEADSET_SERVICE_CHAR)) { this.handleB01InricoButtonEvent(value); - } else if (normalizedServiceUuid === HYS_HEADSET_SERVICE.toUpperCase() && normalizedCharUuid === HYS_HEADSET_SERVICE_CHAR.toUpperCase()) { + } else if (this.areUuidsEqual(serviceUuid, HYS_HEADSET_SERVICE) && this.areUuidsEqual(characteristicUuid, HYS_HEADSET_SERVICE_CHAR)) { this.handleHYSButtonEvent(value); - } else if (BUTTON_CONTROL_CHARACTERISTICS.some((uuid) => uuid.toUpperCase() === normalizedCharUuid)) { + } 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); + } else if (this.connectedDevice && this.connectedDevice.id === peripheralId && this.getDeviceType(this.connectedDevice) === 'specialized') { + logger.debug({ + message: 'Ignoring characteristic update for specialized device (not identified as button control)', + context: { + peripheralId, + serviceUuid, + characteristicUuid, + }, + }); } } @@ -368,74 +444,6 @@ export class BluetoothAudioService { await this.attemptPreferredDeviceConnection(); } - /** - * Forget the preferred Bluetooth device. - * Removes from storage, clears from store, and resets audio selection if it was active. - * @param deviceId - The ID of the device to forget - */ - async forgetPreferredDevice(deviceId: string): Promise { - try { - logger.info({ - message: 'Forgetting preferred Bluetooth device', - context: { deviceId }, - }); - - const store = useBluetoothAudioStore.getState(); - const PREFERRED_BLUETOOTH_DEVICE_KEY = 'preferredBluetoothDevice'; - - // 1. Only remove from persistent storage and store if it's the preferred device - if (store.preferredDevice?.id === deviceId) { - logger.info({ - message: 'Removing device from persistent storage and store', - context: { deviceId }, - }); - - // removeItem is imported from '@/lib/storage' and is async - await removeItem(PREFERRED_BLUETOOTH_DEVICE_KEY); - store.setPreferredDevice(null); - } - - // 2. Disconnect if currently connected - if (store.connectedDevice && store.connectedDevice.id === deviceId) { - logger.info({ message: 'Disconnecting device being forgotten', context: { deviceId } }); - await this.disconnectDevice(); - } - - // 4. Reset audio selection if this device was selected - const { selectedAudioDevices } = store; - let selectionChanged = false; - - // Check microphone - if (selectedAudioDevices.microphone?.id === deviceId) { - logger.info({ message: 'Resetting microphone selection as device is forgotten' }); - store.setSelectedMicrophone({ - id: 'default-mic', - name: 'Default Microphone', - type: 'default', - isAvailable: true, - }); - selectionChanged = true; - } - - // Check speaker - if (selectedAudioDevices.speaker?.id === deviceId) { - logger.info({ message: 'Resetting speaker selection as device is forgotten' }); - store.setSelectedSpeaker({ - id: 'default-speaker', - name: 'Default Speaker', - type: 'speaker', - isAvailable: true, - }); - selectionChanged = true; - } - } catch (error) { - logger.error({ - message: 'Failed to forget preferred Bluetooth device', - context: { error, deviceId }, - }); - } - } - private handleBluetoothDisabled(): void { this.stopScanning(); this.disconnectDevice(); @@ -443,12 +451,20 @@ export class BluetoothAudioService { } async requestPermissions(): Promise { - // Add delay to prevent conflicts with other permission requests during app startup - await new Promise((resolve) => setTimeout(resolve, 500)); - + if (this.isWeb) return true; if (Platform.OS === 'android') { try { - const permissions = [PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT]; + // 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); @@ -472,6 +488,7 @@ export class BluetoothAudioService { } async checkBluetoothState(): Promise { + if (this.isWeb) return State.PoweredOff; try { const bleState = await BleManager.checkState(); return this.mapBleStateToState(bleState); @@ -485,11 +502,14 @@ export class BluetoothAudioService { } async startScanning(durationMs: number = 10000): Promise { + if (this.isWeb) return; const hasPermissions = await this.requestPermissions(); if (!hasPermissions) { 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,18 +563,21 @@ export class BluetoothAudioService { * Use this for troubleshooting device discovery issues */ async startDebugScanning(durationMs: number = 15000): Promise { + if (this.isWeb) return; const hasPermissions = await this.requestPermissions(); if (!hasPermissions) { 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.`); } // Stop any existing scan first - this.stopScanning(); + await this.stopScanning(); useBluetoothAudioStore.getState().setIsScanning(true); useBluetoothAudioStore.getState().clearDevices(); @@ -597,15 +620,10 @@ export 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 with normalized UUID comparison + // 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 expectedAudioUuids = [AUDIO_SERVICE_UUID, HFP_SERVICE_UUID, HSP_SERVICE_UUID, AINA_HEADSET_SERVICE, B01INRICO_HEADSET_SERVICE, HYS_HEADSET_SERVICE].map((uuid) => uuid.toUpperCase()); - - const hasAudioService = - advertisingData?.serviceUUIDs?.some((uuid: string) => { - const normalizedUuid = this.normalizeUuid(uuid); - return expectedAudioUuids.includes(normalizedUuid); - }) || 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; @@ -624,8 +642,6 @@ export class BluetoothAudioService { hasAudioManufacturerData, hasAudioServiceData, serviceUUIDs: advertisingData?.serviceUUIDs, - normalizedServiceUUIDs: advertisingData?.serviceUUIDs?.map((uuid) => this.normalizeUuid(uuid)), - expectedAudioUuids, manufacturerData: advertisingData?.manufacturerData, serviceData: advertisingData?.serviceData, }, @@ -675,7 +691,7 @@ export class BluetoothAudioService { const normalizedServiceUuid = this.normalizeUuid(serviceUuid); // Check if the service UUID itself indicates audio capability - const expectedAudioServiceUuids = [ + const isAudioServiceUuid = [ AUDIO_SERVICE_UUID, HFP_SERVICE_UUID, HSP_SERVICE_UUID, @@ -684,9 +700,7 @@ export 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) - ].map((uuid) => uuid.toUpperCase()); - - const isAudioServiceUuid = expectedAudioServiceUuids.includes(normalizedServiceUuid); + ].some((uuid) => this.normalizeUuid(uuid) === normalizedServiceUuid); if (isAudioServiceUuid) { logger.debug({ @@ -719,6 +733,28 @@ export class BluetoothAudioService { } } + private getDeviceType(device: Device): 'specialized' | 'system' { + const advertisingData = device.advertising; + const serviceUUIDs = advertisingData?.serviceUUIDs || []; + + // Check for specialized PTT service UUIDs + const isSpecialized = serviceUUIDs.some((uuid: string) => { + return [AINA_HEADSET_SERVICE, B01INRICO_HEADSET_SERVICE, HYS_HEADSET_SERVICE].some((specialized) => this.areUuidsEqual(uuid, specialized)); + }); + + if (isSpecialized) { + return 'specialized'; + } + + // Check by name for known specialized devices if UUID check fails + const name = device.name?.toLowerCase() || ''; + if (name.includes('aina') || name.includes('inrico') || name.includes('hys')) { + return 'specialized'; + } + + return 'system'; + } + private decodeServiceDataString(data: string): Buffer { try { // Service data can be in various formats: hex string, base64, etc. @@ -769,7 +805,7 @@ export class BluetoothAudioService { '1203', // Audio/Video Remote Control Profile ]; - // Look for manufacturer-specific ASCII identifiers in service data + // 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 @@ -866,45 +902,47 @@ export class BluetoothAudioService { const byte1 = data[i + 1]; const byte2 = data[i + 2]; - if (byte0 !== undefined && byte1 !== undefined && byte2 !== undefined) { - const cod = (byte2 << 16) | (byte1 << 8) | byte0; - - // Extract major and minor device class - const majorDeviceClass = (cod >> 8) & 0x1f; - const minorDeviceClass = (cod >> 2) & 0x3f; - - // Major device class 0x04 = Audio/Video devices - if (majorDeviceClass === 0x04) { - logger.debug({ - message: 'Found audio/video device class in service data', - context: { - cod: cod.toString(16), - majorClass: majorDeviceClass, - minorClass: minorDeviceClass, - position: i, - }, - }); - return true; - } + if (byte0 === undefined || byte1 === undefined || byte2 === undefined) { + continue; + } - // Check service class bits for audio services - // Service class bits are in bits 13-23 of the 24-bit CoD - const serviceClasses = (cod >> 13) & 0x7ff; - const hasAudioService = (serviceClasses & 0x200) !== 0; // Audio bit (bit 21 -> bit 8 in service class) - const hasRenderingService = (serviceClasses & 0x40) !== 0; // Rendering bit (bit 18 -> bit 5 in service class) + const cod = (byte2 << 16) | (byte1 << 8) | byte0; + + // Extract major and minor device class + const majorDeviceClass = (cod >> 8) & 0x1f; + const minorDeviceClass = (cod >> 2) & 0x3f; + + // Major device class 0x04 = Audio/Video devices + if (majorDeviceClass === 0x04) { + logger.debug({ + message: 'Found audio/video device class in service data', + context: { + cod: cod.toString(16), + majorClass: majorDeviceClass, + minorClass: minorDeviceClass, + position: i, + }, + }); + return true; + } - if (hasAudioService || hasRenderingService) { - logger.debug({ - message: 'Found audio service class bits in service data', - context: { - cod: cod.toString(16), - hasAudioService, - hasRenderingService, - position: i, - }, - }); - return true; - } + // Check service class bits for audio services + // Service class bits are in bits 13-23 of the 24-bit CoD + const serviceClasses = (cod >> 13) & 0x7ff; + const hasAudioService = (serviceClasses & 0x200) !== 0; // Audio bit (bit 21 -> bit 8 in service class) + const hasRenderingService = (serviceClasses & 0x40) !== 0; // Rendering bit (bit 18 -> bit 5 in service class) + + if (hasAudioService || hasRenderingService) { + logger.debug({ + message: 'Found audio service class bits in service data', + context: { + cod: cod.toString(16), + hasAudioService, + hasRenderingService, + position: i, + }, + }); + return true; } } @@ -922,11 +960,12 @@ export class BluetoothAudioService { const audioDevice: BluetoothAudioDevice = { id: device.id, name: device.name || null, - rssi: device.rssi, + rssi: device.rssi || undefined, isConnected: false, hasAudioCapability: true, supportsMicrophoneControl: this.supportsMicrophoneControl(device), device, + type: this.getDeviceType(device), }; logger.info({ @@ -946,13 +985,13 @@ export class BluetoothAudioService { } private async checkAndAutoConnectPreferredDevice(device: BluetoothAudioDevice): Promise { - const { preferredDevice, connectedDevice, isConnecting } = useBluetoothAudioStore.getState(); + const { preferredDevice, connectedDevice } = useBluetoothAudioStore.getState(); // Only auto-connect if: // 1. This is the preferred device // 2. No device is currently connected // 3. We're not already in the process of connecting - if (preferredDevice?.id === device.id && !connectedDevice && !isConnecting) { + if (preferredDevice?.id === device.id && !connectedDevice && !this.isConnecting) { try { logger.info({ message: 'Auto-connecting to preferred Bluetooth device', @@ -973,13 +1012,13 @@ export class BluetoothAudioService { // Check if device likely supports microphone control based on service UUIDs const advertisingData = device.advertising; const serviceUUIDs = advertisingData?.serviceUUIDs || []; - const expectedMicrophoneUuids = [HFP_SERVICE_UUID, HSP_SERVICE_UUID].map((uuid) => uuid.toUpperCase()); - return serviceUUIDs.some((uuid: string) => expectedMicrophoneUuids.includes(this.normalizeUuid(uuid))); + return serviceUUIDs.some((uuid: string) => [HFP_SERVICE_UUID, HSP_SERVICE_UUID].some((expected) => this.areUuidsEqual(uuid, expected))); } - stopScanning(): void { + async stopScanning(): Promise { + if (this.isWeb) return; try { - BleManager.stopScan(); + await BleManager.stopScan(); } catch (error) { logger.debug({ message: 'Error stopping scan', @@ -999,44 +1038,32 @@ export class BluetoothAudioService { }); } - private clearConnectionTimeout(): void { - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout); - this.connectionTimeout = null; - } - } - - /** - * Race a connect operation against CONNECTION_TIMEOUT_MS. A hung native connect would - * otherwise leave the store's isConnecting flag true forever with no way to recover. - */ - private withConnectionTimeout(operation: Promise, deviceId: string): Promise { - return new Promise((resolve, reject) => { - this.clearConnectionTimeout(); - - this.connectionTimeout = setTimeout(() => { - this.connectionTimeout = null; - reject(new Error(`Timed out connecting to Bluetooth device ${deviceId}`)); - }, CONNECTION_TIMEOUT_MS); - - operation.then( - (value) => { - this.clearConnectionTimeout(); - resolve(value); - }, - (error) => { - this.clearConnectionTimeout(); - reject(error instanceof Error ? error : new Error(String(error))); - } - ); - }); - } - async connectToDevice(deviceId: string): Promise { + if (this.isWeb) return; + if (this.isConnecting) { + logger.info({ + message: 'Bluetooth device connection already in progress, ignoring duplicate connect request', + context: { deviceId }, + }); + return; + } + this.isConnecting = true; try { + useBluetoothAudioStore.getState().clearConnectionError(); useBluetoothAudioStore.getState().setIsConnecting(true); + // Ensure scanning is stopped before connecting + // Connecting while scanning often fails on Android + await this.stopScanning(); + + // Small delay to allow radio to switch modes + await new Promise((resolve) => setTimeout(resolve, 500)); + // Connect to the device + logger.info({ + message: 'Attempting to connect to device via BleManager', + context: { deviceId }, + }); await this.withConnectionTimeout(Promise.resolve(BleManager.connect(deviceId)), deviceId); logger.info({ @@ -1053,175 +1080,459 @@ export class BluetoothAudioService { } // Discover services and characteristics - await BleManager.retrieveServices(deviceId); + logger.info({ + message: 'Retrieving services which triggers discovery', + context: { deviceId }, + }); + const peripheralInfo = await BleManager.retrieveServices(deviceId); + logger.info({ + message: 'Services retrieved successfully', + context: { + deviceId, + serviceCount: peripheralInfo.services?.length, + services: peripheralInfo.services?.map((s: any) => s.uuid), + }, + }); this.connectedDevice = device; useBluetoothAudioStore.getState().setConnectedDevice({ id: device.id, name: device.name || null, - rssi: device.rssi, + rssi: device.rssi || undefined, isConnected: true, hasAudioCapability: true, supportsMicrophoneControl: this.supportsMicrophoneControl(device), device, + type: this.getDeviceType(device), }); - // Set up button event monitoring - await this.setupButtonEventMonitoring(device); + // Special handling for specialized PTT devices to prevent mute loops + if (this.getDeviceType(device) === 'specialized') { + callKeepService.removeMuteListener(); + logger.info({ + message: 'Specialized PTT device connected - CallKeep mute listener removed', + context: { deviceId }, + }); + } else { + // Ensure listener is active for system devices + callKeepService.restoreMuteListener(); + } + + // Set up button event monitoring with peripheral info + await this.setupButtonEventMonitoring(device, peripheralInfo); + + // Start media-button fallback monitoring for Android headsets/earbuds/PTT devices + this.startMediaButtonFallbackMonitoring(); // Integrate with LiveKit audio routing await this.setupLiveKitAudioRouting(device); // Play connected device sound - if (audioService?.playConnectedDeviceSound) { - await audioService.playConnectedDeviceSound(); - } + await audioService.playConnectedDeviceSound(); useBluetoothAudioStore.getState().setIsConnecting(false); } catch (error) { + // Extract meaningful error message + let errorMessage = 'Unknown connection error'; + + if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'string') { + errorMessage = error; + } else if (typeof error === 'object' && error !== null) { + // Try to find a message property or basic string representation + if ('message' in error && typeof (error as any).message === 'string') { + errorMessage = (error as any).message; + } else { + try { + errorMessage = JSON.stringify(error); + } catch { + errorMessage = String(error); + } + } + } + logger.error({ message: 'Failed to connect to Bluetooth audio device', - context: { deviceId, error }, + context: { deviceId, error, errorMessage }, }); useBluetoothAudioStore.getState().setIsConnecting(false); - useBluetoothAudioStore.getState().setConnectionError(error instanceof Error ? error.message : 'Unknown connection error'); + useBluetoothAudioStore.getState().setConnectionError(errorMessage); throw error; + } finally { + this.isConnecting = false; } } - private handleDeviceDisconnected(args: { peripheral: string }): void { - logger.info({ - message: 'Bluetooth audio device disconnected', - context: { - deviceId: args.peripheral, - }, - }); + private withConnectionTimeout(operation: Promise, deviceId: string): Promise { + return new Promise((resolve, reject) => { + this.clearConnectionTimeout(); - // Only handle if this is our connected device - if (this.connectedDevice && this.connectedDevice.id === args.peripheral) { - this.connectedDevice = null; + 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); - useBluetoothAudioStore.getState().setConnectedDevice(null); - useBluetoothAudioStore.getState().clearConnectionError(); + operation.then( + (value) => { + this.clearConnectionTimeout(); + resolve(value); + }, + (error: unknown) => { + this.clearConnectionTimeout(); + reject(error); + } + ); + }); + } - // Revert LiveKit audio routing to default - this.revertLiveKitAudioRouting(); + private clearConnectionTimeout(): void { + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; } } - private async setupButtonEventMonitoring(device: Device): Promise { + async connectToSystemAudio(): Promise { + if (this.isWeb) return; try { - const peripheralInfo = await BleManager.getDiscoveredPeripherals(); - const deviceInfo = peripheralInfo.find((p) => p.id === device.id); + logger.info({ message: 'Switching to System Audio' }); - if (!deviceInfo) { - logger.warn({ - message: 'Device not found in discovered peripherals', - context: { deviceId: device.id }, - }); - return; + // Disconnect any currently connected specialized device + if (this.connectedDevice) { + try { + await BleManager.disconnect(this.connectedDevice.id); + } catch (error) { + logger.warn({ message: 'Error disconnecting device for System Audio switch', context: { error } }); + } + this.connectedDevice = null; + useBluetoothAudioStore.getState().setConnectedDevice(null); } - logger.info({ - message: 'Setting up button event monitoring', - context: { - deviceId: device.id, - deviceName: device.name, - }, - }); + // Ensure system audio state + callKeepService.restoreMuteListener(); + + // Revert LiveKit audio routing explicitly to be safe + this.revertLiveKitAudioRouting(); + + // Update preferred device + const systemAudioDevice = { id: 'system-audio', name: 'System Audio' }; + useBluetoothAudioStore.getState().setPreferredDevice(systemAudioDevice); + + // Save to storage (implied by previous code loading it from storage, but we need to save it too? + // usage of usePreferredBluetoothDevice hook elsewhere handles saving, + // but here we are in service. The UI calls logic that saves it eventually + // or we should do it here if we want persistence.) + // The service reads from storage using require('@/lib/storage'), so we should probably save it too if we want it to persist. + // However, the UI calls setPreferredDevice from the hook which likely saves it. + // We will let the UI handle the persistence call or add it here if needed. + // For now, updating the store is enough for the session. + } catch (error) { + logger.error({ message: 'Failed to switch to System Audio', context: { error } }); + throw error; + } + } + + private handleDeviceDisconnected(args: { peripheral: string }): void { + logger.info({ + message: '[DISCONNECT EVENT] Bluetooth audio device disconnected', + context: { + deviceId: args.peripheral, + }, + }); + + // 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(); + + // Revert LiveKit audio routing to default + this.revertLiveKitAudioRouting(); + + // Restore CallKeep mute listener when device disconnects + callKeepService.restoreMuteListener(); + } + } + + private async setupButtonEventMonitoring(device: Device, peripheralInfo: PeripheralInfo): Promise { + try { + useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); + this.monitoringStartedAt = Date.now(); // Start notifications for known button control characteristics - await this.startNotificationsForButtonControls(device.id); + await this.startNotificationsForButtonControls(device.id, peripheralInfo); + + this.startMonitoringWatchdog(device.id); } catch (error) { logger.warn({ message: 'Could not set up button event monitoring', context: { deviceId: device.id, error }, }); + useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); + this.stopMonitoringWatchdog(); } } - private async startNotificationsForButtonControls(deviceId: string): Promise { - // Try to start notifications for known button control service/characteristic combinations - const buttonControlConfigs = [ - { service: AINA_HEADSET_SERVICE, characteristic: AINA_HEADSET_SVC_PROP }, - { service: B01INRICO_HEADSET_SERVICE, characteristic: B01INRICO_HEADSET_SERVICE_CHAR }, - { service: HYS_HEADSET_SERVICE, characteristic: HYS_HEADSET_SERVICE_CHAR }, - ]; + private startMediaButtonFallbackMonitoring(): void { + if (Platform.OS !== 'android') { + return; + } - // Add generic button control service/characteristic combinations - for (const serviceUuid of BUTTON_CONTROL_SERVICES) { - for (const characteristicUuid of BUTTON_CONTROL_CHARACTERISTICS) { - buttonControlConfigs.push({ - service: serviceUuid, - characteristic: characteristicUuid, + this.stopMediaButtonFallbackMonitoring(); + + const module = (NativeModules as { MediaButtonModule?: { startListening?: () => void } }).MediaButtonModule; + + if (module?.startListening) { + try { + module.startListening(); + this.mediaButtonListeningActive = true; + } catch (error) { + logger.debug({ + message: 'Failed to start Android media-button fallback monitoring', + context: { error }, }); } } - // Also try to discover services and their characteristics dynamically - try { - const peripheralInfo = await BleManager.retrieveServices(deviceId); + this.mediaButtonEventListener = DeviceEventEmitter.addListener('onMediaButtonEvent', this.handleMediaButtonFallbackEvent.bind(this)); + this.addEventListener(this.mediaButtonEventListener); + } - logger.debug({ - message: 'Retrieved device services for button monitoring', - context: { - deviceId, - services: peripheralInfo.services?.length || 0, - characteristics: peripheralInfo.characteristics?.length || 0, - }, - }); + private stopMediaButtonFallbackMonitoring(): void { + if (this.mediaButtonEventListener) { + this.removeEventListener(this.mediaButtonEventListener); + this.mediaButtonEventListener.remove(); + this.mediaButtonEventListener = null; + } - // Look for potential button characteristics in discovered services - if (peripheralInfo.characteristics) { - for (const characteristic of peripheralInfo.characteristics) { - // Check if this characteristic could be a button control based on properties - if (characteristic.properties?.Notify || characteristic.properties?.Indicate) { - // Add service/characteristic pairs for characteristics that support notifications - const serviceUuid = characteristic.service; - const characteristicUuid = characteristic.characteristic; - - // Never subscribe standard GATT telemetry/descriptor characteristics: they are - // not button frames and would be misread as presses (e.g. Battery Level -> 'mute'). - if (EXCLUDED_STANDARD_CHARACTERISTICS.some((uuid) => uuid === this.normalizeUuid(characteristicUuid))) { - continue; - } - - // Skip if we already have this combination - const alreadyAdded = buttonControlConfigs.some((config) => config.service.toUpperCase() === serviceUuid.toUpperCase() && config.characteristic.toUpperCase() === characteristicUuid.toUpperCase()); - - if (!alreadyAdded) { - buttonControlConfigs.push({ - service: serviceUuid, - characteristic: characteristicUuid, - }); - - logger.debug({ - message: 'Added discovered characteristic for button monitoring', - context: { - deviceId, - service: serviceUuid, - characteristic: characteristicUuid, - properties: characteristic.properties, - }, - }); - } - } - } + if (Platform.OS !== 'android') { + this.mediaButtonListeningActive = false; + return; + } + + if (!this.mediaButtonListeningActive) { + return; + } + + const module = (NativeModules as { MediaButtonModule?: { stopListening?: () => void } }).MediaButtonModule; + if (module?.stopListening) { + try { + module.stopListening(); + } catch (error) { + logger.debug({ + message: 'Failed to stop Android media-button fallback monitoring', + context: { error }, + }); } - } catch (error) { - logger.debug({ - message: 'Could not retrieve services for dynamic button characteristic discovery', - context: { deviceId, error }, + } + + this.mediaButtonListeningActive = false; + } + + private handleMediaButtonFallbackEvent(event: { keyCode?: number; action?: string; timestamp?: number }): void { + const { mediaButtonPTTSettings } = useBluetoothAudioStore.getState(); + if (!mediaButtonPTTSettings.enabled || !mediaButtonPTTSettings.usePlayPauseForPTT) { + return; + } + + const keyCode = event?.keyCode; + const action = (event?.action || '').toUpperCase(); + + // KEYCODE_HEADSETHOOK=79, KEYCODE_MEDIA_PLAY_PAUSE=85, KEYCODE_MEDIA_PLAY=126, KEYCODE_MEDIA_PAUSE=127 + const isPttCapableKey = keyCode === 79 || keyCode === 85 || keyCode === 126 || keyCode === 127; + if (!isPttCapableKey) { + return; + } + + if (mediaButtonPTTSettings.pttMode === 'push_to_talk') { + if (action === 'ACTION_DOWN') { + this.processButtonEvent( + { + type: 'press', + button: 'ptt_start', + timestamp: Date.now(), + }, + 'media' + ); + } else if (action === 'ACTION_UP') { + this.processButtonEvent( + { + type: 'press', + button: 'ptt_stop', + timestamp: Date.now(), + }, + 'media' + ); + } + + return; + } + + if (action === 'ACTION_DOWN') { + this.processButtonEvent({ + type: 'press', + button: 'mute', + timestamp: Date.now(), }); } + } + + private startMonitoringWatchdog(deviceId: string): void { + this.stopMonitoringWatchdog(); + + const watchdogRecoveryThresholdMs = 12000; + + this.monitoringWatchdogInterval = setInterval(() => { + if (!this.connectedDevice || this.connectedDevice.id !== deviceId) { + this.stopMonitoringWatchdog(); + return; + } + + const monitoring = useBluetoothAudioStore.getState().isHeadsetButtonMonitoring; + if (!monitoring) { + this.stopMonitoringWatchdog(); + return; + } + + if (!this.monitoringStartedAt) { + return; + } + + const monitoringDurationMs = Date.now() - this.monitoringStartedAt; + if (monitoringDurationMs < watchdogRecoveryThresholdMs) { + return; + } + + this.monitoringStartedAt = Date.now(); + this.ensurePttInputMonitoring('watchdog'); + }, 4000); + } + + public ensurePttInputMonitoring(_reason: string): void { + if (!this.connectedDevice) { + return; + } + + this.startMediaButtonFallbackMonitoring(); + this.startMonitoringWatchdog(this.connectedDevice.id); + } + + private stopMonitoringWatchdog(): void { + if (this.monitoringWatchdogInterval) { + clearInterval(this.monitoringWatchdogInterval); + this.monitoringWatchdogInterval = null; + } + } + + /** + * Check if a service and characteristic exist on the peripheral + */ + private hasCharacteristic(peripheralInfo: PeripheralInfo, serviceUuid: string, characteristicUuid: string): boolean { + if (!peripheralInfo?.services) { + return false; + } + + if (!peripheralInfo.characteristics) { + return false; + } + + const characteristicFound = peripheralInfo.characteristics.some((c: any) => this.areUuidsEqual(c.service, serviceUuid) && this.areUuidsEqual(c.characteristic, characteristicUuid)); + + logger.debug({ + message: '[DEBUG_MATCH] Checking characteristic', + context: { + lookingForService: serviceUuid, + lookingForChar: characteristicUuid, + characteristicFound, + }, + }); + + return characteristicFound; + } + + /** + * 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 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(); + } + } + + /** + * 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; + + return this.normalizeUuid(uuid1) === this.normalizeUuid(uuid2); + } + + 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 + const buttonControlConfigs = [ + { service: AINA_HEADSET_SERVICE, characteristic: AINA_HEADSET_SVC_PROP }, + { service: B01INRICO_HEADSET_SERVICE, characteristic: B01INRICO_HEADSET_SERVICE_CHAR }, + { service: HYS_HEADSET_SERVICE, characteristic: HYS_HEADSET_SERVICE_CHAR }, + ]; + + logger.debug({ + message: 'Iterating button control configs', + context: { configCount: buttonControlConfigs.length }, + }); - // Try to start notifications for all collected service/characteristic pairs - let successCount = 0; for (const config of buttonControlConfigs) { try { + // Check if the characteristic exists before trying to start notifications + if (!this.hasCharacteristic(peripheralInfo, config.service, config.characteristic)) { + logger.debug({ + message: 'Characteristic not available on device, skipping', + context: { + deviceId, + service: config.service, + characteristic: config.characteristic, + }, + }); + continue; + } + await BleManager.startNotification(deviceId, config.service, config.characteristic); - successCount++; + 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', context: { @@ -1231,7 +1542,8 @@ export class BluetoothAudioService { }, }); } catch (error) { - logger.debug({ + logger.warn({ + // Changed to warn to make it more visible message: 'Failed to start notifications for characteristic', context: { deviceId, @@ -1243,16 +1555,283 @@ export class BluetoothAudioService { } } - logger.info({ - message: 'Button event monitoring setup completed', - context: { - deviceId, - totalAttempted: buttonControlConfigs.length, - successfulSubscriptions: successCount, - }, + if (peripheralInfo?.characteristics?.length) { + for (const characteristic of peripheralInfo.characteristics) { + const serviceUuid = characteristic?.service; + const characteristicUuid = characteristic?.characteristic; + + if (!serviceUuid || !characteristicUuid) { + continue; + } + + // 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)) { + continue; + } + + try { + await BleManager.startNotification(deviceId, serviceUuid, characteristicUuid); + successfullySubscribed.add(pairKey); + + if (this.hasReadCapability(characteristic?.properties)) { + this.registerReadPollingCharacteristic(serviceUuid, characteristicUuid); + } + + logger.info({ + message: 'Started fallback notification subscription for characteristic', + context: { + deviceId, + serviceUuid, + characteristicUuid, + properties: characteristic?.properties, + }, + }); + } catch (error) { + logger.debug({ + message: 'Fallback notification subscription failed for characteristic', + context: { + deviceId, + serviceUuid, + characteristicUuid, + error, + }, + }); + } + } + } + + if (successfullySubscribed.size > 0) { + useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(true); + this.startReadPollingFallback(deviceId); + } else { + useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); + } + } + + private registerReadPollingCharacteristic(serviceUuid: string, characteristicUuid: string): void { + const exists = this.monitoredReadCharacteristics.some((entry) => this.areUuidsEqual(entry.serviceUuid, serviceUuid) && this.areUuidsEqual(entry.characteristicUuid, characteristicUuid)); + + if (exists) { + return; + } + + this.monitoredReadCharacteristics.push({ + serviceUuid, + 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(); + + if (this.monitoredReadCharacteristics.length === 0) { + return; + } + + this.readPollingInterval = setInterval(() => { + if (!this.connectedDevice || this.connectedDevice.id !== deviceId) { + this.stopReadPollingFallback(); + return; + } + + const monitoring = useBluetoothAudioStore.getState().isHeadsetButtonMonitoring; + if (!monitoring) { + this.stopReadPollingFallback(); + return; + } + + // 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; + } + + if (this.isReadPollingInFlight) { + return; + } + + this.isReadPollingInFlight = true; + void this.pollReadCharacteristics(deviceId).finally(() => { + this.isReadPollingInFlight = false; + }); + }, 700); + } + + private async pollReadCharacteristics(deviceId: string): Promise { + // 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) { + continue; + } + 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) { + continue; + } + + if (entry.lastHexValue === null) { + entry.lastHexValue = nextHexValue; + continue; + } + + if (entry.lastHexValue === nextHexValue) { + continue; + } + + entry.lastHexValue = nextHexValue; + + 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: { + deviceId, + serviceUuid: entry.serviceUuid, + characteristicUuid: entry.characteristicUuid, + error, + }, + }); + } + } + } + + private stopReadPollingFallback(): void { + if (this.readPollingInterval) { + clearInterval(this.readPollingInterval); + this.readPollingInterval = null; + } + + this.isReadPollingInFlight = false; + } + + private hasReadCapability(properties: unknown): boolean { + if (!properties) { + return false; + } + + if (Array.isArray(properties)) { + return properties.some((property) => String(property).toLowerCase().includes('read')); + } + + if (typeof properties === 'object') { + const entries = Object.entries(properties as Record); + return entries.some(([key, value]) => { + const normalizedKey = key.toLowerCase(); + const normalizedValue = String(value).toLowerCase(); + const indicatesRead = normalizedKey.includes('read') || normalizedValue.includes('read'); + + if (!indicatesRead) { + return false; + } + + return value === true || value === 1 || normalizedValue === normalizedKey || normalizedValue === 'true' || normalizedValue === '1'; + }); + } + + return false; + } + + private hasNotificationOrReadCapability(properties: unknown): boolean { + if (!properties) { + return false; + } + + if (Array.isArray(properties)) { + return properties.some((property) => { + const normalized = String(property).toLowerCase(); + return normalized.includes('notify') || normalized.includes('indicate') || normalized.includes('read'); + }); + } + + if (typeof properties === 'object') { + const entries = Object.entries(properties as Record); + return entries.some(([key, value]) => { + const normalizedKey = key.toLowerCase(); + const normalizedValue = String(value).toLowerCase(); + const hasCapabilityToken = + normalizedKey.includes('notify') || + normalizedKey.includes('indicate') || + normalizedKey.includes('read') || + normalizedValue.includes('notify') || + normalizedValue.includes('indicate') || + normalizedValue.includes('read'); + + if (!hasCapabilityToken) { + return false; + } + + return value === true || value === 1 || normalizedValue === normalizedKey || normalizedValue === 'true' || normalizedValue === '1'; + }); + } + + return false; + } + + private isLikelyButtonCharacteristic(serviceUuid: string, characteristicUuid: string): boolean { + const normalizedService = serviceUuid.replace(/-/g, '').toUpperCase(); + 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_CHARACTERISTICS].some((uuid) => this.areUuidsEqual(characteristicUuid, uuid)); + + if (knownServiceMatch || knownCharacteristicMatch) { + return true; + } + + // 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)); + } + // Remove all the old button monitoring methods as they're replaced by the event-based approach // Button events are now handled in handleCharacteristicValueUpdate method @@ -1395,35 +1974,12 @@ export class BluetoothAudioService { private parseB01InricoButtonData(buffer: Buffer): AudioButtonEvent | null { if (buffer.length === 0) return null; - // Log all raw button data for debugging const rawHex = buffer.toString('hex'); - const allBytes = Array.from(buffer) - .map((b) => `0x${b.toString(16).padStart(2, '0')}`) - .join(', '); - - logger.info({ - message: 'B01 Inrico raw button data analysis', - context: { - bufferLength: buffer.length, - rawHex, - allBytes, - firstByte: buffer.length > 0 ? `0x${buffer[0]!.toString(16).padStart(2, '0')}` : 'N/A', - secondByte: buffer.length > 1 ? `0x${buffer[1]!.toString(16).padStart(2, '0')}` : 'N/A', - }, - }); // B01 Inrico-specific parsing logic const byte = buffer[0]; const byte2 = buffer[5] || 0; // Fallback to 0 if not present - if (byte === undefined) { - logger.warn({ - message: 'B01 Inrico buffer is empty', - context: { bufferLength: buffer.length }, - }); - return null; - } - let buttonType: AudioButtonEvent['button'] = 'unknown'; let eventType: AudioButtonEvent['type'] = 'press'; @@ -1510,7 +2066,9 @@ export class BluetoothAudioService { // Re-check button mapping with the actual button byte (without long press flag) switch (actualButtonByte) { case 0x00: - buttonType = 'ptt_stop'; + // Ignore 0x80 (Long press on 0x00). This is often sent while holding PTT + // and should NOT be interpreted as a STOP command. + buttonType = 'unknown'; break; case 0x01: buttonType = 'ptt_start'; @@ -1614,7 +2172,6 @@ export class BluetoothAudioService { if (buffer.length === 0) return null; const byte = buffer[0]; - if (byte === undefined) return null; // Example parsing logic (varies by manufacturer) let buttonType: AudioButtonEvent['button'] = 'unknown'; @@ -1651,7 +2208,7 @@ export class BluetoothAudioService { }; } - private processButtonEvent(buttonEvent: AudioButtonEvent): void { + private processButtonEvent(buttonEvent: AudioButtonEvent, source: 'ble' | 'media' = 'ble'): void { logger.info({ message: 'Button event processed', context: { buttonEvent }, @@ -1666,12 +2223,38 @@ export class BluetoothAudioService { } if (buttonEvent.button === 'ptt_start') { - this.setMicrophoneEnabled(true); + if (this.pttPressActive) { + if (source === 'media') { + this.schedulePttReleaseFallback(); + } + return; + } + + this.pttPressActive = true; + if (source === 'media') { + this.schedulePttReleaseFallback(); + } else { + this.clearPttReleaseFallback(); + } + + // Proactively lock CallKeep events to prevent HFP interactions/spam + // when we are explicitly handling PTT via SPP/GATT + callKeepService.ignoreMuteEvents(1000); + this.requestMicrophoneState(true); return; } if (buttonEvent.button === 'ptt_stop') { - this.setMicrophoneEnabled(false); + if (!this.pttPressActive) { + return; + } + + this.pttPressActive = false; + this.clearPttReleaseFallback(); + + // Keep locked for a bit after release to handle trailing events + callKeepService.ignoreMuteEvents(1000); + this.requestMicrophoneState(false); return; } @@ -1703,17 +2286,7 @@ export class BluetoothAudioService { private async handleMuteToggle(): Promise { try { - // Use the LiveKit store action which handles: - // 1. Updating the debounce timestamp (CRITICAL for fixing PTT desync) - // 2. Toggling the microphone - // 3. Playing validation sounds - // 4. Updating CallKeep state - // 5. Updating headset state - await useLiveKitStore.getState().toggleMicrophone(); - - logger.info({ - message: 'Microphone toggled via Bluetooth button', - }); + await getLiveKitStore().getState().toggleMicrophone(); } catch (error) { logger.error({ message: 'Failed to toggle microphone via Bluetooth button', @@ -1722,35 +2295,126 @@ export class BluetoothAudioService { } } + private schedulePttReleaseFallback(): void { + this.clearPttReleaseFallback(); + + this.pttReleaseFallbackTimeout = setTimeout(() => { + if (!this.pttPressActive) { + return; + } + + this.pttPressActive = false; + callKeepService.ignoreMuteEvents(1000); + this.requestMicrophoneState(false); + }, 1400); + } + + private clearPttReleaseFallback(): void { + if (this.pttReleaseFallbackTimeout) { + clearTimeout(this.pttReleaseFallbackTimeout); + this.pttReleaseFallbackTimeout = null; + } + } + /** - * Toggle microphone state. - * - * CRITICAL LOGIC: - * This method is the EXCLUSIVE path for Bluetooth PTT devices to control the microphone. - * When a Bluetooth PTT device is selected, we STRICTLY IGNORE CallKit/System mute events - * in `useLiveKitCallStore` to prevent interference. - * - * - PTT PRESS -> setMicrophoneEnabled(true) - * - PTT RELEASE -> setMicrophoneEnabled(false) + * 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; + + if (this.micApplyRetryTimeout) { + return; + } + + this.micApplyRetryTimeout = setTimeout(() => { + this.micApplyRetryTimeout = null; + + const pendingEnabled = this.retryMicEnabled; + this.retryMicEnabled = null; + if (pendingEnabled === null) { + return; + } + + this.pendingMicEnabled = pendingEnabled; + this.requestMicrophoneState(pendingEnabled); + }, 160); + } + + private clearMicApplyRetry(): void { + if (this.micApplyRetryTimeout) { + clearTimeout(this.micApplyRetryTimeout); + this.micApplyRetryTimeout = null; + } + + this.retryMicEnabled = null; + } + + private requestMicrophoneState(enabled: boolean): void { + this.pendingMicEnabled = enabled; + + if (this.isApplyingMicState) { + return; + } + + this.isApplyingMicState = true; + + void (async () => { + try { + while (this.pendingMicEnabled !== null) { + const targetEnabled = this.pendingMicEnabled; + this.pendingMicEnabled = null; + await this.applyMicrophoneEnabled(targetEnabled); + } + } finally { + this.isApplyingMicState = false; + + if (this.pendingMicEnabled !== null) { + this.requestMicrophoneState(this.pendingMicEnabled); + } + } + })(); + } + private async setMicrophoneEnabled(enabled: boolean): Promise { + await this.applyMicrophoneEnabled(enabled); + } + + private async applyMicrophoneEnabled(enabled: boolean): Promise { try { - // Use the LiveKit store action which handles: - // 1. Updating the debounce timestamp (CRITICAL for fixing PTT desync) - // 2. Setting the microphone state - // 3. Playing validation sounds - // 4. Updating CallKeep state - // 5. Updating headset state - await useLiveKitStore.getState().setMicrophoneEnabled(enabled); + const liveKitState = getLiveKitStore().getState(); + const hasRoom = Boolean(liveKitState.currentRoom?.localParticipant); - logger.info({ - message: 'Microphone state set via Bluetooth button', - context: { enabled }, - }); + if (!hasRoom && liveKitState.isConnecting) { + this.scheduleMicApplyRetry(enabled); + return; + } + + this.clearMicApplyRetry(); + + await liveKitState.setMicrophoneEnabled(enabled); } catch (error) { logger.error({ - message: 'Failed to set microphone state via Bluetooth button', - context: { error }, + message: 'Failed to set microphone via Bluetooth PTT button', + context: { error, enabled }, }); } } @@ -1819,8 +2483,7 @@ export class BluetoothAudioService { bluetoothStore.setAvailableAudioDevices(nonBluetoothDevices); // Revert to default audio devices - const defaultMic = nonBluetoothDevices.find((d) => d.type === 'default' && d.id.includes('mic')); - const defaultSpeaker = nonBluetoothDevices.find((d) => d.type === 'default' && d.id.includes('speaker')); + const { microphone: defaultMic, speaker: defaultSpeaker } = this.findDefaultAudioDevices(nonBluetoothDevices); if (defaultMic) { bluetoothStore.setSelectedMicrophone(defaultMic); @@ -1831,6 +2494,11 @@ export class BluetoothAudioService { // Revert audio routing to default (phone speaker/microphone) bluetoothStore.setAudioRoutingActive(false); + bluetoothStore.setIsHeadsetButtonMonitoring(false); + this.resetPttState(); + this.stopMonitoringWatchdog(); + this.stopReadPollingFallback(); + this.stopMediaButtonFallbackMonitoring(); } catch (error) { logger.error({ message: 'Failed to revert LiveKit audio routing', @@ -1839,13 +2507,77 @@ export 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.resetPttState(); + this.stopMonitoringWatchdog(); + this.stopReadPollingFallback(); + this.stopMediaButtonFallbackMonitoring(); if (this.connectedDevice && this.connectedDevice.id) { + const deviceId = this.connectedDevice.id; try { - await BleManager.disconnect(this.connectedDevice.id); + await BleManager.disconnect(deviceId); logger.info({ message: 'Bluetooth audio device disconnected manually', - context: { deviceId: this.connectedDevice.id }, + context: { deviceId }, }); } catch (error) { logger.error({ @@ -1854,7 +2586,7 @@ export class BluetoothAudioService { }); } - this.handleDeviceDisconnected({ peripheral: this.connectedDevice.id }); + this.handleDeviceDisconnected({ peripheral: deviceId }); } } @@ -1863,6 +2595,7 @@ export class BluetoothAudioService { } async isDeviceConnected(deviceId: string): Promise { + if (this.isWeb) return false; try { const connectedPeripherals = await BleManager.getConnectedPeripherals(); return connectedPeripherals.some((p) => p.id === deviceId); @@ -1910,9 +2643,14 @@ export class BluetoothAudioService { } } - destroy(): void { - this.stopScanning(); - this.disconnectDevice(); + 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) => { @@ -1920,14 +2658,67 @@ export class BluetoothAudioService { }); this.eventListeners = []; - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout); - this.connectionTimeout = null; - } - // Reset initialization flags this.isInitialized = false; + this.isBleManagerStarted = false; this.hasAttemptedPreferredDeviceConnection = false; + + try { + await this.stopScanning(); + } catch (error) { + logger.warn({ + message: 'Error stopping scan during Bluetooth service destroy', + context: { error }, + }); + } + try { + await this.disconnectDevice(); + } catch (error) { + logger.warn({ + message: 'Error disconnecting device during Bluetooth service destroy', + context: { error }, + }); + } + } + + /** + * Fully reset the Bluetooth service state. + * Clears connections, scanning, and preferred device tracking. + */ + async reset(): Promise { + if (this.isWeb) return; + logger.info({ + message: 'Resetting Bluetooth Audio Service state', + }); + + try { + await this.stopScanning(); + await this.disconnectDevice(); + + this.connectedDevice = null; + this.hasAttemptedPreferredDeviceConnection = false; + + // Revert LiveKit audio routing + this.revertLiveKitAudioRouting(); + + const store = useBluetoothAudioStore.getState(); + store.clearDevices(); + store.setConnectedDevice(null); + store.setPreferredDevice(null); + store.clearConnectionError(); + store.setIsConnecting(false); + store.setIsScanning(false); + store.setIsHeadsetButtonMonitoring(false); + this.resetPttState(); + this.stopMonitoringWatchdog(); + this.stopReadPollingFallback(); + this.stopMediaButtonFallbackMonitoring(); + } catch (error) { + logger.error({ + message: 'Error resetting Bluetooth Audio Service', + context: { error }, + }); + } } } diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts index 5dbfcbc7..2022f9f9 100644 --- a/src/services/callkeep.service.android.ts +++ b/src/services/callkeep.service.android.ts @@ -21,6 +21,11 @@ export class CallKeepService { private isCallActive = false; private muteStateCallback: ((muted: boolean) => void) | null = null; private endCallCallback: (() => void) | null = null; + private lastMuteEventTime: number = 0; + private muteEventStormEndTime: number = 0; + private ignoreEventsUntil: number = 0; + // Internal flag to control if we should emit mute events + private shouldEmitMuteEvents = true; private constructor() {} @@ -264,6 +269,19 @@ export class CallKeepService { this.endCallCallback = callback; } + /** + * Externally lock/ignore mute events for a duration. + * Useful when we know a PTT button is being pressed and want to ignore system side-effects. + */ + ignoreMuteEvents(durationMs: number): void { + const now = Date.now(); + this.ignoreEventsUntil = Math.max(this.ignoreEventsUntil, now + durationMs); + logger.debug({ + message: 'CallKeep mute events ignored via external lock', + context: { durationMs, until: this.ignoreEventsUntil }, + }); + } + /** * Check if there's an active CallKit call */ @@ -310,25 +328,69 @@ export class CallKeepService { }); // Mute/unmute events - RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { + RNCallKeep.addEventListener('didPerformSetMutedCallAction', this.handleMutedCallAction); + } + + private handleMutedCallAction = ({ muted, callUUID }: { muted: boolean; callUUID: string }) => { + // Check internal gate (closed while a specialized PTT device is connected) + if (!this.shouldEmitMuteEvents) { + return; + } + + const now = Date.now(); + + // Check for external lock (e.g. set by PTT button logic) + if (now < this.ignoreEventsUntil) { logger.debug({ - message: 'CallKeep mute state changed', - context: { muted, callUUID }, + message: 'Ignored CallKeep mute state change (external lock)', + context: { muted, callUUID, lockedUntil: this.ignoreEventsUntil }, }); + return; + } - // Call the registered callback if available - if (this.muteStateCallback) { - try { - this.muteStateCallback(muted); - } catch (error) { - logger.warn({ - message: 'Failed to execute mute state callback', - context: { error, muted, callUUID }, - }); - } - } + const timeSinceLastEvent = now - this.lastMuteEventTime; + this.lastMuteEventTime = now; + + // Storm Detection / Spam Protection + // If events are coming in rapidly (< 500ms), we consider it a "storm" (e.g. faulty headset or HFP conflict) + // We block ALL events during a storm and extend the block window as long as the storm continues. + if (timeSinceLastEvent < 500) { + this.muteEventStormEndTime = now + 800; // Block for 800ms from this event + + logger.debug({ + message: 'Ignored CallKeep mute state change (storm detected)', + context: { muted, callUUID, timeSinceLastEvent }, + }); + return; + } + + // Still within the storm cooldown window + if (now < this.muteEventStormEndTime) { + this.muteEventStormEndTime = now + 800; // Extend block + logger.debug({ + message: 'Ignored CallKeep mute state change (storm cooldown)', + context: { muted, callUUID, stormEndsAt: this.muteEventStormEndTime }, + }); + return; + } + + logger.debug({ + message: 'CallKeep mute state changed', + context: { muted, callUUID }, }); - } + + // Call the registered callback if available + if (this.muteStateCallback) { + try { + this.muteStateCallback(muted); + } catch (error) { + logger.warn({ + message: 'Failed to execute mute state callback', + context: { error, muted, callUUID }, + }); + } + } + }; /** * Generate a UUID for CallKeep calls @@ -346,17 +408,20 @@ export class CallKeepService { } /** - * Remove the CallKeep mute listener (No-op on Android or handled differently) + * Remove the CallKeep mute listener. + * This is used to prevent PTT loop oscillation when a specialized device is active. */ removeMuteListener(): void { - // Android implementation if needed + this.shouldEmitMuteEvents = false; + logger.debug({ message: 'CallKeep mute listener disabled (internal flag)' }); } /** - * Restore the CallKeep mute listener (No-op on Android or handled differently) + * Restore the CallKeep mute listener. */ restoreMuteListener(): void { - // Android implementation if needed + this.shouldEmitMuteEvents = true; + logger.debug({ message: 'CallKeep mute listener restored (internal flag)' }); } /** diff --git a/src/services/callkeep.service.d.ts b/src/services/callkeep.service.d.ts index 3f5810a2..577bb671 100644 --- a/src/services/callkeep.service.d.ts +++ b/src/services/callkeep.service.d.ts @@ -14,6 +14,9 @@ export interface CallKeepServiceInterface { startCall(roomName: string, handle?: string): Promise; endCall(): Promise; setMuteStateCallback(callback: ((muted: boolean) => void) | null): void; + ignoreMuteEvents(durationMs: number): void; + removeMuteListener(): void; + restoreMuteListener(): void; isCallActiveNow(): boolean; getCurrentCallUUID(): string | null; cleanup(): Promise; @@ -25,6 +28,9 @@ export declare class CallKeepService implements CallKeepServiceInterface { startCall(roomName: string, handle?: string): Promise; endCall(): Promise; setMuteStateCallback(callback: ((muted: boolean) => void) | null): void; + ignoreMuteEvents(durationMs: number): void; + removeMuteListener(): void; + restoreMuteListener(): void; isCallActiveNow(): boolean; getCurrentCallUUID(): string | null; cleanup(): Promise; diff --git a/src/services/callkeep.service.ios.ts b/src/services/callkeep.service.ios.ts index 53d11fc5..99589db3 100644 --- a/src/services/callkeep.service.ios.ts +++ b/src/services/callkeep.service.ios.ts @@ -22,6 +22,9 @@ export class CallKeepService { private isCallActive = false; private muteStateCallback: ((muted: boolean) => void) | null = null; private endCallCallback: (() => void) | null = null; + private lastMuteEventTime: number = 0; + private muteEventStormEndTime: number = 0; + private ignoreEventsUntil: number = 0; private constructor() {} @@ -261,6 +264,19 @@ export class CallKeepService { this.endCallCallback = callback; } + /** + * Externally lock/ignore mute events for a duration. + * Useful when we know a PTT button is being pressed and want to ignore system side-effects. + */ + ignoreMuteEvents(durationMs: number): void { + const now = Date.now(); + this.ignoreEventsUntil = Math.max(this.ignoreEventsUntil, now + durationMs); + logger.debug({ + message: 'CallKeep mute events ignored via external lock', + context: { durationMs, until: this.ignoreEventsUntil }, + }); + } + /** * Check if there's an active CallKit call */ @@ -329,33 +345,72 @@ export class CallKeepService { }); // Mute/unmute events - RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { - // Check internal gate - if (!this._shouldEmitMuteEvents) { - return; - } + RNCallKeep.addEventListener('didPerformSetMutedCallAction', this.handleMutedCallAction); + } + + // Internal flag to control if we should emit mute events + private _shouldEmitMuteEvents = true; + + private handleMutedCallAction = ({ muted, callUUID }: { muted: boolean; callUUID: string }) => { + // Check internal gate + if (!this._shouldEmitMuteEvents) { + return; + } + + const now = Date.now(); + // Check for external lock (e.g. set by PTT button logic) + if (now < this.ignoreEventsUntil) { logger.debug({ - message: 'CallKeep mute state changed', - context: { muted, callUUID }, + message: 'Ignored CallKeep mute state change (external lock)', + context: { muted, callUUID, lockedUntil: this.ignoreEventsUntil }, }); + return; + } - // Call the registered callback if available - if (this.muteStateCallback) { - try { - this.muteStateCallback(muted); - } catch (error) { - logger.warn({ - message: 'Failed to execute mute state callback', - context: { error, muted, callUUID }, - }); - } - } + const timeSinceLastEvent = now - this.lastMuteEventTime; + this.lastMuteEventTime = now; + + // Storm Detection / Spam Protection + // If events are coming in rapidly (< 500ms), we consider it a "storm" (e.g. faulty headset or HFP conflict) + // We block ALL events during a storm and extend the block window as long as the storm continues. + if (timeSinceLastEvent < 500) { + this.muteEventStormEndTime = now + 800; // Block for 800ms from this event + + logger.debug({ + message: 'Ignored CallKeep mute state change (storm detected)', + context: { muted, callUUID, timeSinceLastEvent }, + }); + return; + } + + // Still within the storm cooldown window + if (now < this.muteEventStormEndTime) { + this.muteEventStormEndTime = now + 800; // Extend block + logger.debug({ + message: 'Ignored CallKeep mute state change (storm cooldown)', + context: { muted, callUUID, stormEndsAt: this.muteEventStormEndTime }, + }); + return; + } + + logger.debug({ + message: 'CallKeep mute state changed', + context: { muted, callUUID }, }); - } - // Internal flag to control if we should emit mute events - private _shouldEmitMuteEvents = true; + // Call the registered callback if available + if (this.muteStateCallback) { + try { + this.muteStateCallback(muted); + } catch (error) { + logger.warn({ + message: 'Failed to execute mute state callback', + context: { error, muted, callUUID }, + }); + } + } + }; /** * Remove the CallKeep mute listener. diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index e552ca5e..fee58825 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -135,6 +135,10 @@ class OfflineEventManager { const store = useOfflineQueueStore.getState(); + // Events held for another session are dropped once they expire. The purge at rehydrate usually runs before the + // session has, so it cannot tell whose they are; this pass can. + store.purgeStaleFailedEvents(); + // Don't process if offline if (!store.isConnected || !store.isNetworkReachable) { logger.debug({ diff --git a/src/services/offline-queue-processor.ts b/src/services/offline-queue-processor.ts index 7d448885..3520efff 100644 --- a/src/services/offline-queue-processor.ts +++ b/src/services/offline-queue-processor.ts @@ -4,8 +4,10 @@ import CryptoJS from 'crypto-js'; import { savePersonnelStatus } from '@/api/personnel/personnelStatuses'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { currentQueueOwner, isAbandonedQueueItem, isOwnedByCurrentSession } from '@/lib/offline-queue-owner'; import { getResponseStatus, isClientRejection } from '@/lib/request-errors'; import { getOfflineQueueStorage } from '@/lib/storage/secure-storage'; +import type { QueueOwner } from '@/models/offline-queue/queued-event'; import type { SavePersonStatusInput } from '@/models/v4/personnelStatuses/savePersonStatusInput'; const MAX_RETRIES = 5; @@ -41,11 +43,15 @@ interface QueueItem { retries: number; attempts?: number; nextRetryAt?: number; + /** Null when nobody was signed in to queue it for; absent on items queued before owners were recorded. */ + owner?: QueueOwner | null; + queuedAt?: number; } export class RealOfflineQueueProcessor { private static instance: RealOfflineQueueProcessor | null = null; private processing = false; + private rerunRequested = false; private storageKey = 'offline_queue'; private queueMutationChain: Promise = Promise.resolve(); private retryTimer: ReturnType | null = null; @@ -122,16 +128,35 @@ export class RealOfflineQueueProcessor { } async processQueue(): Promise { - if (this.processing) return; + if (this.processing) { + // Run once more when the active pass ends: an item it already held back may be sendable by then (its owner + // signed in mid-pass), and on a steady connection nothing else would wake the queue for it. + this.rerunRequested = true; + return; + } this.processing = true; try { await this.serializeQueueMutation(async () => { const storage = await getOfflineQueueStorage(); const items = await this.readQueue(storage); const remaining: QueueItem[] = []; + // Only the signed-in session's items get a retry timer: one for an item that is being held would fire at once + // and keep firing, since holding it never moves its retry time on. + const retrying: QueueItem[] = []; for (const item of items) { + // Each item goes to its own server under its own account, never to whoever is signed in now. + if (!isOwnedByCurrentSession(item.owner)) { + if (isAbandonedQueueItem(item.owner, item.queuedAt)) { + logger.warn({ message: 'Dropping offline personnel status held too long for another session', context: { id: item.id } }); + } else { + remaining.push(item); + } + continue; + } + if (item.nextRetryAt && item.nextRetryAt > Date.now()) { remaining.push(item); + retrying.push(item); continue; } @@ -150,16 +175,22 @@ export class RealOfflineQueueProcessor { const backoff = Math.min(2 ** item.retries * 1000, 30000); item.nextRetryAt = Date.now() + backoff; remaining.push(item); + retrying.push(item); logger.warn({ message: 'Scheduled offline queue item retry', context: { id: item.id, attempts: item.attempts, nextRetryAt: item.nextRetryAt, error } }); } await storage.set(this.storageKey, JSON.stringify(remaining)); - this.scheduleNextRetry(remaining); + this.scheduleNextRetry(retrying); }); } catch (error) { logger.error({ message: 'Processing offline queue failed', context: { error } }); } finally { this.processing = false; } + + if (this.rerunRequested) { + this.rerunRequested = false; + await this.processQueue(); + } } /** @@ -213,7 +244,7 @@ export class RealOfflineQueueProcessor { addPersonnelStatusToQueue(status: SavePersonStatusInput): string { const id = `${Date.now()}-${Math.random()}`; - void this.enqueue({ id, type: 'personnelStatus', payload: status, retries: 0, attempts: 0 }).catch((error) => { + void this.enqueue({ id, type: 'personnelStatus', payload: status, retries: 0, attempts: 0, owner: currentQueueOwner(), queuedAt: Date.now() }).catch((error) => { logger.error({ message: 'Failed to enqueue personnel status', context: { id, error } }); }); return id; diff --git a/src/services/offline-queue.service.ts b/src/services/offline-queue.service.ts index a4e59f56..17dde218 100644 --- a/src/services/offline-queue.service.ts +++ b/src/services/offline-queue.service.ts @@ -21,6 +21,11 @@ export class OfflineQueueService { logger.debug({ message: 'Offline queue service already initialized, skipping', }); + // Runs again on every sign-in: send what was held for this session while signed out or while someone else was. + // Not awaited, so a slow link never holds up the rest of start-up. + void offlineQueueProcessor.processQueue().catch((error: unknown) => { + logger.error({ message: 'Offline queue processing failed after sign-in', context: { error } }); + }); return; } diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index d4a0b518..ea96700e 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -9,10 +9,20 @@ import { openWeatherAlertDetail } from '@/components/weather-alerts/weather-aler import { useAuthStore } from '@/lib/auth'; import { logger } from '@/lib/logging'; import { type RouterPushRetryOptions, routerPushWithRetry } from '@/lib/navigation'; -import { getDeviceUuid } from '@/lib/storage/app'; +import { getBaseApiUrl, getDeviceUuid } from '@/lib/storage/app'; +import { registerStoreReset } from '@/lib/storage/clear-all-data'; +import { useAppLifecycleStore } from '@/stores/app/app-lifecycle'; import { isSafeRouteId, parseNotificationData, usePushNotificationModalStore } from '@/stores/push-notification/store'; import { securityStore } from '@/stores/security/store'; +/** RegisterDevice answers 200 even when it fails; only these statuses mean the server took the registration (it is queued). */ +const REGISTERED_STATUSES = ['queued', 'success']; + +interface RegisterOptions { + /** Prompt for notification permission when it has not been granted. Defaults to true. */ + requestPermission?: boolean; +} + // Define notification response types export interface PushNotificationData { title?: string; @@ -140,6 +150,13 @@ class PushNotificationService { // Identifier of the last notification response we surfaced, used to avoid presenting the launch // response twice (once from getLastNotificationResponseAsync and once from the response listener). private lastHandledResponseId: string | null = null; + // Server, user, department and token of the last registration the server took. Core files the token under the + // department code it is sent ({code}_User_{userId} on Novu), so any of the four changing needs a new registration. + private registeredKey: string | null = null; + // Bumped at sign-out so a registration still in flight cannot mark the next session as registered. + private registrationGeneration = 0; + // Registrations run one at a time, so the newest identity is always the last one written to the server. + private registrationQueue: Promise = Promise.resolve(); private static toErrorContext(error: unknown): { errorMessage: string; errorName: string } { return { @@ -383,7 +400,24 @@ class PushNotificationService { } }; - public async registerForPushNotifications(userId: string, departmentCode: string): Promise { + /** + * Registers this device's token for the user and department at the server the app currently points at. Safe to call + * repeatedly: calls run one at a time and a call for an identity the server already took does nothing more than + * re-read the permission and token, so a token that rotated or a registration the server failed is picked up again. + */ + public registerForPushNotifications(userId: string, departmentCode: string, options: RegisterOptions = {}): Promise { + const run = this.registrationQueue.then(() => this.register(userId, departmentCode, options.requestPermission ?? true)); + this.registrationQueue = run.catch(() => undefined); + return run; + } + + /** Forgets what was registered, so the next session registers again. Runs at sign-out. */ + public resetRegistration(): void { + this.registeredKey = null; + this.registrationGeneration += 1; + } + + private async register(userId: string, departmentCode: string, requestPermission: boolean): Promise { // A browser registers through services/web-push.web.ts, and only once the person asks: asking for permission // here, with no click behind it, is refused by most browsers and failed for want of a web push config anyway. if (Platform.OS === 'web') { @@ -397,11 +431,13 @@ class PushNotificationService { return null; } + const generation = this.registrationGeneration; + try { const { status: existingStatus } = await Notifications.getPermissionsAsync(); let finalStatus = existingStatus; - if (existingStatus !== 'granted') { + if (existingStatus !== 'granted' && requestPermission) { const { status } = await Notifications.requestPermissionsAsync({ ios: { allowAlert: true, @@ -414,20 +450,30 @@ class PushNotificationService { } if (finalStatus !== 'granted') { - logger.warn({ - message: 'Failed to get push notification permissions', - context: { status: finalStatus }, - }); + if (requestPermission) { + logger.warn({ + message: 'Failed to get push notification permissions', + context: { status: finalStatus }, + }); + } return null; } // Get the token using the non-Expo push notification service method const devicePushToken = await Notifications.getDevicePushTokenAsync(); + this.pushToken = devicePushToken.data as string; - // The token format depends on the platform - const token = Platform.OS === 'ios' ? devicePushToken.data : devicePushToken.data; + const serverUrl = getBaseApiUrl(); + const key = [serverUrl, userId, departmentCode, this.pushToken].join('|'); + if (key === this.registeredKey) { + return this.pushToken; + } - this.pushToken = token as string; + // The identity was captured when this call was queued. If the session has since signed out or moved to another + // department, registering it would park the token on a subscriber nothing notifies; the newer call does it right. + if (generation !== this.registrationGeneration || useAuthStore.getState().userId !== userId || securityStore.getState().rights?.DepartmentCode !== departmentCode) { + return null; + } logger.info({ message: 'Push notification token obtained', @@ -435,10 +481,11 @@ class PushNotificationService { tokenPreview: PushNotificationService.truncateToken(this.pushToken), userId, platform: Platform.OS, + serverUrl, }, }); - await registerDevice({ + const result = await registerDevice({ UserId: userId, Token: this.pushToken, Platform: Platform.OS === 'ios' ? 1 : 2, @@ -446,6 +493,24 @@ class PushNotificationService { Prefix: departmentCode, }); + // Remembered only once the server took it, so the next foreground retries a registration it refused or failed. + if (!REGISTERED_STATUSES.includes(result?.Status?.toLowerCase() ?? '')) { + logger.warn({ + message: 'Push registration was not accepted by the server', + context: { status: result?.Status, userId, serverUrl }, + }); + return null; + } + + if (generation === this.registrationGeneration) { + this.registeredKey = key; + } + + logger.info({ + message: 'Successfully registered for push notifications', + context: { userId, serverUrl }, + }); + return this.pushToken; } catch (error) { logger.error({ @@ -505,43 +570,44 @@ class PushNotificationService { export const pushNotificationService = PushNotificationService.getInstance(); +registerStoreReset('push-notification', () => pushNotificationService.resetRegistration()); + +const registerInBackground = (userId: string, departmentCode: string, requestPermission: boolean): void => { + pushNotificationService.registerForPushNotifications(userId, departmentCode, { requestPermission }).catch((error) => { + logger.error({ + message: 'Error in push notification registration hook', + context: { + errorMessage: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : 'UnknownError', + }, + }); + }); +}; + // React hook for component usage export const usePushNotifications = () => { const userId = useAuthStore((state) => state.userId); - const rights = securityStore((state) => state.rights); - const previousUserIdRef = useRef(null); + const departmentCode = securityStore((state) => state.rights?.DepartmentCode); + const isActive = useAppLifecycleStore((state) => state.isActive); + const wasActiveRef = useRef(isActive); + // Keyed on the department as well as the user: the token is filed under the department code, and keying on the user + // alone kept whichever code was present first, including one left over from a previous session or server. useEffect(() => { - // Only register if we have an active user ID and it's different from the previous one - if (rights && userId && userId !== previousUserIdRef.current) { - pushNotificationService - .registerForPushNotifications(userId, rights.DepartmentCode) - .then((token) => { - if (token) { - logger.info({ - message: 'Successfully registered for push notifications', - context: { userId: userId }, - }); - } - }) - .catch((error) => { - logger.error({ - message: 'Error in push notification registration hook', - context: { - errorMessage: error instanceof Error ? error.message : String(error), - errorName: error instanceof Error ? error.name : 'UnknownError', - }, - }); - }); - - previousUserIdRef.current = userId; + if (userId && departmentCode) { + registerInBackground(userId, departmentCode, true); } + }, [userId, departmentCode]); - // Cleanup function - return () => { - // No need to clean up here as the service handles its own cleanup - }; - }, [userId, rights]); + // Back in the foreground, check again without prompting: this retries a registration that failed and picks up a + // permission granted in system settings or a token that rotated. A no-op once the server has the current token. + useEffect(() => { + const cameToForeground = isActive && !wasActiveRef.current; + wasActiveRef.current = isActive; + if (cameToForeground && userId && departmentCode) { + registerInBackground(userId, departmentCode, false); + } + }, [isActive, userId, departmentCode]); return { pushToken: pushNotificationService.getPushToken(), diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index 8203ad65..cb687a80 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -46,6 +46,7 @@ jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), + debug: jest.fn(), }, })); @@ -621,4 +622,72 @@ describe('Core Store', () => { ]); }); }); + + describe('sign-out', () => { + it('forgets the signed-out server\'s config so the next session never connects to its endpoints', async () => { + const { clearAllAppData } = require('@/lib/storage/clear-all-data'); + useCoreStore.setState({ + config: { EventingUrl: 'https://events.us.example/', NovuBackendApiUrl: 'https://novu.us.example' } as any, + isInitialized: true, + isInitializing: true, + activeStatuses: [] as any, + currentStatus: { StatusId: 1 } as any, + }); + + await clearAllAppData({ resetStores: true, clearStorage: false, clearFilters: false }); + + const state = useCoreStore.getState(); + expect(state.config).toBeNull(); + expect(state.isInitialized).toBe(false); + // An init() still in flight from the old session must not make the next session's init() skip. + expect(state.isInitializing).toBe(false); + expect(state.activeStatuses).toBeNull(); + expect(state.currentStatus).toBeNull(); + }); + + it('ignores an init() from the signed-out session that finishes after the reset', async () => { + const { clearAllAppData } = require('@/lib/storage/clear-all-data'); + const config = await mockGetConfig('test-app-key'); + let resolveConfig: (value: typeof config) => void = () => undefined; + mockGetConfig.mockReturnValueOnce( + new Promise((resolve) => { + resolveConfig = resolve; + }) + ); + mockApplyServerMapboxToken.mockClear(); + + const pending = useCoreStore.getState().init(); + await clearAllAppData({ resetStores: true, clearStorage: false, clearFilters: false }); + resolveConfig({ ...config, Data: { ...config.Data, EventingUrl: 'https://events.us.example/' } }); + await pending; + + const state = useCoreStore.getState(); + expect(state.config).toBeNull(); + expect(state.isInitialized).toBe(false); + expect(state.activeStatuses).toBeNull(); + expect(mockApplyServerMapboxToken).not.toHaveBeenCalled(); + + // The next session's init() still runs and loads its own server's config. + await useCoreStore.getState().init(); + expect(useCoreStore.getState().isInitialized).toBe(true); + }); + + it('ignores a fetchConfig() from the signed-out session that finishes after the reset', async () => { + const { clearAllAppData } = require('@/lib/storage/clear-all-data'); + const config = await mockGetConfig('test-app-key'); + let resolveConfig: (value: typeof config) => void = () => undefined; + mockGetConfig.mockReturnValueOnce( + new Promise((resolve) => { + resolveConfig = resolve; + }) + ); + + const pending = useCoreStore.getState().fetchConfig(); + await clearAllAppData({ resetStores: true, clearStorage: false, clearFilters: false }); + resolveConfig(config); + await pending; + + expect(useCoreStore.getState().config).toBeNull(); + }); + }); }); diff --git a/src/stores/app/bluetooth-audio-store.ts b/src/stores/app/bluetooth-audio-store.ts index c32a41e6..94d1839f 100644 --- a/src/stores/app/bluetooth-audio-store.ts +++ b/src/stores/app/bluetooth-audio-store.ts @@ -1,6 +1,11 @@ import { type Peripheral } from 'react-native-ble-manager'; import { create } from 'zustand'; +import { createDefaultPTTSettings, DEFAULT_MEDIA_BUTTON_PTT_SETTINGS, type MediaButtonPTTSettings, type PTTMode } from '@/types/ptt'; + +// Re-export PTT types for backwards compatibility +export { DEFAULT_MEDIA_BUTTON_PTT_SETTINGS, type MediaButtonPTTSettings, type PTTMode }; + // Re-export Peripheral as Device for compatibility export type Device = Peripheral; @@ -22,6 +27,7 @@ export interface BluetoothAudioDevice { hasAudioCapability: boolean; supportsMicrophoneControl: boolean; device: Device; + type: 'specialized' | 'system'; } export const SYSTEM_AUDIO_DEVICE: BluetoothAudioDevice = { @@ -30,6 +36,7 @@ export const SYSTEM_AUDIO_DEVICE: BluetoothAudioDevice = { isConnected: false, hasAudioCapability: true, supportsMicrophoneControl: true, + type: 'system', device: { id: 'system-audio', name: 'System Audio / Airpods', @@ -56,7 +63,7 @@ export interface ButtonAction { export interface AudioDeviceInfo { id: string; name: string; - type: 'bluetooth' | 'wired' | 'speaker' | 'default'; + type: 'bluetooth' | 'wired' | 'speaker' | 'default' | 'microphone'; isAvailable: boolean; } @@ -104,6 +111,9 @@ interface BluetoothAudioState { isHeadsetButtonMonitoring: boolean; headsetButtonConfig: HeadsetButtonConfig; + // Media button PTT settings (for AirPods/earbuds) + mediaButtonPTTSettings: MediaButtonPTTSettings; + // Actions setBluetoothState: (state: State) => void; setIsScanning: (isScanning: boolean) => void; @@ -138,6 +148,10 @@ interface BluetoothAudioState { // Headset button PTT actions setIsHeadsetButtonMonitoring: (isMonitoring: boolean) => void; setHeadsetButtonConfig: (config: Partial) => void; + + // Media button PTT settings (for AirPods/earbuds) + setMediaButtonPTTSettings: (settings: Partial) => void; + setMediaButtonPTTEnabled: (enabled: boolean) => void; } export const useBluetoothAudioStore = create((set, get) => ({ @@ -168,6 +182,7 @@ export const useBluetoothAudioStore = create((set, get) => longPressAction: 'none', soundFeedback: true, }, + mediaButtonPTTSettings: createDefaultPTTSettings(), // Bluetooth state actions setBluetoothState: (state) => set({ bluetoothState: state }), @@ -284,4 +299,25 @@ export const useBluetoothAudioStore = create((set, get) => }, }); }, + + // Media button PTT settings actions + setMediaButtonPTTSettings: (settings) => { + const { mediaButtonPTTSettings } = get(); + set({ + mediaButtonPTTSettings: { + ...mediaButtonPTTSettings, + ...settings, + }, + }); + }, + + setMediaButtonPTTEnabled: (enabled) => { + const { mediaButtonPTTSettings } = get(); + set({ + mediaButtonPTTSettings: { + ...mediaButtonPTTSettings, + enabled, + }, + }); + }, })); diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index b989be13..8cdc4447 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -9,6 +9,7 @@ import { useAuthStore } from '@/lib/auth'; import { logger } from '@/lib/logging'; import { applyServerMapboxToken } from '@/lib/mapbox-token'; import { zustandStorage } from '@/lib/storage'; +import { registerStoreReset } from '@/lib/storage/clear-all-data'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; import { type GetCurrentStaffingResultData } from '@/models/v4/personnelStaffing/getCurrentStaffingResultData'; @@ -67,6 +68,10 @@ const migrateCoreStorage = (persistedState: unknown, version: number): Persisted return state as unknown as PersistedCoreState; }; +// Bumped when sign-out resets the store: a request still in flight then belongs to the ended session (and maybe another +// server) and must not write its data or flags over the reset. +let sessionGeneration = 0; + export const useCoreStore = create()( persist( (set, get) => ({ @@ -93,6 +98,7 @@ export const useCoreStore = create()( } set({ isLoading: true, isInitializing: true, error: null }); + const generation = sessionGeneration; try { const userId = useAuthStore.getState().userId; @@ -107,6 +113,9 @@ export const useCoreStore = create()( userId ? getCurrentPersonStatus(userId) : Promise.resolve(null), userId ? getCurrentPersonStaffing(userId) : Promise.resolve(null), ]); + if (generation !== sessionGeneration) { + return; + } set({ isInitialized: true, @@ -128,6 +137,10 @@ export const useCoreStore = create()( message: 'Core store initialization completed successfully', }); } catch (error) { + if (generation !== sessionGeneration) { + return; + } + set({ error: 'Failed to init core app data', isLoading: false, @@ -141,6 +154,8 @@ export const useCoreStore = create()( }, getStatusesAndStaffing: async () => { set({ error: null }); + const generation = sessionGeneration; + try { const userId = useAuthStore.getState().userId; @@ -150,6 +165,9 @@ export const useCoreStore = create()( userId ? getCurrentPersonStatus(userId) : Promise.resolve(null), userId ? getCurrentPersonStaffing(userId) : Promise.resolve(null), ]); + if (generation !== sessionGeneration) { + return; + } set({ activeStatuses: personnelStatuses.Data, @@ -157,6 +175,10 @@ export const useCoreStore = create()( ...(userId ? { currentStatus: currentStatus?.Data ?? null, currentStaffing: currentStaffing?.Data ?? null } : {}), }); } catch (error) { + if (generation !== sessionGeneration) { + return; + } + set({ error: 'Failed to fetch and set active statuses and staffing', isLoading: false, @@ -168,8 +190,14 @@ export const useCoreStore = create()( } }, fetchConfig: async () => { + const generation = sessionGeneration; + try { const config = await getConfig(Env.APP_KEY); + if (generation !== sessionGeneration) { + return; + } + set({ config: config.Data }); // Fire and forget: checking the token with Mapbox must never hold up or fail the config load. @@ -177,6 +205,10 @@ export const useCoreStore = create()( void applyServerMapboxToken(config.Data.AppMapboxAccessToken); } } catch (error) { + if (generation !== sessionGeneration) { + return; + } + set({ error: 'Failed to fetch config', isLoading: false }); logger.error({ message: `Failed to fetch config: ${JSON.stringify(error)}`, @@ -216,3 +248,24 @@ export const useCoreStore = create()( } ) ); + +// Config describes the server that was signed in to: its EventingUrl, Novu and web push endpoints. Every server switch +// signs out, and the next session must connect to its own server's endpoints, never ones left over in memory (an +// init() that failed used to leave the hubs pointed at the previous server). Clearing isInitializing also keeps an +// init() that was in flight at sign-out from making the next session's init() skip as "already in progress". +registerStoreReset('core', () => { + sessionGeneration += 1; + useCoreStore.setState({ + config: null, + isLoading: false, + isInitialized: false, + isInitializing: false, + error: null, + activeStatuses: null, + activeStaffing: null, + currentStatus: null, + currentStatusValue: null, + currentStaffing: null, + currentStaffingValue: null, + }); +}); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 7cee25c5..1759cdd4 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -11,6 +11,7 @@ import { translate } from '../../lib/i18n/utils'; import { logger } from '../../lib/logging'; import { type DepartmentVoiceChannelResultData } from '../../models/v4/voice/departmentVoiceResultData'; import { audioService } from '../../services/audio.service'; +import { bluetoothAudioService } from '../../services/bluetooth-audio.service'; import { callKeepService } from '../../services/callkeep.service'; import { headsetButtonService } from '../../services/headset-button.service'; import { toggleMicrophone } from '../../utils/microphone-toggle'; @@ -233,6 +234,8 @@ export const useLiveKitStore = create((set, get) => ({ }); } + bluetoothAudioService.ensurePttInputMonitoring('livekit-store connectToRoom start'); + // Disconnect from current room if connected. Clearing the reference matters: if this // connect attempt fails, the failure teardown below must not find the room we just left. if (currentRoom) { @@ -423,7 +426,6 @@ export const useLiveKitStore = create((set, get) => ({ try { await headsetButtonService.initialize(); headsetButtonService.startMonitoring(); - useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(true); logger.info({ message: 'Headset button monitoring started for PTT (AirPods/Bluetooth earbuds)', @@ -434,6 +436,8 @@ export const useLiveKitStore = create((set, get) => ({ context: { error }, }); } + + bluetoothAudioService.ensurePttInputMonitoring('livekit-store connectToRoom connected'); } catch (error) { logger.error({ message: 'Failed to connect to room', @@ -469,7 +473,6 @@ export const useLiveKitStore = create((set, get) => ({ // Stop headset button monitoring try { headsetButtonService.stopMonitoring(); - useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); logger.info({ message: 'Headset button monitoring stopped', @@ -629,7 +632,6 @@ export const useLiveKitStore = create((set, get) => ({ // Initialize and start headset button service await headsetButtonService.initialize(); headsetButtonService.startMonitoring(); - useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(true); logger.info({ message: 'Headset button monitoring started for PTT', @@ -645,7 +647,6 @@ export const useLiveKitStore = create((set, get) => ({ stopHeadsetButtonMonitoring: () => { try { headsetButtonService.stopMonitoring(); - useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); logger.info({ message: 'Headset button monitoring stopped', diff --git a/src/stores/calls/__tests__/active-call-store.test.ts b/src/stores/calls/__tests__/active-call-store.test.ts index 7738d1e3..8270161d 100644 --- a/src/stores/calls/__tests__/active-call-store.test.ts +++ b/src/stores/calls/__tests__/active-call-store.test.ts @@ -144,5 +144,26 @@ describe('useActiveCallStore', () => { expect(useActiveCallStore.getState().activeCall).toBeNull(); expect(mockCallsState.fetchCalls).not.toHaveBeenCalled(); }); + + it('does not select the call when sign-out resets the store while the calls are loading', async () => { + const { clearAllAppData } = require('@/lib/storage/clear-all-data'); + let finishFetch: () => void = () => undefined; + mockCallsState.fetchCalls = jest.fn( + () => + new Promise((resolve) => { + finishFetch = () => { + mockCallsState.calls = [mockCall]; + resolve(); + }; + }) + ); + + const pending = useActiveCallStore.getState().setActiveCallById('123'); + await clearAllAppData({ resetStores: true, clearStorage: false, clearFilters: false }); + finishFetch(); + await pending; + + expect(useActiveCallStore.getState().activeCallId).toBeNull(); + }); }); }); diff --git a/src/stores/calls/active-call-store.ts b/src/stores/calls/active-call-store.ts index 5138d8ab..1085ecbc 100644 --- a/src/stores/calls/active-call-store.ts +++ b/src/stores/calls/active-call-store.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { zustandStorage } from '@/lib/storage'; +import { registerStoreReset } from '@/lib/storage/clear-all-data'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { useCallsStore } from './store'; @@ -21,6 +22,10 @@ interface ActiveCallState { isActiveCall: (callId: string) => boolean; } +// Bumped when sign-out resets the store: a setActiveCallById() still in flight then belongs to the ended session and +// must not select its call in the next one. +let sessionGeneration = 0; + export const useActiveCallStore = create()( persist( (set, get) => ({ @@ -37,7 +42,12 @@ export const useActiveCallStore = create()( return; } + const generation = sessionGeneration; await useCallsStore.getState().fetchCalls(); + if (generation !== sessionGeneration) { + return; + } + // Re-read after the fetch: the state captured before it still holds the old list. const call = useCallsStore.getState().calls.find((openCall) => openCall.CallId === callId); @@ -60,3 +70,10 @@ export const useActiveCallStore = create()( } ) ); + +// The active call is the default destination of a status update; one from the previous session (or server) must not +// be sent by the next. +registerStoreReset('active-call', () => { + sessionGeneration += 1; + useActiveCallStore.getState().clearActiveCall(); +}); diff --git a/src/stores/offline-queue/__tests__/store.test.ts b/src/stores/offline-queue/__tests__/store.test.ts index ad24f1e4..37f95aae 100644 --- a/src/stores/offline-queue/__tests__/store.test.ts +++ b/src/stores/offline-queue/__tests__/store.test.ts @@ -39,6 +39,14 @@ jest.mock('@/utils/id-generator', () => ({ generateEventId: jest.fn(), })); +// The session events belong to: user-1 signed in to the US server, unless a test changes it. +const US_SERVER = 'https://api.us.example/api/v4'; +const EU_SERVER = 'https://api.eu.example/api/v4'; +const mockSession: { status: string; userId: string | null } = { status: 'signedIn', userId: 'user-1' }; +let mockServerUrl = US_SERVER; +jest.mock('@/stores/auth/store', () => ({ __esModule: true, default: { getState: () => mockSession } })); +jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: () => mockServerUrl })); + const mockGenerateEventId = require('@/utils/id-generator').generateEventId as jest.MockedFunction<() => string>; const mockLogger = logger as jest.Mocked; @@ -58,6 +66,10 @@ describe('OfflineQueueStore', () => { completedEvents: 0, }); + mockSession.status = 'signedIn'; + mockSession.userId = 'user-1'; + mockServerUrl = US_SERVER; + // Reset event ID counter and mock eventIdCounter = 0; mockGenerateEventId.mockImplementation(() => `test-event-id-${++eventIdCounter}`); @@ -537,4 +549,89 @@ describe('OfflineQueueStore', () => { expect(mockLogger.warn).not.toHaveBeenCalledWith(expect.objectContaining({ message: 'Offline queue size cap reached, dropped oldest events' })); }); }); + + describe('owner', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const usOwner = { serverUrl: US_SERVER, userId: 'user-1' }; + + const queueAs = (owner: QueuedEvent['owner'], createdAt = Date.now()): string => { + const eventId = useOfflineQueueStore.getState().addEvent(QueuedEventType.CHECK_IN, { callId: 1 }); + useOfflineQueueStore.setState((state) => ({ + queuedEvents: state.queuedEvents.map((event) => { + if (event.id !== eventId) return event; + const { owner: _owner, ...rest } = event; + return owner === undefined ? { ...rest, createdAt } : { ...rest, owner, createdAt }; + }), + })); + return eventId; + }; + + const pendingIds = () => useOfflineQueueStore.getState().getPendingEvents().map((event) => event.id); + const queuedIds = () => useOfflineQueueStore.getState().queuedEvents.map((event) => event.id); + + it('records the signed-in server and user on each event', () => { + useOfflineQueueStore.getState().addEvent(QueuedEventType.CHECK_IN, { callId: 1 }); + + expect(useOfflineQueueStore.getState().queuedEvents[0]?.owner).toEqual(usOwner); + }); + + it('records no owner for an event queued while nobody is signed in', () => { + mockSession.status = 'signedOut'; + mockSession.userId = null; + + useOfflineQueueStore.getState().addEvent(QueuedEventType.CHECK_IN, { callId: 1 }); + + expect(useOfflineQueueStore.getState().queuedEvents[0]?.owner).toBeNull(); + }); + + it('hands out only the signed-in session\'s events to send', () => { + const own = queueAs(usOwner); + const otherServer = queueAs({ serverUrl: EU_SERVER, userId: 'user-1' }); + const otherUser = queueAs({ serverUrl: US_SERVER, userId: 'user-2' }); + const nobodys = queueAs(null); + const legacy = queueAs(undefined); + + expect(pendingIds()).toEqual([own, legacy]); + expect(queuedIds()).toEqual(expect.arrayContaining([otherServer, otherUser, nobodys])); + }); + + it('hands out nothing while signed out', () => { + queueAs(usOwner); + mockSession.status = 'signedOut'; + mockSession.userId = null; + + expect(pendingIds()).toEqual([]); + }); + + it('hands out a held event once its owner is signed in to its server again', () => { + const held = queueAs(usOwner); + mockServerUrl = EU_SERVER; + expect(pendingIds()).toEqual([]); + + mockServerUrl = US_SERVER; + expect(pendingIds()).toEqual([held]); + }); + + it('drops another session\'s events held for more than 7 days and keeps the rest', () => { + const now = Date.now(); + const abandoned = queueAs({ serverUrl: EU_SERVER, userId: 'user-1' }, now - 8 * DAY_MS); + const recent = queueAs({ serverUrl: EU_SERVER, userId: 'user-1' }, now - DAY_MS); + const ownOld = queueAs(usOwner, now - 8 * DAY_MS); + + useOfflineQueueStore.getState().purgeStaleFailedEvents(); + + expect(queuedIds()).not.toContain(abandoned); + expect(queuedIds()).toEqual(expect.arrayContaining([recent, ownOld])); + }); + + it('keeps every event while nobody is signed in, however old', () => { + const old = queueAs({ serverUrl: EU_SERVER, userId: 'user-1' }, Date.now() - 8 * DAY_MS); + mockSession.status = 'signedOut'; + mockSession.userId = null; + + useOfflineQueueStore.getState().purgeStaleFailedEvents(); + + expect(queuedIds()).toEqual([old]); + }); + }); }); diff --git a/src/stores/offline-queue/store.ts b/src/stores/offline-queue/store.ts index 6dd28851..ec30474c 100644 --- a/src/stores/offline-queue/store.ts +++ b/src/stores/offline-queue/store.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { logger } from '@/lib/logging'; +import { currentQueueOwner, isAbandonedQueueItem, isOwnedByCurrentSession } from '@/lib/offline-queue-owner'; import { secureOfflineQueueStorage } from '@/lib/storage/offline-queue-storage'; import { type QueuedEvent, QueuedEventStatus, type QueuedEventType } from '@/models/offline-queue/queued-event'; import { generateEventId } from '@/utils/id-generator'; @@ -30,12 +31,14 @@ interface OfflineQueueState { removeEvent: (eventId: string) => void; getEventById: (eventId: string) => QueuedEvent | undefined; getEventsByType: (type: QueuedEventType) => QueuedEvent[]; + /** Events ready to send for the signed-in session. Other sessions' events are held, never returned. */ getPendingEvents: () => QueuedEvent[]; getFailedEvents: () => QueuedEvent[]; clearCompletedEvents: () => void; clearAllEvents: () => void; retryEvent: (eventId: string) => void; retryAllFailedEvents: () => void; + /** Drops expired dead letters and events abandoned by another session, and enforces the size cap. */ purgeStaleFailedEvents: () => void; // Internal actions @@ -58,14 +61,19 @@ const isStaleFailedEvent = (event: QueuedEvent, now: number): boolean => { return now - referenceTime > FAILED_EVENT_TTL_MS; }; -const pruneEvents = (events: QueuedEvent[]): { events: QueuedEvent[]; purgedStale: number } => { +const pruneEvents = (events: QueuedEvent[]): { events: QueuedEvent[]; purgedStale: number; purgedAbandoned: number } => { const now = Date.now(); let purgedStale = 0; + let purgedAbandoned = 0; let pruned = events.filter((event) => { if (isStaleFailedEvent(event, now)) { purgedStale++; return false; } + if (isAbandonedQueueItem(event.owner, event.createdAt, now)) { + purgedAbandoned++; + return false; + } return true; }); @@ -95,7 +103,7 @@ const pruneEvents = (events: QueuedEvent[]): { events: QueuedEvent[]; purgedStal } } - return { events: pruned, purgedStale }; + return { events: pruned, purgedStale, purgedAbandoned }; }; export const useOfflineQueueStore = create()( @@ -170,14 +178,15 @@ export const useOfflineQueueStore = create()( retryCount: 0, maxRetries, createdAt: now, + owner: currentQueueOwner(), }; set((state) => { - const { events, purgedStale } = pruneEvents(state.queuedEvents); - if (purgedStale > 0) { + const { events, purgedStale, purgedAbandoned } = pruneEvents(state.queuedEvents); + if (purgedStale > 0 || purgedAbandoned > 0) { logger.info({ - message: 'Purged stale failed events from offline queue', - context: { purgedStale }, + message: 'Purged expired events from offline queue', + context: { purgedStale, purgedAbandoned }, }); } return { @@ -254,7 +263,9 @@ export const useOfflineQueueStore = create()( // Get pending events getPendingEvents: () => { return get().queuedEvents.filter( - (event) => event.status === QueuedEventStatus.PENDING || (event.status === QueuedEventStatus.FAILED && event.retryCount < event.maxRetries && (!event.nextRetryAt || event.nextRetryAt <= Date.now())) + (event) => + isOwnedByCurrentSession(event.owner) && + (event.status === QueuedEventStatus.PENDING || (event.status === QueuedEventStatus.FAILED && event.retryCount < event.maxRetries && (!event.nextRetryAt || event.nextRetryAt <= Date.now()))) ); }, @@ -338,11 +349,11 @@ export const useOfflineQueueStore = create()( // Purge dead-letter events older than the TTL and enforce queue size cap purgeStaleFailedEvents: () => { set((state) => { - const { events, purgedStale } = pruneEvents(state.queuedEvents); + const { events, purgedStale, purgedAbandoned } = pruneEvents(state.queuedEvents); if (purgedStale > 0 || events.length !== state.queuedEvents.length) { logger.info({ message: 'Purged events from offline queue', - context: { purgedStale, removed: state.queuedEvents.length - events.length }, + context: { purgedStale, purgedAbandoned, removed: state.queuedEvents.length - events.length }, }); return { queuedEvents: events }; } diff --git a/src/stores/security/__tests__/store.test.ts b/src/stores/security/__tests__/store.test.ts index 3941c9da..08133f9e 100644 --- a/src/stores/security/__tests__/store.test.ts +++ b/src/stores/security/__tests__/store.test.ts @@ -254,4 +254,55 @@ describe('Security Store', () => { }); }); }); + + describe('sign-out', () => { + it('drops the session\'s rights so the next sign-in never registers push under the old department code', async () => { + const { clearAllAppData } = require('@/lib/storage/clear-all-data'); + securityStore.setState({ rights: { DepartmentCode: 'US123', DepartmentId: '1' } as any, error: 'stale' }); + + await clearAllAppData({ resetStores: true, clearStorage: false, clearFilters: false }); + + expect(securityStore.getState().rights).toBeNull(); + expect(securityStore.getState().error).toBeNull(); + }); + + it('ignores rights that arrive after sign-out, but still loads the next session\'s', async () => { + const { clearAllAppData } = require('@/lib/storage/clear-all-data'); + let resolveRights: (value: unknown) => void = () => undefined; + getCurrentUsersRights.mockReturnValueOnce( + new Promise((resolve) => { + resolveRights = resolve; + }) + ); + + const pending = securityStore.getState().getRights(); + await clearAllAppData({ resetStores: true, clearStorage: false, clearFilters: false }); + resolveRights({ Data: { DepartmentCode: 'US123', DepartmentId: '1' } }); + await pending; + + expect(securityStore.getState().rights).toBeNull(); + + getCurrentUsersRights.mockResolvedValueOnce({ Data: { DepartmentCode: 'EU456', DepartmentId: '2' } }); + await securityStore.getState().getRights(); + + expect(securityStore.getState().rights?.DepartmentCode).toBe('EU456'); + }); + + it('ignores a failure that arrives after sign-out', async () => { + const { clearAllAppData } = require('@/lib/storage/clear-all-data'); + let rejectRights: (error: Error) => void = () => undefined; + getCurrentUsersRights.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectRights = reject; + }) + ); + + const pending = securityStore.getState().getRights(); + await clearAllAppData({ resetStores: true, clearStorage: false, clearFilters: false }); + rejectRights(new Error('Network Error')); + await pending; + + expect(securityStore.getState().error).toBeNull(); + }); + }); }); diff --git a/src/stores/security/store.ts b/src/stores/security/store.ts index a63077aa..2b32304e 100644 --- a/src/stores/security/store.ts +++ b/src/stores/security/store.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { getCurrentUsersRights } from '@/api/security/security'; +import { registerStoreReset } from '@/lib/storage/clear-all-data'; import { type DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData'; import type { ApiResponse } from '@/types/api'; @@ -14,14 +15,23 @@ export interface SecurityState { rights: DepartmentRightsResultData | null; } +// Bumped when sign-out resets the store: a getRights() still in flight then belongs to the ended session and must not +// write its rights (or its failure) over the reset. +let sessionGeneration = 0; + export const securityStore = create()( persist( (set, _get) => ({ error: null, rights: null, getRights: async () => { + const generation = sessionGeneration; + try { const response = await getCurrentUsersRights(); + if (generation !== sessionGeneration) { + return; + } // Type guard to ensure response is properly structured if (response && typeof response === 'object' && 'Data' in response) { @@ -46,6 +56,10 @@ export const securityStore = create()( }); } } catch (error) { + if (generation !== sessionGeneration) { + return; + } + // Set error state and null out rights on failure set({ rights: null, @@ -61,6 +75,14 @@ export const securityStore = create()( ) ); +// Rights belong to the session that loaded them. Sign-out clears the persisted copy, but the in-memory one used to +// survive it, so the next sign-in (on another server after a switch) registered its push token and Novu inbox under +// the previous session's department code before its own rights arrived. +registerStoreReset('security', () => { + sessionGeneration += 1; + securityStore.setState({ rights: null, error: null }); +}); + /** * Field-level selectors plus a memoized result: subscribing to the whole store and rebuilding * the permission object (and its `isUserGroupAdmin` closure) on every render handed every call diff --git a/src/types/ptt.ts b/src/types/ptt.ts new file mode 100644 index 00000000..bf01ed5e --- /dev/null +++ b/src/types/ptt.ts @@ -0,0 +1,51 @@ +/** + * PTT (Push-to-Talk) types and settings for media button functionality. + * Used by bluetooth-audio-store and headset-button.service. + */ + +/** + * PTT mode configuration for media buttons (AirPods/earbuds) + * - 'toggle': Single press toggles mute state on/off + * - 'push_to_talk': Press and hold to unmute, release to mute + */ +export type PTTMode = 'toggle' | 'push_to_talk'; + +/** + * Settings for media button PTT functionality + */ +export interface MediaButtonPTTSettings { + /** Whether media button PTT functionality is enabled */ + enabled: boolean; + /** The PTT mode to use */ + pttMode: PTTMode; + /** + * Whether to use play/pause button for PTT + * - For toggle mode: single press toggles mute state + * - For push_to_talk mode: press to unmute, release to mute + */ + usePlayPauseForPTT: boolean; + /** Double tap action behavior */ + doubleTapAction: 'none' | 'toggle_mute'; + /** Timeout in milliseconds to detect double tap */ + doubleTapTimeoutMs: number; +} + +/** + * Default media button PTT settings. + * This object is frozen to prevent accidental mutations. + */ +export const DEFAULT_MEDIA_BUTTON_PTT_SETTINGS: Readonly = Object.freeze({ + enabled: true, + pttMode: 'toggle', + usePlayPauseForPTT: true, + doubleTapAction: 'toggle_mute', + doubleTapTimeoutMs: 400, +}); + +/** + * Creates a mutable copy of the default PTT settings. + * Use this when initializing state that needs to be modified. + */ +export const createDefaultPTTSettings = (): MediaButtonPTTSettings => ({ + ...DEFAULT_MEDIA_BUTTON_PTT_SETTINGS, +});