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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/api/calls/callFiles.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {
Expand Down
7 changes: 1 addition & 6 deletions src/components/livekit/livekit-bottom-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);

Expand Down
10 changes: 8 additions & 2 deletions src/components/maps/map-view.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,16 @@ export const Camera = forwardRef<any, CameraProps>(({ 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;
}

Expand Down
49 changes: 49 additions & 0 deletions src/components/maps/mapbox.native.ts
Original file line number Diff line number Diff line change
@@ -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;
61 changes: 6 additions & 55 deletions src/components/maps/mapbox.ts
Original file line number Diff line number Diff line change
@@ -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';
50 changes: 50 additions & 0 deletions src/components/maps/mapbox.web.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 0 additions & 2 deletions src/components/maps/static-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

return (
<Box style={StyleSheet.flatten([styles.container, { height }])}>
<Mapbox.MapView

Check warning on line 40 in src/components/maps/static-map.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `⏎········style={StyleSheet.flatten([styles.map,·{·height·}])}⏎········styleURL={mapStyle}⏎········logoEnabled={false}⏎········attributionEnabled={false}⏎········compassEnabled={true}⏎········zoomEnabled={true}⏎········rotateEnabled={true}⏎······` with `·style={StyleSheet.flatten([styles.map,·{·height·}])}·styleURL={mapStyle}·logoEnabled={false}·attributionEnabled={false}·compassEnabled={true}·zoomEnabled={true}·rotateEnabled={true}`
style={StyleSheet.flatten([styles.map, { height }])}
styleURL={mapStyle}
logoEnabled={false}
Expand All @@ -45,8 +45,6 @@
compassEnabled={true}
zoomEnabled={true}
rotateEnabled={true}
initialCenter={[longitude, latitude]}
initialZoom={zoom}
>
<Mapbox.Camera zoomLevel={zoom} centerCoordinate={[longitude, latitude]} animationMode="flyTo" animationDuration={1000} />
{/* Marker pin for the location */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +64,7 @@ const mockUseAudioStreamStore = useAudioStreamStore as jest.MockedFunction<typeo
describe('SidebarUnitCard', () => {
const mockSetMapLocked = jest.fn();
const mockSetIsBottomSheetVisible = jest.fn();
const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true);
const mockSetAudioStreamBottomSheetVisible = jest.fn();

const defaultProps = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -166,12 +171,13 @@ describe('SidebarUnitCard', () => {
});
});

it('opens LiveKit when call button is pressed', () => {
it('opens LiveKit when call button is pressed', async () => {
render(<SidebarUnitCard {...defaultProps} />);

const callButton = screen.getByTestId('call-button');
fireEvent.press(callButton);
await fireEvent.press(callButton);

expect(mockEnsureMicrophonePermission).toHaveBeenCalled();
expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true);
});
});
Expand Down
12 changes: 10 additions & 2 deletions src/components/sidebar/__tests__/unit-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -167,20 +172,23 @@ describe('SidebarUnitCard', () => {
render(<SidebarUnitCard {...defaultProps} />);

const callButton = screen.getByTestId('call-button');
fireEvent.press(callButton);
await fireEvent.press(callButton);

expect(mockEnsureMicrophonePermission).toHaveBeenCalled();
expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true);
});

it('shows room status when connected', () => {
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,
Expand Down
7 changes: 6 additions & 1 deletion src/components/sidebar/unit-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
};

Expand Down
Loading