Skip to content

Conversation

@ucswift
Copy link
Member

@ucswift ucswift commented Jan 29, 2026

Summary by CodeRabbit

  • New Features

    • Full native Android call support and improved in-call audio handling
    • In-call audio assets integrated for better Android playback
  • Bug Fixes

    • Stricter filtering of audio devices (removes wired/unavailable/unknown entries)
    • More reliable Bluetooth discovery for weaker signals
  • Improvements

    • Enhanced cross-platform audio routing and clearer connection error feedback
    • Device selection UI shows connecting state with spinner
    • Added "Device disconnected" translation
  • Tests

    • New tests for Bluetooth parsing and navigation scenarios

✏️ Tip: You can customize this high-level summary in your review settings.


try {
// Generate a new UUID for this call
currentCallUUID = this.generateUUID();

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI 4 days ago

In general, the fix is to stop using Math.random() for generating the random bits of the UUID and instead use a cryptographically secure source of randomness. On React Native / JS environments, that means preferring global.crypto.getRandomValues (or crypto.randomUUID) and falling back to any available secure random byte function rather than Math.random().

The best targeted fix here is to keep the existing fast path using crypto.randomUUID() if present, but replace the Math.random-based fallback with a version that uses crypto.getRandomValues (or global.crypto.getRandomValues) to fill a byte array and then format those bytes into a UUID string. This preserves functionality (we still return a standards-like UUID string), but ensures that all randomness comes from a cryptographically secure generator whenever crypto is available. If crypto is not available at all, we can fall back to a deterministic but unique-enough identifier not based on Math.random (for example, timestamp plus an incrementing counter). That’s not cryptographically strong, but it removes the specific use of Math.random that CodeQL is detecting and is safer than pseudo-random values in a security context.

Concretely, in src/services/callkeep.service.android.ts:

  • Update generateUUID to:
    • Try global.crypto.randomUUID() (current behavior).
    • Else, if global.crypto.getRandomValues exists, generate a 16-byte buffer with getRandomValues and format it into a UUID of the form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx using bit operations on those bytes only (no use of Math.random()).
    • Else, fall back to a non-random but unique-ish identifier that does not use Math.random, such as ${Date.now()}-${++this.fallbackUUIDCounter} (introducing a private static or instance counter field). This keeps behavior reasonable even in very constrained environments while addressing the CodeQL finding.

No new external dependencies are required; we rely solely on the standard crypto object if present.


Suggested changeset 1
src/services/callkeep.service.android.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts
--- a/src/services/callkeep.service.android.ts
+++ b/src/services/callkeep.service.android.ts
@@ -278,15 +278,56 @@
    * Generate a UUID for CallKeep calls
    */
   private generateUUID(): string {
-    // RN 0.76 typically provides global crypto.randomUUID via Hermes/JSI
+    // Prefer native/JSI crypto.randomUUID when available
     const rndUUID = (global as any)?.crypto?.randomUUID?.();
     if (typeof rndUUID === 'string') return rndUUID;
-    // Fallback
-    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
-      const r = (Math.random() * 16) | 0;
-      const v = c === 'x' ? r : (r & 0x3) | 0x8;
-      return v.toString(16);
-    });
+
+    const cryptoObj: any = (global as any)?.crypto;
+    if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
+      // Secure fallback: use crypto.getRandomValues to generate RFC4122 v4-like UUID
+      const bytes = new Uint8Array(16);
+      cryptoObj.getRandomValues(bytes);
+
+      // Per RFC4122 section 4.4
+      bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
+      bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
+
+      const byteToHex: string[] = [];
+      for (let i = 0; i < 256; i++) {
+        byteToHex.push((i + 0x100).toString(16).substring(1));
+      }
+
+      return (
+        byteToHex[bytes[0]] +
+        byteToHex[bytes[1]] +
+        byteToHex[bytes[2]] +
+        byteToHex[bytes[3]] +
+        '-' +
+        byteToHex[bytes[4]] +
+        byteToHex[bytes[5]] +
+        '-' +
+        byteToHex[bytes[6]] +
+        byteToHex[bytes[7]] +
+        '-' +
+        byteToHex[bytes[8]] +
+        byteToHex[bytes[9]] +
+        '-' +
+        byteToHex[bytes[10]] +
+        byteToHex[bytes[11]] +
+        byteToHex[bytes[12]] +
+        byteToHex[bytes[13]] +
+        byteToHex[bytes[14]] +
+        byteToHex[bytes[15]]
+      );
+    }
+
+    // Last-resort fallback: deterministic unique-ish ID without Math.random
+    // This should only be hit in very constrained environments without crypto.
+    const ts = Date.now().toString(16);
+    const pid = (typeof process !== 'undefined' && process.pid ? process.pid : 0).toString(16);
+    const counter = (this as any)._uuidCounter = (((this as any)._uuidCounter || 0) + 1);
+    const ctr = counter.toString(16);
+    return `${ts}-${pid}-${ctr}`;
   }
 
   /**
EOF
@@ -278,15 +278,56 @@
* Generate a UUID for CallKeep calls
*/
private generateUUID(): string {
// RN 0.76 typically provides global crypto.randomUUID via Hermes/JSI
// Prefer native/JSI crypto.randomUUID when available
const rndUUID = (global as any)?.crypto?.randomUUID?.();
if (typeof rndUUID === 'string') return rndUUID;
// Fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});

const cryptoObj: any = (global as any)?.crypto;
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
// Secure fallback: use crypto.getRandomValues to generate RFC4122 v4-like UUID
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);

// Per RFC4122 section 4.4
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1

const byteToHex: string[] = [];
for (let i = 0; i < 256; i++) {
byteToHex.push((i + 0x100).toString(16).substring(1));
}

return (
byteToHex[bytes[0]] +
byteToHex[bytes[1]] +
byteToHex[bytes[2]] +
byteToHex[bytes[3]] +
'-' +
byteToHex[bytes[4]] +
byteToHex[bytes[5]] +
'-' +
byteToHex[bytes[6]] +
byteToHex[bytes[7]] +
'-' +
byteToHex[bytes[8]] +
byteToHex[bytes[9]] +
'-' +
byteToHex[bytes[10]] +
byteToHex[bytes[11]] +
byteToHex[bytes[12]] +
byteToHex[bytes[13]] +
byteToHex[bytes[14]] +
byteToHex[bytes[15]]
);
}

// Last-resort fallback: deterministic unique-ish ID without Math.random
// This should only be hit in very constrained environments without crypto.
const ts = Date.now().toString(16);
const pid = (typeof process !== 'undefined' && process.pid ? process.pid : 0).toString(16);
const counter = (this as any)._uuidCounter = (((this as any)._uuidCounter || 0) + 1);
const ctr = counter.toString(16);
return `${ts}-${pid}-${ctr}`;
}

/**
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
@coderabbitai
Copy link

coderabbitai bot commented Jan 29, 2026

📝 Walkthrough

Walkthrough

Adds Android CallKeep and an in-call audio native module, refactors audio playback/routing, tightens Bluetooth discovery/connection flows, updates LiveKit audio routing and permissions, UI/store adjustments for audio device selection and bottom-sheet navigation, dependency bumps, tests, and translation keys.

Changes

Cohort / File(s) Summary
Expo config & plugin
app.config.ts, plugins/withInCallAudioModule.js
Added Android runtime permissions and new Expo config plugin that injects an Android InCallAudio native module and assets.
Native CallKeep (Android / iOS)
src/services/callkeep.service.android.ts, src/services/callkeep.service.ios.ts
New full Android CallKeepService (setup/start/end/cleanup/mute handling); iOS CallKeep gains storm-detection and ignoreMuteEvents.
Audio playback & in-call module
src/services/audio.service.ts, plugins/.../InCallAudioModule.kt (generated)
Refactors playback to use native in-call audio path on Android and consolidated load/play/cleanup logic; batch-loading and unified play helper added.
LiveKit store & routing
src/stores/app/livekit-store.ts, src/components/livekit/livekit-bottom-sheet.tsx
New applyAudioRouting and setupAudioRouting, Android permission requests, CallKeep/media-button integration, previousView navigation for audio settings, and speaker selection routing.
Bluetooth service & store
src/services/bluetooth-audio.service.ts, src/stores/app/bluetooth-audio-store.ts
Relaxed RSSI filter, UUID equality helper, richer logs, awaited stopScanning(): Promise<void>, added reset(): Promise<void>, introduced exported INITIAL_STATE and new device type microphone.
Bluetooth UI & selection
src/components/settings/bluetooth-device-selection-bottom-sheet.tsx, src/components/settings/audio-device-selection.tsx
Connection-focused flow with connectingDeviceId, spinner/disabled UI states, use showMessage for errors, stricter device filtering (isAvailable + allowed types).
Actionsheet / Select components
src/components/ui/actionsheet/index.tsx, src/components/ui/select/select-actionsheet.tsx
ActionsheetFlatList now accepts style, wraps the FlatList in a View, forwards ref and sets estimatedItemSize={94}.
App init / reset & auth
src/services/app-initialization.service.ts, src/services/app-reset.service.ts, src/stores/auth/store.tsx
CallKeep init extended to Android; reset uses exported initial-state constants; deferred onRehydrateStorage handling with setTimeout.
Tests & mocks
src/components/.../__tests__/*, src/services/__tests__/*
Many tests updated/added: audio playback tests switched to replay-based flow, new Bluetooth and LiveKit tests, updated mocks for bluetooth store and UI, CallKeep iOS mute timing test, and new PTT long-press test.
Dependencies & misc
package.json, expo-env.d.ts, src/app/login/login-form.tsx, src/translations/*
Bumped countly-sdk-react-native-bridge; added promise@8.3.0; added default export for LoginForm; added bluetooth.device_disconnected translations; EOF/formatting tweaks.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant UI as UI/User
participant Store as LiveKit Store
participant BT as BluetoothAudioService
participant AV as AudioService / InCallAudioModule
participant CK as CallKeepService
participant LK as LiveKit (room)

UI->>Store: request connect / select device
Store->>BT: connect(deviceId)
BT-->>Store: connected(device metadata)
Store->>Store: setupAudioRouting() -> determine targetType
Store->>AV: applyAudioRouting(targetType)
AV-->>Store: routing applied / ack
Store->>CK: startCall(roomName)
CK-->>Store: call started (UUID)
Store->>LK: connectToRoom()
LK-->>Store: connected
UI->>Store: open audio settings / change speaker
Store->>AV: applyAudioRouting('speaker'|'earpiece'|...)
AV-->>Store: routing updated
UI->>Store: disconnect / end call
Store->>CK: endCall()
CK-->>Store: call ended
Store->>BT: reset()
BT-->>Store: disconnected
Store->>AV: applyAudioRouting('default')

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐇 I hopped through code with nimble paws,
Android rings and native claws.
Bluetooth sings, routing finds the way,
Sounds play true at break of day.
A cheerful rabbit hops—deploy hooray!

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Develop' is vague and non-descriptive, failing to convey any meaningful information about the substantial changes in this pull request. Provide a more descriptive title that summarizes the primary changes, such as 'Add audio routing, CallKeep integration, and Bluetooth enhancements' or similar.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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

Tip

🧪 Unit Test Generation v2 is now available!

We have significantly improved our unit test generation capabilities.

To enable: Add this to your .coderabbit.yaml configuration:

reviews:
  finishing_touches:
    unit_tests:
      enabled: true

Try it out by using the @coderabbitai generate unit tests command on your code files or under ✨ Finishing Touches on the walkthrough!

Have feedback? Share your thoughts on our Discord thread!


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: 12

Caution

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

⚠️ Outside diff range comments (3)
src/services/app-initialization.service.ts (1)

4-4: Use the platform-agnostic barrel export for CallKeep service.

The platform check now supports both iOS and Android (line 92), but callKeepService is imported directly from callkeep.service.ios. A platform-agnostic barrel export already exists at src/services/callkeep.service.ts that correctly routes to the Android or iOS implementation based on the platform. Change the import on line 4 to:

import { callKeepService } from './callkeep.service';

This ensures Android devices use the Android-specific implementation instead of the iOS one. Other files in the codebase (e.g., bluetooth-audio.service.ts, livekit-store.ts) already follow this pattern.

src/components/settings/audio-device-selection.tsx (1)

43-45: Missing translation for fallback device type label.

The fallback at line 44 returns a dynamically constructed string that won't be translated. Per coding guidelines, all user-facing text should use t().

🌐 Proposed fix
       default:
-        return deviceType.charAt(0).toUpperCase() + deviceType.slice(1) + ' Device';
+        return t('settings.audio_device_selection.unknown_device_type', { type: deviceType });
src/services/app-reset.service.ts (1)

223-241: Use replace flag to ensure complete state reset. The setState calls at lines 223, 235, 238, and 241 omit the replace flag, which causes Zustand to merge state instead of replace it. This leaves stale properties:

  • useLiveKitStore: isVoiceEnabled, voipServerWebsocketSslAddress, callerIdName, canConnectApiToken, and canConnectToVoiceSession are not in INITIAL_LIVEKIT_STATE, so they persist after reset.
  • useAudioStreamStore: isLoadingStreams and soundObject are not in INITIAL_AUDIO_STREAM_STATE, so they persist after reset.

Change to setState(INITIAL_*_STATE, true) to replace the entire state and ensure a clean reset after logout.

🤖 Fix all issues with AI agents
In `@app.config.ts`:
- Around line 83-85: Remove the unnecessary dangerous permission
'android.permission.READ_PHONE_NUMBERS' from the Android permissions array in
app.config.ts (where the manifest permissions list contains
'android.permission.READ_PHONE_STATE', 'android.permission.READ_PHONE_NUMBERS',
'android.permission.MANAGE_OWN_CALLS'); keep
'android.permission.READ_PHONE_STATE' and 'android.permission.MANAGE_OWN_CALLS'
for react-native-callkeep integration and only re-add READ_PHONE_NUMBERS if your
app explicitly uses TelephonyManager APIs to read the device phone number.

In `@expo-env.d.ts`:
- Line 3: The file expo-env.d.ts is missing a trailing newline which breaks
Prettier in CI; update expo-env.d.ts (the file containing the comment "// NOTE:
This file should not be edited and should be in your git ignore") by adding a
single newline character at the end of the file (ensure the file ends with a
blank line / newline) and commit the change so Prettier/CI accepts the file.

In `@package.json`:
- Line 171: The PR added the native dependency "react-native-sound" which
duplicates audio functionality already provided by "expo-audio"; remove
"react-native-sound" from package.json and refactor your audio service to use
the existing expo-audio APIs instead of native react-native-sound calls. Locate
imports and usage in files referencing "react-native-sound" (e.g., AudioService,
any play/pause/stop helper functions) and replace them with expo-audio
equivalents (update imports from "expo-audio" and adapt to its
loadAsync/playAsync/unloadAsync patterns), remove any native-only setup/config,
and update README/dependency notes accordingly so the project uses only
expo-audio. Ensure package.json and Metro/bundler config no longer reference
react-native-sound and run a build to verify no native modules remain.

In `@src/components/livekit/livekit-bottom-sheet.tsx`:
- Around line 150-176: Replace console.log and console.error in the
updateAudioRouting useEffect with the shared logger from "@/lib/logging" (use
logger.info or logger.debug for the 'Updating audio routing for:' message and
logger.error for failures) and keep references to selectedAudioDevices.speaker
and applyAudioRouting; then evaluate/remove this entire useEffect if it
duplicates setupAudioRouting from livekit-store (called during connectToRoom) to
avoid two competing routing flows—if you remove it, ensure connectToRoom →
setupAudioRouting covers all speaker type branches formerly handled by
updateAudioRouting.

In `@src/components/settings/bluetooth-device-selection-bottom-sheet.tsx`:
- Around line 1-5: The imports at the top of the Bluetooth device selection
component are out of order and failing simple-import-sort; run eslint --fix (or
manually reorder) to group and sort imports: external React and react-native
imports first (e.g., import React, { useCallback, useEffect, useState } from
'react'; import { Alert, useWindowDimensions } from 'react-native';), then
third-party hooks and libraries (e.g., import { useTranslation } from
'react-i18next'; import { showMessage } from 'react-native-flash-message';),
then icon imports (e.g., import { BluetoothIcon, RefreshCwIcon, WifiIcon } from
'lucide-react-native';), ensuring alphabetical order within each group so the
file-level imports in bluetooth-device-selection-bottom-sheet.tsx satisfy
simple-import-sort/imports.
- Around line 110-125: Remove the stray blank line after the onClose() call and
eliminate the redundant parentheses around the ternary fallback expression
passed to showMessage so the block reads compactly; specifically inside the
connect/handler function in bluetooth-device-selection-bottom-sheet.tsx (where
onClose(), logger.warn({...}), errorMessage is derived, and showMessage({...})
is called), collapse the extra newline after onClose() and change the
description value from (... ? ... : (errorMessage ||
t('bluetooth.connection_error_message') || 'Could not connect to device')) to
... ? ... : errorMessage || t('bluetooth.connection_error_message') || 'Could
not connect to device' so linting errors are resolved.

In `@src/services/__tests__/bluetooth-audio-b01inrico.test.ts`:
- Around line 191-203: The test block for the case "should ignore long press on
PTT stop (0x80)" has an extra closing brace causing a syntax error; open the
test containing the call to service.parseB01InricoButtonData(...) and remove the
duplicate closing brace so the single it(...) block is properly closed (ensure
only one "});" ends that test).

In `@src/services/audio.service.ts`:
- Around line 99-152: The Sound constructor loads asynchronously but you're not
awaiting it: wrap each new Sound(...) creation in a Promise that resolves when
the constructor callback reports success and rejects/logs on error (e.g., create
a helper like loadSound(uri): Promise<Sound>), replace the direct assignments to
this.startTransmittingSound, this.stopTransmittingSound,
this.connectedDeviceSound, this.connectToAudioRoomSound,
this.disconnectedFromAudioRoomSound with awaited calls to that helper (or use
Promise.all to load concurrently), and only set isInitialized = true after all
sound Promises have resolved so initialization truly waits for sounds to be
ready.

In `@src/services/bluetooth-audio.service.ts`:
- Around line 1794-1796: Remove the duplicated assignment to this.isInitialized
so it's only set once (leave the single initialization for this.isInitialized
and keep the existing this.hasAttemptedPreferredDeviceConnection
initialization); locate the consecutive lines where this.isInitialized = false
appears and delete the redundant one (e.g., in the initialization
block/constructor of the BluetoothAudioService where this.isInitialized and
this.hasAttemptedPreferredDeviceConnection are set).
- Around line 1259-1260: Replace the direct console.log call with the module's
logger instance: locate the console.log(`[PTT_DEBUG] Raw Buffer: ${rawHex} |
Bytes: ${allBytes}`) in bluetooth-audio.service.ts and change it to use
logger.debug (or the appropriate logger level) passing the same message and
variables (rawHex and allBytes) so logging follows the app's logger utility and
formatting conventions.

In `@src/stores/app/bluetooth-audio-store.ts`:
- Around line 148-155: The audio-device type strings changed from 'default' to
role-based values which breaks recovery logic; update the recovery filter in
bluetooth-audio.service (the code currently using d.type === 'default') to look
up devices by role (use d.type === 'microphone' when recovering microphone and
d.type === 'speaker' when recovering speaker), remove the unused 'default'
member from the type union in bluetooth-audio-store (the type for
availableAudioDevices/selectedAudioDevices), and update the test fixtures in
livekit-bottom-sheet.test.tsx to use 'microphone' and 'speaker' instead of
'default-mic'/'default-speaker' types so tests match the new role-based types.

In `@src/stores/app/livekit-store.ts`:
- Line 4: Remove the unused import RTCAudioSession from the top of the file;
locate the import statement that reads "import { RTCAudioSession } from
'@livekit/react-native-webrtc'" and delete RTCAudioSession (or remove the entire
import if nothing else is imported from that module) since the only usage is
commented out in the block around the commented lines referencing
RTCAudioSession (around the code that sets audio session behavior).
🧹 Nitpick comments (14)
src/services/audio.service.ts (2)

17-20: Misleading comment: all sounds now use react-native-sound.

The comment says "Keep others as expo-av for now" but all five sound fields are typed as Sound | null from react-native-sound. Update or remove the comment to avoid confusion.

💡 Suggested fix
-  // Keep others as expo-av for now
-  private connectedDeviceSound: Sound | null = null;
+  private connectedDeviceSound: Sound | null = null;

169-187: Async methods don't await anything.

The playStartTransmittingSound method is marked async but doesn't await anything. The Sound.play() API is callback-based, not promise-based. Consider either:

  1. Removing async since it's misleading, or
  2. Wrapping the play call in a Promise if you need to wait for completion

This applies to all play methods in this file.

src/services/callkeep.service.android.ts (5)

6-7: Module-level state alongside instance state creates potential inconsistency.

currentCallUUID is declared at module level but the service also tracks isCallActive as an instance property. If somehow multiple instances existed (despite singleton pattern), or if the singleton were reset, these could become out of sync. Consider moving currentCallUUID to be an instance property for consistency.

♻️ Proposed refactor to use instance property
-// UUID for the CallKeep call - should be unique per session
-let currentCallUUID: string | null = null;
-
 export interface CallKeepConfig {
   appName: string;
   ...
 }
 
 export class CallKeepService {
   private static instance: CallKeepService | null = null;
   private isSetup = false;
   private isCallActive = false;
   private muteStateCallback: ((muted: boolean) => void) | null = null;
+  private currentCallUUID: string | null = null;

Then update all references from currentCallUUID to this.currentCallUUID.


54-66: iOS audio session options in Android-specific file appear intentional but could benefit from a comment.

The ios configuration block is included in the Android service file. This is likely required by the RNCallKeep.setup() API which expects both platform configurations. A brief comment clarifying this would improve maintainability.

📝 Suggested clarification comment
     try {
+      // RNCallKeep.setup() requires both ios and android config objects
+      // even when running on a single platform
       const options = {
         ios: {

212-214: Unused parameter in ignoreMuteEvents.

The durationMs parameter is unused. While documented as a no-op for Android parity with iOS, consider prefixing with underscore to signal intent and satisfy linters.

♻️ Proposed fix
-  ignoreMuteEvents(durationMs: number): void {
+  ignoreMuteEvents(_durationMs: number): void {
     // No-op on Android
   }

305-308: Event listeners removed without verifying they were registered.

If cleanup() is called before setup() completes (or if setup failed), removing non-existent event listeners could potentially throw. Consider guarding with this.isSetup check before removing listeners.

🛡️ Proposed defensive check
       // Remove event listeners
+      if (this.isSetup) {
         RNCallKeep.removeEventListener('endCall');
         RNCallKeep.removeEventListener('answerCall');
         RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
+      }
 
       this.isSetup = false;

280-290: Consider using expo-crypto for more robust UUID generation instead of Math.random() fallback.

The Math.random() fallback for CallKeep session identifiers can be improved by using Crypto.randomUUID() from expo-crypto, which provides a cryptographically stronger implementation. While CallKeep UUIDs don't handle sensitive data, this approach is more robust and eliminates the use of weak randomness for ID generation.

♻️ Proposed fix using expo-crypto
+import * as Crypto from 'expo-crypto';
+
 /**
  * Generate a UUID for CallKeep calls
  */
 private generateUUID(): string {
   // RN 0.76 typically provides global crypto.randomUUID via Hermes/JSI
   const rndUUID = (global as any)?.crypto?.randomUUID?.();
   if (typeof rndUUID === 'string') return rndUUID;
-  // Fallback
-  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
-    const r = (Math.random() * 16) | 0;
-    const v = c === 'x' ? r : (r & 0x3) | 0x8;
-    return v.toString(16);
-  });
+  // Fallback using expo-crypto
+  return Crypto.randomUUID();
 }

Note: Requires installing expo-crypto if not already present: npx expo install expo-crypto

src/components/settings/__tests__/audio-device-selection.test.tsx (1)

226-239: Test correctly updated to reflect stricter device filtering.

The test now properly expects that unavailable wired devices are filtered out, matching the updated filtering logic that requires isAvailable for all device types. The comment at line 230 ("Should still show even if unavailable") appears to be stale since the assertion now expects it to be null.

📝 Update stale comment
       const availableBluetooth = createMockDevice('bt-available', 'Available BT', 'bluetooth', true);
       const unavailableBluetooth = createMockDevice('bt-unavailable', 'Unavailable BT', 'bluetooth', false);
-      const wiredDevice = createMockDevice('wired-1', 'Wired Device', 'wired', false); // Should still show even if unavailable
+      const wiredDevice = createMockDevice('wired-1', 'Wired Device', 'wired', false); // Should be filtered out when unavailable

       mockStore.availableAudioDevices = [availableBluetooth, unavailableBluetooth, wiredDevice];
src/components/settings/audio-device-selection.tsx (1)

73-75: LGTM on the filtering logic enhancement.

The updated filters now explicitly require isAvailable and restrict to known device types (default, microphone/speaker, bluetooth, wired). This is a good improvement for clarity and correctness.

Optional: Consider extracting the valid types into constants for maintainability:

♻️ Optional refactor for readability
+const MICROPHONE_TYPES = ['default', 'microphone', 'bluetooth', 'wired'] as const;
+const SPEAKER_TYPES = ['default', 'speaker', 'bluetooth', 'wired'] as const;
+
-const availableMicrophones = availableAudioDevices.filter((device) => device.isAvailable && (device.type === 'default' || device.type === 'microphone' || device.type === 'bluetooth' || device.type === 'wired'));
+const availableMicrophones = availableAudioDevices.filter((device) => device.isAvailable && MICROPHONE_TYPES.includes(device.type));

-const availableSpeakers = availableAudioDevices.filter((device) => device.isAvailable && (device.type === 'default' || device.type === 'speaker' || device.type === 'bluetooth' || device.type === 'wired'));
+const availableSpeakers = availableAudioDevices.filter((device) => device.isAvailable && SPEAKER_TYPES.includes(device.type));

,

src/stores/auth/store.tsx (1)

260-301: Appropriate deferral pattern for store initialization.

Wrapping the rehydration logic in setTimeout(..., 0) correctly ensures the store is fully initialized before scheduling token refresh. This addresses potential race conditions during app startup.

Consider extracting duplicate timeout management logic:

The pattern of clearing existing timeout and scheduling a new one is repeated across login, refreshAccessToken, and both rehydration branches.

♻️ Optional refactor to reduce duplication
// Helper function to schedule token refresh with timeout management
const scheduleTokenRefresh = (delayMs: number) => {
  const existingTimeoutId = useAuthStore.getState().refreshTimeoutId;
  if (existingTimeoutId !== null) {
    clearTimeout(existingTimeoutId);
  }
  const timeoutId = setTimeout(() => {
    useAuthStore.getState().refreshAccessToken();
  }, delayMs);
  useAuthStore.setState({ refreshTimeoutId: timeoutId });
};

,

src/components/settings/bluetooth-device-selection-bottom-sheet.tsx (2)

63-83: Remove commented-out code and inline TODO comments.

Lines 63-83 contain development artifacts (commented-out disconnect logic and inline design notes). Clean these up before merging:

🧹 Proposed cleanup
     async (device: BluetoothAudioDevice) => {
       try {
-        // Disconnect from any currently connected device first?
-        // The service connectToDevice usually handles this or we do it here.
-        // Previous code did disconnect manually.
-        /*
-        if (connectedDevice) {
-           ... disconnect ...
-        }
-        */
-        // User wants "When attempting to connect... display loading".
-        // User wants "If error... don't save device".
-
-        // 1. Set connecting state
-        // We can resolve this by using local state for the specific device being connected to,
-        // or rely on store's global isConnecting.
-        // Let's use the store's setIsConnecting to be consistent if the UI uses it.
+        // Set connecting state
         useBluetoothAudioStore.getState().setIsConnecting(true);
         setConnectingDeviceId(device.id);
-
-        // 2. Clear existing preferred temporarily? Or just wait?
-        // User said "don't save the save device, only do that when connection is successful".
-        // So we shouldn't touch preferredDevice yet.

251-261: Good use of extraData for connecting state; consider getItemLayout for better performance.

The extraData={connectingDeviceId} correctly ensures re-renders when the connecting state changes.

For further optimization, consider adding getItemLayout since device items have consistent height (~60px based on estimatedItemSize). Based on learnings: "Use getItemLayout for FlatList when items have consistent size."

♻️ Optional performance improvement
+const ITEM_HEIGHT = 60;
+const getItemLayout = (_: unknown, index: number) => ({
+  length: ITEM_HEIGHT,
+  offset: ITEM_HEIGHT * index,
+  index,
+});
+
 <FlatList
   data={availableDevices}
   renderItem={renderDeviceItem}
   keyExtractor={(item) => item.id}
   ListEmptyComponent={renderEmptyState}
   showsVerticalScrollIndicator={false}
-  estimatedItemSize={60}
+  getItemLayout={getItemLayout}
   extraData={connectingDeviceId}
 />

,

src/stores/app/livekit-store.ts (1)

38-52: Remove or convert commented-out code to actionable TODO.

Lines 38-49 contain commented-out RTCAudioSession logic with explanatory comments. Either:

  1. Remove the dead code entirely, or
  2. Convert to a clear TODO comment if this is intended future work
🧹 Suggested cleanup
       await Audio.setAudioModeAsync({
         allowsRecordingIOS: true,
         staysActiveInBackground: true,
         playsInSilentModeIOS: true,
         shouldDuckAndroid: true,
-        // For speaker, we want false (speaker). For others, simple routing.
         playThroughEarpieceAndroid: deviceType !== 'speaker',
         interruptionModeIOS: InterruptionModeIOS.DoNotMix,
       });

-      // Use RTCAudioSession to force route selection for WebRTC (Not available on Android in this package)
-      // We rely on Audio.setAudioModeAsync and system behavior.
-      /*
-      const RTCAudioSessionAny = RTCAudioSession as any;
-      if (RTCAudioSessionAny.getAudioDevices && RTCAudioSessionAny.selectAudioDevice) {
-         // ... (Logic removed as it's iOS only)
-      } else {
-         logger.info({
-            message: 'RTCAudioSession Android methods not available (Relying on system routing)',
-         });
-      }
-      */
+      // Note: RTCAudioSession device selection APIs are iOS-only.
+      // Android relies on Audio.setAudioModeAsync and system behavior for routing.
       logger.info({
         message: 'Android audio routing applied via Audio.setAudioModeAsync',
       });
src/services/bluetooth-audio.service.ts (1)

2-2: Consider addressing the underlying type resolution issue.

The @ts-ignore comment suggests a type resolution issue with the callkeep service import. This may indicate that the barrel file (@/services/callkeep.service) isn't properly exporting types or that the platform-specific implementations need unified type declarations.

#!/bin/bash
# Check how callkeep.service is exported and if there's a barrel file
fd -t f "callkeep.service" --exec cat {} \; 2>/dev/null | head -50
# Check for an index file in the services directory
cat src/services/index.ts 2>/dev/null || echo "No barrel file found"

/// <reference types="expo/types" />

// NOTE: This file should not be edited and should be in your git ignore
// NOTE: This file should not be edited and should be in your git ignore No newline at end of file
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

Add missing newline at end of file.

The CI pipeline is failing because Prettier requires a trailing newline. Add a newline after line 3.

🧰 Tools
🪛 GitHub Actions: React Native CI/CD

[warning] 3-3: prettier/prettier: Insert ⏎

🪛 GitHub Check: test

[warning] 3-3:
Insert

🤖 Prompt for AI Agents
In `@expo-env.d.ts` at line 3, The file expo-env.d.ts is missing a trailing
newline which breaks Prettier in CI; update expo-env.d.ts (the file containing
the comment "// NOTE: This file should not be edited and should be in your git
ignore") by adding a single newline character at the end of the file (ensure the
file ends with a blank line / newline) and commit the change so Prettier/CI
accepts the file.

package.json Outdated
"react-native-restart": "0.0.27",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-sound": "^0.13.0",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all references to react-native-sound
rg "react-native-sound" -g '!node_modules'

Repository: Resgrid/Unit

Length of output: 1148


🏁 Script executed:

#!/bin/bash
# Check for expo config files and plugins
find . -maxdepth 3 -type f \( -name "app.json" -o -name "eas.json" -o -name "app.config.*" \) | head -20

Repository: Resgrid/Unit

Length of output: 82


🏁 Script executed:

#!/bin/bash
# Check if expo-av is already in package.json
grep -A 2 -B 2 '"expo-av"' package.json

Repository: Resgrid/Unit

Length of output: 211


🏁 Script executed:

#!/bin/bash
# Search for audio-related code
rg "audio\." -g '!node_modules' --type ts --type js | head -30

Repository: Resgrid/Unit

Length of output: 3404


🌐 Web query:

react-native-sound expo config plugin compatibility 2025

💡 Result:

  • react-native-sound is a native module, so it won’t work in Expo Go. To use it in an Expo app you need a custom development build / Dev Client and to generate native projects via CNG (expo prebuild) / EAS Build. (docs.expo.dev)
  • Config plugin status (2025): there doesn’t appear to be an official or commonly used community Expo config plugin specifically for react-native-sound (it’s not among the community-maintained plugin repo’s catalog). In many cases, a library needs no config plugin at all—you just install it and rebuild—unless it requires extra native edits (Info.plist/AndroidManifest/AppDelegate, etc.). (github.com)
  • Practical recommendation for Expo apps: if you primarily need audio playback/recording and want “Expo-native” support (including a built-in config plugin), prefer expo-audio (or other Expo audio libraries) instead of react-native-sound. (docs.expo.dev)

Citations:


Consider consolidating to expo-audio instead of adding react-native-sound.

react-native-sound requires native code and has no official Expo config plugin. While your project's Expo prebuild setup can handle it, you already have expo-audio (v0.4.9) as a dependency—which is purpose-built for Expo's managed workflow and fully supported by Expo SDK.

Evaluate whether expo-audio can cover your audio playback needs for the audio service instead of adding another native audio library. This will reduce native dependencies and align better with Expo's managed workflow approach.

🤖 Prompt for AI Agents
In `@package.json` at line 171, The PR added the native dependency
"react-native-sound" which duplicates audio functionality already provided by
"expo-audio"; remove "react-native-sound" from package.json and refactor your
audio service to use the existing expo-audio APIs instead of native
react-native-sound calls. Locate imports and usage in files referencing
"react-native-sound" (e.g., AudioService, any play/pause/stop helper functions)
and replace them with expo-audio equivalents (update imports from "expo-audio"
and adapt to its loadAsync/playAsync/unloadAsync patterns), remove any
native-only setup/config, and update README/dependency notes accordingly so the
project uses only expo-audio. Ensure package.json and Metro/bundler config no
longer reference react-native-sound and run a build to verify no native modules
remain.

Comment on lines +150 to +176
// Audio Routing Logic
useEffect(() => {
const updateAudioRouting = async () => {
if (!selectedAudioDevices.speaker) return;

try {
const speaker = selectedAudioDevices.speaker;
console.log('Updating audio routing for:', speaker.type);

let targetType: 'bluetooth' | 'speaker' | 'earpiece' | 'default' = 'default';

if (speaker.type === 'speaker') {
targetType = 'speaker';
} else if (speaker.type === 'bluetooth') {
targetType = 'bluetooth';
} else {
targetType = 'earpiece';
}

await applyAudioRouting(targetType);
} catch (error) {
console.error('Failed to update audio routing:', error);
}
};

updateAudioRouting();
}, [selectedAudioDevices.speaker]);
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

Replace console.log/error with logger for consistency.

The codebase uses logger from @/lib/logging throughout. Lines 157 and 171 use console.log/console.error instead.

Additionally, this audio routing logic appears duplicated with setupAudioRouting in src/stores/app/livekit-store.ts. Consider whether this effect is necessary given that setupAudioRouting is called during connectToRoom.

🔧 Proposed fix for logging
-        console.log('Updating audio routing for:', speaker.type);
+        logger.debug({
+          message: 'Updating audio routing for speaker',
+          context: { speakerType: speaker.type },
+        });
...
-        console.error('Failed to update audio routing:', error);
+        logger.error({
+          message: 'Failed to update audio routing',
+          context: { error },
+        });

,

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Audio Routing Logic
useEffect(() => {
const updateAudioRouting = async () => {
if (!selectedAudioDevices.speaker) return;
try {
const speaker = selectedAudioDevices.speaker;
console.log('Updating audio routing for:', speaker.type);
let targetType: 'bluetooth' | 'speaker' | 'earpiece' | 'default' = 'default';
if (speaker.type === 'speaker') {
targetType = 'speaker';
} else if (speaker.type === 'bluetooth') {
targetType = 'bluetooth';
} else {
targetType = 'earpiece';
}
await applyAudioRouting(targetType);
} catch (error) {
console.error('Failed to update audio routing:', error);
}
};
updateAudioRouting();
}, [selectedAudioDevices.speaker]);
// Audio Routing Logic
useEffect(() => {
const updateAudioRouting = async () => {
if (!selectedAudioDevices.speaker) return;
try {
const speaker = selectedAudioDevices.speaker;
logger.debug({
message: 'Updating audio routing for speaker',
context: { speakerType: speaker.type },
});
let targetType: 'bluetooth' | 'speaker' | 'earpiece' | 'default' = 'default';
if (speaker.type === 'speaker') {
targetType = 'speaker';
} else if (speaker.type === 'bluetooth') {
targetType = 'bluetooth';
} else {
targetType = 'earpiece';
}
await applyAudioRouting(targetType);
} catch (error) {
logger.error({
message: 'Failed to update audio routing',
context: { error },
});
}
};
updateAudioRouting();
}, [selectedAudioDevices.speaker]);
🤖 Prompt for AI Agents
In `@src/components/livekit/livekit-bottom-sheet.tsx` around lines 150 - 176,
Replace console.log and console.error in the updateAudioRouting useEffect with
the shared logger from "@/lib/logging" (use logger.info or logger.debug for the
'Updating audio routing for:' message and logger.error for failures) and keep
references to selectedAudioDevices.speaker and applyAudioRouting; then
evaluate/remove this entire useEffect if it duplicates setupAudioRouting from
livekit-store (called during connectToRoom) to avoid two competing routing
flows—if you remove it, ensure connectToRoom → setupAudioRouting covers all
speaker type branches formerly handled by updateAudioRouting.

Comment on lines +1259 to +1260
// FORCE LOG COMPATIBILITY
console.log(`[PTT_DEBUG] Raw Buffer: ${rawHex} | Bytes: ${allBytes}`);
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

Replace console.log with the logger utility.

Direct console.log usage violates the coding guidelines. Use the existing logger instance for consistent logging behavior across the application.

Proposed fix
-    // FORCE LOG COMPATIBILITY
-    console.log(`[PTT_DEBUG] Raw Buffer: ${rawHex} | Bytes: ${allBytes}`);
+    logger.debug({
+      message: '[PTT_DEBUG] Raw buffer data',
+      context: { rawHex, allBytes },
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// FORCE LOG COMPATIBILITY
console.log(`[PTT_DEBUG] Raw Buffer: ${rawHex} | Bytes: ${allBytes}`);
logger.debug({
message: '[PTT_DEBUG] Raw buffer data',
context: { rawHex, allBytes },
});
🤖 Prompt for AI Agents
In `@src/services/bluetooth-audio.service.ts` around lines 1259 - 1260, Replace
the direct console.log call with the module's logger instance: locate the
console.log(`[PTT_DEBUG] Raw Buffer: ${rawHex} | Bytes: ${allBytes}`) in
bluetooth-audio.service.ts and change it to use logger.debug (or the appropriate
logger level) passing the same message and variables (rawHex and allBytes) so
logging follows the app's logger utility and formatting conventions.

Comment on lines 1794 to 1796
this.isInitialized = false;
this.isInitialized = false;
this.hasAttemptedPreferredDeviceConnection = false;
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

Remove duplicate assignment.

this.isInitialized = false is assigned twice consecutively, which appears to be a copy-paste error.

Proposed fix
     // Reset initialization flags
     this.isInitialized = false;
-    this.isInitialized = false;
     this.hasAttemptedPreferredDeviceConnection = false;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.isInitialized = false;
this.isInitialized = false;
this.hasAttemptedPreferredDeviceConnection = false;
this.isInitialized = false;
this.hasAttemptedPreferredDeviceConnection = false;
🤖 Prompt for AI Agents
In `@src/services/bluetooth-audio.service.ts` around lines 1794 - 1796, Remove the
duplicated assignment to this.isInitialized so it's only set once (leave the
single initialization for this.isInitialized and keep the existing
this.hasAttemptedPreferredDeviceConnection initialization); locate the
consecutive lines where this.isInitialized = false appears and delete the
redundant one (e.g., in the initialization block/constructor of the
BluetoothAudioService where this.isInitialized and
this.hasAttemptedPreferredDeviceConnection are set).

Comment on lines 148 to 155
availableAudioDevices: [
{ id: 'default-mic', name: 'Default Microphone', type: 'default', isAvailable: true },
{ id: 'default-mic', name: 'Default Microphone', type: 'microphone', isAvailable: true },
{ id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true },
],
selectedAudioDevices: {
microphone: { id: 'default-mic', name: 'Default Microphone', type: 'default', isAvailable: true },
microphone: { id: 'default-mic', name: 'Default Microphone', type: 'microphone', isAvailable: true },
speaker: { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: 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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for code that filters audio devices by type 'default' which may need updating
rg -n "type.*['\"]default['\"]" --type ts -C2

Repository: Resgrid/Unit

Length of output: 3409


🏁 Script executed:

# Search for microphone-related type filtering and device type usage
rg -n "type.*microphone|\.type\s*===?\s*['\"]" --type ts -C2

Repository: Resgrid/Unit

Length of output: 9375


🏁 Script executed:

# Read the bluetooth-audio-store.ts file to understand the context
wc -l src/stores/app/bluetooth-audio-store.ts

Repository: Resgrid/Unit

Length of output: 99


🏁 Script executed:

# Search for any references to availableAudioDevices or selectedAudioDevices to see how they're used
rg -n "availableAudioDevices|selectedAudioDevices" --type ts -C2

Repository: Resgrid/Unit

Length of output: 24705


Update device type handling when changing from 'default' to role-based types.

The type change from 'default' to 'microphone' and 'speaker' breaks the audio device recovery logic in src/services/bluetooth-audio.service.ts (lines 1686-1687), which explicitly filters for type === 'default' when reverting to non-Bluetooth devices. Update the service to filter by role instead:

  • Search for microphone: d.type === 'microphone'
  • Search for speaker: d.type === 'speaker'

Also update the type union in src/stores/app/bluetooth-audio-store.ts (line 46) to remove the unused 'default' value, and update test data in src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx (lines 133-134) to use the new types.

🤖 Prompt for AI Agents
In `@src/stores/app/bluetooth-audio-store.ts` around lines 148 - 155, The
audio-device type strings changed from 'default' to role-based values which
breaks recovery logic; update the recovery filter in bluetooth-audio.service
(the code currently using d.type === 'default') to look up devices by role (use
d.type === 'microphone' when recovering microphone and d.type === 'speaker' when
recovering speaker), remove the unused 'default' member from the type union in
bluetooth-audio-store (the type for availableAudioDevices/selectedAudioDevices),
and update the test fixtures in livekit-bottom-sheet.test.tsx to use
'microphone' and 'speaker' instead of 'default-mic'/'default-speaker' types so
tests match the new role-based types.

import notifee, { AndroidForegroundServiceType, AndroidImportance } from '@notifee/react-native';
import { Audio, InterruptionModeIOS } from 'expo-av';
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
import { RTCAudioSession } from '@livekit/react-native-webrtc';
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

Remove unused import: RTCAudioSession.

RTCAudioSession is imported but the code that would use it is commented out (lines 40-48). Remove the unused import.

-import { RTCAudioSession } from '@livekit/react-native-webrtc';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { RTCAudioSession } from '@livekit/react-native-webrtc';
🤖 Prompt for AI Agents
In `@src/stores/app/livekit-store.ts` at line 4, Remove the unused import
RTCAudioSession from the top of the file; locate the import statement that reads
"import { RTCAudioSession } from '@livekit/react-native-webrtc'" and delete
RTCAudioSession (or remove the entire import if nothing else is imported from
that module) since the only usage is commented out in the block around the
commented lines referencing RTCAudioSession (around the code that sets audio
session 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 (1)
src/services/audio.service.ts (1)

68-119: Don’t mark the service initialized if audio loads fail.

preloadAudioAssets and loadAudioFiles swallow errors, so initializeAudio still sets isInitialized = true and logs success even when sounds didn’t load—this blocks retries and hides failures.

✅ Suggested fix
  public async preloadAudioAssets(): Promise<void> {
    try {
      await Promise.all([
        Asset.loadAsync(require('@assets/audio/ui/space_notification1.mp3')),
        Asset.loadAsync(require('@assets/audio/ui/space_notification2.mp3')),
        Asset.loadAsync(require('@assets/audio/ui/positive_interface_beep.mp3')),
        Asset.loadAsync(require('@assets/audio/ui/software_interface_start.mp3')),
        Asset.loadAsync(require('@assets/audio/ui/software_interface_back.mp3')),
      ]);
      logger.debug({
        message: 'Audio assets preloaded successfully',
      });
    } catch (error) {
      logger.error({
        message: 'Error preloading audio assets',
        context: { error },
      });
+     throw error;
    }
  }

  private async loadAudioFiles(): Promise<void> {
    try {
      const { sound: startSound } = await Audio.Sound.createAsync(require('@assets/audio/ui/space_notification1.mp3'));
      this.startTransmittingSound = startSound;
      const { sound: stopSound } = await Audio.Sound.createAsync(require('@assets/audio/ui/space_notification2.mp3'));
      this.stopTransmittingSound = stopSound;
      const { sound: connectedSound } = await Audio.Sound.createAsync(require('@assets/audio/ui/positive_interface_beep.mp3'));
      this.connectedDeviceSound = connectedSound;
      const { sound: connectRoomSound } = await Audio.Sound.createAsync(require('@assets/audio/ui/software_interface_start.mp3'));
      this.connectToAudioRoomSound = connectRoomSound;
      const { sound: disconnectRoomSound } = await Audio.Sound.createAsync(require('@assets/audio/ui/software_interface_back.mp3'));
      this.disconnectedFromAudioRoomSound = disconnectRoomSound;

      logger.debug({
        message: 'Audio files loaded successfully',
      });
    } catch (error) {
      logger.error({
        message: 'Failed to load audio files',
        context: { error },
      });
+     throw error;
    }
  }
🤖 Fix all issues with AI agents
In `@src/services/callkeep.service.android.ts`:
- Around line 230-275: The setupEventListeners method currently calls
RNCallKeep.addEventListener without storing the returned subscription objects;
change setupEventListeners to save each subscription returned by
RNCallKeep.addEventListener into private fields (e.g., this.endCallSubscription,
this.answerCallSubscription, this.muteSubscription), and update the
cleanup/teardown code to call .remove() on those stored subscriptions (instead
of RNCallKeep.removeEventListener). Ensure references to symbols like
setupEventListeners, currentCallUUID, this.isCallActive, and
this.muteStateCallback remain unchanged while adding the private subscription
fields and invoking .remove() on them during teardown.

In `@src/stores/app/livekit-store.ts`:
- Around line 208-222: The permission request flow calls
PermissionsAndroid.requestMultiple for READ_PHONE_NUMBERS and READ_PHONE_STATE
but does not check the returned result; update the try block in the function
containing this call (the PermissionsAndroid.requestMultiple invocation) to
inspect the granted object for each permission key, and if any are denied or
never_ask_again, log a clear warning via logger.warn (include the denied
permissions and result) and return early or set a flag so downstream CallKeep
usage is skipped; keep the existing catch for thrown errors and ensure you still
log the error as before.
🧹 Nitpick comments (3)
src/stores/app/bluetooth-audio-store.ts (1)

117-165: Consider a factory for INITIAL_STATE to avoid shared references.

INITIAL_STATE contains nested arrays/objects; exporting and spreading it risks accidental mutations leaking into future resets. A factory keeps fresh copies for store init and reset flows.

♻️ Suggested refactor
-export const INITIAL_STATE: Omit<
+export const createInitialState = (): Omit<
   BluetoothAudioState,
   | 'setBluetoothState'
   | 'setIsScanning'
   | 'setIsConnecting'
   | 'addDevice'
   | 'updateDevice'
   | 'removeDevice'
   | 'clearDevices'
   | 'setConnectedDevice'
   | 'setPreferredDevice'
   | 'setAvailableAudioDevices'
   | 'setSelectedMicrophone'
   | 'setSelectedSpeaker'
   | 'updateAudioDeviceAvailability'
   | 'setConnectionError'
   | 'clearConnectionError'
   | 'setAudioRoutingActive'
   | 'addButtonEvent'
   | 'clearButtonEvents'
   | 'setLastButtonAction'
   | 'setMediaButtonPTTSettings'
   | 'setMediaButtonPTTEnabled'
-> = {
+> => ({
   bluetoothState: State.Unknown,
   isScanning: false,
   isConnecting: false,
   availableDevices: [],
   connectedDevice: null,
   preferredDevice: null,
   availableAudioDevices: [
     { id: 'default-mic', name: 'Default Microphone', type: 'microphone', isAvailable: true },
     { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true },
   ],
   selectedAudioDevices: {
     microphone: { id: 'default-mic', name: 'Default Microphone', type: 'microphone', isAvailable: true },
     speaker: { id: 'default-speaker', name: 'Default Speaker', type: 'speaker', isAvailable: true },
   },
   connectionError: null,
   isAudioRoutingActive: false,
   buttonEvents: [],
   lastButtonAction: null,
   mediaButtonPTTSettings: createDefaultPTTSettings(),
-};
+});
+
+export const INITIAL_STATE = createInitialState();

 export const useBluetoothAudioStore = create<BluetoothAudioState>((set, get) => ({
-  ...INITIAL_STATE,
+  ...createInitialState(),
src/stores/app/livekit-store.ts (2)

17-71: Avoid forcing earpiece when routing to Bluetooth/default.

playThroughEarpieceAndroid is set for all non‑speaker routes, which can override Bluetooth routing. Consider only setting it for explicit speaker/earpiece choices and letting Bluetooth/default fall through to system routing.

💡 Suggested refinement
-      await Audio.setAudioModeAsync({
+      const audioMode = {
         allowsRecordingIOS: true,
         staysActiveInBackground: true,
         playsInSilentModeIOS: true,
         shouldDuckAndroid: true,
-        // For speaker, we want false (speaker). For others, simple routing.
-        playThroughEarpieceAndroid: deviceType !== 'speaker',
+        ...(deviceType === 'speaker' && { playThroughEarpieceAndroid: false }),
+        ...(deviceType === 'earpiece' && { playThroughEarpieceAndroid: true }),
         interruptionModeIOS: InterruptionModeIOS.DoNotMix,
-      });
+      };
+      await Audio.setAudioModeAsync(audioMode);

79-118: Consider using a true “default” route when nothing is selected.

The current default forces earpiece, which can override the OS default even when the user hasn’t chosen a device. If the intent is to defer to system routing, use 'default' and only coerce to speaker/earpiece when explicitly selected.

💡 Suggested adjustment
-    let targetType: 'bluetooth' | 'speaker' | 'earpiece' = 'earpiece';
+    let targetType: 'bluetooth' | 'speaker' | 'earpiece' | 'default' = 'default';
...
-    if (selectedAudioDevices.speaker?.type === 'speaker') {
+    if (selectedAudioDevices.speaker?.type === 'speaker') {
       targetType = 'speaker';
     }
+    if (selectedAudioDevices.speaker?.type === 'earpiece') {
+      targetType = 'earpiece';
+    }

Comment on lines +230 to +275
/**
* Setup event listeners for CallKeep events
*/
private setupEventListeners(): void {
// Call ended from CallKit UI
RNCallKeep.addEventListener('endCall', ({ callUUID }: { callUUID: string }) => {
logger.info({
message: 'CallKeep call ended from system UI',
context: { callUUID },
});

if (callUUID === currentCallUUID) {
currentCallUUID = null;
this.isCallActive = false;
}
});

// Call answered (not typically used for outgoing calls, but good to handle)
RNCallKeep.addEventListener('answerCall', ({ callUUID }: { callUUID: string }) => {
logger.debug({
message: 'CallKeep call answered',
context: { callUUID },
});
RNCallKeep.setCurrentCallActive(callUUID);
});

// Mute/unmute events
RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }: { muted: boolean; callUUID: string }) => {
logger.debug({
message: 'CallKeep mute state changed',
context: { muted, callUUID },
});

// Call the registered callback if available
if (this.muteStateCallback) {
try {
this.muteStateCallback(muted);
} catch (error) {
logger.warn({
message: 'Failed to execute mute state callback',
context: { error, muted, callUUID },
});
}
}
});
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# First, locate and read the callkeep service file
if [ -f "src/services/callkeep.service.android.ts" ]; then
  echo "=== File found: src/services/callkeep.service.android.ts ==="
  wc -l src/services/callkeep.service.android.ts
else
  echo "File not found at expected location, searching..."
  fd "callkeep.service" --type f
fi

Repository: Resgrid/Unit

Length of output: 161


🏁 Script executed:

#!/bin/bash

# Read the setupEventListeners method and surrounding context
cat -n src/services/callkeep.service.android.ts | sed -n '225,315p'

Repository: Resgrid/Unit

Length of output: 3277


🏁 Script executed:

#!/bin/bash

# Search for cleanup method to see how event listeners are currently being removed
rg "cleanup|removeEventListener" src/services/callkeep.service.android.ts -A 5 -B 2

Repository: Resgrid/Unit

Length of output: 689


🏁 Script executed:

#!/bin/bash

# Check the version of react-native-callkeep
rg "react-native-callkeep" package.json

Repository: Resgrid/Unit

Length of output: 223


🏁 Script executed:

#!/bin/bash

# Search for react-native-callkeep usage elsewhere in the codebase to see patterns
rg "RNCallKeep\." --type ts --type tsx | head -20

Repository: Resgrid/Unit

Length of output: 83


🏁 Script executed:

#!/bin/bash

# Check if there are any type definitions or imports from react-native-callkeep
fd "node_modules/react-native-callkeep" -type d 2>/dev/null | head -1 | xargs -I {} ls -la {} 2>/dev/null | head -20

Repository: Resgrid/Unit

Length of output: 38


🌐 Web query:

react-native-callkeep addEventListener return value subscription API

💡 Result:

Web search failed: Server error: no LLM provider could handle the message


🏁 Script executed:

#!/bin/bash

# Try finding type definitions for react-native-callkeep
find . -name "*.d.ts" -type f | xargs grep -l "addEventListener" 2>/dev/null | head -5

Repository: Resgrid/Unit

Length of output: 283


🏁 Script executed:

#!/bin/bash

# Check if there's a tsconfig or any type configuration
if [ -f "tsconfig.json" ]; then
  cat tsconfig.json | head -30
fi

Repository: Resgrid/Unit

Length of output: 797


🏁 Script executed:

#!/bin/bash

# Look for how addEventListener is being used elsewhere - check for subscription pattern
rg "addEventListener" --type ts -A 2 -B 1 | head -40

Repository: Resgrid/Unit

Length of output: 3011


🏁 Script executed:

#!/bin/bash

# Check node_modules for the actual react-native-callkeep module structure
if [ -d "node_modules/react-native-callkeep" ]; then
  ls -la node_modules/react-native-callkeep/ 2>/dev/null || echo "Directory exists but listing failed"
  # Try to find index.d.ts or similar
  find node_modules/react-native-callkeep -name "*.d.ts" 2>/dev/null | head -5
fi

Repository: Resgrid/Unit

Length of output: 38


🌐 Web query:

react-native-callkeep addEventListener Irfanwani fork API subscription remove

💡 Result:

In the react-native-webrtc/react-native-callkeep API, RNCallKeep.addEventListener(...) is not documented as returning a subscription object. Instead, listeners are removed via CallKeep’s own RNCallKeep.removeEventListener(eventName) API. The README even shows removal like this (no subscription, no handler reference): RNCallKeep.removeEventListener('checkReachability'); [1]

So, for CallKeep, the pattern is typically:

RNCallKeep.addEventListener('answerCall', onAnswerCall);
// ...
RNCallKeep.removeEventListener('answerCall');

If your Irfanwani fork changed addEventListener to follow modern React Native EventEmitter conventions (i.e., return an EmitterSubscription), then the removal pattern would be:

const sub = RNCallKeep.addEventListener('answerCall', onAnswerCall);
// ...
sub.remove();

That “subscription with .remove()” pattern is how many core RN APIs work today (e.g., BackHandler.addEventListener returns a subscription you remove via subscription.remove()), but it’s not the removal API shown in CallKeep’s upstream README [1], whereas it is shown in React Native’s docs for similar event APIs [2].

Practical check for your fork: log the return value:

const ret = RNCallKeep.addEventListener('answerCall', onAnswerCall);
console.log(ret); // if it has { remove: fn }, use ret.remove()

Sources: [1] react-native-callkeep README/API docs, including removeEventListener(...) usage; [2] React Native docs example of addEventListener returning a subscription with .remove().


🏁 Script executed:

#!/bin/bash

# Look for cleanup patterns with addEventListener subscriptions in other services
rg "private.*Sub.*\?\s*:" src/services --type ts -A 1 | head -20

Repository: Resgrid/Unit

Length of output: 38


🏁 Script executed:

#!/bin/bash

# Check for any existing subscription storage patterns
rg "addEventListener.*\=" src/services --type ts -B 2 | head -30

Repository: Resgrid/Unit

Length of output: 160


🏁 Script executed:

#!/bin/bash

# Check for addEventListener patterns with assignment (fixed regex)
rg "addEventListener.*=" src/services --type ts -B 2 -A 1 | head -40

Repository: Resgrid/Unit

Length of output: 2691


🏁 Script executed:

#!/bin/bash

# Look at location.service.ts to see how it handles AppState listener cleanup
rg "AppState.addEventListener\|cleanup" src/services/location.service.ts -B 2 -A 3

Repository: Resgrid/Unit

Length of output: 128


🏁 Script executed:

#!/bin/bash

# Find all service files and look for cleanup with event listeners
fd ".*\.service\.ts$" src/services --type f | head -10

Repository: Resgrid/Unit

Length of output: 428


🏁 Script executed:

#!/bin/bash

# Check one of the services that had subscription patterns in tests - offline-event-manager
cat -n src/services/offline-event-manager.service.ts | grep -A 15 "private.*Sub\|addEventListener\|cleanup" | head -50

Repository: Resgrid/Unit

Length of output: 1676


🏁 Script executed:

#!/bin/bash

# Check the iOS version to see if it has the same issue
grep -n "addEventListener\|removeEventListener\|cleanup" src/services/callkeep.service.ios.ts | tail -20

Repository: Resgrid/Unit

Length of output: 929


Store and properly remove event listener subscriptions using the .remove() method.

The addEventListener calls in this service return subscription objects with a .remove() method. The current cleanup implementation uses RNCallKeep.removeEventListener(eventName), which does not match the actual API behavior. Store subscriptions in private fields and call .remove() during cleanup, consistent with the pattern used elsewhere in the codebase (e.g., OfflineEventManager).

🔧 Suggested pattern
+  private endCallSub?: { remove: () => void };
+  private answerCallSub?: { remove: () => void };
+  private muteCallSub?: { remove: () => void };

   private setupEventListeners(): void {
-    RNCallKeep.addEventListener('endCall', ({ callUUID }: { callUUID: string }) => {
+    this.endCallSub = RNCallKeep.addEventListener('endCall', ({ callUUID }: { callUUID: string }) => {
       ...
     });
-    RNCallKeep.addEventListener('answerCall', ({ callUUID }: { callUUID: string }) => {
+    this.answerCallSub = RNCallKeep.addEventListener('answerCall', ({ callUUID }: { callUUID: string }) => {
       ...
     });
-    RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }: { muted: boolean; callUUID: string }) => {
+    this.muteCallSub = RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }: { muted: boolean; callUUID: string }) => {
       ...
     });
   }

   async cleanup(): Promise<void> {
     ...
-    RNCallKeep.removeEventListener('endCall');
-    RNCallKeep.removeEventListener('answerCall');
-    RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
+    this.endCallSub?.remove();
+    this.answerCallSub?.remove();
+    this.muteCallSub?.remove();
     ...
   }
🤖 Prompt for AI Agents
In `@src/services/callkeep.service.android.ts` around lines 230 - 275, The
setupEventListeners method currently calls RNCallKeep.addEventListener without
storing the returned subscription objects; change setupEventListeners to save
each subscription returned by RNCallKeep.addEventListener into private fields
(e.g., this.endCallSubscription, this.answerCallSubscription,
this.muteSubscription), and update the cleanup/teardown code to call .remove()
on those stored subscriptions (instead of RNCallKeep.removeEventListener).
Ensure references to symbols like setupEventListeners, currentCallUUID,
this.isCallActive, and this.muteStateCallback remain unchanged while adding the
private subscription fields and invoking .remove() on them during teardown.

Comment on lines +208 to +222
// Request phone state/numbers permissions for CallKeep (required for Android 11+)
try {
// We need these permissions to use the ConnectionService (CallKeep) properly without crashing
const granted = await PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS, PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE]);

logger.debug({
message: 'Android Phone permissions requested',
context: { result: granted },
});
} catch (err) {
logger.warn({
message: 'Failed to request Android phone permissions',
context: { error: err },
});
}
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

🧩 Analysis chain

🏁 Script executed:

# First, let's find and read the livekit-store.ts file
fd -t f "livekit-store.ts" --exec cat -n {} \;

Repository: Resgrid/Unit

Length of output: 20192


Handle denied phone permissions proactively in permission request flow.

The result of requestMultiple isn't checked. While CallKeep failures are gracefully handled downstream (lines 349-355), detecting permission denials here would surface the issue earlier to users. Consider checking the result and either warning immediately or returning early if permissions are denied.

🛠️ Suggested handling
-            const granted = await PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS, PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE]);
+            const granted = await PermissionsAndroid.requestMultiple([
+              PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS,
+              PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
+            ]);
+            const denied = Object.entries(granted).filter(
+              ([, status]) => status !== PermissionsAndroid.RESULTS.GRANTED,
+            );
+            if (denied.length) {
+              logger.warn({
+                message: 'Android Phone permissions denied',
+                context: { denied },
+              });
+              return;
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Request phone state/numbers permissions for CallKeep (required for Android 11+)
try {
// We need these permissions to use the ConnectionService (CallKeep) properly without crashing
const granted = await PermissionsAndroid.requestMultiple([PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS, PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE]);
logger.debug({
message: 'Android Phone permissions requested',
context: { result: granted },
});
} catch (err) {
logger.warn({
message: 'Failed to request Android phone permissions',
context: { error: err },
});
}
// Request phone state/numbers permissions for CallKeep (required for Android 11+)
try {
// We need these permissions to use the ConnectionService (CallKeep) properly without crashing
const granted = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS,
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
]);
const denied = Object.entries(granted).filter(
([, status]) => status !== PermissionsAndroid.RESULTS.GRANTED,
);
if (denied.length) {
logger.warn({
message: 'Android Phone permissions denied',
context: { denied },
});
return;
}
logger.debug({
message: 'Android Phone permissions requested',
context: { result: granted },
});
} catch (err) {
logger.warn({
message: 'Failed to request Android phone permissions',
context: { error: err },
});
}
🤖 Prompt for AI Agents
In `@src/stores/app/livekit-store.ts` around lines 208 - 222, The permission
request flow calls PermissionsAndroid.requestMultiple for READ_PHONE_NUMBERS and
READ_PHONE_STATE but does not check the returned result; update the try block in
the function containing this call (the PermissionsAndroid.requestMultiple
invocation) to inspect the granted object for each permission key, and if any
are denied or never_ask_again, log a clear warning via logger.warn (include the
denied permissions and result) and return early or set a flag so downstream
CallKeep usage is skipped; keep the existing catch for thrown errors and ensure
you still log the error as before.

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

🤖 Fix all issues with AI agents
In `@src/services/callkeep.service.android.ts`:
- Around line 280-290: The generateUUID() function uses Math.random() fallback
which is not cryptographically secure; import and use the secure randomUUID
implementation from expo-crypto when global.crypto.randomUUID is unavailable:
add an import (e.g. randomUUID from 'expo-crypto') and call it as the fallback
in generateUUID() inside callkeep.service.android.ts (and mirror the same change
in callkeep.service.ios.ts), and update the uuidv4() utility in src/lib/utils.ts
to prefer global.crypto.randomUUID, then expo-crypto.randomUUID, only falling
back to Math.random() as a last resort; reference the generateUUID and uuidv4
function names when making the changes.

In `@src/services/callkeep.service.ios.ts`:
- Around line 218-229: The ignoreMuteEvents function can set ignoreEventsUntil
to NaN when durationMs is non‑finite; add a guard in ignoreMuteEvents to
validate durationMs with Number.isFinite(durationMs) and durationMs > 0, and if
validation fails log a warning via logger.warn (include the bad value) and
return early; otherwise compute const target = Date.now() + durationMs and set
this.ignoreEventsUntil = Math.max(this.ignoreEventsUntil, target) and keep the
existing logger.debug with durationMs and until fields.
🧹 Nitpick comments (10)
src/services/audio.service.ts (5)

2-3: Remove unused imports.

AVPlaybackSource and Platform are imported but not used anywhere in this file.

🧹 Proposed fix
-import { Audio, type AVPlaybackSource, InterruptionModeIOS } from 'expo-av';
-import { Platform } from 'react-native';
+import { Audio, InterruptionModeIOS } from 'expo-av';

17-17: Remove stale comment.

The comment "Keep others as expo-av for now" is incorrect—all sound fields are now typed as Sound from react-native-sound, not expo-av.

🧹 Proposed fix
-  // Keep others as expo-av for now
-  private connectedDeviceSound: Sound | null = null;
+  private connectedDeviceSound: Sound | null = null;

169-187: Misleading async signature with no awaited operations.

The method is marked async but doesn't await anything—play() uses a callback, not a Promise. The try-catch block only catches synchronous exceptions (e.g., null dereference) but won't catch playback errors reported via the callback. This pattern repeats in all play methods.

Consider either:

  1. Remove async since there's nothing to await, or
  2. Wrap the callback-based API in a Promise if you need to know when playback completes
♻️ Option 1: Remove async (simpler)
-  async playStartTransmittingSound(): Promise<void> {
+  playStartTransmittingSound(): void {
     try {
       if (this.startTransmittingSound) {
-        // Stop if playing
         this.startTransmittingSound.stop();
-        // Play
         this.startTransmittingSound.play((success) => {
-            if (!success) {
-                logger.warn({ message: 'Failed to play start transmitting sound (playback error)' });
-            }
+          if (!success) {
+            logger.warn({ message: 'Failed to play start transmitting sound (playback error)' });
+          }
         });
       }
     } catch (error) {

214-219: Inconsistent success logging across play methods.

playConnectedDeviceSound logs success at debug level, but other play methods (playStartTransmittingSound, playStopTransmittingSound, etc.) do not. Consider applying consistent logging across all play methods.


16-16: Minor: Trailing whitespace detected by linter.

Static analysis flagged trailing whitespace on lines 16, 45, 53, 103, and 128. Consider running your formatter to clean these up.

src/components/ui/select/select-actionsheet.tsx (1)

392-400: Consider default FlashList/FlatList perf props in this wrapper.

You’re already setting estimatedItemSize, but the wrapper doesn’t set or document other perf props (e.g., removeClippedSubviews, maxToRenderPerBatch, windowSize) or a fixed-size layout hook when rows are uniform. If ActionsheetFlatList has consistent row heights, consider adding the appropriate layout/perf props (or at least documenting that callers should pass them). Please verify the supported prop names for @shopify/flash-list before adding.
As per coding guidelines: "Optimize FlatLists with props like removeClippedSubviews, maxToRenderPerBatch, and windowSize" and "Use getItemLayout for FlatLists when items have a consistent size to improve performance."

src/components/ui/actionsheet/index.tsx (1)

392-400: Consider default FlashList/FlatList perf props in this wrapper.

This wrapper now standardizes estimatedItemSize, but it still relies on callers to set windowing/perf props. If list rows are uniform, consider adding or documenting the appropriate perf/layout props (e.g., windowing props or fixed-size layout hooks). Please confirm which of these are supported by @shopify/flash-list before wiring them in.
As per coding guidelines: "Optimize FlatLists with props like removeClippedSubviews, maxToRenderPerBatch, and windowSize" and "Use getItemLayout for FlatLists when items have a consistent size to improve performance."

src/services/bluetooth-audio.service.ts (1)

1-8: Consider removing the @ts-ignore comment.

The @ts-ignore suppresses TypeScript errors for the callkeep service import. If barrel file updates are pending, consider adding an issue to track this technical debt. If the import resolves correctly in the build, the comment may no longer be necessary.

src/components/settings/audio-device-selection.tsx (1)

22-33: Consider adding a case for 'microphone' type in renderDeviceIcon.

The new 'microphone' type added to AudioDeviceInfo falls through to the default Mic icon, which is correct but implicit. Consider adding an explicit case for clarity and future maintainability.

Optional explicit case
   const renderDeviceIcon = (device: AudioDeviceInfo) => {
     switch (device.type) {
       case 'bluetooth':
         return <Headphones size={20} className="text-gray-600 dark:text-gray-400" />;
       case 'wired':
         return <Headphones size={20} className="text-gray-600 dark:text-gray-400" />;
       case 'speaker':
         return <Speaker size={20} className="text-gray-600 dark:text-gray-400" />;
+      case 'microphone':
+        return <Mic size={20} className="text-gray-600 dark:text-gray-400" />;
       default:
         return <Mic size={20} className="text-gray-600 dark:text-gray-400" />;
     }
   };
src/stores/app/bluetooth-audio-store.ts (1)

43-48: Consider removing unused 'default' type from the union.

With the move to role-based types ('microphone', 'speaker'), the 'default' type may no longer be used. If no code creates devices with type: 'default', consider removing it from the union for type safety.

#!/bin/bash
# Check if 'default' type is still used anywhere
rg -n "type:\s*['\"]default['\"]" --type ts

Comment on lines +280 to +290
private generateUUID(): string {
// RN 0.76 typically provides global crypto.randomUUID via Hermes/JSI
const rndUUID = (global as any)?.crypto?.randomUUID?.();
if (typeof rndUUID === 'string') return rndUUID;
// Fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

expo-crypto randomUUID function API documentation

💡 Result:

expo-crypto exposes Crypto.randomUUID() (synchronous) to generate a UUID v4 (RFC 4122) using cryptographically secure random values.

  • Signature: Crypto.randomUUID(): string
  • Example:
    import * as Crypto from 'expo-crypto';
    
    const id = Crypto.randomUUID();

Official docs: Expo Crypto SDK reference (see the Crypto.randomUUID() section). [1]
Related standard Web API reference (same method name/behavior): MDN Crypto.randomUUID(). [2]

Sources
[1] https://docs.expo.dev/versions/latest/sdk/crypto
[2] https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID


🏁 Script executed:

# Check if expo-crypto is already used in the project
rg "expo-crypto" --type ts --type tsx --type json

# Check package.json for dependencies
fd package.json | head -5 | xargs cat

Repository: Resgrid/Unit

Length of output: 11136


🏁 Script executed:

# Read the actual callkeep.service.android.ts file
fd "callkeep.service.android.ts" -type f | head -1 | xargs wc -l

Repository: Resgrid/Unit

Length of output: 245


🏁 Script executed:

# Find the callkeep.service.android.ts file
fd "callkeep.service.android.ts" | head -1 | xargs wc -l

Repository: Resgrid/Unit

Length of output: 100


🏁 Script executed:

# Read the file to see the generateUUID implementation and imports
cat -n src/services/callkeep.service.android.ts | head -30

Repository: Resgrid/Unit

Length of output: 1213


🏁 Script executed:

# Read the generateUUID method around lines 280-290
cat -n src/services/callkeep.service.android.ts | sed -n '275,295p'

Repository: Resgrid/Unit

Length of output: 827


🏁 Script executed:

# Check if crypto is used elsewhere in the codebase
rg "crypto" src/ --type ts --type tsx -i

# Check what uuid generation is used elsewhere
rg "uuid\|UUID" src/ --type ts --type tsx -A 2

Repository: Resgrid/Unit

Length of output: 111


🏁 Script executed:

# Check if crypto is used elsewhere in the codebase
rg "crypto" src/ -i

# Check what uuid generation is used elsewhere
rg "uuid|UUID" src/ -A 2

Repository: Resgrid/Unit

Length of output: 32919


🌐 Web query:

React Native 0.79 Hermes crypto randomUUID availability support

💡 Result:

In React Native 0.79 (where Hermes is the default JS engine), you should not assume globalThis.crypto.randomUUID() is available.

  • Hermes does not provide the Web Crypto API surface by default (notably crypto.getRandomValues has historically been missing, which is a prerequisite for many UUID/crypto libraries). (github.com)
  • React Native’s Hermes documentation for 0.79 describes Hermes usage/bundling but does not claim Web Crypto (crypto.*) compatibility. (reactnative.dev)
  • crypto.randomUUID() is a Web API method (common in browsers), not something guaranteed in Hermes/React Native. (developer.mozilla.org)

What to do instead (common options)

  1. Polyfill crypto.getRandomValues, then use a UUID library (like uuid) that can run on top of it:

    • react-native-get-random-values is specifically intended to polyfill crypto.getRandomValues for React Native. (npm.io)
  2. If you need an actual crypto.randomUUID() API, you typically implement it yourself (e.g., via a native module such as expo-crypto in compatible setups) or rely on uuid’s v4 generator after installing the getRandomValues polyfill. (stackoverflow.com)

Citations:


🏁 Script executed:

# Check if uuid or react-native-get-random-values are installed
rg "uuid|react-native-get-random-values" package.json

Repository: Resgrid/Unit

Length of output: 38


🏁 Script executed:

# Check package.json more directly for uuid and related packages
cat package.json | grep -i "uuid\|crypto"

Repository: Resgrid/Unit

Length of output: 38


Use cryptographically secure UUID generation for CallKeep sessions.

The Math.random() fallback in generateUUID() is cryptographically weak. React Native 0.79.6 with Hermes does not guarantee crypto.randomUUID() availability, meaning the insecure fallback is likely being used in production to generate call identifiers. Replace with expo-crypto.randomUUID(), which provides secure RFC 4122 UUID generation (also apply the same fix to callkeep.service.ios.ts and the uuidv4() utility function in src/lib/utils.ts).

Proposed fix
+import * as Crypto from 'expo-crypto';
+
  private generateUUID(): string {
-   // RN 0.76 typically provides global crypto.randomUUID via Hermes/JSI
-   const rndUUID = (global as any)?.crypto?.randomUUID?.();
-   if (typeof rndUUID === 'string') return rndUUID;
-   // Fallback
-   return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
-     const r = (Math.random() * 16) | 0;
-     const v = c === 'x' ? r : (r & 0x3) | 0x8;
-     return v.toString(16);
-   });
+   return Crypto.randomUUID();
  }
🤖 Prompt for AI Agents
In `@src/services/callkeep.service.android.ts` around lines 280 - 290, The
generateUUID() function uses Math.random() fallback which is not
cryptographically secure; import and use the secure randomUUID implementation
from expo-crypto when global.crypto.randomUUID is unavailable: add an import
(e.g. randomUUID from 'expo-crypto') and call it as the fallback in
generateUUID() inside callkeep.service.android.ts (and mirror the same change in
callkeep.service.ios.ts), and update the uuidv4() utility in src/lib/utils.ts to
prefer global.crypto.randomUUID, then expo-crypto.randomUUID, only falling back
to Math.random() as a last resort; reference the generateUUID and uuidv4
function names when making the changes.

Comment on lines 218 to 229
/**
* Externally lock/ignore mute events for a duration.
* Useful when we know a PTT button is being pressed and want to ignore system side-effects.
*/
ignoreMuteEvents(durationMs: number): void {
const now = Date.now();
this.ignoreEventsUntil = Math.max(this.ignoreEventsUntil, now + durationMs);
logger.debug({
message: 'CallKeep mute events ignored via external lock',
context: { durationMs, until: this.ignoreEventsUntil }
});
}
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

Guard against non‑finite or non‑positive durations.
If durationMs is NaN, Math.max yields NaN, which permanently disables the lock check (now < NaN is always false). A simple validation avoids this edge case and misleading logs.

✅ Proposed fix
  ignoreMuteEvents(durationMs: number): void {
+   if (!Number.isFinite(durationMs) || durationMs <= 0) {
+     logger.debug({
+       message: 'CallKeep ignoreMuteEvents no-op (invalid duration)',
+       context: { durationMs },
+     });
+     return;
+   }
    const now = Date.now();
    this.ignoreEventsUntil = Math.max(this.ignoreEventsUntil, now + durationMs);
    logger.debug({
       message: 'CallKeep mute events ignored via external lock',
       context: { durationMs, until: this.ignoreEventsUntil }
    });
  }
🤖 Prompt for AI Agents
In `@src/services/callkeep.service.ios.ts` around lines 218 - 229, The
ignoreMuteEvents function can set ignoreEventsUntil to NaN when durationMs is
non‑finite; add a guard in ignoreMuteEvents to validate durationMs with
Number.isFinite(durationMs) and durationMs > 0, and if validation fails log a
warning via logger.warn (include the bad value) and return early; otherwise
compute const target = Date.now() + durationMs and set this.ignoreEventsUntil =
Math.max(this.ignoreEventsUntil, target) and keep the existing logger.debug with
durationMs and until fields.

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: 4

🤖 Fix all issues with AI agents
In `@src/components/ui/actionsheet/index.tsx`:
- Around line 392-398: The ActionsheetFlatList currently destructures and
applies the prop named "style" only to the wrapper View, preventing consumers
from styling the inner UIActionsheet.FlatList; update the component
(ActionsheetFlatList) so the FlatList also receives styling (either by
forwarding the existing "style" prop into UIActionsheet.FlatList as well or by
adding a new prop like "flatListStyle" or "listStyle" that is applied to
UIActionsheet.FlatList while keeping "style" for the wrapper View), and ensure
to forward any FlashList-specific style props (e.g., scroll indicator styles) to
UIActionsheet.FlatList so existing consumers aren’t broken.

In `@src/components/ui/select/select-actionsheet.tsx`:
- Around line 392-398: The wrapper View in ActionsheetFlatList is consuming the
incoming style prop instead of forwarding it to the inner
UIActionsheet.FlatList; update ActionsheetFlatList so the container uses a
distinct prop (e.g., containerStyle or wrapperStyle) and forward the original
style prop to UIActionsheet.FlatList (UIActionsheet.FlatList ref and props) so
consumers can style the FlashList directly; apply the same pattern you used to
fix src/components/ui/actionsheet/index.tsx to keep API consistency.

In `@src/services/audio.service.ts`:
- Around line 55-59: Sound mappings are inconsistent between the native module
calls (InCallAudioModule.loadSound for keys startTransmitting, stopTransmitting,
connectToAudioRoom, disconnectedFromAudioRoom) and the Expo AV fallback; update
the Expo AV loading logic so the same sound keys map to the identical asset
names used by the native module (i.e., ensure
startTransmitting→software_interface_start,
stopTransmitting→software_interface_back,
connectToAudioRoom→space_notification1,
disconnectedFromAudioRoom→space_notification2) by changing the Expo AV mapping
block to use those exact asset identifiers where the Expo loader registers those
four keys.

In `@src/translations/ar.json`:
- Line 50: The "device_disconnected" translation value is still in English;
update the JSON key "device_disconnected" in translations/ar.json to its Arabic
translation (for example: "تم فصل الجهاز") so the value is localized
consistently with the rest of the file; ensure the string is enclosed in double
quotes and the trailing comma/JSON formatting remains valid.
🧹 Nitpick comments (8)
src/app/login/login-form.tsx (2)

77-81: User-facing text should be wrapped in t() for i18n support.

Several hardcoded strings are not internationalized:

  • Line 77: "Sign In"
  • Lines 79-81: The descriptive paragraph
  • Line 118: "Username not found"
  • Line 172: "Log in"

As per coding guidelines, all user-facing text should be wrapped in t() from react-i18next.

🌐 Proposed fix for internationalization
-          <Text className="pb-6 text-center text-4xl font-bold">Sign In</Text>
+          <Text className="pb-6 text-center text-4xl font-bold">{t('login.sign_in')}</Text>

-          <Text className="mb-6 max-w-xl text-center text-gray-500">
-            To login in to the Resgrid Unit app, please enter your username and password. Resgrid Unit is an app designed to interface between a Unit (apparatus, team, etc) and the Resgrid system.
-          </Text>
+          <Text className="mb-6 max-w-xl text-center text-gray-500">
+            {t('login.description')}
+          </Text>

Similarly for other hardcoded strings.


96-98: Avoid using any type in catch blocks.

The error: any type annotation violates the guideline to avoid any and strive for precise types. Consider using unknown and narrowing the type, or using Zod's error type.

🔧 Proposed fix
-              } catch (error: any) {
-                return error.message;
+              } catch (error: unknown) {
+                if (error instanceof z.ZodError) {
+                  return error.errors[0]?.message;
+                }
+                return 'Validation failed';
               }

Apply the same fix to the password validation block at lines 135-137.

src/components/ui/select/select-actionsheet.tsx (1)

243-246: Consider documenting the style prop's new behavior in the type.

The style prop from React.ComponentProps<typeof UIActionsheet.FlatList> now applies to the wrapper View, not the FlatList itself. Adding a JSDoc comment on the type or renaming the prop would help clarify this for consumers.

 type IActionsheetFlatListProps = VariantProps<typeof actionsheetFlatListStyle> &
-  React.ComponentProps<typeof UIActionsheet.FlatList> & {
+  Omit<React.ComponentProps<typeof UIActionsheet.FlatList>, 'style'> & {
     className?: string;
+    /** Style applied to the wrapper View container, not the inner FlatList */
+    style?: ViewStyle;
   };
plugins/withInCallAudioModule.js (1)

236-252: Package registration logic may miss edge cases.

The conditional logic for adding InCallAudioPackage assumes specific patterns in MainApplication.kt. If another plugin has modified the packages declaration differently, this may not insert correctly.

Consider adding a fallback or verification:

🛠️ Suggested improvement
       if (packagesMatch) {
         // Using the simplest replacement that ensures toMutableList()
         const replacement = `val packages = PackageList(this).packages.toMutableList()\n            packages.add(InCallAudioPackage())`;

         // Avoid double adding if MediaButtonPackage logic already changed it to mutable
         if (mainApplication.contents.includes('packages.add(MediaButtonPackage()')) {
           // Add ours after MediaButtonPackage
           mainApplication.contents = mainApplication.contents.replace('packages.add(MediaButtonPackage())', 'packages.add(MediaButtonPackage())\n            packages.add(InCallAudioPackage())');
         } else {
           // Standard replacement
           mainApplication.contents = mainApplication.contents.replace(packagesPattern, replacement);
         }
         console.log('[withInCallAudioModule] Registered InCallAudioPackage in MainApplication.kt');
+      } else {
+        console.warn('[withInCallAudioModule] Could not find packages declaration pattern in MainApplication.kt - manual registration may be required');
       }
src/services/audio.service.ts (1)

103-103: Avoid using any for the module parameter.

The module parameter uses any type. Consider using a more specific type.

-  private async loadSound(module: any): Promise<Audio.Sound | null> {
+  private async loadSound(module: number): Promise<Audio.Sound | null> {

The require() call returns a number (asset ID) when using Metro bundler with assets.

src/components/settings/bluetooth-device-selection-bottom-sheet.tsx (2)

63-83: Remove commented-out code block.

This large block of commented code explaining the previous implementation should be removed to keep the codebase clean. The git history preserves the previous approach if needed.

Proposed fix
     async (device: BluetoothAudioDevice) => {
       try {
-        // Disconnect from any currently connected device first?
-        // The service connectToDevice usually handles this or we do it here.
-        // Previous code did disconnect manually.
-        /*
-        if (connectedDevice) {
-           ... disconnect ...
-        }
-        */
-        // User wants "When attempting to connect... display loading".
-        // User wants "If error... don't save device".
-
-        // 1. Set connecting state
-        // We can resolve this by using local state for the specific device being connected to,
-        // or rely on store's global isConnecting.
-        // Let's use the store's setIsConnecting to be consistent if the UI uses it.
         useBluetoothAudioStore.getState().setIsConnecting(true);

119-121: Wrap fallback strings in t() for consistency.

The fallback strings 'Connection Failed' and 'Could not connect to device' should be wrapped in t() to maintain i18n consistency across the application.

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,
         });

As per coding guidelines: Wrap all user-facing text in t() from react-i18next.

src/services/callkeep.service.android.ts (1)

209-214: Consider implementing mute event suppression for Android.

The ignoreMuteEvents method is currently a no-op on Android. If the iOS implementation provides storm-protection for mute events, consider whether Android would benefit from similar behavior for consistency, or document why it's not needed.

Comment on lines +392 to 398
const ActionsheetFlatList = React.forwardRef<React.ElementRef<typeof UIActionsheet.FlatList>, IActionsheetFlatListProps>(({ className, style, ...props }, ref) => {
return (
<UIActionsheet.FlatList
className={actionsheetFlatListStyle({
class: className,
})}
ref={ref}
{...props}
/>
<View className={actionsheetFlatListStyle({ class: className })} style={style}>
<UIActionsheet.FlatList ref={ref} estimatedItemSize={94} {...props} />
</View>
);
});
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

Breaking change: style prop now applies to wrapper View, not the FlatList.

The style prop is destructured and applied to the wrapper View (line 394), meaning consumers expecting to style the underlying FlashList directly can no longer do so. Consider either:

  1. Passing style through to the inner FlatList as well, or
  2. Adding a separate prop (e.g., listStyle or flatListStyle) for styling the inner FlatList, or
  3. Documenting this as a breaking change in the PR.

Additionally, FlashList-specific style props like scroll indicator styling may not reach the inner component as expected.

🔧 Suggested approach to preserve both wrapper and inner styling
-const ActionsheetFlatList = React.forwardRef<React.ElementRef<typeof UIActionsheet.FlatList>, IActionsheetFlatListProps>(({ className, style, ...props }, ref) => {
+const ActionsheetFlatList = React.forwardRef<React.ElementRef<typeof UIActionsheet.FlatList>, IActionsheetFlatListProps & { wrapperStyle?: ViewStyle }>(({ className, style, wrapperStyle, ...props }, ref) => {
   return (
-    <View className={actionsheetFlatListStyle({ class: className })} style={style}>
-      <UIActionsheet.FlatList ref={ref} estimatedItemSize={94} {...props} />
+    <View className={actionsheetFlatListStyle({ class: className })} style={wrapperStyle}>
+      <UIActionsheet.FlatList ref={ref} estimatedItemSize={94} style={style} {...props} />
     </View>
   );
 });
🤖 Prompt for AI Agents
In `@src/components/ui/actionsheet/index.tsx` around lines 392 - 398, The
ActionsheetFlatList currently destructures and applies the prop named "style"
only to the wrapper View, preventing consumers from styling the inner
UIActionsheet.FlatList; update the component (ActionsheetFlatList) so the
FlatList also receives styling (either by forwarding the existing "style" prop
into UIActionsheet.FlatList as well or by adding a new prop like "flatListStyle"
or "listStyle" that is applied to UIActionsheet.FlatList while keeping "style"
for the wrapper View), and ensure to forward any FlashList-specific style props
(e.g., scroll indicator styles) to UIActionsheet.FlatList so existing consumers
aren’t broken.

Comment on lines +392 to 398
const ActionsheetFlatList = React.forwardRef<React.ComponentRef<typeof UIActionsheet.FlatList>, IActionsheetFlatListProps>(function ActionsheetFlatList({ className, style, ...props }, ref) {
return (
<UIActionsheet.FlatList
className={actionsheetFlatListStyle({
class: className,
})}
ref={ref}
{...props}
/>
<View className={actionsheetFlatListStyle({ class: className })} style={style}>
<UIActionsheet.FlatList ref={ref} estimatedItemSize={94} {...props} />
</View>
);
});
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

Same style prop behavior concern as src/components/ui/actionsheet/index.tsx.

This implementation mirrors the changes in actionsheet/index.tsx, which is good for API consistency. However, the same concern applies: style is now consumed by the wrapper View rather than being forwarded to the inner FlashList.

Consider applying the same fix pattern to both files if you decide to preserve direct FlashList styling capability.

🤖 Prompt for AI Agents
In `@src/components/ui/select/select-actionsheet.tsx` around lines 392 - 398, The
wrapper View in ActionsheetFlatList is consuming the incoming style prop instead
of forwarding it to the inner UIActionsheet.FlatList; update ActionsheetFlatList
so the container uses a distinct prop (e.g., containerStyle or wrapperStyle) and
forward the original style prop to UIActionsheet.FlatList
(UIActionsheet.FlatList ref and props) so consumers can style the FlashList
directly; apply the same pattern you used to fix
src/components/ui/actionsheet/index.tsx to keep API consistency.

Comment on lines +55 to +59
InCallAudioModule.loadSound('startTransmitting', 'software_interface_start');
InCallAudioModule.loadSound('stopTransmitting', 'software_interface_back');
InCallAudioModule.loadSound('connectedDevice', 'positive_interface_beep');
InCallAudioModule.loadSound('connectToAudioRoom', 'space_notification1');
InCallAudioModule.loadSound('disconnectedFromAudioRoom', 'space_notification2');
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sound file mapping inconsistency between native module and Expo AV.

The sound files are mapped differently between the Android native module (lines 55-59) and the Expo AV fallback (lines 118-124):

Sound Name Native Module Expo AV
startTransmitting software_interface_start space_notification1
stopTransmitting software_interface_back space_notification2
connectToAudioRoom space_notification1 software_interface_start
disconnectedFromAudioRoom space_notification2 software_interface_back

This means Android users will hear different sounds than iOS users for the same actions.

🔧 Proposed fix - align Expo AV with native module mapping
       const [startTransmittingSound, stopTransmittingSound, connectedDeviceSound, connectToAudioRoomSound, disconnectedFromAudioRoomSound] = await Promise.all([
-        this.loadSound(require('@assets/audio/ui/space_notification1.mp3')),
-        this.loadSound(require('@assets/audio/ui/space_notification2.mp3')),
+        this.loadSound(require('@assets/audio/ui/software_interface_start.mp3')),
+        this.loadSound(require('@assets/audio/ui/software_interface_back.mp3')),
         this.loadSound(require('@assets/audio/ui/positive_interface_beep.mp3')),
-        this.loadSound(require('@assets/audio/ui/software_interface_start.mp3')),
-        this.loadSound(require('@assets/audio/ui/software_interface_back.mp3')),
+        this.loadSound(require('@assets/audio/ui/space_notification1.mp3')),
+        this.loadSound(require('@assets/audio/ui/space_notification2.mp3')),
       ]);

Also applies to: 118-124

🤖 Prompt for AI Agents
In `@src/services/audio.service.ts` around lines 55 - 59, Sound mappings are
inconsistent between the native module calls (InCallAudioModule.loadSound for
keys startTransmitting, stopTransmitting, connectToAudioRoom,
disconnectedFromAudioRoom) and the Expo AV fallback; update the Expo AV loading
logic so the same sound keys map to the identical asset names used by the native
module (i.e., ensure startTransmitting→software_interface_start,
stopTransmitting→software_interface_back,
connectToAudioRoom→space_notification1,
disconnectedFromAudioRoom→space_notification2) by changing the Expo AV mapping
block to use those exact asset identifiers where the Expo loader registers those
four keys.

"connected": "متصل",
"connectionError": "خطأ في الاتصال",
"current_selection": "الاختيار الحالي",
"device_disconnected": "Device disconnected",
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

Missing Arabic translation.

The value "Device disconnected" is in English instead of Arabic. Based on the translation patterns in this file, the Arabic translation should be:

-    "device_disconnected": "Device disconnected",
+    "device_disconnected": "تم قطع اتصال الجهاز",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"device_disconnected": "Device disconnected",
"device_disconnected": "تم قطع اتصال الجهاز",
🤖 Prompt for AI Agents
In `@src/translations/ar.json` at line 50, The "device_disconnected" translation
value is still in English; update the JSON key "device_disconnected" in
translations/ar.json to its Arabic translation (for example: "تم فصل الجهاز") so
the value is localized consistently with the rest of the file; ensure the string
is enclosed in double quotes and the trailing comma/JSON formatting remains
valid.

@ucswift
Copy link
Member Author

ucswift commented Jan 30, 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 63321a8 into master Jan 30, 2026
16 of 18 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.

2 participants