diff --git a/__mocks__/@livekit/react-native-webrtc.ts b/__mocks__/@livekit/react-native-webrtc.ts
new file mode 100644
index 00000000..4e3d21c6
--- /dev/null
+++ b/__mocks__/@livekit/react-native-webrtc.ts
@@ -0,0 +1,12 @@
+// Mock for @livekit/react-native-webrtc
+export const RTCAudioSession = {
+ configure: jest.fn().mockResolvedValue(undefined),
+ setCategory: jest.fn().mockResolvedValue(undefined),
+ setMode: jest.fn().mockResolvedValue(undefined),
+ getActiveAudioSession: jest.fn().mockReturnValue(null),
+ setActive: jest.fn().mockResolvedValue(undefined),
+};
+
+export default {
+ RTCAudioSession,
+};
diff --git a/expo-env.d.ts b/expo-env.d.ts
index 5411fdde..bf3c1693 100644
--- a/expo-env.d.ts
+++ b/expo-env.d.ts
@@ -1,3 +1,3 @@
///
-// NOTE: This file should not be edited and should be in your git ignore
\ No newline at end of file
+// NOTE: This file should not be edited and should be in your git ignore
diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts
index dfae5528..ed4809b1 100644
--- a/src/api/calls/calls.ts
+++ b/src/api/calls/calls.ts
@@ -1,11 +1,16 @@
+import { cacheManager } from '@/lib/cache/cache-manager';
import { type ActiveCallsResult } from '@/models/v4/calls/activeCallsResult';
import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult';
import { type CallResult } from '@/models/v4/calls/callResult';
import { type SaveCallResult } from '@/models/v4/calls/saveCallResult';
+import { createCachedApiEndpoint } from '../common/cached-client';
import { createApiEndpoint } from '../common/client';
-const callsApi = createApiEndpoint('/Calls/GetActiveCalls');
+const callsApi = createCachedApiEndpoint('/Calls/GetActiveCalls', {
+ ttl: 30 * 1000, // Cache for 30 seconds - calls can change frequently
+ enabled: true,
+});
const getCallApi = createApiEndpoint('/Calls/GetCall');
const getCallExtraDataApi = createApiEndpoint('/Calls/GetCallExtraData');
const createCallApi = createApiEndpoint('/Calls/SaveCall');
@@ -78,33 +83,34 @@ export interface CloseCallRequest {
note?: string;
}
-export const createCall = async (callData: CreateCallRequest) => {
- let dispatchList = '';
-
- if (callData.dispatchEveryone) {
- dispatchList = '0';
- } else {
- const dispatchEntries: string[] = [];
-
- if (callData.dispatchUsers) {
- //dispatchEntries.push(...callData.dispatchUsers.map((user) => `U:${user}`));
- dispatchEntries.push(...callData.dispatchUsers);
- }
- if (callData.dispatchGroups) {
- //dispatchEntries.push(...callData.dispatchGroups.map((group) => `G:${group}`));
- dispatchEntries.push(...callData.dispatchGroups);
- }
- if (callData.dispatchRoles) {
- //dispatchEntries.push(...callData.dispatchRoles.map((role) => `R:${role}`));
- dispatchEntries.push(...callData.dispatchRoles);
- }
- if (callData.dispatchUnits) {
- //dispatchEntries.push(...callData.dispatchUnits.map((unit) => `U:${unit}`));
- dispatchEntries.push(...callData.dispatchUnits);
- }
-
- dispatchList = dispatchEntries.join('|');
+/**
+ * Helper function to build the dispatch list string from dispatch data
+ */
+const buildDispatchList = (data: { dispatchEveryone?: boolean; dispatchUsers?: string[]; dispatchGroups?: string[]; dispatchRoles?: string[]; dispatchUnits?: string[] }): string => {
+ if (data.dispatchEveryone) {
+ return '0';
+ }
+
+ const dispatchEntries: string[] = [];
+
+ if (data.dispatchUsers) {
+ dispatchEntries.push(...data.dispatchUsers);
+ }
+ if (data.dispatchGroups) {
+ dispatchEntries.push(...data.dispatchGroups);
+ }
+ if (data.dispatchRoles) {
+ dispatchEntries.push(...data.dispatchRoles);
}
+ if (data.dispatchUnits) {
+ dispatchEntries.push(...data.dispatchUnits);
+ }
+
+ return dispatchEntries.join('|');
+};
+
+export const createCall = async (callData: CreateCallRequest) => {
+ const dispatchList = buildDispatchList(callData);
const data = {
Name: callData.name,
@@ -122,36 +128,20 @@ export const createCall = async (callData: CreateCallRequest) => {
};
const response = await createCallApi.post(data);
+
+ // Invalidate cache after successful mutation
+ try {
+ cacheManager.remove('/Calls/GetActiveCalls');
+ } catch (error) {
+ // Silently handle cache removal errors
+ console.warn('Failed to invalidate calls cache:', error);
+ }
+
return response.data;
};
export const updateCall = async (callData: UpdateCallRequest) => {
- let dispatchList = '';
-
- if (callData.dispatchEveryone) {
- dispatchList = '0';
- } else {
- const dispatchEntries: string[] = [];
-
- if (callData.dispatchUsers) {
- //dispatchEntries.push(...callData.dispatchUsers.map((user) => `U:${user}`));
- dispatchEntries.push(...callData.dispatchUsers);
- }
- if (callData.dispatchGroups) {
- //dispatchEntries.push(...callData.dispatchGroups.map((group) => `G:${group}`));
- dispatchEntries.push(...callData.dispatchGroups);
- }
- if (callData.dispatchRoles) {
- //dispatchEntries.push(...callData.dispatchRoles.map((role) => `R:${role}`));
- dispatchEntries.push(...callData.dispatchRoles);
- }
- if (callData.dispatchUnits) {
- //dispatchEntries.push(...callData.dispatchUnits.map((unit) => `U:${unit}`));
- dispatchEntries.push(...callData.dispatchUnits);
- }
-
- dispatchList = dispatchEntries.join('|');
- }
+ const dispatchList = buildDispatchList(callData);
const data = {
CallId: callData.callId,
@@ -170,6 +160,15 @@ export const updateCall = async (callData: UpdateCallRequest) => {
};
const response = await updateCallApi.post(data);
+
+ // Invalidate cache after successful mutation
+ try {
+ cacheManager.remove('/Calls/GetActiveCalls');
+ } catch (error) {
+ // Silently handle cache removal errors
+ console.warn('Failed to invalidate calls cache:', error);
+ }
+
return response.data;
};
@@ -181,5 +180,14 @@ export const closeCall = async (callData: CloseCallRequest) => {
};
const response = await closeCallApi.put(data);
+
+ // Invalidate cache after successful mutation
+ try {
+ cacheManager.remove('/Calls/GetActiveCalls');
+ } catch (error) {
+ // Silently handle cache removal errors
+ console.warn('Failed to invalidate calls cache:', error);
+ }
+
return response.data;
};
diff --git a/src/components/calls/__tests__/call-files-modal.test.tsx b/src/components/calls/__tests__/call-files-modal.test.tsx
index ed137d19..0e729df7 100644
--- a/src/components/calls/__tests__/call-files-modal.test.tsx
+++ b/src/components/calls/__tests__/call-files-modal.test.tsx
@@ -37,14 +37,29 @@ const defaultMockFiles = [
},
];
-let mockStoreState: any = {
- callFiles: defaultMockFiles,
+// Create a single object that will be mutated - never reassign!
+const mockStoreState = {
+ callFiles: defaultMockFiles as any,
isLoadingFiles: false,
- errorFiles: null,
+ errorFiles: null as string | null,
fetchCallFiles: mockFetchCallFiles,
clearFiles: mockClearFiles,
};
+// Helper function to update mock state without replacing the object
+const setMockStoreState = (updates: Partial) => {
+ Object.assign(mockStoreState, updates);
+};
+
+// Reset mock state to defaults
+const resetMockStoreState = () => {
+ mockStoreState.callFiles = defaultMockFiles;
+ mockStoreState.isLoadingFiles = false;
+ mockStoreState.errorFiles = null;
+ mockStoreState.fetchCallFiles = mockFetchCallFiles;
+ mockStoreState.clearFiles = mockClearFiles;
+};
+
jest.mock('@/stores/calls/detail-store', () => ({
useCallDetailStore: () => mockStoreState,
}));
@@ -301,13 +316,7 @@ describe('CallFilesModal', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to default state
- mockStoreState = {
- callFiles: defaultMockFiles,
- isLoadingFiles: false,
- errorFiles: null,
- fetchCallFiles: mockFetchCallFiles,
- clearFiles: mockClearFiles,
- };
+ resetMockStoreState();
});
it('renders correctly when closed', () => {
@@ -421,13 +430,11 @@ describe('CallFilesModal', () => {
describe('Loading States', () => {
beforeEach(() => {
// Mock loading state
- mockStoreState = {
+ setMockStoreState({
callFiles: null,
isLoadingFiles: true,
errorFiles: null,
- fetchCallFiles: mockFetchCallFiles,
- clearFiles: mockClearFiles,
- };
+ });
});
it('displays loading spinner when fetching files', () => {
@@ -443,13 +450,11 @@ describe('CallFilesModal', () => {
describe('Error States', () => {
beforeEach(() => {
// Mock error state
- mockStoreState = {
+ setMockStoreState({
callFiles: [],
isLoadingFiles: false,
errorFiles: 'Network error occurred',
- fetchCallFiles: mockFetchCallFiles,
- clearFiles: mockClearFiles,
- };
+ });
});
it('displays error message when file fetch fails', () => {
@@ -476,13 +481,11 @@ describe('CallFilesModal', () => {
describe('Empty States', () => {
beforeEach(() => {
// Mock empty state
- mockStoreState = {
+ setMockStoreState({
callFiles: [],
isLoadingFiles: false,
errorFiles: null,
- fetchCallFiles: mockFetchCallFiles,
- clearFiles: mockClearFiles,
- };
+ });
});
it('displays empty state when no files available', () => {
@@ -503,12 +506,11 @@ describe('CallFilesModal', () => {
beforeEach(() => {
// Reset to default state with files
- mockStoreState = {
+ setMockStoreState({
callFiles: defaultMockFiles,
isLoadingFiles: false,
errorFiles: null,
- fetchCallFiles: mockFetchCallFiles,
- };
+ });
});
it('downloads and shares file when clicked', async () => {
@@ -591,13 +593,7 @@ describe('CallFilesModal', () => {
describe('File Format Utilities', () => {
beforeEach(() => {
// Reset to default state
- mockStoreState = {
- callFiles: defaultMockFiles,
- isLoadingFiles: false,
- errorFiles: null,
- fetchCallFiles: mockFetchCallFiles,
- clearFiles: mockClearFiles,
- };
+ resetMockStoreState();
});
it('formats file sizes correctly', () => {
@@ -625,13 +621,7 @@ describe('CallFilesModal', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to default state
- mockStoreState = {
- callFiles: defaultMockFiles,
- isLoadingFiles: false,
- errorFiles: null,
- fetchCallFiles: mockFetchCallFiles,
- clearFiles: mockClearFiles,
- };
+ resetMockStoreState();
});
it('should track analytics event when modal is opened', () => {
@@ -653,10 +643,9 @@ describe('CallFilesModal', () => {
});
it('should track analytics event with loading state', () => {
- mockStoreState = {
- ...mockStoreState,
+ setMockStoreState({
isLoadingFiles: true,
- };
+ });
render();
@@ -670,10 +659,9 @@ describe('CallFilesModal', () => {
});
it('should track analytics event with error state', () => {
- mockStoreState = {
- ...mockStoreState,
+ setMockStoreState({
errorFiles: 'Failed to load files',
- };
+ });
render();
@@ -687,10 +675,9 @@ describe('CallFilesModal', () => {
});
it('should track analytics event with no files', () => {
- mockStoreState = {
- ...mockStoreState,
+ setMockStoreState({
callFiles: [],
- };
+ });
render();
diff --git a/src/components/calls/__tests__/call-notes-modal-new.test.tsx b/src/components/calls/__tests__/call-notes-modal-new.test.tsx
index 6ae11779..3e1bc4f6 100644
--- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx
+++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx
@@ -9,6 +9,11 @@ import { useCallDetailStore } from '@/stores/calls/detail-store';
jest.mock('react-i18next');
jest.mock('@/lib/auth');
jest.mock('@/stores/calls/detail-store');
+jest.mock('@/hooks/use-analytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: jest.fn(),
+ }),
+}));
// Mock navigation
jest.mock('@react-navigation/native', () => ({
@@ -25,10 +30,13 @@ jest.mock('nativewind', () => ({
}));
// Mock lucide-react-native icons
-jest.mock('lucide-react-native', () => ({
- SearchIcon: 'SearchIcon',
- X: 'X',
-}));
+jest.mock('lucide-react-native', () => {
+ const { View } = require('react-native');
+ return {
+ SearchIcon: (props: any) => ,
+ X: (props: any) => ,
+ };
+});
// Mock Loading component
jest.mock('../../common/loading', () => ({
@@ -58,6 +66,10 @@ jest.mock('react-native-keyboard-controller', () => ({
const { View } = require('react-native');
return {children};
},
+ KeyboardStickyView: ({ children }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
}));
// Mock react-native-gesture-handler
@@ -94,10 +106,86 @@ jest.mock('@gorhom/bottom-sheet', () => {
};
});
-// Mock lucide-react-native icons
-jest.mock('lucide-react-native', () => ({
- SearchIcon: 'SearchIcon',
- X: 'X',
+// Mock UI components
+jest.mock('../../ui/box', () => ({
+ Box: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('../../ui/button', () => ({
+ Button: ({ children, onPress, disabled, isDisabled, ...props }: any) => {
+ const { TouchableOpacity } = require('react-native');
+ const isButtonDisabled = disabled || isDisabled;
+ return (
+
+ {children}
+
+ );
+ },
+ ButtonText: ({ children, ...props }: any) => {
+ const { Text } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('../../ui/heading', () => ({
+ Heading: ({ children, ...props }: any) => {
+ const { Text } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('../../ui/hstack', () => ({
+ HStack: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('../../ui/vstack', () => ({
+ VStack: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('../../ui/text', () => ({
+ Text: ({ children, ...props }: any) => {
+ const { Text: RNText } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('../../ui/input', () => ({
+ Input: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ InputField: ({ placeholder, value, onChangeText, ...props }: any) => {
+ const { TextInput } = require('react-native');
+ return ;
+ },
+ InputSlot: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('../../ui/textarea', () => ({
+ Textarea: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ TextareaInput: ({ placeholder, value, onChangeText, ...props }: any) => {
+ const { TextInput } = require('react-native');
+ return ;
+ },
}));
const mockUseTranslation = useTranslation as jest.MockedFunction;
@@ -146,6 +234,9 @@ describe('CallNotesModal', () => {
'callNotes.searchPlaceholder': 'Search notes...',
'callNotes.addNotePlaceholder': 'Add a note...',
'callNotes.addNote': 'Add Note',
+ 'callNotes.noNotesFound': 'No notes found',
+ 'callNotes.addNoteLabel': 'Add a note',
+ 'common.cancel': 'Cancel',
};
return translations[key] || key;
},
@@ -185,8 +276,8 @@ describe('CallNotesModal', () => {
it('renders correctly when closed', () => {
const { queryByText } = render();
- // Bottom sheet should still render but with index -1 (closed)
- expect(queryByText('Call Notes')).toBeTruthy();
+ // Component returns null when closed
+ expect(queryByText('Call Notes')).toBeFalsy();
});
it('handles search input correctly', () => {
diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
index e344f53a..75a70eca 100644
--- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
+++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
@@ -121,41 +121,61 @@ jest.mock('@/components/ui/form-control', () => ({
},
}));
-jest.mock('@/components/ui/select', () => ({
- Select: ({ children, testID, selectedValue, onValueChange, ...props }: any) => {
- const { View, TouchableOpacity, Text } = require('react-native');
- return (
-
- {children}
- onValueChange && onValueChange('1')}>
- Select Option
-
-
- );
- },
- SelectTrigger: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- SelectInput: ({ placeholder, ...props }: any) => {
- const { Text } = require('react-native');
- return {placeholder};
- },
- SelectIcon: () => null,
- SelectPortal: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- SelectBackdrop: () => null,
- SelectContent: ({ children, ...props }: any) => {
- const { View } = require('react-native');
- return {children};
- },
- SelectItem: ({ label, value, ...props }: any) => {
- const { View, Text } = require('react-native');
- return {label};
- },
-}));
+jest.mock('@/components/ui/select', () => {
+ // Store the callback for each select
+ const selectCallbacks: Record void> = {};
+
+ return {
+ Select: ({ children, testID, selectedValue, onValueChange, ...props }: any) => {
+ const React = require('react');
+ const { View, TouchableOpacity, Text } = require('react-native');
+
+ // Store the callback for external access
+ React.useEffect(() => {
+ if (testID && onValueChange) {
+ selectCallbacks[testID] = onValueChange;
+ }
+ return () => {
+ if (testID) {
+ delete selectCallbacks[testID];
+ }
+ };
+ }, [testID, onValueChange]);
+
+ return (
+
+ {children}
+
+ );
+ },
+ SelectTrigger: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ SelectInput: ({ placeholder, ...props }: any) => {
+ const { Text } = require('react-native');
+ return {placeholder};
+ },
+ SelectIcon: () => null,
+ SelectPortal: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ SelectBackdrop: () => null,
+ SelectContent: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ SelectItem: ({ label, value, ...props }: any) => {
+ const { View, Text } = require('react-native');
+ return {label};
+ },
+ };
+});
jest.mock('@/components/ui/textarea', () => ({
Textarea: ({ children, ...props }: any) => {
@@ -170,6 +190,7 @@ jest.mock('@/components/ui/textarea', () => ({
const mockRouter = {
back: jest.fn(),
+ replace: jest.fn(),
};
const mockUseTranslation = {
@@ -272,7 +293,7 @@ describe('CloseCallBottomSheet', () => {
});
expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success');
expect(mockFetchCalls).toHaveBeenCalled();
- expect(mockRouter.back).toHaveBeenCalled();
+ expect(mockRouter.replace).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
});
@@ -300,7 +321,7 @@ describe('CloseCallBottomSheet', () => {
});
expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success');
expect(mockFetchCalls).toHaveBeenCalled();
- expect(mockRouter.back).toHaveBeenCalled();
+ expect(mockRouter.replace).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled();
});
});
@@ -324,7 +345,7 @@ describe('CloseCallBottomSheet', () => {
});
expect(mockFetchCalls).not.toHaveBeenCalled();
- expect(mockRouter.back).not.toHaveBeenCalled();
+ expect(mockRouter.replace).not.toHaveBeenCalled();
});
it.each([
@@ -429,19 +450,20 @@ describe('CloseCallBottomSheet', () => {
// Wait for the entire flow to complete
await waitFor(() => {
expect(mockCloseCall).toHaveBeenCalled();
- expect(mockFetchCalls).toHaveBeenCalled();
});
- // Wait for all toast messages and error handling to complete
+ // Wait for success toast and close since closeCall succeeded
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith('success', 'call_detail.close_call_success');
expect(mockOnClose).toHaveBeenCalled();
- expect(console.error).toHaveBeenCalledWith('Error closing call:', expect.any(Error));
- expect(mockShowToast).toHaveBeenCalledWith('error', 'call_detail.close_call_error');
});
- // Since closeCall succeeded, the modal should be closed but router.back() should not be called due to fetchCalls failure
- expect(mockRouter.back).not.toHaveBeenCalled();
+ // router.replace is called BEFORE fetchCalls, so it should have been called
+ // even though fetchCalls failed
+ await waitFor(() => {
+ expect(mockRouter.replace).toHaveBeenCalled();
+ expect(mockFetchCalls).toHaveBeenCalled();
+ });
});
it('should not render when isOpen is false', () => {
diff --git a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
index ad621f4a..65695a26 100644
--- a/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
+++ b/src/components/livekit/__tests__/livekit-bottom-sheet.test.tsx
@@ -2,6 +2,26 @@ import { render, act } from '@testing-library/react-native';
import React from 'react';
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
+// Mock @livekit/react-native-webrtc before any imports that use it
+jest.mock('@livekit/react-native-webrtc', () => ({
+ RTCAudioSession: {
+ audioSessionDidActivate: jest.fn(),
+ audioSessionDidDeactivate: jest.fn(),
+ },
+}));
+
+// Mock CallKeep service
+jest.mock('@/services/callkeep.service.ios', () => ({
+ callKeepService: {
+ setup: (jest.fn() as jest.Mock<() => Promise>).mockResolvedValue(undefined),
+ startCall: (jest.fn() as jest.Mock<() => Promise>).mockResolvedValue('test-uuid'),
+ endCall: (jest.fn() as jest.Mock<() => Promise>).mockResolvedValue(undefined),
+ isCallActiveNow: (jest.fn() as jest.Mock<() => boolean>).mockReturnValue(false),
+ getCurrentCallUUID: (jest.fn() as jest.Mock<() => string | null>).mockReturnValue(null),
+ setMuteStateCallback: jest.fn(),
+ },
+}));
+
// Mock React Native and NativeWind
jest.mock('nativewind', () => ({
useColorScheme: () => ({ colorScheme: 'light' }),
diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx
index 9db7f4ab..3f832ebd 100644
--- a/src/components/livekit/livekit-bottom-sheet.tsx
+++ b/src/components/livekit/livekit-bottom-sheet.tsx
@@ -57,8 +57,8 @@ export const LiveKitBottomSheet = () => {
currentRoomName: currentRoomInfo?.Name || 'none',
isMuted: isMuted,
isTalking: isTalking,
- hasBluetoothMicrophone: selectedAudioDevices.microphone?.type === 'bluetooth',
- hasBluetoothSpeaker: selectedAudioDevices.speaker?.type === 'bluetooth',
+ hasBluetoothMicrophone: selectedAudioDevices?.microphone?.type === 'bluetooth',
+ hasBluetoothSpeaker: selectedAudioDevices?.speaker?.type === 'bluetooth',
permissionsRequested: permissionsRequested,
});
}
@@ -72,8 +72,8 @@ export const LiveKitBottomSheet = () => {
currentRoomInfo,
isMuted,
isTalking,
- selectedAudioDevices.microphone?.type,
- selectedAudioDevices.speaker?.type,
+ selectedAudioDevices?.microphone?.type,
+ selectedAudioDevices?.speaker?.type,
permissionsRequested,
]);
diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx
index 1415a20a..f0e5604b 100644
--- a/src/components/maps/full-screen-location-picker.tsx
+++ b/src/components/maps/full-screen-location-picker.tsx
@@ -1,14 +1,27 @@
import Mapbox from '@rnmapbox/maps';
import * as Location from 'expo-location';
-import { MapPinIcon, XIcon } from 'lucide-react-native';
+import { LocateIcon, MapPinIcon, XIcon } from 'lucide-react-native';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Dimensions, StyleSheet, TouchableOpacity } from 'react-native';
+import { ActivityIndicator, Dimensions, StyleSheet, TouchableOpacity } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
+import { Env } from '@/lib/env';
+
+// Ensure Mapbox access token is set before using any Mapbox components
+Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
+
+// Default location (center of USA) used when user location is unavailable
+const DEFAULT_LOCATION = {
+ latitude: 39.8283,
+ longitude: -98.5795,
+};
+
+// Timeout for location fetching (in milliseconds)
+const LOCATION_TIMEOUT = 10000;
interface FullScreenLocationPickerProps {
initialLocation?: {
@@ -24,77 +37,92 @@ const FullScreenLocationPicker: React.FC = ({ ini
const insets = useSafeAreaInsets();
const mapRef = useRef(null);
const cameraRef = useRef(null);
+ // Always start with a location - either initial, or default
const [currentLocation, setCurrentLocation] = useState<{
latitude: number;
longitude: number;
- } | null>(initialLocation || null);
- const [isLoading, setIsLoading] = useState(false);
+ }>(initialLocation || DEFAULT_LOCATION);
+ const [isLocating, setIsLocating] = useState(false);
const [isReverseGeocoding, setIsReverseGeocoding] = useState(false);
const [address, setAddress] = useState(undefined);
- const [isMounted, setIsMounted] = useState(true);
+ const [hasUserLocation, setHasUserLocation] = useState(!!initialLocation);
+ const isMountedRef = useRef(true);
- const reverseGeocode = React.useCallback(
- async (latitude: number, longitude: number) => {
- if (!isMounted) return;
+ const reverseGeocode = React.useCallback(async (latitude: number, longitude: number) => {
+ if (!isMountedRef.current) return;
- setIsReverseGeocoding(true);
- try {
- const result = await Location.reverseGeocodeAsync({
- latitude,
- longitude,
- });
+ setIsReverseGeocoding(true);
+ try {
+ const result = await Location.reverseGeocodeAsync({
+ latitude,
+ longitude,
+ });
+
+ if (!isMountedRef.current) return;
+
+ if (result && result.length > 0) {
+ const { street, name, city, region, country, postalCode } = result[0];
+ const addressParts: string[] = [];
- if (!isMounted) return;
-
- if (result && result.length > 0) {
- const { street, name, city, region, country, postalCode } = result[0];
- let addressParts = [];
-
- if (street) addressParts.push(street);
- if (name && name !== street) addressParts.push(name);
- if (city) addressParts.push(city);
- if (region) addressParts.push(region);
- if (postalCode) addressParts.push(postalCode);
- if (country) addressParts.push(country);
-
- setAddress(addressParts.join(', '));
- } else {
- setAddress(undefined);
- }
- } catch (error) {
- console.error('Error reverse geocoding:', error);
- if (isMounted) setAddress(undefined);
- } finally {
- if (isMounted) setIsReverseGeocoding(false);
+ if (street) addressParts.push(street);
+ if (name && name !== street) addressParts.push(name);
+ if (city) addressParts.push(city);
+ if (region) addressParts.push(region);
+ if (postalCode) addressParts.push(postalCode);
+ if (country) addressParts.push(country);
+
+ setAddress(addressParts.join(', '));
+ } else {
+ setAddress(undefined);
}
- },
- [isMounted]
- );
+ } catch (error) {
+ console.error('Error reverse geocoding:', error);
+ if (isMountedRef.current) setAddress(undefined);
+ } finally {
+ if (isMountedRef.current) setIsReverseGeocoding(false);
+ }
+ }, []);
const getUserLocation = React.useCallback(async () => {
- if (!isMounted) return;
+ if (!isMountedRef.current) return;
- setIsLoading(true);
+ setIsLocating(true);
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
console.error('Location permission not granted');
- if (isMounted) setIsLoading(false);
return;
}
- const location = await Location.getCurrentPositionAsync({});
- if (!isMounted) return;
+ // Create a timeout promise with cleanup
+ let timeoutId: ReturnType | undefined;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT);
+ });
+
+ // Race between getting location and timeout
+ const location = await Promise.race([
+ Location.getCurrentPositionAsync({
+ accuracy: Location.Accuracy.Balanced,
+ }),
+ timeoutPromise,
+ ]);
+
+ // Clear timeout if location resolved first
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
+
+ if (!isMountedRef.current) return;
const newLocation = {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
};
setCurrentLocation(newLocation);
+ setHasUserLocation(true);
reverseGeocode(newLocation.latitude, newLocation.longitude);
// Move camera to user location
- if (cameraRef.current && isMounted) {
+ if (cameraRef.current && isMountedRef.current) {
cameraRef.current.setCamera({
centerCoordinate: [location.coords.longitude, location.coords.latitude],
zoomLevel: 15,
@@ -103,97 +131,89 @@ const FullScreenLocationPicker: React.FC = ({ ini
}
} catch (error) {
console.error('Error getting location:', error);
+ // Don't update location - keep using whatever we have (initial or default)
} finally {
- if (isMounted) setIsLoading(false);
+ if (isMountedRef.current) setIsLocating(false);
}
- }, [isMounted, reverseGeocode]);
+ }, [reverseGeocode]);
useEffect(() => {
- setIsMounted(true);
+ isMountedRef.current = true;
if (initialLocation) {
setCurrentLocation(initialLocation);
+ setHasUserLocation(true);
reverseGeocode(initialLocation.latitude, initialLocation.longitude);
} else {
+ // Try to get user location, but don't block the map from showing
getUserLocation();
}
return () => {
- setIsMounted(false);
+ isMountedRef.current = false;
};
}, [initialLocation, getUserLocation, reverseGeocode]);
- const handleMapPress = (event: any) => {
- const { coordinates } = event.geometry;
- const newLocation = {
- latitude: coordinates[1],
- longitude: coordinates[0],
- };
- setCurrentLocation(newLocation);
- reverseGeocode(newLocation.latitude, newLocation.longitude);
+ const handleMapPress = (event: GeoJSON.Feature) => {
+ if (event.geometry.type !== 'GeometryCollection' && 'coordinates' in event.geometry) {
+ const coords = event.geometry.coordinates as number[];
+ const [longitude, latitude] = coords;
+ const newLocation = {
+ latitude,
+ longitude,
+ };
+ setCurrentLocation(newLocation);
+ setHasUserLocation(true);
+ reverseGeocode(newLocation.latitude, newLocation.longitude);
+ }
};
const handleConfirmLocation = () => {
- if (currentLocation) {
- onLocationSelected({
- ...currentLocation,
- address,
- });
- onClose();
- }
+ onLocationSelected({
+ ...currentLocation,
+ address,
+ });
+ onClose();
};
- if (isLoading) {
- return (
-
- {t('common.loading')}
-
- );
- }
-
return (
- {currentLocation ? (
-
-
- {/* Marker for the selected location */}
-
-
-
-
-
-
- ) : (
-
- {t('common.no_location')}
-
- {t('common.get_my_location')}
-
-
- )}
+
+
+ {/* Marker for the selected location */}
+
+
+
+
+
+
{/* Close button */}
+ {/* My Location button */}
+
+ {isLocating ? : }
+
+
{/* Location info and confirm button */}
+ {!hasUserLocation ? {t('common.tap_map_to_select')} : null}
{isReverseGeocoding ? (
{t('common.loading_address')}
) : address ? (
{address}
- ) : (
+ ) : hasUserLocation ? (
{t('common.no_address_found')}
- )}
+ ) : null}
- {currentLocation && (
-
- {currentLocation.latitude.toFixed(6)}, {currentLocation.longitude.toFixed(6)}
-
- )}
+
+ {currentLocation.latitude.toFixed(6)}, {currentLocation.longitude.toFixed(6)}
+
-
@@ -227,6 +247,22 @@ const styles = StyleSheet.create({
elevation: 4,
zIndex: 10,
},
+ myLocationButton: {
+ position: 'absolute',
+ right: 16,
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'white',
+ justifyContent: 'center',
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.2,
+ shadowRadius: 4,
+ elevation: 4,
+ zIndex: 10,
+ },
bottomPanel: {
position: 'absolute',
bottom: 0,
diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx
index 730436d1..ac9cd8ba 100644
--- a/src/components/maps/location-picker.tsx
+++ b/src/components/maps/location-picker.tsx
@@ -1,12 +1,30 @@
import Mapbox from '@rnmapbox/maps';
import * as Location from 'expo-location';
+import { LocateIcon, MapPinIcon } from 'lucide-react-native';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { StyleSheet, TouchableOpacity } from 'react-native';
+import { ActivityIndicator, StyleSheet, TouchableOpacity } from 'react-native';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
+import { Env } from '@/lib/env';
+
+// Ensure Mapbox access token is set before using any Mapbox components
+if (!Env.UNIT_MAPBOX_PUBKEY) {
+ console.error('Mapbox access token is not configured. Please set UNIT_MAPBOX_PUBKEY in your environment.');
+} else {
+ Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
+}
+
+// Default location (center of USA) used when user location is unavailable
+const DEFAULT_LOCATION = {
+ latitude: 39.8283,
+ longitude: -98.5795,
+};
+
+// Timeout for location fetching (in milliseconds)
+const LOCATION_TIMEOUT = 10000;
interface LocationPickerProps {
initialLocation?: {
@@ -21,30 +39,54 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca
const { t } = useTranslation();
const mapRef = useRef(null);
const cameraRef = useRef(null);
+ const isMountedRef = useRef(true);
+ // Always start with a location - either initial, or default
const [currentLocation, setCurrentLocation] = useState<{
latitude: number;
longitude: number;
- } | null>(initialLocation || null);
- const [isLoading, setIsLoading] = useState(false);
+ }>(initialLocation || DEFAULT_LOCATION);
+ const [isLocating, setIsLocating] = useState(false);
+ const [hasUserLocation, setHasUserLocation] = useState(!!initialLocation);
const getUserLocation = React.useCallback(async () => {
- setIsLoading(true);
+ if (!isMountedRef.current) return;
+
+ setIsLocating(true);
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
console.error('Location permission not granted');
- setIsLoading(false);
return;
}
- const location = await Location.getCurrentPositionAsync({});
- setCurrentLocation({
+ // Create a timeout promise with cleanup
+ let timeoutId: ReturnType | undefined;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT);
+ });
+
+ // Race between getting location and timeout
+ const location = await Promise.race([
+ Location.getCurrentPositionAsync({
+ accuracy: Location.Accuracy.Balanced,
+ }),
+ timeoutPromise,
+ ]);
+
+ // Clear timeout if location resolved first
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
+
+ if (!isMountedRef.current) return;
+
+ const newLocation = {
latitude: location.coords.latitude,
longitude: location.coords.longitude,
- });
+ };
+ setCurrentLocation(newLocation);
+ setHasUserLocation(true);
// Move camera to user location
- if (cameraRef.current) {
+ if (cameraRef.current && isMountedRef.current) {
cameraRef.current.setCamera({
centerCoordinate: [location.coords.longitude, location.coords.latitude],
zoomLevel: 15,
@@ -53,14 +95,18 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca
}
} catch (error) {
console.error('Error getting location:', error);
+ // Don't update location - keep using whatever we have (initial or default)
} finally {
- setIsLoading(false);
+ if (isMountedRef.current) setIsLocating(false);
}
}, []);
useEffect(() => {
+ isMountedRef.current = true;
+
if (initialLocation) {
setCurrentLocation(initialLocation);
+ setHasUserLocation(true);
// Move camera to the new location
if (cameraRef.current) {
cameraRef.current.setCamera({
@@ -70,55 +116,49 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca
});
}
} else {
- getUserLocation().catch((error) => {
- console.error('Failed to get user location:', error);
- });
+ // Try to get user location, but don't block the map from showing
+ getUserLocation();
}
+
+ return () => {
+ isMountedRef.current = false;
+ };
}, [initialLocation, getUserLocation]);
- const handleMapPress = (event: any) => {
- const { coordinates } = event.geometry;
- setCurrentLocation({
- latitude: coordinates[1],
- longitude: coordinates[0],
- });
+ const handleMapPress = (event: GeoJSON.Feature) => {
+ const geometry = event.geometry as GeoJSON.Point;
+ const [longitude, latitude] = geometry.coordinates;
+ const newLocation = {
+ latitude,
+ longitude,
+ };
+ setCurrentLocation(newLocation);
+ setHasUserLocation(true);
};
const handleConfirmLocation = () => {
- if (currentLocation) {
- onLocationSelected(currentLocation);
- }
+ onLocationSelected(currentLocation);
};
- if (isLoading) {
- return (
-
- {t('common.loading')}
-
- );
- }
-
return (
- {currentLocation ? (
-
-
- {/* Marker for the selected location */}
-
-
-
-
- ) : (
-
- {t('common.no_location')}
-
- {t('common.get_my_location')}
-
-
- )}
+
+
+ {/* Marker for the selected location */}
+
+
+
+
+
+
+
+ {/* My Location button */}
+
+ {isLocating ? : }
+
-
+
{t('common.confirm_location')}
@@ -136,6 +176,23 @@ const styles = StyleSheet.create({
map: {
flex: 1,
},
+ myLocationButton: {
+ position: 'absolute',
+ top: 8,
+ right: 8,
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ backgroundColor: 'white',
+ justifyContent: 'center',
+ alignItems: 'center',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.2,
+ shadowRadius: 4,
+ elevation: 4,
+ zIndex: 10,
+ },
});
export default LocationPicker;
diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx
index a2abe9db..e3b19bd9 100644
--- a/src/components/maps/static-map.tsx
+++ b/src/components/maps/static-map.tsx
@@ -6,6 +6,10 @@ import { StyleSheet } from 'react-native';
import { Box } from '@/components/ui/box';
import { Text } from '@/components/ui/text';
+import { Env } from '@/lib/env';
+
+// Ensure Mapbox access token is set before using any Mapbox components
+Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY);
interface StaticMapProps {
latitude: number;
diff --git a/src/components/settings/unit-selection-bottom-sheet.tsx b/src/components/settings/unit-selection-bottom-sheet.tsx
index 7b9cd0f6..78c08ab4 100644
--- a/src/components/settings/unit-selection-bottom-sheet.tsx
+++ b/src/components/settings/unit-selection-bottom-sheet.tsx
@@ -33,13 +33,7 @@ const UnitItem = React.memo(({ unit, isSelected, isLoading, onSel
}, [onSelect, unit]);
return (
-
+
{unit.Name}
@@ -190,13 +184,7 @@ export const UnitSelectionBottomSheet = React.memo 0 ? (
{units.map((unit) => (
-
+
))}
) : (
diff --git a/src/components/sidebar/__tests__/unit-sidebar.test.tsx b/src/components/sidebar/__tests__/unit-sidebar.test.tsx
index 57f8b126..64b8a064 100644
--- a/src/components/sidebar/__tests__/unit-sidebar.test.tsx
+++ b/src/components/sidebar/__tests__/unit-sidebar.test.tsx
@@ -1,6 +1,17 @@
import { render, screen, fireEvent } from '@testing-library/react-native';
import React from 'react';
+// Mock @livekit/react-native-webrtc before any imports that use it
+jest.mock('@livekit/react-native-webrtc', () => ({
+ RTCAudioSession: {
+ configure: jest.fn().mockResolvedValue(undefined),
+ setCategory: jest.fn().mockResolvedValue(undefined),
+ setMode: jest.fn().mockResolvedValue(undefined),
+ getActiveAudioSession: jest.fn().mockReturnValue(null),
+ setActive: jest.fn().mockResolvedValue(undefined),
+ },
+}));
+
import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useLiveKitStore } from '@/stores/app/livekit-store';
diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts
index df709077..a02cf478 100644
--- a/src/features/livekit-call/store/useLiveKitCallStore.ts
+++ b/src/features/livekit-call/store/useLiveKitCallStore.ts
@@ -1,6 +1,6 @@
import { ConnectionState, type LocalParticipant, type Participant, type RemoteParticipant, Room, type RoomConnectOptions, RoomEvent, type RoomOptions } from 'livekit-client'; // livekit-react-native re-exports these
import { Platform } from 'react-native';
-import create from 'zustand';
+import { create } from 'zustand';
import { logger } from '../../../lib/logging';
import { callKeepService } from '../../../services/callkeep.service.ios';
diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts
index 853cc747..02adf71c 100644
--- a/src/services/__tests__/app-reset.service.test.ts
+++ b/src/services/__tests__/app-reset.service.test.ts
@@ -422,7 +422,7 @@ describe('app-reset.service', () => {
await resetAllStores();
expect(mockLiveKitDisconnect).toHaveBeenCalled();
- expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE);
+ expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE, true);
});
it('should not disconnect from LiveKit room if not connected', async () => {
@@ -437,7 +437,7 @@ describe('app-reset.service', () => {
await resetAllStores();
expect(localMockDisconnect).not.toHaveBeenCalled();
- expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE);
+ expect(useLiveKitStore.setState).toHaveBeenCalledWith(INITIAL_LIVEKIT_STATE, true);
});
});
@@ -463,7 +463,9 @@ describe('app-reset.service', () => {
cleanup: jest.fn().mockRejectedValue(error),
});
- await expect(clearAllAppData()).rejects.toThrow('Cleanup failed');
+ // The cleanup error is caught and logged within resetAllStores,
+ // so clearAllAppData should complete without throwing
+ await expect(clearAllAppData()).resolves.toBeUndefined();
});
});
});
diff --git a/src/services/__tests__/bluetooth-audio.service.test.ts b/src/services/__tests__/bluetooth-audio.service.test.ts
index 406699d3..05637a93 100644
--- a/src/services/__tests__/bluetooth-audio.service.test.ts
+++ b/src/services/__tests__/bluetooth-audio.service.test.ts
@@ -1,6 +1,26 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import 'react-native';
+// Mock @livekit/react-native-webrtc before any imports that use it
+jest.mock('@livekit/react-native-webrtc', () => ({
+ RTCAudioSession: {
+ audioSessionDidActivate: jest.fn(),
+ audioSessionDidDeactivate: jest.fn(),
+ },
+}));
+
+// Mock CallKeep service before importing modules that use it
+jest.mock('@/services/callkeep.service.ios', () => ({
+ callKeepService: {
+ setup: jest.fn().mockResolvedValue(undefined),
+ startCall: jest.fn().mockResolvedValue('test-uuid'),
+ endCall: jest.fn().mockResolvedValue(undefined),
+ isCallActiveNow: jest.fn().mockReturnValue(false),
+ getCurrentCallUUID: jest.fn().mockReturnValue(null),
+ setMuteStateCallback: jest.fn(),
+ },
+}));
+
// Mock dependencies first before importing the service
jest.mock('react-native-ble-manager', () => ({
__esModule: true,
diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts
index 78bd5819..f403c48f 100644
--- a/src/services/bluetooth-audio.service.ts
+++ b/src/services/bluetooth-audio.service.ts
@@ -1456,17 +1456,18 @@ class BluetoothAudioService {
private async setMicrophoneEnabled(enabled: boolean): Promise {
const liveKitStore = useLiveKitStore.getState();
if (liveKitStore.currentRoom) {
- const currentMuteState = !liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
+ const currentMicEnabled = liveKitStore.currentRoom.localParticipant.isMicrophoneEnabled;
try {
- if (enabled && !currentMuteState) return; // already enabled
- if (!enabled && currentMuteState) return; // already disabled
+ // Skip if already in the desired state
+ if (enabled && currentMicEnabled) return; // already enabled
+ if (!enabled && !currentMicEnabled) return; // already disabled
- await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(currentMuteState);
+ await liveKitStore.currentRoom.localParticipant.setMicrophoneEnabled(enabled);
logger.info({
- message: 'Microphone toggled via Bluetooth button',
- context: { enabled: currentMuteState },
+ message: 'Microphone set via Bluetooth PTT button',
+ context: { enabled },
});
useBluetoothAudioStore.getState().setLastButtonAction({
@@ -1481,10 +1482,15 @@ class BluetoothAudioService {
}
} catch (error) {
logger.error({
- message: 'Failed to toggle microphone via Bluetooth button',
- context: { error },
+ message: 'Failed to set microphone via Bluetooth PTT button',
+ context: { error, enabled },
});
}
+ } else {
+ logger.warn({
+ message: 'Cannot set microphone - no active LiveKit room',
+ context: { enabled },
+ });
}
}
diff --git a/src/services/callkeep.service.android.ts b/src/services/callkeep.service.android.ts
new file mode 100644
index 00000000..f10ab868
--- /dev/null
+++ b/src/services/callkeep.service.android.ts
@@ -0,0 +1,69 @@
+import { logger } from '../lib/logging';
+
+export interface CallKeepConfig {
+ appName: string;
+ maximumCallGroups: number;
+ maximumCallsPerCallGroup: number;
+ includesCallsInRecents: boolean;
+ supportsVideo: boolean;
+ ringtoneSound?: string;
+}
+
+/**
+ * No-op implementation of CallKeepService for Android
+ * CallKeep functionality is only supported on iOS
+ */
+export class CallKeepService {
+ private static instance: CallKeepService | null = null;
+
+ private constructor() {}
+
+ static getInstance(): CallKeepService {
+ if (!CallKeepService.instance) {
+ CallKeepService.instance = new CallKeepService();
+ }
+ return CallKeepService.instance;
+ }
+
+ async setup(_config: CallKeepConfig): Promise {
+ logger.debug({
+ message: 'CallKeep setup skipped - not supported on Android',
+ });
+ }
+
+ async startCall(_roomName: string, _handle?: string): Promise {
+ logger.debug({
+ message: 'CallKeep startCall skipped - not supported on Android',
+ });
+ return '';
+ }
+
+ async endCall(): Promise {
+ logger.debug({
+ message: 'CallKeep endCall skipped - not supported on Android',
+ });
+ }
+
+ setMuteStateCallback(_callback: ((muted: boolean) => void) | null): void {
+ logger.debug({
+ message: 'CallKeep setMuteStateCallback skipped - not supported on Android',
+ });
+ }
+
+ isCallActiveNow(): boolean {
+ return false;
+ }
+
+ getCurrentCallUUID(): string | null {
+ return null;
+ }
+
+ async cleanup(): Promise {
+ logger.debug({
+ message: 'CallKeep cleanup skipped - not supported on Android',
+ });
+ }
+}
+
+// Export singleton instance
+export const callKeepService = CallKeepService.getInstance();
diff --git a/src/services/callkeep.service.ts b/src/services/callkeep.service.ts
new file mode 100644
index 00000000..8d0952a7
--- /dev/null
+++ b/src/services/callkeep.service.ts
@@ -0,0 +1,11 @@
+import { Platform } from 'react-native';
+
+import { callKeepService as androidCallKeepService } from './callkeep.service.android';
+import { callKeepService as iosCallKeepService } from './callkeep.service.ios';
+
+// Export the appropriate platform-specific implementation
+export const callKeepService = Platform.OS === 'ios' ? iosCallKeepService : androidCallKeepService;
+
+// Re-export types from iOS (they're the same in both)
+export type { CallKeepConfig } from './callkeep.service.ios';
+export { CallKeepService } from './callkeep.service.ios';
diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts
index 0ba5222f..0ac901b0 100644
--- a/src/stores/app/livekit-store.ts
+++ b/src/stores/app/livekit-store.ts
@@ -8,6 +8,7 @@ import { getCanConnectToVoiceSession, getDepartmentVoiceSettings } from '../../a
import { logger } from '../../lib/logging';
import { type DepartmentVoiceChannelResultData } from '../../models/v4/voice/departmentVoiceResultData';
import { audioService } from '../../services/audio.service';
+import { callKeepService } from '../../services/callkeep.service';
import { useBluetoothAudioStore } from './bluetooth-audio-store';
// Helper function to setup audio routing based on selected devices
@@ -235,7 +236,27 @@ export const useLiveKitStore = create((set, get) => ({
message: 'Failed to register foreground service',
context: { error },
});
+ // Don't fail the connection if foreground service fails on Android
+ // The call will still work but may be killed in background
}
+
+ // Start CallKeep call for iOS background audio support
+ if (Platform.OS === 'ios') {
+ try {
+ const callUUID = await callKeepService.startCall(roomInfo.Name || 'Voice Channel');
+ logger.info({
+ message: 'CallKeep call started for iOS background support',
+ context: { callUUID, roomName: roomInfo.Name },
+ });
+ } catch (callKeepError) {
+ logger.warn({
+ message: 'Failed to start CallKeep call - background audio may not work',
+ context: { error: callKeepError },
+ });
+ // Don't fail the connection if CallKeep fails
+ }
+ }
+
set({
currentRoom: room,
currentRoomInfo: roomInfo,
@@ -257,6 +278,21 @@ export const useLiveKitStore = create((set, get) => ({
await currentRoom.disconnect();
await audioService.playDisconnectedFromAudioRoomSound();
+ // End CallKeep call on iOS
+ if (Platform.OS === 'ios') {
+ try {
+ await callKeepService.endCall();
+ logger.debug({
+ message: 'CallKeep call ended',
+ });
+ } catch (callKeepError) {
+ logger.warn({
+ message: 'Failed to end CallKeep call',
+ context: { error: callKeepError },
+ });
+ }
+ }
+
try {
await notifee.stopForegroundService();
} catch (error) {
@@ -269,6 +305,7 @@ export const useLiveKitStore = create((set, get) => ({
currentRoom: null,
currentRoomInfo: null,
isConnected: false,
+ isTalking: false,
});
}
},
diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts
index 21f8c97a..16270a51 100644
--- a/src/stores/calls/store.ts
+++ b/src/stores/calls/store.ts
@@ -13,6 +13,7 @@ interface CallsState {
callTypes: CallTypeResultData[];
isLoading: boolean;
error: string | null;
+ lastFetchedAt: number;
fetchCalls: () => Promise;
fetchCallPriorities: () => Promise;
fetchCallTypes: () => Promise;
@@ -25,23 +26,25 @@ export const useCallsStore = create((set, get) => ({
callTypes: [],
isLoading: false,
error: null,
+ lastFetchedAt: 0,
init: async () => {
set({ isLoading: true, error: null });
const callsResponse = await getCalls();
const callPrioritiesResponse = await getCallPriorities();
const callTypesResponse = await getCallTypes();
set({
- calls: callsResponse.Data,
- callPriorities: callPrioritiesResponse.Data,
- callTypes: callTypesResponse.Data,
+ calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [],
+ callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [],
+ callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [],
isLoading: false,
+ lastFetchedAt: Date.now(),
});
},
fetchCalls: async () => {
set({ isLoading: true, error: null });
try {
const response = await getCalls();
- set({ calls: response.Data, isLoading: false });
+ set({ calls: Array.isArray(response.Data) ? response.Data : [], isLoading: false, lastFetchedAt: Date.now() });
} catch (error) {
set({ error: 'Failed to fetch calls', isLoading: false });
}
@@ -50,7 +53,7 @@ export const useCallsStore = create((set, get) => ({
set({ isLoading: true, error: null });
try {
const response = await getCallPriorities();
- set({ callPriorities: response.Data, isLoading: false });
+ set({ callPriorities: Array.isArray(response.Data) ? response.Data : [], isLoading: false });
} catch (error) {
set({ error: 'Failed to fetch call priorities', isLoading: false });
}
@@ -65,7 +68,7 @@ export const useCallsStore = create((set, get) => ({
set({ isLoading: true, error: null });
try {
const response = await getCallTypes();
- set({ callTypes: response.Data, isLoading: false });
+ set({ callTypes: Array.isArray(response.Data) ? response.Data : [], isLoading: false });
} catch (error) {
set({ error: 'Failed to fetch call types', isLoading: false });
}
diff --git a/src/stores/status/__tests__/store.test.ts b/src/stores/status/__tests__/store.test.ts
index 6adb7044..bb357499 100644
--- a/src/stores/status/__tests__/store.test.ts
+++ b/src/stores/status/__tests__/store.test.ts
@@ -56,6 +56,14 @@ jest.mock('@/stores/roles/store', () => ({
})),
},
}));
+jest.mock('@/stores/calls/store', () => ({
+ useCallsStore: {
+ getState: jest.fn(() => ({
+ calls: [],
+ })),
+ setState: jest.fn(),
+ },
+}));
jest.mock('@/services/offline-event-manager.service', () => ({
offlineEventManager: {
queueUnitStatusEvent: jest.fn(),
diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts
index 5ff58d54..cdf268d6 100644
--- a/src/stores/status/store.ts
+++ b/src/stores/status/store.ts
@@ -13,6 +13,7 @@ import { offlineEventManager } from '@/services/offline-event-manager.service';
import { useCoreStore } from '../app/core-store';
import { useLocationStore } from '../app/location-store';
+import { useCallsStore } from '../calls/store';
import { useRolesStore } from '../roles/store';
type StatusStep = 'select-status' | 'select-destination' | 'add-note';
@@ -21,6 +22,9 @@ type DestinationType = 'none' | 'call' | 'station';
// Status type that can accept both custom statuses and regular statuses
type StatusType = CustomStatusResultData | StatusesResultData;
+// Store TTL: 5 minutes in milliseconds
+const STORE_TTL_MS = 5 * 60 * 1000;
+
interface StatusBottomSheetStore {
isOpen: boolean;
currentStep: StatusStep;
@@ -76,14 +80,39 @@ export const useStatusBottomSheetStore = create((set, ge
fetchDestinationData: async (unitId: string) => {
set({ isLoading: true, error: null });
try {
- // Fetch calls and groups (stations) in parallel
- const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]);
+ // Check if we already have calls in the calls store and if they're still fresh
+ const callsStore = useCallsStore.getState();
+ const existingCalls = callsStore.calls;
+ const lastFetchedAt = callsStore.lastFetchedAt || 0;
+ const isStale = !lastFetchedAt || Date.now() - lastFetchedAt > STORE_TTL_MS;
- set({
- availableCalls: callsResponse.Data || [],
- availableStations: groupsResponse.Data || [],
- isLoading: false,
- });
+ // Fetch calls if we don't have any or if they're stale
+ // Groups are cached (2 day TTL) so getAllGroups is already fast
+ const needsCallsFetch = existingCalls.length === 0 || isStale;
+
+ if (needsCallsFetch) {
+ // Fetch calls and groups in parallel
+ const [callsResponse, groupsResponse] = await Promise.all([getCalls(), getAllGroups()]);
+
+ // Update the calls store with fresh data and timestamp
+ useCallsStore.setState({ calls: callsResponse.Data || [], lastFetchedAt: Date.now() });
+
+ // Set availableCalls from the fresh response
+ set({
+ availableCalls: callsResponse.Data || [],
+ availableStations: groupsResponse.Data || [],
+ isLoading: false,
+ });
+ } else {
+ // Use existing calls, only fetch groups (which is cached)
+ const groupsResponse = await getAllGroups();
+
+ set({
+ availableCalls: existingCalls,
+ availableStations: groupsResponse.Data || [],
+ isLoading: false,
+ });
+ }
} catch (error) {
set({
error: 'Failed to fetch destination data',
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 28fb07f0..3e22b0ac 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -316,6 +316,7 @@
"go_back": "رجوع",
"loading": "جاري التحميل...",
"loading_address": "جاري تحميل العنوان...",
+ "my_location": "موقعي",
"next": "التالي",
"noActiveUnit": "لم يتم تعيين وحدة نشطة",
"noActiveUnitDescription": "يرجى تعيين وحدة نشطة من صفحة الإعدادات للوصول إلى عناصر التحكم في الحالة",
@@ -335,11 +336,13 @@
"route": "مسار",
"save": "حفظ",
"search": "بحث...",
+ "selected_location": "الموقع المحدد",
"set_location": "تعيين الموقع",
"share": "مشاركة",
"step": "خطوة",
"submit": "إرسال",
"submitting": "جاري الإرسال...",
+ "tap_map_to_select": "انقر على الخريطة لتحديد الموقع",
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
"unknown": "غير معروف",
"upload": "رفع",
diff --git a/src/translations/en.json b/src/translations/en.json
index 17d2f126..8f5c9bbe 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -316,6 +316,7 @@
"go_back": "Go Back",
"loading": "Loading...",
"loading_address": "Loading address...",
+ "my_location": "My Location",
"next": "Next",
"noActiveUnit": "No Active Unit Set",
"noActiveUnitDescription": "Please set an active unit from the settings page to access status controls",
@@ -335,11 +336,13 @@
"route": "Route",
"save": "Save",
"search": "Search...",
+ "selected_location": "Selected Location",
"set_location": "Set Location",
"share": "Share",
"step": "Step",
"submit": "Submit",
"submitting": "Submitting...",
+ "tap_map_to_select": "Tap on the map to select a location",
"tryAgainLater": "Please try again later",
"unknown": "Unknown",
"upload": "Upload",
diff --git a/src/translations/es.json b/src/translations/es.json
index 8175eabb..aa650563 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -316,6 +316,7 @@
"go_back": "Volver",
"loading": "Cargando...",
"loading_address": "Cargando dirección...",
+ "my_location": "Mi ubicación",
"next": "Siguiente",
"noActiveUnit": "No hay unidad activa establecida",
"noActiveUnitDescription": "Por favor establezca una unidad activa desde la página de configuración para acceder a los controles de estado",
@@ -335,11 +336,13 @@
"route": "Ruta",
"save": "Guardar",
"search": "Buscar...",
+ "selected_location": "Ubicación seleccionada",
"set_location": "Establecer ubicación",
"share": "Compartir",
"step": "Paso",
"submit": "Enviar",
"submitting": "Enviando...",
+ "tap_map_to_select": "Toca el mapa para seleccionar una ubicación",
"tryAgainLater": "Por favor, inténtelo de nuevo más tarde",
"unknown": "Desconocido",
"upload": "Subir",