Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions __mocks__/mapbox-gl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Mock for mapbox-gl in Jest tests
module.exports = {
accessToken: '',
Map: jest.fn().mockImplementation(() => ({
on: jest.fn(),
remove: jest.fn(),
addControl: jest.fn(),
removeControl: jest.fn(),
setCenter: jest.fn(),
setZoom: jest.fn(),
flyTo: jest.fn(),
easeTo: jest.fn(),
jumpTo: jest.fn(),
resize: jest.fn(),
getCanvas: jest.fn(() => ({
style: {},
})),
getStyle: jest.fn(() => ({})),
setStyle: jest.fn(),
})),
Marker: jest.fn().mockImplementation(() => ({
setLngLat: jest.fn().mockReturnThis(),
addTo: jest.fn().mockReturnThis(),
remove: jest.fn(),
getLngLat: jest.fn(),
setPopup: jest.fn().mockReturnThis(),
})),
Popup: jest.fn().mockImplementation(() => ({
setLngLat: jest.fn().mockReturnThis(),
setHTML: jest.fn().mockReturnThis(),
addTo: jest.fn().mockReturnThis(),
remove: jest.fn(),
})),
NavigationControl: jest.fn(),
GeolocateControl: jest.fn(),
ScaleControl: jest.fn(),
FullscreenControl: jest.fn(),
};
2 changes: 2 additions & 0 deletions __mocks__/styleMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Mock for CSS/Style imports in Jest tests
module.exports = {};
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ module.exports = {
],
coverageDirectory: '<rootDir>/coverage/',
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(css|less|scss|sass)$': '<rootDir>/__mocks__/styleMock.js',
'mapbox-gl': '<rootDir>/__mocks__/mapbox-gl.js',
},
};
2 changes: 1 addition & 1 deletion src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ export default function TabLayout() {
<DrawerBackdrop onPress={handleCloseDrawer} />
<DrawerContent className="w-4/5 bg-white p-1 dark:bg-gray-900">
<DrawerBody>
<Sidebar />
<Sidebar onClose={handleCloseDrawer} />
</DrawerBody>
</DrawerContent>
</Drawer>
Expand Down
2 changes: 1 addition & 1 deletion src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import '../lib/i18n';

import { Env } from '@env';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { registerGlobals } from '@livekit/react-native';
import notifee from '@notifee/react-native';
import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import * as Sentry from '@sentry/react-native';
Expand Down Expand Up @@ -68,7 +69,6 @@ Sentry.init({
// Only register LiveKit globals on native platforms
// Web/Electron uses livekit-client which handles WebRTC natively
if (Platform.OS !== 'web') {
const { registerGlobals } = require('@livekit/react-native');
registerGlobals();
}

Expand Down
20 changes: 15 additions & 5 deletions src/components/livekit/livekit-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import { t } from 'i18next';
import { Headphones, Mic, MicOff, PhoneOff, Settings } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { ActivityIndicator, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';

import { useAnalytics } from '@/hooks/use-analytics';
import { type DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData';
import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store';
import { applyAudioRouting, requestAndroidPhonePermissions, useLiveKitStore } from '@/stores/app/livekit-store';

import { Card } from '../../components/ui/card';
import { Text } from '../../components/ui/text';
import { applyAudioRouting, useLiveKitStore } from '../../stores/app/livekit-store';
import { AudioDeviceSelection } from '../settings/audio-device-selection';
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet';
import { Card } from '../ui/card';
import { HStack } from '../ui/hstack';
import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';

export enum BottomSheetView {
Expand Down Expand Up @@ -86,6 +86,11 @@ export const LiveKitBottomSheet = () => {
// If we're showing the sheet, make sure we have the latest rooms
if (isBottomSheetVisible && currentView === BottomSheetView.ROOM_SELECT) {
fetchVoiceSettings();
// Pre-warm Android phone-state permissions (READ_PHONE_STATE / READ_PHONE_NUMBERS)
// while the user is browsing the room list. The system dialog, if any, appears
// here in a clean window instead of blocking the Join flow later. On subsequent
// opens this is an instant no-op (permissions already granted).
void requestAndroidPhonePermissions();
}
}, [isBottomSheetVisible, currentView, fetchVoiceSettings]);

Expand Down Expand Up @@ -162,7 +167,12 @@ export const LiveKitBottomSheet = () => {

const renderRoomSelect = () => (
<View style={styles.content}>
{availableRooms.length === 0 ? (
{isConnecting ? (
<View className="flex-1 items-center justify-center" testID="connecting-indicator">
<ActivityIndicator size="large" />
<Text className="mt-3 text-center text-base font-medium text-gray-500">{t('livekit.connecting')}</Text>
</View>
) : availableRooms.length === 0 ? (
<View className="flex-1 items-center justify-center">
<Text className="text-center text-lg font-medium text-gray-500">{t('livekit.no_rooms_available')}</Text>
</View>
Expand Down
10 changes: 5 additions & 5 deletions src/components/maps/map-view.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ export const StyleURL = {
};

// UserTrackingMode enum matching native SDK
export const UserTrackingMode = {
Follow: 'follow',
FollowWithHeading: 'followWithHeading',
FollowWithCourse: 'followWithCourse',
};
export enum UserTrackingMode {
Follow = 'normal',
FollowWithHeading = 'compass',
FollowWithCourse = 'course',
}
Comment on lines +31 to +35
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
# Check how UserTrackingMode is used across the codebase
echo "=== UserTrackingMode usage ==="
rg -n 'UserTrackingMode' --type=ts --type=tsx -C2

echo ""
echo "=== Direct string comparisons with old values ==="
rg -n "'follow'|'followWithHeading'|'followWithCourse'" --type=ts --type=tsx

echo ""
echo "=== Direct string comparisons with new values ==="
rg -n "'normal'|'compass'|'course'" --type=ts --type=tsx -C1

echo ""
echo "=== Native map-view implementation ==="
fd 'map-view.native' --exec cat {}

Repository: Resgrid/Unit

Length of output: 1389


🏁 Script executed:

#!/bin/bash
# Check map-view web implementation
echo "=== map-view.web.tsx content ==="
cat src/components/maps/map-view.web.tsx

echo ""
echo "=== All references to UserTrackingMode (broader search) ==="
rg 'UserTrackingMode' -C2

echo ""
echo "=== Check mapbox.ts aggregator ==="
cat src/components/maps/mapbox.ts

echo ""
echo "=== Check how Mapbox UserTrackingMode is used (any reference) ==="
rg 'followUserMode|tracking' -C2

Repository: Resgrid/Unit

Length of output: 48693


Update test mocks to match new UserTrackingMode enum values.

The enum values changed from 'follow'/'followWithHeading'/'followWithCourse' to 'normal'/'compass'/'course' to align with mapbox-gl. The test file at src/app/(app)/__tests__/index.test.tsx contains a mock with outdated values and needs updating:

UserTrackingMode: {
  Follow: 'follow',  // Should be 'normal'
  FollowWithHeading: 'followWithHeading',  // Should be 'compass'
  FollowWithCourse: 'followWithCourse',  // Should be 'course'
}

Production code safely accesses the enum via property names (Mapbox.UserTrackingMode.FollowWithHeading), so no runtime breakage occurs, but the mock should remain consistent with the actual implementation.

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

In `@src/components/maps/map-view.web.tsx` around lines 31 - 35, The test mock for
the UserTrackingMode enum is using outdated string values; update the mock
object in the test (the UserTrackingMode entry in index.test.tsx) to match the
new enum strings by replacing Follow: 'follow' -> 'normal', FollowWithHeading:
'followWithHeading' -> 'compass', and FollowWithCourse: 'followWithCourse' ->
'course' so the mock aligns with the UserTrackingMode enum in map-view.web.tsx.


// Access token setter for compatibility
export const setAccessToken = (token: string) => {
Expand Down
56 changes: 39 additions & 17 deletions src/components/maps/mapbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,54 @@
*/
import { Platform } from 'react-native';

import * as MapboxNative from './map-view.native';
import * as MapboxWeb from './map-view.web';

// Import the platform-specific implementation
// Metro bundler will resolve to the correct file based on platform
const MapboxImpl = Platform.OS === 'web' ? require('./map-view.web').default : require('./map-view.native').default;
const MapboxImpl = Platform.OS === 'web' ? MapboxWeb.default : MapboxNative.default;

// Re-export all components
export const MapView = MapboxImpl.MapView || MapboxImpl;
export const Camera = Platform.OS === 'web' ? require('./map-view.web').Camera : require('./map-view.native').Camera;
export const PointAnnotation = Platform.OS === 'web' ? require('./map-view.web').PointAnnotation : require('./map-view.native').PointAnnotation;
export const UserLocation = Platform.OS === 'web' ? require('./map-view.web').UserLocation : require('./map-view.native').UserLocation;
export const MarkerView = Platform.OS === 'web' ? require('./map-view.web').MarkerView : require('./map-view.native').MarkerView;
export const ShapeSource = Platform.OS === 'web' ? require('./map-view.web').ShapeSource : require('./map-view.native').ShapeSource;
export const SymbolLayer = Platform.OS === 'web' ? require('./map-view.web').SymbolLayer : require('./map-view.native').SymbolLayer;
export const CircleLayer = Platform.OS === 'web' ? require('./map-view.web').CircleLayer : require('./map-view.native').CircleLayer;
export const LineLayer = Platform.OS === 'web' ? require('./map-view.web').LineLayer : require('./map-view.native').LineLayer;
export const FillLayer = Platform.OS === 'web' ? require('./map-view.web').FillLayer : require('./map-view.native').FillLayer;
export const Images = Platform.OS === 'web' ? require('./map-view.web').Images : require('./map-view.native').Images;
export const Callout = Platform.OS === 'web' ? require('./map-view.web').Callout : require('./map-view.native').Callout;
export const Camera = Platform.OS === 'web' ? MapboxWeb.Camera : MapboxNative.Camera;
export const PointAnnotation = Platform.OS === 'web' ? MapboxWeb.PointAnnotation : MapboxNative.PointAnnotation;
export const UserLocation = Platform.OS === 'web' ? MapboxWeb.UserLocation : MapboxNative.UserLocation;
export const MarkerView = Platform.OS === 'web' ? MapboxWeb.MarkerView : MapboxNative.MarkerView;
export const ShapeSource = Platform.OS === 'web' ? MapboxWeb.ShapeSource : MapboxNative.ShapeSource;
export const SymbolLayer = Platform.OS === 'web' ? MapboxWeb.SymbolLayer : MapboxNative.SymbolLayer;
export const CircleLayer = Platform.OS === 'web' ? MapboxWeb.CircleLayer : MapboxNative.CircleLayer;
export const LineLayer = Platform.OS === 'web' ? MapboxWeb.LineLayer : MapboxNative.LineLayer;
export const FillLayer = Platform.OS === 'web' ? MapboxWeb.FillLayer : MapboxNative.FillLayer;
export const Images = Platform.OS === 'web' ? MapboxWeb.Images : MapboxNative.Images;
export const Callout = Platform.OS === 'web' ? MapboxWeb.Callout : MapboxNative.Callout;

// Export style URL constants
export const StyleURL = Platform.OS === 'web' ? require('./map-view.web').StyleURL : require('./map-view.native').StyleURL;
export const StyleURL = Platform.OS === 'web' ? MapboxWeb.StyleURL : MapboxNative.StyleURL;

// Export UserTrackingMode
export const UserTrackingMode = Platform.OS === 'web' ? require('./map-view.web').UserTrackingMode : require('./map-view.native').UserTrackingMode;
export const UserTrackingMode = Platform.OS === 'web' ? MapboxWeb.UserTrackingMode : MapboxNative.UserTrackingMode;

// Export setAccessToken
export const setAccessToken = Platform.OS === 'web' ? require('./map-view.web').setAccessToken : require('./map-view.native').setAccessToken;
export const setAccessToken = Platform.OS === 'web' ? MapboxWeb.setAccessToken : MapboxNative.setAccessToken;

// Default export matching Mapbox structure with all properties
const Mapbox = {
...MapboxImpl,
MapView: MapView,
Camera: Camera,
PointAnnotation: PointAnnotation,
UserLocation: UserLocation,
MarkerView: MarkerView,
ShapeSource: ShapeSource,
SymbolLayer: SymbolLayer,
CircleLayer: CircleLayer,
LineLayer: LineLayer,
FillLayer: FillLayer,
Images: Images,
Callout: Callout,
StyleURL: StyleURL,
UserTrackingMode: UserTrackingMode,
setAccessToken: setAccessToken,
};

// Default export matching Mapbox structure
export default MapboxImpl;
export default Mapbox;
7 changes: 6 additions & 1 deletion src/components/sidebar/sidebar-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { SidebarRolesCard } from './roles-sidebar';
import { SidebarStatusCard } from './status-sidebar';
import { SidebarUnitCard } from './unit-sidebar';

const Sidebar = () => {
interface SidebarProps {
onClose?: () => void;
}

const Sidebar = ({ onClose }: SidebarProps) => {
const activeStatuses = useCoreStore((state) => state.activeStatuses);
const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen);
const { t } = useTranslation();
Expand All @@ -27,6 +31,7 @@ const Sidebar = () => {
const isActiveStatusesEmpty = !activeStatuses?.Statuses || activeStatuses.Statuses.length === 0;

const handleNavigateToSettings = () => {
onClose?.();
router.push('/settings');
};

Expand Down
2 changes: 0 additions & 2 deletions src/components/sidebar/status-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { useCoreStore } from '@/stores/app/core-store';

import { Card } from '../ui/card';

type ItemProps = {};

export const SidebarStatusCard = () => {
const activeUnitStatus = useCoreStore((state) => state.activeUnitStatus);

Expand Down
6 changes: 3 additions & 3 deletions src/services/audio.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Asset } from 'expo-asset';
import { Audio, InterruptionModeIOS } from 'expo-av';
import { Platform } from 'react-native';
import { NativeModules, Platform } from 'react-native';

import { logger } from '@/lib/logging';

Expand Down Expand Up @@ -54,7 +54,7 @@ class AudioService {

// Initialize Native In-Call Audio Module on Android
if (Platform.OS === 'android') {
const { InCallAudioModule } = require('react-native').NativeModules;
const InCallAudioModule = NativeModules.InCallAudioModule;
if (InCallAudioModule) {
// Load sounds into native SoundPool
// Map functional names to resource names (without extension)
Expand Down Expand Up @@ -150,7 +150,7 @@ class AudioService {

private async playSound(sound: Audio.Sound | null, name: string): Promise<void> {
if (Platform.OS === 'android') {
const { InCallAudioModule } = require('react-native').NativeModules;
const InCallAudioModule = NativeModules.InCallAudioModule;
if (InCallAudioModule) {
InCallAudioModule.playSound(name);
logger.debug({ message: 'Played sound via Native Module', context: { soundName: name } });
Expand Down
29 changes: 20 additions & 9 deletions src/services/bluetooth-audio.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@ import { Buffer } from 'buffer';
import { Alert, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native';
import BleManager, { type BleManagerDidUpdateValueForCharacteristicEvent, BleScanCallbackType, BleScanMatchMode, BleScanMode, type BleState, type Peripheral, type PeripheralInfo } from 'react-native-ble-manager';

import { useLiveKitCallStore } from '@/features/livekit-call/store/useLiveKitCallStore';
import { logger } from '@/lib/logging';
import { audioService } from '@/services/audio.service';
import { callKeepService } from '@/services/callkeep.service';
import { type AudioButtonEvent, type BluetoothAudioDevice, type Device, State, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store';
import { useLiveKitStore } from '@/stores/app/livekit-store';
// Lazy getters to avoid circular dependencies with livekit-store and useLiveKitCallStore
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getLiveKitCallStore = (): any => {
// Using import() for lazy loading to avoid circular dependencies
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('@/features/livekit-call/store/useLiveKitCallStore').useLiveKitCallStore;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getLiveKitStore = (): any => {
// Using import() for lazy loading to avoid circular dependencies
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('@/stores/app/livekit-store').useLiveKitStore;
};

// Standard Bluetooth UUIDs for audio services
const AUDIO_SERVICE_UUID = '0000110A-0000-1000-8000-00805F9B34FB'; // Advanced Audio Distribution Profile
Expand Down Expand Up @@ -2072,15 +2083,15 @@ class BluetoothAudioService {

private async handleMuteToggle(): Promise<void> {
try {
const featureLiveKitState = useLiveKitCallStore.getState();
const featureLiveKitState = getLiveKitCallStore().getState();
const featureRoom = featureLiveKitState.roomInstance;
const featureLocalParticipant = featureRoom?.localParticipant ?? featureLiveKitState.localParticipant;

if (featureLiveKitState.isConnected && featureRoom && featureLocalParticipant) {
const nextMicEnabled = !featureLocalParticipant.isMicrophoneEnabled;
await featureLiveKitState.actions.setMicrophoneEnabled(nextMicEnabled);

const updatedState = useLiveKitCallStore.getState();
const updatedState = getLiveKitCallStore().getState();
const updatedParticipant = updatedState.roomInstance?.localParticipant ?? updatedState.localParticipant;

if (updatedParticipant && updatedParticipant.isMicrophoneEnabled === nextMicEnabled) {
Expand All @@ -2096,7 +2107,7 @@ class BluetoothAudioService {
});
}

await useLiveKitStore.getState().toggleMicrophone();
await getLiveKitStore().getState().toggleMicrophone();
} catch (error) {
logger.error({
message: 'Failed to toggle microphone via Bluetooth button',
Expand Down Expand Up @@ -2188,9 +2199,9 @@ class BluetoothAudioService {

private async applyMicrophoneEnabled(enabled: boolean): Promise<void> {
try {
const featureLiveKitState = useLiveKitCallStore.getState();
const featureLiveKitState = getLiveKitCallStore().getState();
const featureRoom = featureLiveKitState.roomInstance;
const legacyLiveKitState = useLiveKitStore.getState();
const legacyLiveKitState = getLiveKitStore().getState();
const hasFeatureRoom = Boolean(featureLiveKitState.isConnected && featureRoom?.localParticipant);
const hasLegacyRoom = Boolean(legacyLiveKitState.currentRoom?.localParticipant);
const stillConnecting = featureLiveKitState.isConnecting || legacyLiveKitState.isConnecting;
Expand All @@ -2210,7 +2221,7 @@ class BluetoothAudioService {

await featureLiveKitState.actions.setMicrophoneEnabled(enabled);

const updatedState = useLiveKitCallStore.getState();
const updatedState = getLiveKitCallStore().getState();
const updatedParticipant = updatedState.roomInstance?.localParticipant ?? updatedState.localParticipant;

if (updatedParticipant && updatedParticipant.isMicrophoneEnabled === enabled) {
Expand All @@ -2226,7 +2237,7 @@ class BluetoothAudioService {
});
}

await useLiveKitStore.getState().setMicrophoneEnabled(enabled);
await getLiveKitStore().getState().setMicrophoneEnabled(enabled);
} catch (error) {
logger.error({
message: 'Failed to set microphone via Bluetooth PTT button',
Expand Down
Loading