Skip to content

Commit 1bee450

Browse files
authored
Merge pull request #228 from Resgrid/develop
RU-T47 Trying to fix android permission issue.
2 parents 102f317 + f352900 commit 1bee450

File tree

13 files changed

+366
-159
lines changed

13 files changed

+366
-159
lines changed

src/api/calls/callFiles.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResponse } from 'axios';
2+
import { Platform } from 'react-native';
23

34
import { createApiEndpoint } from '@/api/common/client';
45
import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult';
@@ -77,8 +78,15 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption
7778
}
7879
};
7980

80-
// Utility function to save a blob as a file
81-
export const saveBlobAsFile = (blob: Blob, fileName: string): void => {
81+
// Utility function to save a blob as a file (web only).
82+
// Returns true on web after the download is triggered, false on native platforms.
83+
// Callers should check the return value and fall back to expo-file-system / expo-sharing on native.
84+
export const saveBlobAsFile = (blob: Blob, fileName: string): boolean => {
85+
if (Platform.OS !== 'web') {
86+
console.warn('saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.');
87+
return false;
88+
}
89+
8290
const url = window.URL.createObjectURL(blob);
8391
const link = document.createElement('a');
8492
link.href = url;
@@ -87,6 +95,7 @@ export const saveBlobAsFile = (blob: Blob, fileName: string): void => {
8795

8896
// Clean up
8997
window.URL.revokeObjectURL(url);
98+
return true;
9099
};
91100

92101
export const getFiles = async (callId: string, includeData: boolean, type: number) => {

src/components/livekit/livekit-bottom-sheet.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ActivityIndicator, ScrollView, StyleSheet, TouchableOpacity, View } fro
77
import { useAnalytics } from '@/hooks/use-analytics';
88
import { type DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData';
99
import { useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store';
10-
import { applyAudioRouting, requestAndroidPhonePermissions, useLiveKitStore } from '@/stores/app/livekit-store';
10+
import { applyAudioRouting, useLiveKitStore } from '@/stores/app/livekit-store';
1111

1212
import { AudioDeviceSelection } from '../settings/audio-device-selection';
1313
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '../ui/actionsheet';
@@ -86,11 +86,6 @@ export const LiveKitBottomSheet = () => {
8686
// If we're showing the sheet, make sure we have the latest rooms
8787
if (isBottomSheetVisible && currentView === BottomSheetView.ROOM_SELECT) {
8888
fetchVoiceSettings();
89-
// Pre-warm Android phone-state permissions (READ_PHONE_STATE / READ_PHONE_NUMBERS)
90-
// while the user is browsing the room list. The system dialog, if any, appears
91-
// here in a clean window instead of blocking the Join flow later. On subsequent
92-
// opens this is an instant no-op (permissions already granted).
93-
void requestAndroidPhonePermissions();
9489
}
9590
}, [isBottomSheetVisible, currentView, fetchVoiceSettings]);
9691

src/components/maps/map-view.web.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,10 +379,16 @@ export const Camera = forwardRef<any, CameraProps>(({ centerCoordinate, zoomLeve
379379
if (!map) return;
380380

381381
if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) {
382-
// Skip the first render — the MapView already initialized at the correct
383-
// position via initialCenter/initialZoom, so no programmatic move needed.
384382
if (!hasInitialized.current) {
385383
hasInitialized.current = true;
384+
// Use jumpTo (instant, no animation) for the initial camera position.
385+
// MapView initializes at a default center; Camera is responsible for
386+
// snapping to the correct location on first render on web.
387+
try {
388+
map.jumpTo({ center: centerCoordinate as [number, number], zoom: zoomLevel, bearing: heading, pitch: pitch }, { _programmatic: true });
389+
} catch {
390+
// ignore projection errors during initialization
391+
}
386392
return;
387393
}
388394

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Native (iOS/Android) implementation of map components using @rnmapbox/maps
3+
* Metro bundler resolves this file on native platforms via the .native extension.
4+
*/
5+
import Mapbox from '@rnmapbox/maps';
6+
7+
// Re-export all Mapbox components for native platforms
8+
export const MapView = Mapbox.MapView;
9+
export const Camera = Mapbox.Camera;
10+
export const PointAnnotation = Mapbox.PointAnnotation;
11+
export const UserLocation = Mapbox.UserLocation;
12+
export const MarkerView = Mapbox.MarkerView;
13+
export const ShapeSource = Mapbox.ShapeSource;
14+
export const SymbolLayer = Mapbox.SymbolLayer;
15+
export const CircleLayer = Mapbox.CircleLayer;
16+
export const LineLayer = Mapbox.LineLayer;
17+
export const FillLayer = Mapbox.FillLayer;
18+
export const Images = Mapbox.Images;
19+
export const Callout = Mapbox.Callout;
20+
21+
// Export style URL constants
22+
export const StyleURL = Mapbox.StyleURL;
23+
24+
// Export UserTrackingMode
25+
export const UserTrackingMode = Mapbox.UserTrackingMode;
26+
27+
// Export setAccessToken
28+
export const setAccessToken = Mapbox.setAccessToken;
29+
30+
// Default export matching Mapbox structure with all properties
31+
const MapboxExports = {
32+
MapView: Mapbox.MapView,
33+
Camera: Mapbox.Camera,
34+
PointAnnotation: Mapbox.PointAnnotation,
35+
UserLocation: Mapbox.UserLocation,
36+
MarkerView: Mapbox.MarkerView,
37+
ShapeSource: Mapbox.ShapeSource,
38+
SymbolLayer: Mapbox.SymbolLayer,
39+
CircleLayer: Mapbox.CircleLayer,
40+
LineLayer: Mapbox.LineLayer,
41+
FillLayer: Mapbox.FillLayer,
42+
Images: Mapbox.Images,
43+
Callout: Mapbox.Callout,
44+
StyleURL: Mapbox.StyleURL,
45+
UserTrackingMode: Mapbox.UserTrackingMode,
46+
setAccessToken: Mapbox.setAccessToken,
47+
};
48+
49+
export default MapboxExports;

src/components/maps/mapbox.ts

Lines changed: 6 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,8 @@
11
/**
2-
* Platform-aware map components
3-
* Automatically selects native (@rnmapbox/maps) or web (mapbox-gl) implementation
2+
* TypeScript type resolution shim for platform-specific Mapbox implementations.
3+
* Metro resolves mapbox.native.ts on iOS/Android and mapbox.web.ts on web,
4+
* but TypeScript needs a base file to satisfy module resolution.
5+
* This file re-exports from the native implementation so types are available.
46
*/
5-
import { Platform } from 'react-native';
6-
7-
import * as MapboxNative from './map-view.native';
8-
import * as MapboxWeb from './map-view.web';
9-
10-
// Import the platform-specific implementation
11-
// Metro bundler will resolve to the correct file based on platform
12-
const MapboxImpl = Platform.OS === 'web' ? MapboxWeb.default : MapboxNative.default;
13-
14-
// Re-export all components
15-
export const MapView = MapboxImpl.MapView || MapboxImpl;
16-
export const Camera = Platform.OS === 'web' ? MapboxWeb.Camera : MapboxNative.Camera;
17-
export const PointAnnotation = Platform.OS === 'web' ? MapboxWeb.PointAnnotation : MapboxNative.PointAnnotation;
18-
export const UserLocation = Platform.OS === 'web' ? MapboxWeb.UserLocation : MapboxNative.UserLocation;
19-
export const MarkerView = Platform.OS === 'web' ? MapboxWeb.MarkerView : MapboxNative.MarkerView;
20-
export const ShapeSource = Platform.OS === 'web' ? MapboxWeb.ShapeSource : MapboxNative.ShapeSource;
21-
export const SymbolLayer = Platform.OS === 'web' ? MapboxWeb.SymbolLayer : MapboxNative.SymbolLayer;
22-
export const CircleLayer = Platform.OS === 'web' ? MapboxWeb.CircleLayer : MapboxNative.CircleLayer;
23-
export const LineLayer = Platform.OS === 'web' ? MapboxWeb.LineLayer : MapboxNative.LineLayer;
24-
export const FillLayer = Platform.OS === 'web' ? MapboxWeb.FillLayer : MapboxNative.FillLayer;
25-
export const Images = Platform.OS === 'web' ? MapboxWeb.Images : MapboxNative.Images;
26-
export const Callout = Platform.OS === 'web' ? MapboxWeb.Callout : MapboxNative.Callout;
27-
28-
// Export style URL constants
29-
export const StyleURL = Platform.OS === 'web' ? MapboxWeb.StyleURL : MapboxNative.StyleURL;
30-
31-
// Export UserTrackingMode
32-
export const UserTrackingMode = Platform.OS === 'web' ? MapboxWeb.UserTrackingMode : MapboxNative.UserTrackingMode;
33-
34-
// Export setAccessToken
35-
export const setAccessToken = Platform.OS === 'web' ? MapboxWeb.setAccessToken : MapboxNative.setAccessToken;
36-
37-
// Default export matching Mapbox structure with all properties
38-
const Mapbox = {
39-
...MapboxImpl,
40-
MapView: MapView,
41-
Camera: Camera,
42-
PointAnnotation: PointAnnotation,
43-
UserLocation: UserLocation,
44-
MarkerView: MarkerView,
45-
ShapeSource: ShapeSource,
46-
SymbolLayer: SymbolLayer,
47-
CircleLayer: CircleLayer,
48-
LineLayer: LineLayer,
49-
FillLayer: FillLayer,
50-
Images: Images,
51-
Callout: Callout,
52-
StyleURL: StyleURL,
53-
UserTrackingMode: UserTrackingMode,
54-
setAccessToken: setAccessToken,
55-
};
56-
57-
export default Mapbox;
7+
export { Callout, Camera, CircleLayer, FillLayer, Images, LineLayer, MapView, MarkerView, PointAnnotation, setAccessToken, ShapeSource, StyleURL, SymbolLayer, UserLocation, UserTrackingMode } from './mapbox.native';
8+
export { default } from './mapbox.native';

src/components/maps/mapbox.web.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Web/Electron implementation of map components using mapbox-gl
3+
* Metro bundler resolves this file on web platforms via the .web extension.
4+
*/
5+
import * as MapboxWeb from './map-view.web';
6+
7+
// Re-export all components from the web implementation
8+
export const MapView = MapboxWeb.MapView;
9+
export const Camera = MapboxWeb.Camera;
10+
export const PointAnnotation = MapboxWeb.PointAnnotation;
11+
export const UserLocation = MapboxWeb.UserLocation;
12+
export const MarkerView = MapboxWeb.MarkerView;
13+
export const ShapeSource = MapboxWeb.ShapeSource;
14+
export const SymbolLayer = MapboxWeb.SymbolLayer;
15+
export const CircleLayer = MapboxWeb.CircleLayer;
16+
export const LineLayer = MapboxWeb.LineLayer;
17+
export const FillLayer = MapboxWeb.FillLayer;
18+
export const Images = MapboxWeb.Images;
19+
export const Callout = MapboxWeb.Callout;
20+
21+
// Export style URL constants
22+
export const StyleURL = MapboxWeb.StyleURL;
23+
24+
// Export UserTrackingMode
25+
export const UserTrackingMode = MapboxWeb.UserTrackingMode;
26+
27+
// Export setAccessToken
28+
export const setAccessToken = MapboxWeb.setAccessToken;
29+
30+
// Default export matching Mapbox structure with all properties
31+
const MapboxExports = {
32+
...MapboxWeb.default,
33+
MapView,
34+
Camera,
35+
PointAnnotation,
36+
UserLocation,
37+
MarkerView,
38+
ShapeSource,
39+
SymbolLayer,
40+
CircleLayer,
41+
LineLayer,
42+
FillLayer,
43+
Images,
44+
Callout,
45+
StyleURL,
46+
UserTrackingMode,
47+
setAccessToken,
48+
};
49+
50+
export default MapboxExports;

src/components/maps/static-map.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,6 @@ const StaticMap: React.FC<StaticMapProps> = ({ latitude, longitude, address, zoo
4545
compassEnabled={true}
4646
zoomEnabled={true}
4747
rotateEnabled={true}
48-
initialCenter={[longitude, latitude]}
49-
initialZoom={zoom}
5048
>
5149
<Mapbox.Camera zoomLevel={zoom} centerCoordinate={[longitude, latitude]} animationMode="flyTo" animationDuration={1000} />
5250
{/* Marker pin for the location */}

src/components/sidebar/__tests__/unit-sidebar-minimal.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,13 @@ jest.mock('@/stores/app/location-store', () => ({
6363
jest.mock('@/stores/app/livekit-store', () => ({
6464
useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
6565
setIsBottomSheetVisible: jest.fn(),
66+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
6667
currentRoomInfo: null,
6768
isConnected: false,
6869
isTalking: false,
6970
}) : {
7071
setIsBottomSheetVisible: jest.fn(),
72+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
7173
currentRoomInfo: null,
7274
isConnected: false,
7375
isTalking: false,

src/components/sidebar/__tests__/unit-sidebar-simplified.test.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ jest.mock('@/stores/app/location-store', () => ({
1919
jest.mock('@/stores/app/livekit-store', () => ({
2020
useLiveKitStore: jest.fn((selector: any) => typeof selector === 'function' ? selector({
2121
setIsBottomSheetVisible: jest.fn(),
22+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
2223
currentRoomInfo: null,
2324
isConnected: false,
2425
isTalking: false,
2526
}) : {
2627
setIsBottomSheetVisible: jest.fn(),
28+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
2729
currentRoomInfo: null,
2830
isConnected: false,
2931
isTalking: false,
@@ -62,6 +64,7 @@ const mockUseAudioStreamStore = useAudioStreamStore as jest.MockedFunction<typeo
6264
describe('SidebarUnitCard', () => {
6365
const mockSetMapLocked = jest.fn();
6466
const mockSetIsBottomSheetVisible = jest.fn();
67+
const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true);
6568
const mockSetAudioStreamBottomSheetVisible = jest.fn();
6669

6770
const defaultProps = {
@@ -90,11 +93,13 @@ describe('SidebarUnitCard', () => {
9093

9194
mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
9295
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
96+
ensureMicrophonePermission: mockEnsureMicrophonePermission,
9397
currentRoomInfo: null,
9498
isConnected: false,
9599
isTalking: false,
96100
}) : {
97101
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
102+
ensureMicrophonePermission: mockEnsureMicrophonePermission,
98103
currentRoomInfo: null,
99104
isConnected: false,
100105
isTalking: false,
@@ -166,12 +171,13 @@ describe('SidebarUnitCard', () => {
166171
});
167172
});
168173

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

172177
const callButton = screen.getByTestId('call-button');
173-
fireEvent.press(callButton);
178+
await fireEvent.press(callButton);
174179

180+
expect(mockEnsureMicrophonePermission).toHaveBeenCalled();
175181
expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true);
176182
});
177183
});

src/components/sidebar/__tests__/unit-sidebar.test.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,13 @@ describe('SidebarUnitCard', () => {
6060
mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ isMapLocked: false, setMapLocked: jest.fn() }) : { isMapLocked: false, setMapLocked: jest.fn() });
6161
mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
6262
setIsBottomSheetVisible: jest.fn(),
63+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
6364
currentRoomInfo: null,
6465
isConnected: false,
6566
isTalking: false,
6667
}) : {
6768
setIsBottomSheetVisible: jest.fn(),
69+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
6870
currentRoomInfo: null,
6971
isConnected: false,
7072
isTalking: false,
@@ -150,15 +152,18 @@ describe('SidebarUnitCard', () => {
150152
expect(mockSetAudioStreamBottomSheetVisible).toHaveBeenCalledWith(true);
151153
});
152154

153-
it('handles call button press', () => {
155+
it('handles call button press', async () => {
154156
const mockSetIsBottomSheetVisible = jest.fn();
157+
const mockEnsureMicrophonePermission = jest.fn().mockResolvedValue(true);
155158
mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
156159
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
160+
ensureMicrophonePermission: mockEnsureMicrophonePermission,
157161
currentRoomInfo: null,
158162
isConnected: false,
159163
isTalking: false,
160164
}) : {
161165
setIsBottomSheetVisible: mockSetIsBottomSheetVisible,
166+
ensureMicrophonePermission: mockEnsureMicrophonePermission,
162167
currentRoomInfo: null,
163168
isConnected: false,
164169
isTalking: false,
@@ -167,20 +172,23 @@ describe('SidebarUnitCard', () => {
167172
render(<SidebarUnitCard {...defaultProps} />);
168173

169174
const callButton = screen.getByTestId('call-button');
170-
fireEvent.press(callButton);
175+
await fireEvent.press(callButton);
171176

177+
expect(mockEnsureMicrophonePermission).toHaveBeenCalled();
172178
expect(mockSetIsBottomSheetVisible).toHaveBeenCalledWith(true);
173179
});
174180

175181
it('shows room status when connected', () => {
176182
const mockRoomInfo = { Name: 'Emergency Call Room' };
177183
mockUseLiveKitStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({
178184
setIsBottomSheetVisible: jest.fn(),
185+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
179186
currentRoomInfo: mockRoomInfo as any,
180187
isConnected: true,
181188
isTalking: false,
182189
}) : {
183190
setIsBottomSheetVisible: jest.fn(),
191+
ensureMicrophonePermission: jest.fn().mockResolvedValue(true),
184192
currentRoomInfo: mockRoomInfo as any,
185193
isConnected: true,
186194
isTalking: false,

0 commit comments

Comments
 (0)