Skip to content

RU-T47 Fixing PTT issue#221

Merged
ucswift merged 5 commits intomasterfrom
develop
Feb 19, 2026
Merged

RU-T47 Fixing PTT issue#221
ucswift merged 5 commits intomasterfrom
develop

Conversation

@ucswift
Copy link
Member

@ucswift ucswift commented Feb 19, 2026

Summary by CodeRabbit

  • New Features

    • System Audio option in device selection; enhanced Bluetooth/media-button monitoring and push-to-talk support
    • Exposed public microphone state for improved mute control and routing
  • Bug Fixes

    • Clearer device display names and more reliable selection/connection flows
    • Suppressed rapid/duplicate mute events on Android; improved connection error handling
  • Documentation

    • Added translations for system audio and default device labels
  • Tests / Chores

    • Updated tests/mocks, scripts, dev tooling and linting for test environments

@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds per-device Bluetooth PTT/media-button monitoring with watchdogs and read-polling fallbacks, wires microphone routing/state into the LiveKit store (isMicrophoneEnabled), adds Android InCallAudioModule routing, hardens CallKeep mute-event handling, adds system-audio UI/translations, and updates tests, logging, and dependencies.

Changes

Cohort / File(s) Summary
Bluetooth audio core & monitoring
src/services/bluetooth-audio.service.ts
Large new subsystem: per-device monitoring/watchdog/read-polling, media-button fallback, per-characteristic tracking, PTT routing, retry/apply semantics, lifecycle cleanup, and public ensurePttInputMonitoring(_reason: string).
LiveKit state & routing
src/stores/app/livekit-store.ts, src/features/livekit-call/store/useLiveKitCallStore.ts
Adds isMicrophoneEnabled & lastLocalMuteChangeTimestamp, normalizes Android audio routing, calls bluetoothAudioService.ensurePttInputMonitoring around connect, and coordinates microphone state updates.
Android InCall / plugin
plugins/withInCallAudioModule.js (and generated Kotlin)
Adds Kotlin ReactMethod setAudioRoute(route, promise) for Android audio routing and removes some imports; integrates with routing paths.
Audio resource mappings
src/services/audio.service.ts
Updated Android sound resource name mappings for InCallAudioModule usages.
CallKeep Android mute handling
src/services/callkeep.service.android.ts
Adds timing fields, ignore-lock, storm detection/cooldown, and a dedicated mute handler to debounce/suppress rapid duplicate mute events.
Bluetooth device UI & selection
src/components/settings/audio-device-selection.tsx, src/components/settings/bluetooth-device-item.tsx, src/components/settings/bluetooth-device-selection-bottom-sheet.tsx
Introduces getDeviceDisplayName helper, system-audio option with translated labels, preferred-device display changes, revised scan/connect UX and error handling, and consistent translated display for selections.
LiveKit UI mute flow refactor
src/components/livekit/livekit-bottom-sheet.tsx
Replaces direct LiveKit participant mic toggles with store-driven isMicrophoneEnabled flow; removes audioService playback and direct participant API calls.
Tests & logging mocks
src/components/.../__tests__/*.test.tsx, src/services/__tests__/*, src/features/livekit-call/store/__tests__/*, src/stores/calls/__tests__/*
Added logger mocks across many tests, new bluetooth-audio mock, tightened audio asset load order assertions, i18n expectation updates, and changes to async test mocks/flow.
Logging infra
src/lib/logging/index.tsx
Detects Jest environment and disables async logging and general logging during Jest runs to avoid noisy test output.
Translations
src/translations/en.json, src/translations/es.json, src/translations/ar.json
Adds keys for system audio and default device labels (e.g., bluetooth.system_audio, settings.audio_device_selection.default_microphone).
UI/type/layout & small refactors
src/components/ui/bottom-sheet.tsx, src/components/ui/html-renderer/index.web.tsx, src/components/sidebar/call-sidebar.tsx, src/lib/utils.ts, src/services/app-lifecycle.ts
Type-only React import and children typing change, small layout CSS tweak, HTML template reformat, constified locals, and switching to named logger usage.
Config & deps
.eslintrc.js, package.json
ESLint rule disabled for translation JSONs; moved/updated babel plugin to devDependencies; updated test script to run Jest in-band and bumped some devDependency versions.

Sequence Diagram(s)

sequenceDiagram
    participant Device as Bluetooth Device
    participant BluetoothSvc as BluetoothAudioService
    participant Watchdog as Watchdog
    participant Polling as ReadPolling
    participant ButtonHandler as ButtonHandler
    participant LiveKitStore as LiveKit Store
    participant FeatureStore as Feature Store
    participant LegacyStore as Legacy LiveKit Store
    participant InCall as InCallAudioModule

    Device->>BluetoothSvc: connect
    BluetoothSvc->>Watchdog: startMonitoringWatchdog()
    BluetoothSvc->>Polling: startReadPollingFallback()
    BluetoothSvc->>BluetoothSvc: startNotificationsForButtonControls()

    Device->>BluetoothSvc: characteristic update / media button event
    alt notify available
        BluetoothSvc->>ButtonHandler: handleCharacteristicValueUpdate(...)
        ButtonHandler->>BluetoothSvc: handleButtonEventFromCharacteristic(...)
    else fallback polling
        Polling->>BluetoothSvc: pollReadCharacteristics()
        BluetoothSvc->>ButtonHandler: handleButtonEventFromCharacteristic(...)
    end

    ButtonHandler->>LiveKitStore: requestMicrophoneState(enabled)
    alt feature store connected
        LiveKitStore->>FeatureStore: setMicrophoneEnabled(enabled)
        FeatureStore-->>LiveKitStore: success
    else feature store unavailable
        LiveKitStore->>LegacyStore: setMicrophoneEnabled(enabled)
        LegacyStore-->>LiveKitStore: success
    end

    LiveKitStore->>BluetoothSvc: applyMicrophoneEnabled(state)
    LiveKitStore->>InCall: setAudioRoute(normalizedDeviceType)
    BluetoothSvc->>BluetoothSvc: clear retries / reset PTT flags
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I twitched my whiskers, checked each route,

Buttons beep, watchdogs patrol about,
Mics and routes now speak in tidy code,
Translations snug where system-audio showed,
Hoppity hop — the audio path's been sowed! 🎧

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'RU-T47 Fixing PTT issue' clearly references the ticket identifier and describes the primary fix for push-to-talk functionality, which aligns with extensive PTT-related changes throughout the changeset including bluetooth audio monitoring, media button handling, and microphone state management.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx (1)

385-387: ⚠️ Potential issue | 🟡 Minor

Inner beforeEach wipes connectToDevice mock set by the outer beforeEach

The outer beforeEach (line 229) sets connectToDevice.mockResolvedValue(undefined). The inner beforeEach at line 386 calls jest.clearAllMocks(), which erases that resolved-value mock. The "Device Selection Flow" tests then run with connectToDevice returning a plain jest.fn() that returns undefined synchronously, not a resolved Promise. The tests happen to pass because await undefined resolves immediately in JavaScript, but this is fragile — any component-side guard like Promise.resolve() around the call or stricter typing could surface the gap.

🔧 Proposed fix — re-set the mock in the inner `beforeEach`
 describe('Device Selection Flow', () => {
   beforeEach(() => {
     jest.clearAllMocks();
+    (bluetoothAudioService.connectToDevice as jest.Mock).mockResolvedValue(undefined);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx`
around lines 385 - 387, The inner beforeEach is calling jest.clearAllMocks(),
which removes the outer beforeEach setup
connectToDevice.mockResolvedValue(undefined); update the inner beforeEach (the
one that calls jest.clearAllMocks()) to re-apply the async mock by calling
connectToDevice.mockResolvedValue(undefined) after clearing mocks so
connectToDevice remains a Promise-returning mock for the "Device Selection Flow"
tests; reference the connectToDevice mock and the inner beforeEach where
jest.clearAllMocks() is invoked and reset the mock there.
src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts (1)

513-560: ⚠️ Potential issue | 🟡 Minor

Duplicate nested describe('Error Handling') with a duplicated test case

Lines 514–548 contain an inner describe('Error Handling', ...) that is nested directly inside the outer describe('Error Handling', ...) at line 513. The inner block's second test ("should handle basic error state management") is duplicated verbatim at lines 549–559 outside the inner block. This creates two identical test runs and misleading nesting in the test report.

🔧 Proposed fix — remove the extra nesting and duplicate test
 describe('Error Handling', () => {
-  describe('Error Handling', () => {
     it('should handle room initialization errors', async () => {
       ...
     });

     it('should handle basic error state management', async () => {
       ...
     });
-  });

-  it('should handle basic error state management', async () => {
-    ...
-  });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts` around
lines 513 - 560, Remove the duplicated nested describe('Error Handling') block
and the duplicate "should handle basic error state management" test: keep a
single outer describe('Error Handling') with the room-initialization error test
and one "should handle basic error state management" test that exercises
result.current.actions._clearError via renderHook(() => useLiveKitCallStore());
delete the inner describe and the repeated test so the tests run only once and
the nesting is correct.
src/features/livekit-call/store/useLiveKitCallStore.ts (2)

63-71: ⚠️ Potential issue | 🟡 Minor

ensurePttInputMonitoring is called before the early-return guard.

If the user is already connecting or connected, the function still invokes ensurePttInputMonitoring before bailing out on line 70. Move it after the guard so it only runs on valid connection attempts:

Proposed fix
     connectToRoom: async (roomId, participantIdentity) => {
-      bluetoothAudioService.ensurePttInputMonitoring('useLiveKitCallStore connectToRoom start');
-
       if (get().isConnecting || get().isConnected) {
         logger.warn({
           message: 'Connection attempt while already connecting or connected',
           context: { roomId, participantIdentity, isConnecting: get().isConnecting, isConnected: get().isConnected },
         });
         return;
       }

+      bluetoothAudioService.ensurePttInputMonitoring('useLiveKitCallStore connectToRoom start');
+
       set({ isConnecting: true, error: null, selectedRoomForJoining: roomId });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/livekit-call/store/useLiveKitCallStore.ts` around lines 63 - 71,
The call to bluetoothAudioService.ensurePttInputMonitoring is executed before
the early-return guard, causing it to run even when a connection should be
skipped; move the ensurePttInputMonitoring(...) invocation to after the guard
that checks get().isConnecting and get().isConnected (the same block where
logger.warn is used) so it only runs on valid connection attempts inside the
connectToRoom flow in useLiveKitCallStore.

229-230: ⚠️ Potential issue | 🔴 Critical

Room connection is commented out — newRoom.connect(...) is never called, breaking the connection flow.

Line 229 shows //await newRoom.connect(LIVEKIT_URL, token, connectOptions); commented out. While event listeners are registered on the room object (lines 93–223), they will never fire because the room is never actually connected to the LiveKit server. The comment at line 230 is misleading — the ConnectionStateChanged listener cannot trigger without an active connection. The room will remain disconnected, isConnected will never be set to true, and the entire call functionality is broken. Uncomment the connect() call to fix this.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/livekit-call/store/useLiveKitCallStore.ts` around lines 229 -
230, Room never connects because newRoom.connect(...) is commented out;
uncomment the call to establish the LiveKit connection. Restore the awaited call
to newRoom.connect(LIVEKIT_URL, token, connectOptions) (or call newRoom.connect
with the same variables) right after the event listeners are registered so the
ConnectionStateChanged handlers can fire and isConnected can be set to true;
ensure the call remains awaited and any thrown errors are handled or propagated
consistent with surrounding error handling.
src/components/settings/audio-device-selection.tsx (1)

46-48: ⚠️ Potential issue | 🟡 Minor

Hardcoded English string ' Device' bypasses i18n.

The default branch concatenates + ' Device' which won't be translated. Wrap this in t() or add a generic translation key.

Proposed fix
       default:
-        return deviceType.charAt(0).toUpperCase() + deviceType.slice(1) + ' Device';
+        return t('settings.audio_device_selection.generic_device', { type: deviceType.charAt(0).toUpperCase() + deviceType.slice(1) });

As per coding guidelines, "Ensure all text is wrapped in t() from react-i18next for translations with dictionary files stored in src/translations".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/audio-device-selection.tsx` around lines 46 - 48, The
default branch in the device label builder uses a hardcoded English suffix "'
Device'" which bypasses i18n; update the code that builds the label (where
deviceType is used) to use t() with a translation key (e.g.,
t('deviceTypeLabel', { type: deviceType })) or a keyed template like t('Device
{{type}}', { type: deviceType }) instead of string concatenation, and add the
corresponding entry to the translations dictionary in src/translations so all
locales get the translated template.
src/components/livekit/livekit-bottom-sheet.tsx (1)

199-205: ⚠️ Potential issue | 🟡 Minor

Device names displayed without localization normalization.

Lines 200 and 204 render selectedAudioDevices.microphone?.name and selectedAudioDevices.speaker?.name directly. When the device is "system-audio," users will see the raw name instead of the localized label. The getDeviceDisplayName helper from audio-device-selection.tsx (or a shared utility) should be used here for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/livekit/livekit-bottom-sheet.tsx` around lines 199 - 205, The
component currently renders raw device names via
selectedAudioDevices.microphone?.name and selectedAudioDevices.speaker?.name;
replace those with the normalized/localized display from the existing helper
getDeviceDisplayName (e.g.,
getDeviceDisplayName(selectedAudioDevices.microphone) and
getDeviceDisplayName(selectedAudioDevices.speaker)), import the helper from
audio-device-selection.tsx (or the shared utility) if missing, and keep the
t('common.unknown') fallback for null/undefined results.
src/components/settings/bluetooth-device-selection-bottom-sheet.tsx (1)

247-259: ⚠️ Potential issue | 🟡 Minor

Storing a translated string as the device name creates a language-dependent value.

Line 258 saves t('bluetooth.system_audio') as the preferred device's name. If the user later switches the app language, the stored name will be stale in the previous locale. Since getDeviceDisplayName (and the inline logic on line 40) already resolves the display name from the 'system-audio' ID at render time, store a fixed English identifier instead:

Proposed fix
-                await setPreferredDevice({ id: 'system-audio', name: t('bluetooth.system_audio') });
+                await setPreferredDevice({ id: 'system-audio', name: 'System Audio' });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` around
lines 247 - 259, The code stores a translated label as the preferred device
name; replace the locale-dependent value passed to setPreferredDevice with a
fixed English identifier instead of t('bluetooth.system_audio'). In the onPress
flow around setConnectingDeviceId, connectToSystemAudio and setPreferredDevice,
change the call setPreferredDevice({ id: 'system-audio', name:
t('bluetooth.system_audio') }) to use a constant English name (e.g. name:
'System Audio' or name: 'system-audio') so the stored name is
language-independent and getDeviceDisplayName/getDeviceDisplayName-style render
logic continues to resolve the localized display string at render time.
src/services/bluetooth-audio.service.ts (1)

2386-2388: ⚠️ Potential issue | 🟡 Minor

Duplicate this.isInitialized = false assignment.

Line 2387 is an exact duplicate of line 2386.

Proposed fix
     this.isInitialized = false;
-    this.isInitialized = false;
     this.hasAttemptedPreferredDeviceConnection = false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2386 - 2388, There is a
duplicate assignment to this.isInitialized (appears twice in the same
initialization block); remove the redundant line so that only one
this.isInitialized = false remains and preserve the subsequent
this.hasAttemptedPreferredDeviceConnection = false initialization; update the
initialization block (e.g., in the constructor or initializer where
isInitialized and hasAttemptedPreferredDeviceConnection are set) to contain each
assignment exactly once.
🧹 Nitpick comments (13)
src/components/status/__tests__/status-gps-integration.test.tsx (1)

68-71: Remove debug console.log from the mock implementation.

This console.log will fire on every useLocationStore invocation across all tests in this file, producing noise in CI output.

♻️ Proposed fix
     mockUseLocationStore.mockImplementation(() => {
-      console.log('useLocationStore called, returning:', mockLocationStore);
       return mockLocationStore;
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/status/__tests__/status-gps-integration.test.tsx` around lines
68 - 71, The mock implementation for mockUseLocationStore currently logs to
console on every call; remove the debug console.log from the mockImplementation
so it simply returns mockLocationStore (update the
mockUseLocationStore.mockImplementation callback to only return
mockLocationStore), ensuring no noisy output from useLocationStore calls in
tests.
src/services/callkeep.service.android.ts (2)

224-252: ignoreMuteEvents, removeMuteListener, and restoreMuteListener lack Platform.OS guard

setup(), startCall(), and endCall() all short-circuit on non-Android platforms. These three newly added / updated public methods do not, creating an inconsistency: callers that check the platform before invoking other service methods may inadvertently trigger RNCallKeep on iOS through these paths.

♻️ Proposed fix
 ignoreMuteEvents(durationMs: number): void {
+  if (Platform.OS !== 'android') return;
   const now = Date.now();
   ...
 }

 removeMuteListener(): void {
+  if (Platform.OS !== 'android') return;
   RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
   ...
 }

 restoreMuteListener(): void {
+  if (Platform.OS !== 'android') return;
   RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
   ...
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/callkeep.service.android.ts` around lines 224 - 252, These three
public methods (ignoreMuteEvents, removeMuteListener, restoreMuteListener) call
RNCallKeep and should short-circuit on non-Android platforms like other methods;
wrap their bodies with a Platform.OS === 'android' guard (or early return when
Platform.OS !== 'android') so RNCallKeep.removeEventListener/addEventListener
and logger.debug only run on Android, keeping behavior consistent with
setup/startCall/endCall and avoiding accidental RNCallKeep calls on iOS.

24-26: New timing fields are not reset in cleanup()

lastMuteEventTime, muteEventStormEndTime, and ignoreEventsUntil are initialised to 0 at declaration but are not zeroed during cleanup(). If the service is ever re-setup after cleanup (e.g., reconnect scenario), a stale ignoreEventsUntil value could silently block mute events until it naturally expires.

♻️ Proposed fix
 // Inside cleanup(), after removing event listeners:
+  this.lastMuteEventTime = 0;
+  this.muteEventStormEndTime = 0;
+  this.ignoreEventsUntil = 0;
   this.isSetup = false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/callkeep.service.android.ts` around lines 24 - 26, The cleanup()
method fails to reset timing fields lastMuteEventTime, muteEventStormEndTime,
and ignoreEventsUntil, which can leave stale values blocking events after a
re-setup; update the cleanup() implementation in the CallKeepService (the class
that declares these fields) to explicitly set this.lastMuteEventTime = 0,
this.muteEventStormEndTime = 0, and this.ignoreEventsUntil = 0 (or the
class-equivalent names) along with any existing teardown logic so the service
state is fully cleared for subsequent initializations.
src/components/settings/bluetooth-device-item.tsx (1)

23-25: Magic string 'system-audio' should be a shared constant

The device ID 'system-audio' is a sentinel value that now needs to be matched in at least two components (BluetoothDeviceItem and BluetoothDeviceSelectionBottomSheet) and likely wherever preferred devices are persisted. A typo in any one location would cause the translation lookup to silently fall through to preferredDevice.name.

♻️ Proposed fix — define a constant in a shared location
// e.g., src/services/bluetooth-audio.service.ts or a new constants file
export const SYSTEM_AUDIO_DEVICE_ID = 'system-audio';
// bluetooth-device-item.tsx
+import { SYSTEM_AUDIO_DEVICE_ID } from '@/services/bluetooth-audio.service';
 ...
-if (preferredDevice.id === 'system-audio') {
+if (preferredDevice.id === SYSTEM_AUDIO_DEVICE_ID) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-item.tsx` around lines 23 - 25,
Replace the hard-coded sentinel 'system-audio' string with a shared exported
constant (e.g., SYSTEM_AUDIO_DEVICE_ID) and import it where needed so all
comparisons use the same source of truth; update the equality check in
BluetoothDeviceItem (the preferredDevice.id === 'system-audio' branch) to use
SYSTEM_AUDIO_DEVICE_ID and do the same in BluetoothDeviceSelectionBottomSheet
and any persistence logic that reads/writes preferred devices to avoid typos and
silent fall-throughs.
src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx (1)

300-307: Missing test coverage for the new system-audio device selection path

This PR introduces the 'system-audio' virtual device (with special display-name logic in BluetoothDeviceItem and the bottom sheet). The test suite covers Bluetooth device connect/disconnect flows but has no test case for selecting the System Audio option. Consider adding a test that:

  1. Renders the sheet with a 'system-audio' preferred device
  2. Verifies the translated label (bluetooth.system_audio) is displayed instead of a raw device name
  3. Verifies setPreferredDevice is called with { id: 'system-audio', ... } on selection
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx`
around lines 300 - 307, Add a test that covers the new 'system-audio' virtual
device path: render BluetoothDeviceSelectionBottomSheet with mockProps where
preferredDevice or devices includes an entry with id 'system-audio', assert the
UI shows the translated label 'bluetooth.system_audio' (handled by
BluetoothDeviceItem) instead of the raw name, simulate selecting that item and
assert setPreferredDevice was called with an object containing id:
'system-audio' (and expected payload shape). Use the existing test helpers/props
and screen/events utilities consistent with the other tests to locate
BluetoothDeviceSelectionBottomSheet, BluetoothDeviceItem, and the mocked
setPreferredDevice function.
plugins/withInCallAudioModule.js (2)

113-113: AudioManager.mode is set to MODE_IN_COMMUNICATION but never restored

The mode is unconditionally applied before the when dispatch (line 113) and is not reverted by the "earpiece"/"default" branch or by cleanup(). After the VoIP session ends the AudioManager remains in MODE_IN_COMMUNICATION, which can distort media playback, ringtone output, and subsequent audio sessions.

♻️ Proposed fix
+  val previousMode = audioManager.mode
   audioManager.mode = AudioManager.MODE_IN_COMMUNICATION

   when (normalizedRoute) {
     "bluetooth" -> { ... }
     "speaker"   -> { ... }
     "earpiece", "default" -> {
       audioManager.stopBluetoothSco()
       audioManager.isBluetoothScoOn = false
       audioManager.isSpeakerphoneOn = false
+      audioManager.mode = previousMode
     }
     else -> { ... }
   }

Alternatively, restore AudioManager.MODE_NORMAL in cleanup() if a single reset point is preferred.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/withInCallAudioModule.js` at line 113, AudioManager.mode is being set
to AudioManager.MODE_IN_COMMUNICATION unconditionally (see the assignment to
audioManager.mode and the when dispatch) and is never restored, leaving the
device stuck in in-communication mode; update the control flow so that after the
when(...) dispatch resolves you restore audioManager.mode back to its previous
value (or explicitly to AudioManager.MODE_NORMAL) in both the
"earpiece"/"default" branch and in cleanup(), ensuring any early returns or
errors also trigger the restore; reference the audioManager.mode assignment, the
when(...) handler, the "earpiece"/"default" branch, and the cleanup() function
to implement the restore logic.

103-104: Indentation inconsistency in generated Kotlin

The new @ReactMethod annotation and fun setAudioRoute declaration use 6-space indentation while the rest of the class methods (e.g., @ReactMethod fun playSound) use 4-space indentation. The generated InCallAudioModule.kt will have inconsistent formatting.

♻️ Proposed fix
-      `@ReactMethod`
-      fun setAudioRoute(route: String, promise: Promise) {
+    `@ReactMethod`
+    fun setAudioRoute(route: String, promise: Promise) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/withInCallAudioModule.js` around lines 103 - 104, The generated
Kotlin snippet for the setAudioRoute method has inconsistent indentation: adjust
the template in withInCallAudioModule.js so the lines containing the
`@ReactMethod` annotation and the fun setAudioRoute(route: String, promise:
Promise) declaration use 4-space indentation to match other methods (e.g.,
`@ReactMethod` fun playSound) and ensure the resulting InCallAudioModule.kt
formatting is consistent.
src/components/settings/audio-device-selection.tsx (1)

138-144: Anonymous arrow functions in .map() callbacks create new references each render.

Lines 139 and 157 use () => setSelectedMicrophone(device) and () => setSelectedSpeaker(device) inline. Per coding guidelines, avoid anonymous functions in render-item or event handlers to prevent unnecessary re-renders. Since renderDeviceItem already receives onSelect, the concern is the anonymous closure passed to it from map. This is a minor optimization opportunity—could memoize or use a stable callback pattern.

Also applies to: 156-162

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/audio-device-selection.tsx` around lines 138 - 144,
The inline anonymous callbacks passed to renderDeviceItem from
availableMicrophones.map and availableSpeakers.map (currently using () =>
setSelectedMicrophone(device) and () => setSelectedSpeaker(device)) create new
function references each render; replace them with stable callbacks: create a
memoized handler factory (e.g., a useCallback that returns a handler given a
device id or a reuseable onSelect handler that accepts the device as an
argument) and pass that stable function to renderDeviceItem, or alternatively
pass a device id to renderDeviceItem and let renderDeviceItem call a single
memoized select function (referencing setSelectedMicrophone, setSelectedSpeaker
and renderDeviceItem) so the onSelect prop remains referentially stable across
renders.
src/components/livekit/livekit-bottom-sheet.tsx (1)

107-108: Replace console.log with logger.

Line 108 uses console.log('Updating audio routing for:', ...) — the rest of the codebase uses the structured logger from @/lib/logging. Use logger.debug(...) here for consistency.

Proposed fix
-        console.log('Updating audio routing for:', speaker.type);
+        logger.debug({ message: 'Updating audio routing', context: { speakerType: speaker.type } });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/livekit/livekit-bottom-sheet.tsx` around lines 107 - 108,
Replace the console.log call that prints the speaker type with the project's
structured logger: change console.log('Updating audio routing for:',
speaker.type) to logger.debug(`Updating audio routing for: ${speaker.type}`) (or
logger.debug('Updating audio routing for:', { type: speaker.type }) if you
prefer structured args), and ensure the logger from '@/lib/logging' is imported
at the top of the module if it's not already; update the snippet near
selectedAudioDevices.speaker handling in the livekit-bottom-sheet component.
src/services/bluetooth-audio.service.ts (3)

2265-2272: Identical cleanup sequence repeated in four places — extract a shared helper.

The same pattern (reset pttPressActive, clear fallback/retry timeouts, null pendingMicEnabled, stop watchdog/polling/media-button monitoring) is duplicated in revertLiveKitAudioRouting, disconnectDevice, destroy, and reset. A single private resetMonitoringState() method would reduce maintenance risk.

Also applies to: 2283-2290, 2366-2372, 2418-2424

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2265 - 2272, Extract
the repeated cleanup block into a single private method (e.g. private
resetMonitoringState()) that performs
bluetoothStore.setIsHeadsetButtonMonitoring(false), this.pttPressActive = false,
this.clearPttReleaseFallback(), this.clearMicApplyRetry(),
this.pendingMicEnabled = null, this.stopMonitoringWatchdog(),
this.stopReadPollingFallback(), and this.stopMediaButtonFallbackMonitoring();
then replace the duplicated sequences in revertLiveKitAudioRouting,
disconnectDevice, destroy, and reset with a call to this.resetMonitoringState()
to centralize maintenance and avoid duplication.

1210-1228: Watchdog interval only self-terminates; it never restarts or repairs monitoring.

The 4-second interval checks if the device is still connected and monitoring is active, but when monitoringStartedAt is set (line 1225-1227), it simply returns — no corrective action is taken. If the intent is to detect and recover from stalled BLE subscriptions, the watchdog needs a restart path. If this is purely for cleanup, a more descriptive name (e.g., startMonitoringCleanupTimer) would clarify the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 1210 - 1228, The
watchdog started by startMonitoringWatchdog currently only stops itself and
never attempts to repair or restart stalled monitoring when monitoringStartedAt
is set; update startMonitoringWatchdog to detect a stale subscription (e.g.,
compare Date.now() - this.monitoringStartedAt to a threshold) and trigger a
recovery path: call a recovery helper (create or reuse a method like
restartMonitoring or reinitializeHeadsetButtonMonitoring) that re-subscribes to
BLE notifications, and ensure monitoringWatchdogInterval is cleared and
restarted around that flow; keep stopMonitoringWatchdog for cleanup and use
useBluetoothAudioStore.getState().isHeadsetButtonMonitoring to gate recovery
attempts.

1408-1419: Read-polling every 700 ms across all characteristics of a specialized device is aggressive.

Lines 1408-1419 register every characteristic on a specialized device for read polling, and the interval at line 1463 fires every 700 ms. For devices with many characteristics this can saturate the BLE link, drain battery, and increase the chance of read timeouts interfering with notification delivery. Consider limiting the polled set to characteristics that actually lack notification support, or increasing the polling interval for non-button characteristics.

Also applies to: 1450-1463

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 1408 - 1419, The
current loop registers every characteristic for 700ms read-polling which can
overload BLE; change the logic in the block that iterates
peripheralInfo.characteristics so registerReadPollingCharacteristic is only
called for characteristics that do not support notifications/indications (check
characteristic.properties for 'notify' or 'indicate' or an explicit
characteristic.notify flag), and further restrict to a smaller set (e.g., only
known button UUIDs or a configurable whitelist) or tag non-button
characteristics to use a longer polling interval; also introduce or use a
configurable polling interval variable (instead of the hardcoded 700ms) and
enforce a maximum number of concurrently polled characteristics to avoid
saturating the link.
src/stores/app/livekit-store.ts (1)

32-48: Use the existing top-level react-native import instead of inline require.

react-native is already imported at line 7. Add NativeModules to that import rather than using a dynamic require inside the function body.

Proposed fix

At the top of the file (line 7):

-import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native';
+import { Alert, Linking, NativeModules, PermissionsAndroid, Platform } from 'react-native';

Then at lines 32–33:

-      const { NativeModules } = require('react-native');
-      const inCallAudioModule = NativeModules?.InCallAudioModule;
+      const inCallAudioModule = NativeModules?.InCallAudioModule;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/app/livekit-store.ts` around lines 32 - 48, Replace the inline
dynamic require for NativeModules with the existing top-level react-native
import: add NativeModules to the existing import at the top of the file and
remove the inline "const { NativeModules } = require('react-native');" line;
leave the rest of the logic using inCallAudioModule
(NativeModules?.InCallAudioModule), setAudioRoute, normalizedDeviceType and
logger unchanged so the try/catch and fallback to Audio.setAudioModeAsync still
work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package.json`:
- Line 112: The package "babel-plugin-transform-import-meta" is listed under
dependencies but it's a compile-time Babel transform and should be a dev-only
dependency; remove the "babel-plugin-transform-import-meta" entry from the
dependencies section of package.json and add the same package (same version
"^2.3.3") under devDependencies, then reinstall (npm install / yarn install) to
update lockfile so production installs won't include this plugin.
- Around line 194-195: Update the `@typescript-eslint` packages in package.json by
bumping "@typescript-eslint/eslint-plugin" and "@typescript-eslint/parser" from
"~7.18.0" to a v8 release (e.g. "^8.x") and then restore TypeScript to a
compatible 5.8 range (e.g. "typescript": "5.8.x") so the parser/plugin support
newer TS syntax; also ensure the existing "eslint" version (~8.57.0) satisfies
v8's peer requirement and run install + lint/test to confirm nothing else
breaks.

In `@plugins/withInCallAudioModule.js`:
- Around line 116-122: The "bluetooth" branch sets audioManager.isBluetoothScoOn
= true and disables speakerphone but may never call
audioManager.startBluetoothSco(), causing a false-success resolution; update the
handler in withInCallAudioModule.js so that when selecting "bluetooth" you call
audioManager.startBluetoothSco() unconditionally (or, if you must respect
audioManager.isBluetoothScoAvailableOffCall, reject the promise when that flag
is false) and only resolve the promise after confirming startBluetoothSco() was
invoked/succeeded; reference audioManager, isSpeakerphoneOn, isBluetoothScoOn,
isBluetoothScoAvailableOffCall, and startBluetoothSco to locate and modify the
logic and ensure the promise correctly reflects actual SCO start state.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`:
- Around line 57-62: The effect that calls startScan when the sheet opens can
enter an infinite retry loop because startScan resets hasScanned to false on
errors; update the error handling so that a caught error does NOT set hasScanned
back to false (or alternatively add a bounded retry counter like scanRetryCount
that increments on each failure and prevents further automatic retries after N
attempts), keeping the existing useEffect, isOpen, hasScanned, and startScan
symbols intact and ensuring the user still sees the alert but must manually
trigger subsequent scans.

In `@src/services/__tests__/bluetooth-audio.service.test.ts`:
- Around line 226-245: The featureStore.getState mock is using mockReturnValue
which persists across tests; change the test that sets featureStore.getState to
use mockReturnValueOnce (or restore the mock in an afterEach/afterAll) so the
override doesn't leak — update the test's call to
featureStore.getState.mockReturnValue(...) to
featureStore.getState.mockReturnValueOnce({...}) (or add an afterEach that calls
featureStore.getState.mockReset()/mockRestore()) to ensure
bluetoothAudioService.setMicrophoneEnabled and subsequent tests see the default
behavior.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 1519-1526: The final clause normalizedValue.length > 0 always
evaluates true and makes the "read" capability check a no-op; remove that clause
from the return expression so explicit falsy values like { Read: false } are
rejected. Update the return in the method(s) (e.g., hasReadCapability and
hasNotificationOrReadCapability) to only check value === true || value === 1 ||
normalizedValue === normalizedKey || normalizedValue === 'true' ||
normalizedValue === '1', and apply the same change at the other occurrence noted
in hasNotificationOrReadCapability.
- Around line 1128-1129: The event listener array is growing with stale
references because startMediaButtonFallbackMonitoring pushes
this.mediaButtonEventListener into this.eventListeners but
stopMediaButtonFallbackMonitoring only calls .remove() on the listener and does
not remove the reference from this.eventListeners; update
stopMediaButtonFallbackMonitoring to also remove the listener reference from
this.eventListeners (e.g., find the index of this.mediaButtonEventListener and
splice it out or filter the array to exclude it) and optionally guard
startMediaButtonFallbackMonitoring against pushing duplicate references so the
array reflects only active listeners.
- Around line 208-212: The characteristic update is being subscribed twice in
setupEventListeners(): keep the modern
BleManager.onDidUpdateValueForCharacteristic subscription (valueUpdateListener)
and remove the legacy DeviceEventEmitter.addListener subscription
(legacyValueUpdateListener) along with its push to this.eventListeners so
handleCharacteristicValueUpdate is only registered once; locate the
legacyValueUpdateListener variable and its this.eventListeners.push call and
delete them.

In `@src/services/callkeep.service.android.ts`:
- Around line 316-332: The cooldown branch unconditionally extends
muteEventStormEndTime causing the window to grow; in the second if (now <
this.muteEventStormEndTime) block remove the update that sets
this.muteEventStormEndTime = now + 800 so the original storm deadline can expire
naturally — keep the logger.debug call and return as-is, but do not modify
muteEventStormEndTime when handling cooldown events (use the initial assignment
only in the first storm-detection branch that checks timeSinceLastEvent < 500).

---

Outside diff comments:
In `@src/components/livekit/livekit-bottom-sheet.tsx`:
- Around line 199-205: The component currently renders raw device names via
selectedAudioDevices.microphone?.name and selectedAudioDevices.speaker?.name;
replace those with the normalized/localized display from the existing helper
getDeviceDisplayName (e.g.,
getDeviceDisplayName(selectedAudioDevices.microphone) and
getDeviceDisplayName(selectedAudioDevices.speaker)), import the helper from
audio-device-selection.tsx (or the shared utility) if missing, and keep the
t('common.unknown') fallback for null/undefined results.

In
`@src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx`:
- Around line 385-387: The inner beforeEach is calling jest.clearAllMocks(),
which removes the outer beforeEach setup
connectToDevice.mockResolvedValue(undefined); update the inner beforeEach (the
one that calls jest.clearAllMocks()) to re-apply the async mock by calling
connectToDevice.mockResolvedValue(undefined) after clearing mocks so
connectToDevice remains a Promise-returning mock for the "Device Selection Flow"
tests; reference the connectToDevice mock and the inner beforeEach where
jest.clearAllMocks() is invoked and reset the mock there.

In `@src/components/settings/audio-device-selection.tsx`:
- Around line 46-48: The default branch in the device label builder uses a
hardcoded English suffix "' Device'" which bypasses i18n; update the code that
builds the label (where deviceType is used) to use t() with a translation key
(e.g., t('deviceTypeLabel', { type: deviceType })) or a keyed template like
t('Device {{type}}', { type: deviceType }) instead of string concatenation, and
add the corresponding entry to the translations dictionary in src/translations
so all locales get the translated template.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`:
- Around line 247-259: The code stores a translated label as the preferred
device name; replace the locale-dependent value passed to setPreferredDevice
with a fixed English identifier instead of t('bluetooth.system_audio'). In the
onPress flow around setConnectingDeviceId, connectToSystemAudio and
setPreferredDevice, change the call setPreferredDevice({ id: 'system-audio',
name: t('bluetooth.system_audio') }) to use a constant English name (e.g. name:
'System Audio' or name: 'system-audio') so the stored name is
language-independent and getDeviceDisplayName/getDeviceDisplayName-style render
logic continues to resolve the localized display string at render time.

In `@src/features/livekit-call/store/__tests__/useLiveKitCallStore.test.ts`:
- Around line 513-560: Remove the duplicated nested describe('Error Handling')
block and the duplicate "should handle basic error state management" test: keep
a single outer describe('Error Handling') with the room-initialization error
test and one "should handle basic error state management" test that exercises
result.current.actions._clearError via renderHook(() => useLiveKitCallStore());
delete the inner describe and the repeated test so the tests run only once and
the nesting is correct.

In `@src/features/livekit-call/store/useLiveKitCallStore.ts`:
- Around line 63-71: The call to bluetoothAudioService.ensurePttInputMonitoring
is executed before the early-return guard, causing it to run even when a
connection should be skipped; move the ensurePttInputMonitoring(...) invocation
to after the guard that checks get().isConnecting and get().isConnected (the
same block where logger.warn is used) so it only runs on valid connection
attempts inside the connectToRoom flow in useLiveKitCallStore.
- Around line 229-230: Room never connects because newRoom.connect(...) is
commented out; uncomment the call to establish the LiveKit connection. Restore
the awaited call to newRoom.connect(LIVEKIT_URL, token, connectOptions) (or call
newRoom.connect with the same variables) right after the event listeners are
registered so the ConnectionStateChanged handlers can fire and isConnected can
be set to true; ensure the call remains awaited and any thrown errors are
handled or propagated consistent with surrounding error handling.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 2386-2388: There is a duplicate assignment to this.isInitialized
(appears twice in the same initialization block); remove the redundant line so
that only one this.isInitialized = false remains and preserve the subsequent
this.hasAttemptedPreferredDeviceConnection = false initialization; update the
initialization block (e.g., in the constructor or initializer where
isInitialized and hasAttemptedPreferredDeviceConnection are set) to contain each
assignment exactly once.

---

Nitpick comments:
In `@plugins/withInCallAudioModule.js`:
- Line 113: AudioManager.mode is being set to AudioManager.MODE_IN_COMMUNICATION
unconditionally (see the assignment to audioManager.mode and the when dispatch)
and is never restored, leaving the device stuck in in-communication mode; update
the control flow so that after the when(...) dispatch resolves you restore
audioManager.mode back to its previous value (or explicitly to
AudioManager.MODE_NORMAL) in both the "earpiece"/"default" branch and in
cleanup(), ensuring any early returns or errors also trigger the restore;
reference the audioManager.mode assignment, the when(...) handler, the
"earpiece"/"default" branch, and the cleanup() function to implement the restore
logic.
- Around line 103-104: The generated Kotlin snippet for the setAudioRoute method
has inconsistent indentation: adjust the template in withInCallAudioModule.js so
the lines containing the `@ReactMethod` annotation and the fun
setAudioRoute(route: String, promise: Promise) declaration use 4-space
indentation to match other methods (e.g., `@ReactMethod` fun playSound) and ensure
the resulting InCallAudioModule.kt formatting is consistent.

In `@src/components/livekit/livekit-bottom-sheet.tsx`:
- Around line 107-108: Replace the console.log call that prints the speaker type
with the project's structured logger: change console.log('Updating audio routing
for:', speaker.type) to logger.debug(`Updating audio routing for:
${speaker.type}`) (or logger.debug('Updating audio routing for:', { type:
speaker.type }) if you prefer structured args), and ensure the logger from
'@/lib/logging' is imported at the top of the module if it's not already; update
the snippet near selectedAudioDevices.speaker handling in the
livekit-bottom-sheet component.

In
`@src/components/settings/__tests__/bluetooth-device-selection-bottom-sheet.test.tsx`:
- Around line 300-307: Add a test that covers the new 'system-audio' virtual
device path: render BluetoothDeviceSelectionBottomSheet with mockProps where
preferredDevice or devices includes an entry with id 'system-audio', assert the
UI shows the translated label 'bluetooth.system_audio' (handled by
BluetoothDeviceItem) instead of the raw name, simulate selecting that item and
assert setPreferredDevice was called with an object containing id:
'system-audio' (and expected payload shape). Use the existing test helpers/props
and screen/events utilities consistent with the other tests to locate
BluetoothDeviceSelectionBottomSheet, BluetoothDeviceItem, and the mocked
setPreferredDevice function.

In `@src/components/settings/audio-device-selection.tsx`:
- Around line 138-144: The inline anonymous callbacks passed to renderDeviceItem
from availableMicrophones.map and availableSpeakers.map (currently using () =>
setSelectedMicrophone(device) and () => setSelectedSpeaker(device)) create new
function references each render; replace them with stable callbacks: create a
memoized handler factory (e.g., a useCallback that returns a handler given a
device id or a reuseable onSelect handler that accepts the device as an
argument) and pass that stable function to renderDeviceItem, or alternatively
pass a device id to renderDeviceItem and let renderDeviceItem call a single
memoized select function (referencing setSelectedMicrophone, setSelectedSpeaker
and renderDeviceItem) so the onSelect prop remains referentially stable across
renders.

In `@src/components/settings/bluetooth-device-item.tsx`:
- Around line 23-25: Replace the hard-coded sentinel 'system-audio' string with
a shared exported constant (e.g., SYSTEM_AUDIO_DEVICE_ID) and import it where
needed so all comparisons use the same source of truth; update the equality
check in BluetoothDeviceItem (the preferredDevice.id === 'system-audio' branch)
to use SYSTEM_AUDIO_DEVICE_ID and do the same in
BluetoothDeviceSelectionBottomSheet and any persistence logic that reads/writes
preferred devices to avoid typos and silent fall-throughs.

In `@src/components/status/__tests__/status-gps-integration.test.tsx`:
- Around line 68-71: The mock implementation for mockUseLocationStore currently
logs to console on every call; remove the debug console.log from the
mockImplementation so it simply returns mockLocationStore (update the
mockUseLocationStore.mockImplementation callback to only return
mockLocationStore), ensuring no noisy output from useLocationStore calls in
tests.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 2265-2272: Extract the repeated cleanup block into a single
private method (e.g. private resetMonitoringState()) that performs
bluetoothStore.setIsHeadsetButtonMonitoring(false), this.pttPressActive = false,
this.clearPttReleaseFallback(), this.clearMicApplyRetry(),
this.pendingMicEnabled = null, this.stopMonitoringWatchdog(),
this.stopReadPollingFallback(), and this.stopMediaButtonFallbackMonitoring();
then replace the duplicated sequences in revertLiveKitAudioRouting,
disconnectDevice, destroy, and reset with a call to this.resetMonitoringState()
to centralize maintenance and avoid duplication.
- Around line 1210-1228: The watchdog started by startMonitoringWatchdog
currently only stops itself and never attempts to repair or restart stalled
monitoring when monitoringStartedAt is set; update startMonitoringWatchdog to
detect a stale subscription (e.g., compare Date.now() - this.monitoringStartedAt
to a threshold) and trigger a recovery path: call a recovery helper (create or
reuse a method like restartMonitoring or reinitializeHeadsetButtonMonitoring)
that re-subscribes to BLE notifications, and ensure monitoringWatchdogInterval
is cleared and restarted around that flow; keep stopMonitoringWatchdog for
cleanup and use useBluetoothAudioStore.getState().isHeadsetButtonMonitoring to
gate recovery attempts.
- Around line 1408-1419: The current loop registers every characteristic for
700ms read-polling which can overload BLE; change the logic in the block that
iterates peripheralInfo.characteristics so registerReadPollingCharacteristic is
only called for characteristics that do not support notifications/indications
(check characteristic.properties for 'notify' or 'indicate' or an explicit
characteristic.notify flag), and further restrict to a smaller set (e.g., only
known button UUIDs or a configurable whitelist) or tag non-button
characteristics to use a longer polling interval; also introduce or use a
configurable polling interval variable (instead of the hardcoded 700ms) and
enforce a maximum number of concurrently polled characteristics to avoid
saturating the link.

In `@src/services/callkeep.service.android.ts`:
- Around line 224-252: These three public methods (ignoreMuteEvents,
removeMuteListener, restoreMuteListener) call RNCallKeep and should
short-circuit on non-Android platforms like other methods; wrap their bodies
with a Platform.OS === 'android' guard (or early return when Platform.OS !==
'android') so RNCallKeep.removeEventListener/addEventListener and logger.debug
only run on Android, keeping behavior consistent with setup/startCall/endCall
and avoiding accidental RNCallKeep calls on iOS.
- Around line 24-26: The cleanup() method fails to reset timing fields
lastMuteEventTime, muteEventStormEndTime, and ignoreEventsUntil, which can leave
stale values blocking events after a re-setup; update the cleanup()
implementation in the CallKeepService (the class that declares these fields) to
explicitly set this.lastMuteEventTime = 0, this.muteEventStormEndTime = 0, and
this.ignoreEventsUntil = 0 (or the class-equivalent names) along with any
existing teardown logic so the service state is fully cleared for subsequent
initializations.

In `@src/stores/app/livekit-store.ts`:
- Around line 32-48: Replace the inline dynamic require for NativeModules with
the existing top-level react-native import: add NativeModules to the existing
import at the top of the file and remove the inline "const { NativeModules } =
require('react-native');" line; leave the rest of the logic using
inCallAudioModule (NativeModules?.InCallAudioModule), setAudioRoute,
normalizedDeviceType and logger unchanged so the try/catch and fallback to
Audio.setAudioModeAsync still work.

package.json Outdated
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

babel-plugin-transform-import-meta belongs in devDependencies, not dependencies.

This is a compile-time Babel transform. It is not needed at runtime and should not be a production dependency. Installing it as a regular dependency inflates node_modules on any environment that installs production deps only.

🔧 Proposed fix
-    "babel-plugin-transform-import-meta": "^2.3.3",
     "buffer": "^6.0.3",

And in devDependencies:

+    "babel-plugin-transform-import-meta": "^2.3.3",
     "babel-jest": "~30.0.0",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 112, The package "babel-plugin-transform-import-meta"
is listed under dependencies but it's a compile-time Babel transform and should
be a dev-only dependency; remove the "babel-plugin-transform-import-meta" entry
from the dependencies section of package.json and add the same package (same
version "^2.3.3") under devDependencies, then reinstall (npm install / yarn
install) to update lockfile so production installs won't include this plugin.

Comment on lines +226 to +245
it('should fall back to legacy livekitStore when feature store is connected but has no local participant', async () => {
const service = bluetoothAudioService as any;
const featureStore = require('@/features/livekit-call/store/useLiveKitCallStore').useLiveKitCallStore;
const mockFeatureSetMicrophoneEnabled = jest.fn();

featureStore.getState.mockReturnValue({
isConnected: true,
roomInstance: null,
localParticipant: null,
actions: {
setMicrophoneEnabled: mockFeatureSetMicrophoneEnabled,
},
});

await service.setMicrophoneEnabled(true);

const legacyStore = require('@/stores/app/livekit-store').useLiveKitStore.getState();
expect(mockFeatureSetMicrophoneEnabled).not.toHaveBeenCalled();
expect(legacyStore.setMicrophoneEnabled).toHaveBeenCalledWith(true);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mock override on featureStore.getState leaks to subsequent tests.

jest.clearAllMocks() in beforeEach clears call history but does not reset implementations set via mockReturnValue (line 231). If tests are added after this one, they'll see isConnected: true, localParticipant: null from the feature store mock. Use mockReturnValueOnce or restore the default mock in an afterEach/afterAll block.

Proposed fix — use mockReturnValueOnce
-      featureStore.getState.mockReturnValue({
+      featureStore.getState.mockReturnValueOnce({
         isConnected: true,
         roomInstance: null,
         localParticipant: null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/__tests__/bluetooth-audio.service.test.ts` around lines 226 -
245, The featureStore.getState mock is using mockReturnValue which persists
across tests; change the test that sets featureStore.getState to use
mockReturnValueOnce (or restore the mock in an afterEach/afterAll) so the
override doesn't leak — update the test's call to
featureStore.getState.mockReturnValue(...) to
featureStore.getState.mockReturnValueOnce({...}) (or add an afterEach that calls
featureStore.getState.mockReset()/mockRestore()) to ensure
bluetoothAudioService.setMicrophoneEnabled and subsequent tests see the default
behavior.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/components/settings/bluetooth-device-selection-bottom-sheet.tsx (2)

122-127: ⚠️ Potential issue | 🟡 Minor

Hardcoded || fallbacks after t() are unreachable dead code.

react-i18next's t() returns the key string (e.g., 'bluetooth.connection_error_title') when a translation is missing — never an empty or falsy value in default config. The || 'Connection Failed' (and similar strings on lines 124, 263, 264) will never be used and can mislead maintainers into thinking they provide real coverage for missing keys. Remove the fallbacks or ensure the i18n keys exist.

🛠️ Proposed fix
  showMessage({
-   message: t('bluetooth.connection_error_title') || 'Connection Failed',
-   description: errorMessage === 'Device disconnected' ? t('bluetooth.device_disconnected') : errorMessage || t('bluetooth.connection_error_message') || 'Could not connect to device',
+   message: t('bluetooth.connection_error_title'),
+   description: errorMessage === 'Device disconnected' ? t('bluetooth.device_disconnected') : errorMessage || t('bluetooth.connection_error_message'),
    type: 'danger',
    duration: 4000,
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` around
lines 122 - 127, The showMessage call in
bluetooth-device-selection-bottom-sheet.tsx is using unreachable "||" fallbacks
after t(...) (e.g., t('bluetooth.connection_error_title') || 'Connection Failed'
and the description fallbacks), so remove those redundant || fallbacks and pass
the t(...) return values directly to showMessage (and any other occurrences of
the same pattern in this file such as the other t(...) uses flagged) to avoid
misleading dead code; locate the showMessage invocation and all t('bluetooth.*')
calls in this component and delete the "||" fallback strings so the i18n key is
used as-is (or ensure the translation key exists elsewhere if you want a real
fallback).

181-181: ⚠️ Potential issue | 🟡 Minor

&& on a numeric rssi will render 0 if the value is 0.

When item.rssi === 0 (which some stacks emit to indicate "not available"), React Native will render the literal 0 character on screen. Use a ternary guard instead.

🛠️ Proposed fix
- {item.rssi && <Text className="text-xs text-neutral-600 dark:text-neutral-400">RSSI: {item.rssi}dBm</Text>}
+ {item.rssi ? <Text className="text-xs text-neutral-600 dark:text-neutral-400">RSSI: {item.rssi}dBm</Text> : null}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` at line
181, The conditional rendering of RSSI using "item.rssi && <Text...>" will
render "0" when item.rssi === 0; update the guard in the
BluetoothDeviceSelectionBottomSheet component to use an explicit null/undefined
check (e.g., item.rssi != null or typeof item.rssi === 'number') and render
<Text className="...">RSSI: {item.rssi}dBm</Text> only when the value is
present, replacing the && expression with a ternary or explicit check to avoid
showing the literal 0.
src/services/bluetooth-audio.service.ts (1)

2393-2394: ⚠️ Potential issue | 🟡 Minor

Duplicate assignment: this.isInitialized = false appears twice.

Line 2394 is a redundant copy of line 2393.

Proposed fix
     this.isInitialized = false;
-    this.isInitialized = false;
     this.hasAttemptedPreferredDeviceConnection = false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2393 - 2394, In the
constructor/initialization block where the BluetoothAudioService (or the class
containing this.isInitialized) is being set up, remove the duplicate assignment
so that this.isInitialized = false only appears once; locate the two identical
lines setting this.isInitialized and delete the redundant one, leaving a single
initialization statement (e.g., in the constructor or initialize method).
🧹 Nitpick comments (5)
src/components/settings/bluetooth-device-selection-bottom-sheet.tsx (3)

318-318: Extract the nested ternary for Bluetooth state message into a variable.

The triple-branch expression is difficult to scan at a glance. Moving it to a named variable before the return improves readability.

♻️ Proposed refactor
+ const bluetoothStateMessage =
+   bluetoothState === State.PoweredOff
+     ? t('bluetooth.poweredOff')
+     : bluetoothState === State.Unauthorized
+     ? t('bluetooth.unauthorized')
+     : t('bluetooth.bluetooth_not_ready', { state: bluetoothState });

  {bluetoothState !== State.PoweredOn && (
    <Box ...>
-     <Text ...>
-       {bluetoothState === State.PoweredOff ? t('bluetooth.poweredOff') : bluetoothState === State.Unauthorized ? t('bluetooth.unauthorized') : t('bluetooth.bluetooth_not_ready', { state: bluetoothState })}
-     </Text>
+     <Text ...>{bluetoothStateMessage}</Text>
    </Box>
  )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` at line
318, The JSX contains a nested ternary for choosing the Bluetooth status string
which is hard to read; extract that expression into a named variable (e.g.,
bluetoothStatusMessage) above the return in the
BluetoothDeviceSelectionBottomSheet component, compute it using bluetoothState
and t (handle State.PoweredOff, State.Unauthorized, and the default
t('bluetooth.bluetooth_not_ready', { state: bluetoothState })), and then replace
the inline ternary in the JSX with the bluetoothStatusMessage variable.

229-229: Replace && with ternary for conditional rendering throughout — coding guideline violation.

Lines 229, 283, 315, and 324 all use && for conditional rendering. While these specific expressions use object or boolean operands (so the 0-rendering bug doesn't apply), they still violate the project rule. As per coding guidelines, "Use the conditional operator (?:) for conditional rendering instead of &&."

♻️ Example fix pattern (apply to all four sites)
- {preferredDevice && (
-   <Box ...> ... </Box>
- )}
+ {preferredDevice ? (
+   <Box ...> ... </Box>
+ ) : null}

Also applies to: 283-283, 315-316, 324-325

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` at line
229, Replace all JSX conditional renderings that use the logical AND operator
with the conditional (?:) operator: for each occurrence around preferredDevice
(the JSX expression currently written as "{preferredDevice && (...)}" and the
other three similar boolean/object checks at the same component), change them to
use "{preferredDevice ? (...) : null}" (and analogous "{condition ? (...) :
null}") so the component renders via the ternary operator; update the
expressions at the four places referenced (the JSX blocks currently gated by &&
at the bluetooth-device-selection-bottom-sheet component) to follow this
pattern.

246-271: Extract the System Audio onPress handler to a named useCallback.

The anonymous async function creates a new reference on every render, which prevents React from bailing out on the Pressable re-render. Extract it to a useCallback alongside the other handlers.

♻️ Proposed refactor
+ const handleSystemAudioSelect = React.useCallback(async () => {
+   try {
+     useBluetoothAudioStore.getState().setIsConnecting(true);
+     setConnectingDeviceId('system-audio');
+     await bluetoothAudioService.connectToSystemAudio();
+     await setPreferredDevice({ id: 'system-audio', name: t('bluetooth.system_audio') });
+     onClose();
+   } catch (error) {
+     logger.error({ message: 'Failed to select System Audio', context: { error } });
+     showMessage({
+       message: t('bluetooth.connection_error_title'),
+       description: t('bluetooth.system_audio_error'),
+       type: 'danger',
+     });
+   } finally {
+     useBluetoothAudioStore.getState().setIsConnecting(false);
+     setConnectingDeviceId(null);
+   }
+ }, [setPreferredDevice, onClose, t]);

  <Pressable
-   onPress={async () => { ... }}
+   onPress={handleSystemAudioSelect}
    disabled={!!connectingDeviceId}
    ...
  >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` around
lines 246 - 271, The inline async onPress handler for the System Audio Pressable
creates a new function each render; extract it into a memoized useCallback named
e.g. handleSelectSystemAudio to prevent unnecessary re-renders. Move the
try/catch/finally logic (calls to
useBluetoothAudioStore.getState().setIsConnecting, setConnectingDeviceId,
bluetoothAudioService.connectToSystemAudio, setPreferredDevice, onClose,
logger.error, showMessage) into that callback and reference it from the
Pressable onPress, and include all external dependencies (t,
setConnectingDeviceId, onClose, bluetoothAudioService, setPreferredDevice,
logger, showMessage, and any store setters) in the useCallback dependency array
so behavior stays correct.
src/services/bluetooth-audio.service.ts (2)

1415-1434: Specialized devices register every characteristic for read polling — consider capping.

For specialized devices, the loop at lines 1415–1426 registers all characteristics for read polling regardless of capability. Combined with the 700ms polling interval, a device advertising 15–20 characteristics would result in 15–20 sequential BLE reads per cycle, which can saturate the BLE radio and drain battery.

Consider filtering to only characteristics with read capability, or capping the registered list.

Proposed guard
   if (isSpecializedDevice && peripheralInfo?.characteristics?.length) {
     for (const characteristic of peripheralInfo.characteristics) {
       const serviceUuid = characteristic?.service;
       const characteristicUuid = characteristic?.characteristic;

       if (!serviceUuid || !characteristicUuid) {
         continue;
       }

+      if (!this.hasReadCapability(characteristic?.properties)) {
+        continue;
+      }
+
       this.registerReadPollingCharacteristic(serviceUuid, characteristicUuid);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 1415 - 1434, The loop
in registerReadPollingCharacteristic registration currently registers every
characteristic from peripheralInfo.characteristics (inside the block guarded by
isSpecializedDevice) which can cause many BLE reads; change the logic in the
block that iterates peripheralInfo.characteristics to first filter
characteristics that are actually readable (e.g., characteristic.properties
includes 'read' or an equivalent isReadable flag) and then limit the number
registered by a configurable cap (introduce a MAX_POLL_CHARACTERISTICS constant)
and only call this.registerReadPollingCharacteristic(serviceUuid,
characteristicUuid) for the top N readable characteristics; reference the
symbols peripheralInfo.characteristics, characteristic.properties (or
isReadable), and registerReadPollingCharacteristic when making the change.

1217-1251: Monitoring watchdog is a health check with no remediation action.

After the guard checks pass (device connected, monitoring active, monitoringStartedAt set), the interval callback has no body—it performs no logging, no restart logic, or other corrective action. The watchdog only validates conditions every 4 seconds and stops itself if any condition fails.

Unlike startReadPollingFallback (which actually polls characteristics in its interval), startMonitoringWatchdog is a pure health-check mechanism. Consider whether this 4-second interval overhead is necessary, or if the guards could be consolidated into a more efficient validation pattern. If the watchdog is intended to detect and recover from stalled monitoring, that remediation logic is missing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 1217 - 1251, The
interval callback in startMonitoringWatchdog currently only checks guards
(connectedDevice, isHeadsetButtonMonitoring, monitoringStartedAt) and does
nothing else, so either add remediation logic (e.g., log health events, restart
monitoring by calling startMediaButtonFallbackMonitoring or restarting whatever
set monitoringStartedAt, and reset
monitoringStartedAt/monitoringWatchdogInterval) when the watchdog detects
stalled monitoring, or remove/consolidate the interval entirely and perform
these guard checks synchronously from ensurePttInputMonitoring; update
references in startMonitoringWatchdog, ensurePttInputMonitoring,
stopMonitoringWatchdog, and any callers (e.g.,
startMediaButtonFallbackMonitoring, startReadPollingFallback) to reflect the
chosen approach.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`:
- Line 40: preferredDeviceDisplayName can become undefined when
preferredDevice.id !== 'system-audio' and preferredDevice.name is missing;
update the expression that defines preferredDeviceDisplayName to fall back to
t('bluetooth.unknown_device') when preferredDevice?.name is falsy. Locate the
const preferredDeviceDisplayName declaration and change its ternary result so
the non-system-audio branch uses preferredDevice?.name ||
t('bluetooth.unknown_device'), keeping the existing system-audio translation
branch intact.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 1457-1470: The interval can re-enter pollReadCharacteristics
causing overlapping BLE reads; add a reentrancy guard (e.g., a boolean property
like isReadPollingInFlight) checked before invoking pollReadCharacteristics
inside the setInterval callback and set true when a poll starts and false in a
finally/cleanup after pollReadCharacteristics completes (including error paths),
so modify the code around readPollingInterval/pollReadCharacteristics to skip
starting a new poll when isReadPollingInFlight is true and ensure the flag is
cleared in stopReadPollingFallback and after the async poll finishes.

---

Outside diff comments:
In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`:
- Around line 122-127: The showMessage call in
bluetooth-device-selection-bottom-sheet.tsx is using unreachable "||" fallbacks
after t(...) (e.g., t('bluetooth.connection_error_title') || 'Connection Failed'
and the description fallbacks), so remove those redundant || fallbacks and pass
the t(...) return values directly to showMessage (and any other occurrences of
the same pattern in this file such as the other t(...) uses flagged) to avoid
misleading dead code; locate the showMessage invocation and all t('bluetooth.*')
calls in this component and delete the "||" fallback strings so the i18n key is
used as-is (or ensure the translation key exists elsewhere if you want a real
fallback).
- Line 181: The conditional rendering of RSSI using "item.rssi && <Text...>"
will render "0" when item.rssi === 0; update the guard in the
BluetoothDeviceSelectionBottomSheet component to use an explicit null/undefined
check (e.g., item.rssi != null or typeof item.rssi === 'number') and render
<Text className="...">RSSI: {item.rssi}dBm</Text> only when the value is
present, replacing the && expression with a ternary or explicit check to avoid
showing the literal 0.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 2393-2394: In the constructor/initialization block where the
BluetoothAudioService (or the class containing this.isInitialized) is being set
up, remove the duplicate assignment so that this.isInitialized = false only
appears once; locate the two identical lines setting this.isInitialized and
delete the redundant one, leaving a single initialization statement (e.g., in
the constructor or initialize method).

---

Duplicate comments:
In `@src/services/__tests__/bluetooth-audio.service.test.ts`:
- Around line 231-238: The test stubs set featureStore.getState with
mockReturnValue which persists across tests; change the setup to avoid leaking
implementations by either replacing mockReturnValue with mockReturnValueOnce
when configuring featureStore.getState in this test (referencing
featureStore.getState and the mock object used in the test) or add
jest.resetAllMocks() to the beforeEach so implementations are cleared between
tests; update the test file to use one of these approaches consistently to
prevent cross-test mock leakage.

---

Nitpick comments:
In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`:
- Line 318: The JSX contains a nested ternary for choosing the Bluetooth status
string which is hard to read; extract that expression into a named variable
(e.g., bluetoothStatusMessage) above the return in the
BluetoothDeviceSelectionBottomSheet component, compute it using bluetoothState
and t (handle State.PoweredOff, State.Unauthorized, and the default
t('bluetooth.bluetooth_not_ready', { state: bluetoothState })), and then replace
the inline ternary in the JSX with the bluetoothStatusMessage variable.
- Line 229: Replace all JSX conditional renderings that use the logical AND
operator with the conditional (?:) operator: for each occurrence around
preferredDevice (the JSX expression currently written as "{preferredDevice &&
(...)}" and the other three similar boolean/object checks at the same
component), change them to use "{preferredDevice ? (...) : null}" (and analogous
"{condition ? (...) : null}") so the component renders via the ternary operator;
update the expressions at the four places referenced (the JSX blocks currently
gated by && at the bluetooth-device-selection-bottom-sheet component) to follow
this pattern.
- Around line 246-271: The inline async onPress handler for the System Audio
Pressable creates a new function each render; extract it into a memoized
useCallback named e.g. handleSelectSystemAudio to prevent unnecessary
re-renders. Move the try/catch/finally logic (calls to
useBluetoothAudioStore.getState().setIsConnecting, setConnectingDeviceId,
bluetoothAudioService.connectToSystemAudio, setPreferredDevice, onClose,
logger.error, showMessage) into that callback and reference it from the
Pressable onPress, and include all external dependencies (t,
setConnectingDeviceId, onClose, bluetoothAudioService, setPreferredDevice,
logger, showMessage, and any store setters) in the useCallback dependency array
so behavior stays correct.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 1415-1434: The loop in registerReadPollingCharacteristic
registration currently registers every characteristic from
peripheralInfo.characteristics (inside the block guarded by isSpecializedDevice)
which can cause many BLE reads; change the logic in the block that iterates
peripheralInfo.characteristics to first filter characteristics that are actually
readable (e.g., characteristic.properties includes 'read' or an equivalent
isReadable flag) and then limit the number registered by a configurable cap
(introduce a MAX_POLL_CHARACTERISTICS constant) and only call
this.registerReadPollingCharacteristic(serviceUuid, characteristicUuid) for the
top N readable characteristics; reference the symbols
peripheralInfo.characteristics, characteristic.properties (or isReadable), and
registerReadPollingCharacteristic when making the change.
- Around line 1217-1251: The interval callback in startMonitoringWatchdog
currently only checks guards (connectedDevice, isHeadsetButtonMonitoring,
monitoringStartedAt) and does nothing else, so either add remediation logic
(e.g., log health events, restart monitoring by calling
startMediaButtonFallbackMonitoring or restarting whatever set
monitoringStartedAt, and reset monitoringStartedAt/monitoringWatchdogInterval)
when the watchdog detects stalled monitoring, or remove/consolidate the interval
entirely and perform these guard checks synchronously from
ensurePttInputMonitoring; update references in startMonitoringWatchdog,
ensurePttInputMonitoring, stopMonitoringWatchdog, and any callers (e.g.,
startMediaButtonFallbackMonitoring, startReadPollingFallback) to reflect the
chosen approach.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/services/bluetooth-audio.service.ts (1)

2403-2404: ⚠️ Potential issue | 🟡 Minor

Duplicate this.isInitialized = false — copy-paste artifact.

Line 2403 and Line 2404 both assign the same value. Remove the duplicate.

🛡️ Proposed fix
  // Reset initialization flags
  this.isInitialized = false;
- this.isInitialized = false;
  this.hasAttemptedPreferredDeviceConnection = false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2403 - 2404, In
BluetoothAudioService remove the duplicate assignment to the instance flag:
there are two consecutive lines setting this.isInitialized = false; (a
copy-paste artifact) — delete the redundant one so only a single
this.isInitialized = false remains in the constructor/initializer where it
appears.
🧹 Nitpick comments (6)
src/components/settings/bluetooth-device-selection-bottom-sheet.tsx (4)

257-257: Storing a translated string as persistent device name is an anti-pattern.

t('bluetooth.system_audio') is locale-dependent at call time; if the user changes the app language, the stored name in preferredDevice becomes stale. In this component the risk is mitigated — preferredDeviceDisplayName (line 40) re-translates for id === 'system-audio' and ignores the stored name. But any other consumer that reads preferredDevice.name directly will display the wrong locale.

Consider storing a stable sentinel instead:

- await setPreferredDevice({ id: 'system-audio', name: t('bluetooth.system_audio') });
+ await setPreferredDevice({ id: 'system-audio', name: 'system-audio' });

Display-layer helpers can then map id === 'system-audio't('bluetooth.system_audio') consistently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` at line
257, The code currently stores a locale-dependent translation via
setPreferredDevice({ id: 'system-audio', name: t('bluetooth.system_audio') }),
which makes preferredDevice.name stale if the user changes language; instead
store a stable sentinel for the special device (e.g., use name: null or a
constant like 'SYSTEM_AUDIO_SENTINEL') when calling setPreferredDevice for id
=== 'system-audio', and update display helpers (e.g.,
preferredDeviceDisplayName) to map that sentinel/id to
t('bluetooth.system_audio') at render time so all consumers read a
locale-independent stored value and the UI always shows the current translation.

56-61: hasScanned is never reset when the sheet closes — auto-scan only fires once per component mount.

Because there's no setHasScanned(false) when isOpen becomes false, reopening the bottom sheet within the same component-lifetime won't trigger an automatic rescan. Users will see the previously scanned device list and must manually press "Scan Again". The manual button makes this recoverable, but the UX expectation is usually a fresh scan on each open.

Consider resetting hasScanned inside the existing close-time effect:

♻️ Proposed fix
  useEffect(() => {
    if (!isOpen && isScanning) {
      stopScan();
    }
+   if (!isOpen) {
+     setHasScanned(false);
+   }
  }, [isOpen, isScanning, stopScan]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` around
lines 56 - 61, The issue: hasScanned is never reset so auto-scan only runs once
per mount; fix by resetting it when the sheet closes. In
bluetooth-device-selection-bottom-sheet.tsx add logic (e.g. a useEffect watching
isOpen) to call setHasScanned(false) when isOpen becomes false (or extend the
existing useEffect that uses isOpen/hasScanned/startScan to setHasScanned(false)
in the else branch), ensuring that on every open the startScan() auto-trigger
can run again.

318-318: Inconsistent translation key naming within a single expression.

The three keys used here follow three different conventions: bluetooth.poweredOff (camelCase), bluetooth.unauthorized (lowercase), and bluetooth.bluetooth_not_ready (snake_case with redundant bluetooth_ prefix under the bluetooth. namespace). Standardizing to one convention across the translation namespace would improve maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` at line
318, The three translation keys used in the expression that calls t(...) with
bluetoothState (inside the bluetoothState logic in the
BluetoothDeviceSelectionBottomSheet component) are inconsistent—replace them
with a single naming convention and remove the redundant namespace prefix (e.g.,
use bluetooth.poweredOff, bluetooth.unauthorized, bluetooth.notReady or
bluetooth.powered_off, bluetooth.unauthorized, bluetooth.not_ready) and update
the corresponding i18n resource keys to match; ensure the variable
bluetoothState and the t(...) calls remain unchanged, only standardize the keys
and resource entries so the expression uses a consistent key format (and update
any other usages of the old keys).

246-271: Extract System Audio onPress to a named useCallback — coding guideline violation.

The entire onPress handler is an inline anonymous async function, creating a new function reference on every render. As per coding guidelines, "Avoid anonymous functions in renderItem or event handlers to prevent re-renders."

♻️ Proposed refactor
+  const handleSystemAudioSelect = React.useCallback(async () => {
+    try {
+      useBluetoothAudioStore.getState().setIsConnecting(true);
+      setConnectingDeviceId('system-audio');
+      await bluetoothAudioService.connectToSystemAudio();
+      await setPreferredDevice({ id: 'system-audio', name: t('bluetooth.system_audio') });
+      onClose();
+    } catch (error) {
+      logger.error({ message: 'Failed to select System Audio', context: { error } });
+      showMessage({
+        message: t('bluetooth.connection_error_title'),
+        description: t('bluetooth.system_audio_error'),
+        type: 'danger',
+      });
+    } finally {
+      useBluetoothAudioStore.getState().setIsConnecting(false);
+      setConnectingDeviceId(null);
+    }
+  }, [setPreferredDevice, onClose, t]);

   <Pressable
-    onPress={async () => {
-      try { ... } catch { ... } finally { ... }
-    }}
+    onPress={handleSystemAudioSelect}
     ...
   >

As per coding guidelines: "Avoid anonymous functions in renderItem or event handlers to prevent re-renders."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx` around
lines 246 - 271, The inline anonymous async onPress handler on the Pressable
should be extracted into a named useCallback to prevent a new function reference
each render; create a handler (e.g., handleSelectSystemAudio) using
React.useCallback that contains the try/catch/finally logic currently in onPress
and calls useBluetoothAudioStore.getState().setIsConnecting,
setConnectingDeviceId('system-audio'), await
bluetoothAudioService.connectToSystemAudio(), await setPreferredDevice({ id:
'system-audio', name: t('bluetooth.system_audio') }), onClose(),
logger.error(...), and showMessage(...); ensure the useCallback dependency array
includes any external values used (t, bluetoothAudioService, setPreferredDevice,
onClose, logger, showMessage, setConnectingDeviceId) so closures remain correct,
then replace the inline onPress with onPress={handleSelectSystemAudio}.
src/services/bluetooth-audio.service.ts (2)

1218-1237: startMonitoringWatchdog has an empty happy-path body — watchdog takes no corrective action.

The interval only self-terminates when preconditions fail. When all conditions are healthy, the callback body is a no-op (monitoringStartedAt is checked but unused). A watchdog that never heals is just a self-cancelling timer. Either add a stale-monitoring detection/restart action (e.g., if Date.now() - this.monitoringStartedAt > threshold) or rename the method to reflect its actual role.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 1218 - 1237,
startMonitoringWatchdog currently only cancels itself when preconditions fail
and never takes corrective action when monitoring becomes stale; update the
interval callback inside startMonitoringWatchdog to detect stale monitoring by
comparing Date.now() against this.monitoringStartedAt (e.g., if Date.now() -
this.monitoringStartedAt > STALE_THRESHOLD_MS) and then perform a corrective
action such as calling a restart method (e.g., this.restartMonitoring() or
re-initializing the headset monitoring flow), logging the event, and/or
resetting this.monitoringStartedAt; keep existing early-exit checks
(this.connectedDevice.id,
useBluetoothAudioStore.getState().isHeadsetButtonMonitoring) and ensure
this.monitoringWatchdogInterval and stopMonitoringWatchdog are used to
clear/restart the timer as needed.

2152-2154: setMicrophoneEnabled is dead code that bypasses the serialization queue.

This private method is never called — all internal callers use requestMicrophoneState or applyMicrophoneEnabled directly. If it were ever called, it would bypass the isApplyingMicState/pendingMicEnabled serializer in requestMicrophoneState, racing with any in-flight apply. Remove it to prevent accidental usage.

♻️ Proposed removal
- private async setMicrophoneEnabled(enabled: boolean): Promise<void> {
-   await this.applyMicrophoneEnabled(enabled);
- }
-
  private async applyMicrophoneEnabled(enabled: boolean): Promise<void> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2152 - 2154, Remove the
unused private method setMicrophoneEnabled which bypasses the
requestMicrophoneState serialization logic; search for and delete the
setMicrophoneEnabled method declaration and any references to it, and ensure
callers continue to use requestMicrophoneState or applyMicrophoneEnabled so the
isApplyingMicState / pendingMicEnabled serializer remains authoritative for
microphone state changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/services/bluetooth-audio.service.ts`:
- Around line 1487-1498: The change-detection logic in
handleButtonEventFromCharacteristic (using entry.lastHexValue and nextHexValue)
treats the initial null lastHexValue as a change and emits a phantom PTT start
for generic devices because parseGenericButtonData(0x00) maps to ptt_start;
instead, on the first successful read prime entry.lastHexValue = nextHexValue
and skip calling handleButtonEventFromCharacteristic/processButtonEvent so no
event is dispatched; specifically, update the code around where
entry.lastHexValue is compared/assigned to detect the "first read" case
(entry.lastHexValue === null) and set entry.lastHexValue = nextHexValue then
continue, preserving existing behavior for subsequent reads and leaving
schedulePttReleaseFallback unchanged.
- Around line 1416-1427: The second loop unconditionally calls
registerReadPollingCharacteristic for every characteristic, which re-adds ones
that failed subscription and causes repeated read errors; fix by tracking which
characteristics were successfully subscribed in the first pass (e.g., a Set
keyed by serviceUuid+characteristicUuid populated when
startNotification/registerReadPollingCharacteristic is invoked successfully) and
then, inside this loop over peripheralInfo.characteristics, skip any
characteristic not present in that "successfullySubscribed" set (only call
registerReadPollingCharacteristic for keys that were successfully subscribed and
have valid serviceUuid and characteristicUuid).
- Around line 2108-2117: The retry currently captures the incoming enabled value
in the closure and ignores subsequent calls because of the if
(this.micApplyRetryTimeout) guard; instead update a shared pendingMicEnabled
property when scheduleMicApplyRetry(enabled) is called (even if a timeout
already exists) and, inside the setTimeout handler, read this.pendingMicEnabled
and pass that to requestMicrophoneState; also ensure the handler clears
this.micApplyRetryTimeout before calling requestMicrophoneState so behavior of
requestMicrophoneState/applyMicrophoneEnabled remains unchanged. Use the
existing fields scheduleMicApplyRetry, micApplyRetryTimeout, and
pendingMicEnabled to implement this.

---

Outside diff comments:
In `@src/services/bluetooth-audio.service.ts`:
- Around line 2403-2404: In BluetoothAudioService remove the duplicate
assignment to the instance flag: there are two consecutive lines setting
this.isInitialized = false; (a copy-paste artifact) — delete the redundant one
so only a single this.isInitialized = false remains in the
constructor/initializer where it appears.

---

Nitpick comments:
In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`:
- Line 257: The code currently stores a locale-dependent translation via
setPreferredDevice({ id: 'system-audio', name: t('bluetooth.system_audio') }),
which makes preferredDevice.name stale if the user changes language; instead
store a stable sentinel for the special device (e.g., use name: null or a
constant like 'SYSTEM_AUDIO_SENTINEL') when calling setPreferredDevice for id
=== 'system-audio', and update display helpers (e.g.,
preferredDeviceDisplayName) to map that sentinel/id to
t('bluetooth.system_audio') at render time so all consumers read a
locale-independent stored value and the UI always shows the current translation.
- Around line 56-61: The issue: hasScanned is never reset so auto-scan only runs
once per mount; fix by resetting it when the sheet closes. In
bluetooth-device-selection-bottom-sheet.tsx add logic (e.g. a useEffect watching
isOpen) to call setHasScanned(false) when isOpen becomes false (or extend the
existing useEffect that uses isOpen/hasScanned/startScan to setHasScanned(false)
in the else branch), ensuring that on every open the startScan() auto-trigger
can run again.
- Line 318: The three translation keys used in the expression that calls t(...)
with bluetoothState (inside the bluetoothState logic in the
BluetoothDeviceSelectionBottomSheet component) are inconsistent—replace them
with a single naming convention and remove the redundant namespace prefix (e.g.,
use bluetooth.poweredOff, bluetooth.unauthorized, bluetooth.notReady or
bluetooth.powered_off, bluetooth.unauthorized, bluetooth.not_ready) and update
the corresponding i18n resource keys to match; ensure the variable
bluetoothState and the t(...) calls remain unchanged, only standardize the keys
and resource entries so the expression uses a consistent key format (and update
any other usages of the old keys).
- Around line 246-271: The inline anonymous async onPress handler on the
Pressable should be extracted into a named useCallback to prevent a new function
reference each render; create a handler (e.g., handleSelectSystemAudio) using
React.useCallback that contains the try/catch/finally logic currently in onPress
and calls useBluetoothAudioStore.getState().setIsConnecting,
setConnectingDeviceId('system-audio'), await
bluetoothAudioService.connectToSystemAudio(), await setPreferredDevice({ id:
'system-audio', name: t('bluetooth.system_audio') }), onClose(),
logger.error(...), and showMessage(...); ensure the useCallback dependency array
includes any external values used (t, bluetoothAudioService, setPreferredDevice,
onClose, logger, showMessage, setConnectingDeviceId) so closures remain correct,
then replace the inline onPress with onPress={handleSelectSystemAudio}.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 1218-1237: startMonitoringWatchdog currently only cancels itself
when preconditions fail and never takes corrective action when monitoring
becomes stale; update the interval callback inside startMonitoringWatchdog to
detect stale monitoring by comparing Date.now() against this.monitoringStartedAt
(e.g., if Date.now() - this.monitoringStartedAt > STALE_THRESHOLD_MS) and then
perform a corrective action such as calling a restart method (e.g.,
this.restartMonitoring() or re-initializing the headset monitoring flow),
logging the event, and/or resetting this.monitoringStartedAt; keep existing
early-exit checks (this.connectedDevice.id,
useBluetoothAudioStore.getState().isHeadsetButtonMonitoring) and ensure
this.monitoringWatchdogInterval and stopMonitoringWatchdog are used to
clear/restart the timer as needed.
- Around line 2152-2154: Remove the unused private method setMicrophoneEnabled
which bypasses the requestMicrophoneState serialization logic; search for and
delete the setMicrophoneEnabled method declaration and any references to it, and
ensure callers continue to use requestMicrophoneState or applyMicrophoneEnabled
so the isApplyingMicState / pendingMicEnabled serializer remains authoritative
for microphone state changes.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/services/bluetooth-audio.service.ts (1)

2421-2422: ⚠️ Potential issue | 🟡 Minor

Duplicate this.isInitialized = false assignment in destroy().

🛡️ Proposed fix
-   this.isInitialized = false;
    this.isInitialized = false;
    this.hasAttemptedPreferredDeviceConnection = false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2421 - 2422, Duplicate
assignment to this.isInitialized is present in the destroy() method; remove the
redundant line so there is only a single this.isInitialized = false assignment
in destroy(), ensuring the property is still reset once during cleanup (refer to
the destroy() method and the this.isInitialized property to locate the change).
🧹 Nitpick comments (1)
src/services/bluetooth-audio.service.ts (1)

1416-1432: Third loop in startNotificationsForButtonControls is redundant for specialized devices.

For isSpecializedDevice, Loop 2 (lines 1385–1411) already calls registerReadPollingCharacteristic for every successfully-subscribed characteristic (condition at line 1389: isSpecializedDevice || hasReadCapability always satisfies when isSpecializedDevice is true). Loop 1 registers known button configs. registerReadPollingCharacteristic itself deduplicates (lines 1443–1446). The third loop therefore adds nothing and can be removed.

♻️ Proposed removal
-   if (isSpecializedDevice && peripheralInfo?.characteristics?.length) {
-     for (const characteristic of peripheralInfo.characteristics) {
-       const serviceUuid = characteristic?.service;
-       const characteristicUuid = characteristic?.characteristic;
-
-       if (!serviceUuid || !characteristicUuid) {
-         continue;
-       }
-
-       const pairKey = `${String(serviceUuid).toUpperCase()}::${String(characteristicUuid).toUpperCase()}`;
-       if (!successfullySubscribed.has(pairKey)) {
-         continue;
-       }
-
-       this.registerReadPollingCharacteristic(serviceUuid, characteristicUuid);
-     }
-   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 1416 - 1432, The third
for-loop inside startNotificationsForButtonControls that iterates when
isSpecializedDevice and calls registerReadPollingCharacteristic for each
characteristic is redundant because Loop 2 already registers read-polling for
successfully-subscribed characteristics when isSpecializedDevice is true and
registerReadPollingCharacteristic deduplicates calls; remove this entire loop
(the block referencing isSpecializedDevice, peripheralInfo?.characteristics,
pairKey, successfullySubscribed, and registerReadPollingCharacteristic) to avoid
duplicate iteration and dead code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/services/__tests__/bluetooth-audio.service.test.ts`:
- Around line 98-100: The afterEach unconditionally calls
bluetoothAudioService.destroy(), causing a second invocation (and duplicate
async disconnectDevice() calls and store updates) for tests that already call
destroy(); either stop calling the real destroy twice by replacing in-test
destroy assertions with a jest.spyOn(bluetoothAudioService, 'destroy') (or spy
on disconnectDevice/ store methods) to verify effects without invoking the real
implementation, or guard the afterEach so it skips calling
bluetoothAudioService.destroy() when the service is already destroyed (e.g.,
check a public isDestroyed flag or set a local test-scoped boolean when a test
calls destroy), and update the tests "should reset flags on destroy method" and
"should allow re-attempting connection after destroy" to use the spy or the
guard accordingly.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 1218-1237: The interval body in startMonitoringWatchdog currently
only cancels itself and never attempts recovery; update it so when the checks
pass (connectedDevice matches deviceId, isHeadsetButtonMonitoring is true, and
monitoringStartedAt exists) you detect stale monitoring (e.g., by comparing
monitoringStartedAt to Date.now() or just on every tick) and call a recovery
routine such as this.ensurePttInputMonitoring('watchdog') or
this.startNotificationsForButtonControls() to re-subscribe notifications; keep
stopMonitoringWatchdog, monitoringWatchdogInterval, monitoringStartedAt and
useBluetoothAudioStore.getState().isHeadsetButtonMonitoring as the referenced
symbols, and if this no-op was intentional instead add a clear TODO comment
explaining why no recovery is performed.
- Around line 2118-2168: The loop busy-wait is caused because
scheduleMicApplyRetry writes this.pendingMicEnabled before the timeout, keeping
requestMicrophoneState's while-loop active; fix by introducing a separate
sentinel (e.g., this.retryMicEnabled) and only set retryMicEnabled inside
scheduleMicApplyRetry (never pendingMicEnabled), have the timeout handler
transfer retryMicEnabled -> pendingMicEnabled and call requestMicrophoneState,
and clear retryMicEnabled in clearMicApplyRetry and in reset paths (methods like
revertLiveKitAudioRouting, disconnectDevice, destroy, reset) so the while-loop
in requestMicrophoneState can exit normally when a retry is pending but not yet
scheduled.

---

Outside diff comments:
In `@src/services/bluetooth-audio.service.ts`:
- Around line 2421-2422: Duplicate assignment to this.isInitialized is present
in the destroy() method; remove the redundant line so there is only a single
this.isInitialized = false assignment in destroy(), ensuring the property is
still reset once during cleanup (refer to the destroy() method and the
this.isInitialized property to locate the change).

---

Duplicate comments:
In `@src/services/__tests__/bluetooth-audio.service.test.ts`:
- Around line 227-246: The test sets a persistent mock implementation on
useLiveKitCallStore.getState via featureStore.getState.mockReturnValue which
survives other tests; change it to mockReturnValueOnce so the override only
applies to this test: update the test that references featureStore.getState
(from useLiveKitCallStore) to call mockReturnValueOnce(...) instead of
mockReturnValue(...), ensuring other tests see the default mock; keep
jest.clearAllMocks() in beforeEach as-is to clear call history but not
implementations.

---

Nitpick comments:
In `@src/services/bluetooth-audio.service.ts`:
- Around line 1416-1432: The third for-loop inside
startNotificationsForButtonControls that iterates when isSpecializedDevice and
calls registerReadPollingCharacteristic for each characteristic is redundant
because Loop 2 already registers read-polling for successfully-subscribed
characteristics when isSpecializedDevice is true and
registerReadPollingCharacteristic deduplicates calls; remove this entire loop
(the block referencing isSpecializedDevice, peripheralInfo?.characteristics,
pairKey, successfullySubscribed, and registerReadPollingCharacteristic) to avoid
duplicate iteration and dead code.

Comment on lines +98 to +100
afterEach(() => {
bluetoothAudioService.destroy();
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

afterEach destroy double-invokes destroy() for tests that already call it.

The tests "should reset flags on destroy method" (line 151) and "should allow re-attempting connection after destroy" (line 197) call bluetoothAudioService.destroy() inside the test body. afterEach then calls it a second time. While destroy() is effectively idempotent (null-guards on all intervals/listeners), it does fire-and-forget disconnectDevice() (which is async) twice and re-invokes the Zustand store calls twice. Consider using jest.spyOn to test destroy()'s effects rather than calling the real singleton method, or move the afterEach guard to skip if already destroyed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/__tests__/bluetooth-audio.service.test.ts` around lines 98 -
100, The afterEach unconditionally calls bluetoothAudioService.destroy(),
causing a second invocation (and duplicate async disconnectDevice() calls and
store updates) for tests that already call destroy(); either stop calling the
real destroy twice by replacing in-test destroy assertions with a
jest.spyOn(bluetoothAudioService, 'destroy') (or spy on disconnectDevice/ store
methods) to verify effects without invoking the real implementation, or guard
the afterEach so it skips calling bluetoothAudioService.destroy() when the
service is already destroyed (e.g., check a public isDestroyed flag or set a
local test-scoped boolean when a test calls destroy), and update the tests
"should reset flags on destroy method" and "should allow re-attempting
connection after destroy" to use the spy or the guard accordingly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/services/bluetooth-audio.service.ts (2)

2289-2342: ⚠️ Potential issue | 🟠 Major

Microphone stuck enabled when BT device disconnects mid-PTT.

Both revertLiveKitAudioRouting (line 2316) and disconnectDevice (line 2335) set this.pttPressActive = false and then this.pendingMicEnabled = null — but never call requestMicrophoneState(false). If PTT was active when the BT device disconnects (or routing is reverted), LiveKit's microphone remains enabled, leaving the user unknowingly broadcasting.

requestMicrophoneState(false) can be safely called before clearing pttPressActive because the async IIFE inside it synchronously reads and clears pendingMicEnabled before its first await (JS single-threaded); the subsequent this.pendingMicEnabled = null reset in both methods is already a no-op.

🛡️ Proposed fix — apply in both revertLiveKitAudioRouting and disconnectDevice
-    this.pttPressActive = false;
+    if (this.pttPressActive) {
+      this.requestMicrophoneState(false);
+    }
+    this.pttPressActive = false;
     this.clearPttReleaseFallback();

Apply the same change at:

  • revertLiveKitAudioRouting, before line 2316
  • disconnectDevice, before line 2335
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2289 - 2342, Both
revertLiveKitAudioRouting and disconnectDevice fail to turn off the LiveKit mic
when a BT PTT disconnects; call requestMicrophoneState(false) in each function
before clearing this.pttPressActive so the microphone is actually disabled, then
proceed with clearing pendingMicEnabled and other cleanup; update
revertLiveKitAudioRouting and disconnectDevice to invoke
requestMicrophoneState(false) prior to setting this.pttPressActive = false (it's
safe because the async IIFE in requestMicrophoneState synchronously reads/clears
pendingMicEnabled before any awaits).

2439-2440: ⚠️ Potential issue | 🟡 Minor

Remove the duplicate this.isInitialized = false assignment.

Line 2440 is an exact copy of line 2439 — the second assignment is a no-op and is clearly a copy-paste mistake.

🐛 Proposed fix
  // Reset initialization flags
  this.isInitialized = false;
- this.isInitialized = false;
  this.hasAttemptedPreferredDeviceConnection = false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 2439 - 2440, Remove the
duplicate no-op assignment to the isInitialized property in the
BluetoothAudioService initialization code: locate the two consecutive lines
setting this.isInitialized = false (both the one on startLine 2439 and the
duplicate on 2440) and delete the redundant second assignment so only a single
this.isInitialized = false remains; ensure no other logic depends on the removed
duplicate.
🧹 Nitpick comments (1)
src/services/bluetooth-audio.service.ts (1)

1427-1443: Third pass loop is redundant.

The third pass (lines 1427–1443) only iterates over successfullySubscribed entries and calls registerReadPollingCharacteristic. However, every entry in successfullySubscribed for a specialized device was already registered in either the first loop (line 1357, explicit buttonControlConfigs) or the second loop (line 1401, guarded by isSpecializedDevice || hasReadCapability, which always evaluates to true for specialized devices). Since registerReadPollingCharacteristic deduplicates internally (lines 1454–1458), this third pass adds nothing.

♻️ Proposed cleanup
-  if (isSpecializedDevice && peripheralInfo?.characteristics?.length) {
-    for (const characteristic of peripheralInfo.characteristics) {
-      const serviceUuid = characteristic?.service;
-      const characteristicUuid = characteristic?.characteristic;
-
-      if (!serviceUuid || !characteristicUuid) {
-        continue;
-      }
-
-      const pairKey = `${String(serviceUuid).toUpperCase()}::${String(characteristicUuid).toUpperCase()}`;
-      if (!successfullySubscribed.has(pairKey)) {
-        continue;
-      }
-
-      this.registerReadPollingCharacteristic(serviceUuid, characteristicUuid);
-    }
-  }
-
   if (successfullySubscribed.size > 0) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/bluetooth-audio.service.ts` around lines 1427 - 1443, The third
loop iterating over peripheralInfo.characteristics and checking
successfullySubscribed before calling registerReadPollingCharacteristic is
redundant for specialized devices because those entries are already handled by
the first loop (buttonControlConfigs) or the second loop (guarded by
isSpecializedDevice || hasReadCapability) and registerReadPollingCharacteristic
already deduplicates; remove the entire third pass (the block that references
peripheralInfo.characteristics and uses pairKey and successfullySubscribed) to
eliminate dead code and rely on the earlier registration paths and internal
deduplication in registerReadPollingCharacteristic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/services/bluetooth-audio.service.ts`:
- Around line 2289-2342: Both revertLiveKitAudioRouting and disconnectDevice
fail to turn off the LiveKit mic when a BT PTT disconnects; call
requestMicrophoneState(false) in each function before clearing
this.pttPressActive so the microphone is actually disabled, then proceed with
clearing pendingMicEnabled and other cleanup; update revertLiveKitAudioRouting
and disconnectDevice to invoke requestMicrophoneState(false) prior to setting
this.pttPressActive = false (it's safe because the async IIFE in
requestMicrophoneState synchronously reads/clears pendingMicEnabled before any
awaits).
- Around line 2439-2440: Remove the duplicate no-op assignment to the
isInitialized property in the BluetoothAudioService initialization code: locate
the two consecutive lines setting this.isInitialized = false (both the one on
startLine 2439 and the duplicate on 2440) and delete the redundant second
assignment so only a single this.isInitialized = false remains; ensure no other
logic depends on the removed duplicate.

---

Nitpick comments:
In `@src/services/bluetooth-audio.service.ts`:
- Around line 1427-1443: The third loop iterating over
peripheralInfo.characteristics and checking successfullySubscribed before
calling registerReadPollingCharacteristic is redundant for specialized devices
because those entries are already handled by the first loop
(buttonControlConfigs) or the second loop (guarded by isSpecializedDevice ||
hasReadCapability) and registerReadPollingCharacteristic already deduplicates;
remove the entire third pass (the block that references
peripheralInfo.characteristics and uses pairKey and successfullySubscribed) to
eliminate dead code and rely on the earlier registration paths and internal
deduplication in registerReadPollingCharacteristic.

@ucswift
Copy link
Member Author

ucswift commented Feb 19, 2026

Approve

Copy link

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is approved.

@ucswift ucswift merged commit 644012e into master Feb 19, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments