diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts index 8157b805..8eb0d801 100644 --- a/src/api/calls/callFiles.ts +++ b/src/api/calls/callFiles.ts @@ -1,4 +1,5 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResponse } from 'axios'; +import { Platform } from 'react-native'; import { createApiEndpoint } from '@/api/common/client'; import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult'; @@ -77,8 +78,15 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption } }; -// Utility function to save a blob as a file -export const saveBlobAsFile = (blob: Blob, fileName: string): void => { +// Utility function to save a blob as a file (web only). +// Returns true on web after the download is triggered, false on native platforms. +// Callers should check the return value and fall back to expo-file-system / expo-sharing on native. +export const saveBlobAsFile = (blob: Blob, fileName: string): boolean => { + if (Platform.OS !== 'web') { + console.warn('saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.'); + return false; + } + const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -87,6 +95,7 @@ export const saveBlobAsFile = (blob: Blob, fileName: string): void => { // Clean up window.URL.revokeObjectURL(url); + return true; }; export const getFiles = async (callId: string, includeData: boolean, type: number) => { diff --git a/src/components/livekit/livekit-bottom-sheet.tsx b/src/components/livekit/livekit-bottom-sheet.tsx index 01d7e5bc..4555d2c0 100644 --- a/src/components/livekit/livekit-bottom-sheet.tsx +++ b/src/components/livekit/livekit-bottom-sheet.tsx @@ -7,7 +7,7 @@ import { ActivityIndicator, ScrollView, StyleSheet, TouchableOpacity, View } fro 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 { applyAudioRouting, useLiveKitStore } from '@/stores/app/livekit-store'; import { AudioDeviceSelection } from '../settings/audio-device-selection'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet'; @@ -86,11 +86,6 @@ 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]); diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index dde8db48..4e1989bb 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -379,10 +379,16 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve if (!map) return; if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) { - // Skip the first render — the MapView already initialized at the correct - // position via initialCenter/initialZoom, so no programmatic move needed. if (!hasInitialized.current) { hasInitialized.current = true; + // Use jumpTo (instant, no animation) for the initial camera position. + // MapView initializes at a default center; Camera is responsible for + // snapping to the correct location on first render on web. + try { + map.jumpTo({ center: centerCoordinate as [number, number], zoom: zoomLevel, bearing: heading, pitch: pitch }, { _programmatic: true }); + } catch { + // ignore projection errors during initialization + } return; } diff --git a/src/components/maps/mapbox.native.ts b/src/components/maps/mapbox.native.ts new file mode 100644 index 00000000..c7a30d2c --- /dev/null +++ b/src/components/maps/mapbox.native.ts @@ -0,0 +1,49 @@ +/** + * Native (iOS/Android) implementation of map components using @rnmapbox/maps + * Metro bundler resolves this file on native platforms via the .native extension. + */ +import Mapbox from '@rnmapbox/maps'; + +// Re-export all Mapbox components for native platforms +export const MapView = Mapbox.MapView; +export const Camera = Mapbox.Camera; +export const PointAnnotation = Mapbox.PointAnnotation; +export const UserLocation = Mapbox.UserLocation; +export const MarkerView = Mapbox.MarkerView; +export const ShapeSource = Mapbox.ShapeSource; +export const SymbolLayer = Mapbox.SymbolLayer; +export const CircleLayer = Mapbox.CircleLayer; +export const LineLayer = Mapbox.LineLayer; +export const FillLayer = Mapbox.FillLayer; +export const Images = Mapbox.Images; +export const Callout = Mapbox.Callout; + +// Export style URL constants +export const StyleURL = Mapbox.StyleURL; + +// Export UserTrackingMode +export const UserTrackingMode = Mapbox.UserTrackingMode; + +// Export setAccessToken +export const setAccessToken = Mapbox.setAccessToken; + +// Default export matching Mapbox structure with all properties +const MapboxExports = { + MapView: Mapbox.MapView, + Camera: Mapbox.Camera, + PointAnnotation: Mapbox.PointAnnotation, + UserLocation: Mapbox.UserLocation, + MarkerView: Mapbox.MarkerView, + ShapeSource: Mapbox.ShapeSource, + SymbolLayer: Mapbox.SymbolLayer, + CircleLayer: Mapbox.CircleLayer, + LineLayer: Mapbox.LineLayer, + FillLayer: Mapbox.FillLayer, + Images: Mapbox.Images, + Callout: Mapbox.Callout, + StyleURL: Mapbox.StyleURL, + UserTrackingMode: Mapbox.UserTrackingMode, + setAccessToken: Mapbox.setAccessToken, +}; + +export default MapboxExports; diff --git a/src/components/maps/mapbox.ts b/src/components/maps/mapbox.ts index c704bcc5..1d333827 100644 --- a/src/components/maps/mapbox.ts +++ b/src/components/maps/mapbox.ts @@ -1,57 +1,8 @@ /** - * Platform-aware map components - * Automatically selects native (@rnmapbox/maps) or web (mapbox-gl) implementation + * TypeScript type resolution shim for platform-specific Mapbox implementations. + * Metro resolves mapbox.native.ts on iOS/Android and mapbox.web.ts on web, + * but TypeScript needs a base file to satisfy module resolution. + * This file re-exports from the native implementation so types are available. */ -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' ? MapboxWeb.default : MapboxNative.default; - -// Re-export all components -export const MapView = MapboxImpl.MapView || MapboxImpl; -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' ? MapboxWeb.StyleURL : MapboxNative.StyleURL; - -// Export UserTrackingMode -export const UserTrackingMode = Platform.OS === 'web' ? MapboxWeb.UserTrackingMode : MapboxNative.UserTrackingMode; - -// Export 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, -}; - -export default Mapbox; +export { Callout, Camera, CircleLayer, FillLayer, Images, LineLayer, MapView, MarkerView, PointAnnotation, setAccessToken, ShapeSource, StyleURL, SymbolLayer, UserLocation, UserTrackingMode } from './mapbox.native'; +export { default } from './mapbox.native'; diff --git a/src/components/maps/mapbox.web.ts b/src/components/maps/mapbox.web.ts new file mode 100644 index 00000000..e1dfdc37 --- /dev/null +++ b/src/components/maps/mapbox.web.ts @@ -0,0 +1,50 @@ +/** + * Web/Electron implementation of map components using mapbox-gl + * Metro bundler resolves this file on web platforms via the .web extension. + */ +import * as MapboxWeb from './map-view.web'; + +// Re-export all components from the web implementation +export const MapView = MapboxWeb.MapView; +export const Camera = MapboxWeb.Camera; +export const PointAnnotation = MapboxWeb.PointAnnotation; +export const UserLocation = MapboxWeb.UserLocation; +export const MarkerView = MapboxWeb.MarkerView; +export const ShapeSource = MapboxWeb.ShapeSource; +export const SymbolLayer = MapboxWeb.SymbolLayer; +export const CircleLayer = MapboxWeb.CircleLayer; +export const LineLayer = MapboxWeb.LineLayer; +export const FillLayer = MapboxWeb.FillLayer; +export const Images = MapboxWeb.Images; +export const Callout = MapboxWeb.Callout; + +// Export style URL constants +export const StyleURL = MapboxWeb.StyleURL; + +// Export UserTrackingMode +export const UserTrackingMode = MapboxWeb.UserTrackingMode; + +// Export setAccessToken +export const setAccessToken = MapboxWeb.setAccessToken; + +// Default export matching Mapbox structure with all properties +const MapboxExports = { + ...MapboxWeb.default, + MapView, + Camera, + PointAnnotation, + UserLocation, + MarkerView, + ShapeSource, + SymbolLayer, + CircleLayer, + LineLayer, + FillLayer, + Images, + Callout, + StyleURL, + UserTrackingMode, + setAccessToken, +}; + +export default MapboxExports; diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index 422b0fca..38b919d8 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -45,8 +45,6 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo compassEnabled={true} zoomEnabled={true} rotateEnabled={true} - initialCenter={[longitude, latitude]} - initialZoom={zoom} > {/* Marker pin for the location */} diff --git a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx b/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx index cb6eab37..305425ab 100644 --- a/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx +++ b/src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx @@ -63,11 +63,13 @@ jest.mock('@/stores/app/location-store', () => ({ jest.mock('@/stores/app/livekit-store', () => ({ useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: null, isConnected: false, isTalking: false, }) : { setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: null, isConnected: false, isTalking: false, diff --git a/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx b/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx index c92f3afd..60f7a85c 100644 --- a/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx +++ b/src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx @@ -19,11 +19,13 @@ jest.mock('@/stores/app/location-store', () => ({ jest.mock('@/stores/app/livekit-store', () => ({ useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({ setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: null, isConnected: false, isTalking: false, }) : { setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: null, isConnected: false, isTalking: false, @@ -62,6 +64,7 @@ const mockUseAudioStreamStore = useAudioStreamStore as jest.MockedFunction { const mockSetMapLocked = jest.fn(); const mockSetIsBottomSheetVisible = jest.fn(); + const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true); const mockSetAudioStreamBottomSheetVisible = jest.fn(); const defaultProps = { @@ -90,11 +93,13 @@ describe('SidebarUnitCard', () => { mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + ensureMicrophonePermission: mockEnsureMicrophonePermission, currentRoomInfo: null, isConnected: false, isTalking: false, }) : { setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + ensureMicrophonePermission: mockEnsureMicrophonePermission, currentRoomInfo: null, isConnected: false, isTalking: false, @@ -166,12 +171,13 @@ describe('SidebarUnitCard', () => { }); }); - it('opens LiveKit when call button is pressed', () => { + it('opens LiveKit when call button is pressed', async () => { render(); const callButton = screen.getByTestId('call-button'); - fireEvent.press(callButton); + await fireEvent.press(callButton); + expect(mockEnsureMicrophonePermission).toHaveBeenCalled(); expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true); }); }); diff --git a/src/components/sidebar/__tests__/unit-sidebar.test.tsx b/src/components/sidebar/__tests__/unit-sidebar.test.tsx index a8afd4ca..9eda8b90 100644 --- a/src/components/sidebar/__tests__/unit-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/unit-sidebar.test.tsx @@ -60,11 +60,13 @@ describe('SidebarUnitCard', () => { mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ isMapLocked: false, setMapLocked: jest.fn() }) : { isMapLocked: false, setMapLocked: jest.fn() }); mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: null, isConnected: false, isTalking: false, }) : { setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: null, isConnected: false, isTalking: false, @@ -150,15 +152,18 @@ describe('SidebarUnitCard', () => { expect(mockSetAudioStreamBottomSheetVisible).toHaveBeenCalledWith(true); }); - it('handles call button press', () => { + it('handles call button press', async () => { const mockSetIsBottomSheetVisible = jest.fn(); + const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true); mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + ensureMicrophonePermission: mockEnsureMicrophonePermission, currentRoomInfo: null, isConnected: false, isTalking: false, }) : { setIsBottomSheetVisible: mockSetIsBottomSheetVisible, + ensureMicrophonePermission: mockEnsureMicrophonePermission, currentRoomInfo: null, isConnected: false, isTalking: false, @@ -167,8 +172,9 @@ describe('SidebarUnitCard', () => { render(); const callButton = screen.getByTestId('call-button'); - fireEvent.press(callButton); + await fireEvent.press(callButton); + expect(mockEnsureMicrophonePermission).toHaveBeenCalled(); expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true); }); @@ -176,11 +182,13 @@ describe('SidebarUnitCard', () => { const mockRoomInfo = { Name: 'Emergency Call Room' }; mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: mockRoomInfo as any, isConnected: true, isTalking: false, }) : { setIsBottomSheetVisible: jest.fn(), + ensureMicrophonePermission: jest.fn().mockResolvedValue(true), currentRoomInfo: mockRoomInfo as any, isConnected: true, isTalking: false, diff --git a/src/components/sidebar/unit-sidebar.tsx b/src/components/sidebar/unit-sidebar.tsx index d1eebe6c..5457bd7d 100644 --- a/src/components/sidebar/unit-sidebar.tsx +++ b/src/components/sidebar/unit-sidebar.tsx @@ -21,6 +21,7 @@ type ItemProps = { export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUnitType, unitGroup: defaultUnitGroup, bgColor }: ItemProps) => { const activeUnit = useCoreStore((state) => state.activeUnit); const setIsBottomSheetVisible = useLiveKitStore((state) => state.setIsBottomSheetVisible); + const ensureMicrophonePermission = useLiveKitStore((state) => state.ensureMicrophonePermission); const currentRoomInfo = useLiveKitStore((state) => state.currentRoomInfo); const isConnected = useLiveKitStore((state) => state.isConnected); const isTalking = useLiveKitStore((state) => state.isTalking); @@ -35,7 +36,11 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn const displayType = activeUnit?.Type ?? defaultUnitType; const displayGroup = activeUnit?.GroupName ?? defaultUnitGroup; - const handleOpenLiveKit = () => { + const handleOpenLiveKit = async () => { + // Request microphone permission before the Actionsheet (Modal) opens. + // On Android, system permission dialogs are hidden behind React Native + // Modals, so we must request while no Modal is on screen. + await ensureMicrophonePermission(); setIsBottomSheetVisible(true); }; diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index df3d8750..8c2c5c75 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -123,11 +123,27 @@ jest.mock('../../../lib/logging', () => ({ }, })); -// Mock Platform +// Mock Platform and PermissionsAndroid +const mockPermissionsAndroidCheck = jest.fn(); +const mockPermissionsAndroidRequest = jest.fn(); jest.mock('react-native', () => ({ Platform: { OS: 'android', }, + PermissionsAndroid: { + check: (...args: any[]) => mockPermissionsAndroidCheck(...args), + request: (...args: any[]) => mockPermissionsAndroidRequest(...args), + PERMISSIONS: { + RECORD_AUDIO: 'android.permission.RECORD_AUDIO', + READ_PHONE_STATE: 'android.permission.READ_PHONE_STATE', + READ_PHONE_NUMBERS: 'android.permission.READ_PHONE_NUMBERS', + }, + RESULTS: { + GRANTED: 'granted', + DENIED: 'denied', + NEVER_ASK_AGAIN: 'never_ask_again', + }, + }, })); const mockGetRecordingPermissionsAsync = getRecordingPermissionsAsync as jest.MockedFunction; @@ -138,6 +154,8 @@ describe('LiveKit Store - Permission Management', () => { beforeEach(() => { // Clear all mocks before each test jest.clearAllMocks(); + mockPermissionsAndroidCheck.mockReset(); + mockPermissionsAndroidRequest.mockReset(); // Reset store state useLiveKitStore.setState({ @@ -155,27 +173,16 @@ describe('LiveKit Store - Permission Management', () => { }); it('should successfully request permissions when not granted initially', async () => { - // Mock initial permission check - not granted - mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: true, - expires: 'never', - status: 'undetermined', - } as any); - - // Mock permission request - granted - mockRequestRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - } as any); + // Mock check - not granted + mockPermissionsAndroidCheck.mockResolvedValueOnce(false); + // Mock request - granted + mockPermissionsAndroidRequest.mockResolvedValueOnce('granted'); const { requestPermissions } = useLiveKitStore.getState(); await requestPermissions(); - expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); + expect(mockPermissionsAndroidCheck).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); + expect(mockPermissionsAndroidRequest).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); expect(mockLogger.info).toHaveBeenCalledWith({ message: 'Microphone permission granted successfully', context: { platform: 'android' }, @@ -183,62 +190,46 @@ describe('LiveKit Store - Permission Management', () => { }); it('should skip request when permissions already granted', async () => { - // Mock initial permission check - already granted - mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: true, - canAskAgain: true, - expires: 'never', - status: 'granted', - } as any); + // Mock check - already granted + mockPermissionsAndroidCheck.mockResolvedValueOnce(true); const { requestPermissions } = useLiveKitStore.getState(); await requestPermissions(); - expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); + expect(mockPermissionsAndroidCheck).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); + expect(mockPermissionsAndroidRequest).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Microphone permission granted successfully', + message: 'Microphone permission already granted', context: { platform: 'android' }, }); }); it('should handle permission denial', async () => { - // Mock initial permission check - not granted - mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: true, - expires: 'never', - status: 'undetermined', - } as any); - - // Mock permission request - denied - mockRequestRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: true, - expires: 'never', - status: 'denied', - } as any); + // Mock check - not granted + mockPermissionsAndroidCheck.mockResolvedValueOnce(false); + // Mock request - denied + mockPermissionsAndroidRequest.mockResolvedValueOnce('denied'); const { requestPermissions } = useLiveKitStore.getState(); await requestPermissions(); - expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); + expect(mockPermissionsAndroidCheck).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); + expect(mockPermissionsAndroidRequest).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); expect(mockLogger.error).toHaveBeenCalledWith({ message: 'Microphone permission not granted', - context: { platform: 'android' }, + context: { platform: 'android', result: 'denied' }, }); }); it('should handle permission errors gracefully', async () => { - // Mock initial permission check - throws error - mockGetRecordingPermissionsAsync.mockRejectedValueOnce(new Error('Permission API error')); + // Mock check - throws error + mockPermissionsAndroidCheck.mockRejectedValueOnce(new Error('Permission API error')); const { requestPermissions } = useLiveKitStore.getState(); await requestPermissions(); - expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); + expect(mockPermissionsAndroidCheck).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); + expect(mockPermissionsAndroidRequest).not.toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalledWith({ message: 'Failed to request permissions', context: { platform: 'android', error: expect.any(Error) }, @@ -246,22 +237,16 @@ describe('LiveKit Store - Permission Management', () => { }); it('should handle request API errors', async () => { - // Mock initial permission check - not granted - mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: true, - expires: 'never', - status: 'undetermined', - } as any); - - // Mock permission request - throws error - mockRequestRecordingPermissionsAsync.mockRejectedValueOnce(new Error('Request API error')); + // Mock check - not granted + mockPermissionsAndroidCheck.mockResolvedValueOnce(false); + // Mock request - throws error + mockPermissionsAndroidRequest.mockRejectedValueOnce(new Error('Request API error')); const { requestPermissions } = useLiveKitStore.getState(); await requestPermissions(); - expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); + expect(mockPermissionsAndroidCheck).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); + expect(mockPermissionsAndroidRequest).toHaveBeenCalledWith('android.permission.RECORD_AUDIO'); expect(mockLogger.error).toHaveBeenCalledWith({ message: 'Failed to request permissions', context: { platform: 'android', error: expect.any(Error) }, @@ -354,32 +339,34 @@ describe('LiveKit Store - Permission Management', () => { }); it('should handle undefined permission response', async () => { - // Mock initial permission check - returns undefined - mockGetRecordingPermissionsAsync.mockResolvedValueOnce(undefined as any); + // Mock check - throws (simulating undefined response access) + mockPermissionsAndroidCheck.mockRejectedValueOnce(new TypeError("Cannot read properties of undefined")); const { requestPermissions } = useLiveKitStore.getState(); await requestPermissions(); - expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); + expect(mockPermissionsAndroidCheck).toHaveBeenCalledTimes(1); expect(mockLogger.error).toHaveBeenCalledWith({ message: 'Failed to request permissions', context: { platform: 'android', error: expect.any(Error) }, }); }); - it('should handle malformed permission response', async () => { - // Mock initial permission check - missing granted property - mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ - canAskAgain: true, - expires: 'never', - status: 'undetermined', - } as any); + it('should handle never_ask_again permission response', async () => { + // Mock check - not granted + mockPermissionsAndroidCheck.mockResolvedValueOnce(false); + // Mock request - never_ask_again + mockPermissionsAndroidRequest.mockResolvedValueOnce('never_ask_again'); const { requestPermissions } = useLiveKitStore.getState(); await requestPermissions(); - expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); + expect(mockPermissionsAndroidCheck).toHaveBeenCalledTimes(1); + expect(mockPermissionsAndroidRequest).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + message: 'Microphone permission not granted', + context: { platform: 'android', result: 'never_ask_again' }, + }); }); }); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 4a6861a5..45110df1 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -16,6 +16,19 @@ import { bluetoothAudioService } from '../../services/bluetooth-audio.service'; import { callKeepService } from '../../services/callkeep.service'; import { useBluetoothAudioStore } from './bluetooth-audio-store'; +/** Wrap a promise with a timeout – rejects with a descriptive error if it doesn't settle in time. */ +const withTimeout = (promise: Promise, ms: number, label: string): Promise => { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms / 1000}s`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +}; + +// Overall connection timeout – if the entire connectToRoom flow hasn't finished +// within this window, isConnecting is forcibly reset so the UI never gets stuck. +const CONNECT_OVERALL_TIMEOUT_MS = 30_000; + // Helper function to apply audio routing export const applyAudioRouting = async (deviceType: 'bluetooth' | 'speaker' | 'earpiece' | 'default') => { // Audio routing is native-only @@ -153,16 +166,17 @@ const setupAudioRouting = async (room: Room): Promise => { let phonePermsAttempted = false; /** - * Pre-request Android phone-state permissions (READ_PHONE_STATE / READ_PHONE_NUMBERS) + * Request Android phone-state permissions (READ_PHONE_STATE / READ_PHONE_NUMBERS) * needed by CallKeep. * - * Called once when the LiveKit bottom sheet first opens. Results are cached for - * the lifetime of the app process so subsequent opens are instant no-ops. + * Called once post-connect, right before CallKeep is started. Results are + * cached for the lifetime of the app process so subsequent calls are instant + * no-ops. * * These permissions are non-critical: CallKeep already gracefully skips when * they are missing, so we only log warnings — no user-facing alerts. */ -export const requestAndroidPhonePermissions = async (): Promise => { +const requestAndroidPhonePermissions = async (): Promise => { if (Platform.OS !== 'android') return; if (phonePermsAttempted) return; phonePermsAttempted = true; @@ -260,6 +274,7 @@ interface LiveKitState { fetchVoiceSettings: () => Promise; fetchCanConnectToVoice: () => Promise; requestPermissions: () => Promise; + ensureMicrophonePermission: () => Promise; } export const useLiveKitStore = create((set, get) => ({ @@ -352,9 +367,36 @@ export const useLiveKitStore = create((set, get) => ({ requestPermissions: async (): Promise => { // Microphone only — phone-state permissions are handled separately by - // `requestAndroidPhonePermissions`, called at the top of `connectToRoom`. + // `requestAndroidPhonePermissions`, called post-connect before CallKeep. try { - if (Platform.OS === 'android' || Platform.OS === 'ios') { + if (Platform.OS === 'android') { + // Use PermissionsAndroid directly — expo-audio's + // requestRecordingPermissionsAsync uses a Fragment-based approach + // that can silently fail to show the system dialog. + const already = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO); + if (already) { + logger.info({ + message: 'Microphone permission already granted', + context: { platform: Platform.OS }, + }); + return true; + } + + const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO); + if (result !== PermissionsAndroid.RESULTS.GRANTED) { + logger.error({ + message: 'Microphone permission not granted', + context: { platform: Platform.OS, result }, + }); + return false; + } + + logger.info({ + message: 'Microphone permission granted successfully', + context: { platform: Platform.OS }, + }); + return true; + } else if (Platform.OS === 'ios') { const micPermission = await getRecordingPermissionsAsync(); if (!micPermission.granted) { @@ -402,6 +444,24 @@ export const useLiveKitStore = create((set, get) => ({ // for permission checks or any other async work below. set({ isConnecting: true }); + // Safety net: if the entire connection flow hasn't finished within the + // overall timeout, forcibly reset isConnecting so the UI is never + // permanently stuck on "Connecting…". + const overallTimeout = setTimeout(() => { + if (get().isConnecting) { + logger.error({ + message: 'connectToRoom overall timeout reached – resetting isConnecting', + context: { roomName: roomInfo.Name, timeoutMs: CONNECT_OVERALL_TIMEOUT_MS }, + }); + set({ isConnecting: false }); + Alert.alert( + 'Voice Connection Timeout', + `The connection to "${roomInfo.Name}" took too long. Please try again.`, + [{ text: 'OK' }] + ); + } + }, CONNECT_OVERALL_TIMEOUT_MS); + try { const { currentRoom, voipServerWebsocketSslAddress } = get(); @@ -425,10 +485,24 @@ export const useLiveKitStore = create((set, get) => ({ return; } - // ─── Request microphone permission ─────────────────────────────────────── - // requestAndroidPhonePermissions is already fired void when the sheet opens; - // do NOT await it here — requestMultiple can open a system dialog that hangs. - const permissionsGranted = await get().requestPermissions(); + // ─── Verify microphone permission ────────────────────────────────────── + // Permission should already be granted via ensureMicrophonePermission() + // called before the bottom sheet opens. We only verify here. + logger.debug({ + message: 'connectToRoom: verifying microphone permission', + context: { roomName: roomInfo.Name }, + }); + + const permissionsGranted = await withTimeout( + get().requestPermissions(), + 10_000, + 'requestPermissions' + ); + + logger.debug({ + message: 'connectToRoom: microphone permission result', + context: { roomName: roomInfo.Name, permissionsGranted }, + }); if (!permissionsGranted) { logger.error({ message: 'Cannot connect to room - permissions not granted', @@ -446,6 +520,7 @@ export const useLiveKitStore = create((set, get) => ({ // Disconnect from current room if connected if (currentRoom) { + logger.debug({ message: 'connectToRoom: disconnecting existing room' }); await currentRoom.disconnect(); } @@ -454,7 +529,12 @@ export const useLiveKitStore = create((set, get) => ({ // cold starts it must be explicitly started for WebRTC to function correctly if (Platform.OS !== 'web') { try { - await AudioSession.startAudioSession(); + logger.debug({ message: 'connectToRoom: starting audio session' }); + await withTimeout( + AudioSession.startAudioSession(), + 10_000, + 'AudioSession.startAudioSession' + ); logger.info({ message: 'Audio session started successfully', }); @@ -507,7 +587,11 @@ export const useLiveKitStore = create((set, get) => ({ hasToken: !!token, }, }); - await room.connect(voipServerWebsocketSslAddress, token); + await withTimeout( + room.connect(voipServerWebsocketSslAddress, token), + 15_000, + 'room.connect' + ); logger.info({ message: 'LiveKit room connected successfully', context: { roomName: roomInfo.Name }, @@ -631,9 +715,11 @@ export const useLiveKitStore = create((set, get) => ({ // On web, callKeepService provides no-op implementation but still tracks call state try { // On Android, CallKeep's VoiceConnectionService requires READ_PHONE_NUMBERS - // permission. If not granted, skip CallKeep to avoid a SecurityException crash. + // permission. Request them here (post-connect) so they never race with + // the microphone permission dialog. If not granted, skip CallKeep. let shouldStartCallKeep = true; if (Platform.OS === 'android') { + await requestAndroidPhonePermissions(); const hasPhoneNumbers = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_PHONE_NUMBERS); const hasPhoneState = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE); if (!hasPhoneNumbers || !hasPhoneState) { @@ -684,6 +770,8 @@ export const useLiveKitStore = create((set, get) => ({ // Show user-visible error so the failure is not silent in production builds const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; Alert.alert('Voice Connection Failed', `Unable to connect to voice channel "${roomInfo?.Name || 'Unknown'}". ${errorMessage}`, [{ text: 'OK' }]); + } finally { + clearTimeout(overallTimeout); } }, @@ -800,4 +888,57 @@ export const useLiveKitStore = create((set, get) => ({ }); } }, + + ensureMicrophonePermission: async (): Promise => { + // Request microphone permission BEFORE the Actionsheet (Modal) opens. + // On Android the system permission dialog is a Dialog attached to the + // Activity window. Gluestack's Actionsheet uses a React Native Modal + // that creates a separate window above the Activity, hiding the + // permission dialog. By requesting here (no Modal on screen), the + // dialog is always visible to the user. + try { + if (Platform.OS === 'android') { + const already = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO); + if (already) return true; + + logger.info({ + message: 'Requesting microphone permission before opening voice UI', + context: { platform: Platform.OS }, + }); + const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO); + if (result !== PermissionsAndroid.RESULTS.GRANTED) { + logger.warn({ + message: 'Microphone permission denied - voice UI will still open but joining will fail', + context: { platform: Platform.OS, result }, + }); + return false; + } + return true; + } else if (Platform.OS === 'ios') { + const mic = await getRecordingPermissionsAsync(); + if (mic.granted) return true; + + logger.info({ + message: 'Requesting microphone permission before opening voice UI', + context: { platform: Platform.OS }, + }); + const result = await requestRecordingPermissionsAsync(); + if (!result.granted) { + logger.warn({ + message: 'Microphone permission denied - voice UI will still open but joining will fail', + context: { platform: Platform.OS }, + }); + return false; + } + return true; + } + return true; + } catch (error) { + logger.error({ + message: 'Failed to request microphone permission', + context: { error, platform: Platform.OS }, + }); + return false; + } + }, }));