From fbc4e395c67d4a131b9ed591540d686cc0d4ae8d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 28 Jul 2026 09:23:03 -0700 Subject: [PATCH 1/2] RC-T40 Fixes --- .../call-video-feeds/video-player-modal.tsx | 2 +- src/components/calls/call-detail-menu.tsx | 2 +- src/components/calls/call-images-modal.tsx | 2 +- src/components/calls/call-notes-modal.tsx | 2 +- .../calls/close-call-bottom-sheet.tsx | 2 +- .../command/__tests__/add-lane-sheet.test.tsx | 21 +++++++-- .../__tests__/work-time-light.test.tsx | 12 ++++++ src/components/command/add-lane-sheet.tsx | 38 ++++++++++++++-- .../command/command-details-sheet.tsx | 2 +- .../command/landscape-structure-board.tsx | 30 +++++++++---- src/components/command/lane-details-sheet.tsx | 28 +++++++++++- src/components/command/structure-section.tsx | 8 +++- src/components/ui/bottom-sheet.tsx | 11 ++++- src/components/ui/side-drawer.tsx | 11 ++++- src/models/offline-queue/queued-event.ts | 2 +- .../incidentCommand/commandStructureNode.ts | 4 ++ .../incidentCommand/incidentCommandModels.ts | 4 ++ src/services/offline-event-manager.service.ts | 2 + .../app/__tests__/livekit-store.test.ts | 43 ++++++------------- src/stores/app/livekit-store.ts | 39 +++++++---------- src/stores/command/store.ts | 4 ++ src/translations/ar.json | 6 ++- src/translations/de.json | 6 ++- src/translations/en.json | 6 ++- src/translations/es.json | 6 ++- src/translations/fr.json | 6 ++- src/translations/it.json | 6 ++- src/translations/pl.json | 6 ++- src/translations/sv.json | 6 ++- src/translations/uk.json | 6 ++- 30 files changed, 235 insertions(+), 88 deletions(-) diff --git a/src/components/call-video-feeds/video-player-modal.tsx b/src/components/call-video-feeds/video-player-modal.tsx index bbfbe92..80d517b 100644 --- a/src/components/call-video-feeds/video-player-modal.tsx +++ b/src/components/call-video-feeds/video-player-modal.tsx @@ -68,7 +68,7 @@ export const VideoPlayerModal: React.FC = ({ isOpen, onCl }; return ( - + {/* Header */} diff --git a/src/components/calls/call-detail-menu.tsx b/src/components/calls/call-detail-menu.tsx index 566edbf..1b43e94 100644 --- a/src/components/calls/call-detail-menu.tsx +++ b/src/components/calls/call-detail-menu.tsx @@ -72,7 +72,7 @@ export const useCallDetailMenu = ({ onEditCall, onCloseCall, canUserCreateCalls } return ( - + diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index dea9296..55e6eb1 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -417,7 +417,7 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call return ( <> - + {/* Header */} diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index 5c5ce8b..4c27fcd 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -104,7 +104,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { } return ( - + {/* Header */} diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx index a83b11e..cb692bc 100644 --- a/src/components/calls/close-call-bottom-sheet.tsx +++ b/src/components/calls/close-call-bottom-sheet.tsx @@ -99,7 +99,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp const isButtonDisabled = isLoading || isSubmitting; return ( - + e.stopPropagation()}> diff --git a/src/components/command/__tests__/add-lane-sheet.test.tsx b/src/components/command/__tests__/add-lane-sheet.test.tsx index 401286d..294846d 100644 --- a/src/components/command/__tests__/add-lane-sheet.test.tsx +++ b/src/components/command/__tests__/add-lane-sheet.test.tsx @@ -37,19 +37,34 @@ describe('AddLaneSheet', () => { fireEvent.press(getByTestId('lane-save')); - expect(onSave).toHaveBeenCalledWith('Fire Attack', CommandNodeType.Group, '#e74c3c', { minUnits: 1, maxUnits: 3, minUnitPersonnel: 2, maxTimeInRole: 30 }); + expect(onSave).toHaveBeenCalledWith('Fire Attack', CommandNodeType.Group, '#e74c3c', { minUnits: 1, maxUnits: 3, minUnitPersonnel: 2, maxTimeInRole: 30, workTimeAmberMinutes: 20, workTimeRedMinutes: 40 }); unmount(); }); - it('passes undefined limits when none are entered', () => { + it('passes the default work-time thresholds when no limits are entered', () => { const onSave = jest.fn(); const { getByTestId, unmount } = render(); fireEvent.changeText(getByTestId('lane-name-input'), 'Staging'); fireEvent.press(getByTestId('lane-save')); - expect(onSave).toHaveBeenCalledWith('Staging', CommandNodeType.Division, undefined, undefined); + expect(onSave).toHaveBeenCalledWith('Staging', CommandNodeType.Division, undefined, { workTimeAmberMinutes: 20, workTimeRedMinutes: 40 }); + + unmount(); + }); + + it('saves custom work-time thresholds and omits cleared ones', () => { + const onSave = jest.fn(); + const { getByTestId, unmount } = render(); + + fireEvent.changeText(getByTestId('lane-name-input'), 'Rehab'); + fireEvent.press(getByTestId('lane-limits-toggle')); + fireEvent.changeText(getByTestId('limit-worktime-amber'), '15'); + fireEvent.changeText(getByTestId('limit-worktime-red'), ''); + fireEvent.press(getByTestId('lane-save')); + + expect(onSave).toHaveBeenCalledWith('Rehab', CommandNodeType.Division, undefined, { workTimeAmberMinutes: 15 }); unmount(); }); diff --git a/src/components/command/__tests__/work-time-light.test.tsx b/src/components/command/__tests__/work-time-light.test.tsx index 96e5869..493643d 100644 --- a/src/components/command/__tests__/work-time-light.test.tsx +++ b/src/components/command/__tests__/work-time-light.test.tsx @@ -53,4 +53,16 @@ describe('WorkTimeLight', () => { expect(workTimeColor(25)).toBe('#f59e0b'); expect(workTimeColor(45)).toBe('#ef4444'); }); + + it('honors per-lane fatigue thresholds', () => { + expect(workTimeColor(10, 5, 15)).toBe('#f59e0b'); + expect(workTimeColor(16, 5, 15)).toBe('#ef4444'); + expect(workTimeColor(4, 5, 15)).toBe('#22c55e'); + }); + + it('treats a 0 lane threshold as disabled', () => { + expect(workTimeColor(90, 0, 0)).toBe('#22c55e'); + expect(workTimeColor(90, 10, 0)).toBe('#f59e0b'); + expect(workTimeColor(90, 0, 30)).toBe('#ef4444'); + }); }); diff --git a/src/components/command/add-lane-sheet.tsx b/src/components/command/add-lane-sheet.tsx index e47c7d0..7368b22 100644 --- a/src/components/command/add-lane-sheet.tsx +++ b/src/components/command/add-lane-sheet.tsx @@ -31,7 +31,7 @@ const LANE_NAME_SUGGESTION_KEYS = [ 'lane_suggestion_operations', ]; -/** Optional per-lane limits: 0/undefined = no limit. */ +/** Optional per-lane limits: 0/undefined = no limit. Work-time thresholds: 0/blank = that color disabled. */ export interface LaneLimits { minUnits?: number; maxUnits?: number; @@ -39,6 +39,8 @@ export interface LaneLimits { maxUnitPersonnel?: number; minTimeInRole?: number; maxTimeInRole?: number; + workTimeAmberMinutes?: number; + workTimeRedMinutes?: number; } interface AddLaneSheetProps { @@ -54,7 +56,16 @@ export const AddLaneSheet: React.FC = ({ isOpen, onClose, onS const [nodeType, setNodeType] = useState(CommandNodeType.Division); const [color, setColor] = useState(undefined); const [showLimits, setShowLimits] = useState(false); - const [limits, setLimits] = useState>({ minUnits: '', maxUnits: '', minUnitPersonnel: '', maxUnitPersonnel: '', minTimeInRole: '', maxTimeInRole: '' }); + const [limits, setLimits] = useState>({ + minUnits: '', + maxUnits: '', + minUnitPersonnel: '', + maxUnitPersonnel: '', + minTimeInRole: '', + maxTimeInRole: '', + workTimeAmberMinutes: '20', + workTimeRedMinutes: '40', + }); const setLimit = useCallback((key: keyof LaneLimits, value: string) => { setLimits((current) => ({ ...current, [key]: value.replace(/[^0-9]/g, '') })); @@ -76,7 +87,7 @@ export const AddLaneSheet: React.FC = ({ isOpen, onClose, onS setNodeType(CommandNodeType.Division); setColor(undefined); setShowLimits(false); - setLimits({ minUnits: '', maxUnits: '', minUnitPersonnel: '', maxUnitPersonnel: '', minTimeInRole: '', maxTimeInRole: '' }); + setLimits({ minUnits: '', maxUnits: '', minUnitPersonnel: '', maxUnitPersonnel: '', minTimeInRole: '', maxTimeInRole: '', workTimeAmberMinutes: '20', workTimeRedMinutes: '40' }); onClose(); }, [name, nodeType, color, limits, onSave, onClose]); @@ -180,6 +191,27 @@ export const AddLaneSheet: React.FC = ({ isOpen, onClose, onS {t('command.limit_time_help')} + + + {t('command.limit_worktime_amber')} + + setLimit('workTimeAmberMinutes', v)} + testID="limit-worktime-amber" + /> + + + + {t('command.limit_worktime_red')} + + setLimit('workTimeRedMinutes', v)} testID="limit-worktime-red" /> + + + + {t('command.limit_worktime_help')} ) : null} diff --git a/src/components/command/command-details-sheet.tsx b/src/components/command/command-details-sheet.tsx index 978efb0..547547e 100644 --- a/src/components/command/command-details-sheet.tsx +++ b/src/components/command/command-details-sheet.tsx @@ -251,7 +251,7 @@ export const CommandDetailsSheet: React.FC = ({ isOpen {/* Drop-a-pin picker rendered as a stacked modal so it covers the sheet full-screen */} - setPickerTarget(null)}> + setPickerTarget(null)} supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}> {pickerTarget !== null ? setPickerTarget(null)} /> : null} diff --git a/src/components/command/landscape-structure-board.tsx b/src/components/command/landscape-structure-board.tsx index 44260d3..fee3093 100644 --- a/src/components/command/landscape-structure-board.tsx +++ b/src/components/command/landscape-structure-board.tsx @@ -26,17 +26,25 @@ const containerButtonRole = isWeb ? undefined : ('button' as const); /** Two-line clamp: numberOfLines leaks to the DOM through the styling pipeline on web, so use CSS line-clamp there. */ const twoLine = isWeb ? {} : ({ numberOfLines: 2 } as const); -/** Work-time light thresholds (Tablet Command-style crew fatigue): green under 20m, amber under 40m, red past that. */ -const WORK_TIME_AMBER_MINUTES = 20; -const WORK_TIME_RED_MINUTES = 40; +/** Default work-time light thresholds (Tablet Command-style crew fatigue): green under 20m, amber under 40m, red past that. Lanes can override; 0 disables that color. */ +const DEFAULT_WORK_TIME_AMBER_MINUTES = 20; +const DEFAULT_WORK_TIME_RED_MINUTES = 40; -export const workTimeColor = (minutes: number) => (minutes < WORK_TIME_AMBER_MINUTES ? '#22c55e' : minutes < WORK_TIME_RED_MINUTES ? '#f59e0b' : '#ef4444'); +export const workTimeColor = (minutes: number, amberAfterMinutes: number = DEFAULT_WORK_TIME_AMBER_MINUTES, redAfterMinutes: number = DEFAULT_WORK_TIME_RED_MINUTES) => + redAfterMinutes > 0 && minutes >= redAfterMinutes ? '#ef4444' : amberAfterMinutes > 0 && minutes >= amberAfterMinutes ? '#f59e0b' : '#22c55e'; /** * Elapsed minutes since a resource was assigned, ticking once a minute. When the lane sets a * MaxTimeInRole, exceeding it turns the light red and flags the resource as rotation-due. + * Amber/red thresholds come from the lane (0 = that color disabled; unset = 20/40 defaults). */ -export const WorkTimeLight: React.FC<{ assignedOn?: string | null; rotationAfterMinutes?: number; testID?: string }> = ({ assignedOn, rotationAfterMinutes, testID }) => { +export const WorkTimeLight: React.FC<{ assignedOn?: string | null; rotationAfterMinutes?: number; amberAfterMinutes?: number; redAfterMinutes?: number; testID?: string }> = ({ + assignedOn, + rotationAfterMinutes, + amberAfterMinutes = DEFAULT_WORK_TIME_AMBER_MINUTES, + redAfterMinutes = DEFAULT_WORK_TIME_RED_MINUTES, + testID, +}) => { const { t } = useTranslation(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -57,7 +65,7 @@ export const WorkTimeLight: React.FC<{ assignedOn?: string | null; rotationAfter return ( - + {`${minutes}m`} {isRotationDue ? ( @@ -91,6 +99,10 @@ interface DraggableResourceCardProps { name: string; /** Lane MaxTimeInRole (minutes) — flags the resource rotation-due when exceeded. */ rotationAfterMinutes?: number; + /** Lane work-time amber threshold (0 = amber disabled; unset = 20). */ + amberAfterMinutes?: number; + /** Lane work-time red threshold (0 = red disabled; unset = 40). */ + redAfterMinutes?: number; isSelected: boolean; onSelect: (assignmentId: string) => void; onDragStart: (assignmentId: string) => void; @@ -107,7 +119,7 @@ interface LaneRect { height: number; } -const DraggableResourceCard: React.FC = React.memo(({ assignment, name, rotationAfterMinutes, isSelected, onSelect, onDragStart, onDragEnd, onDrop, onView }) => { +const DraggableResourceCard: React.FC = React.memo(({ assignment, name, rotationAfterMinutes, amberAfterMinutes, redAfterMinutes, isSelected, onSelect, onDragStart, onDragEnd, onDrop, onView }) => { const { t } = useTranslation(); const translation = useRef(new Animated.ValueXY()).current; const dragReadyRef = useRef(false); @@ -203,7 +215,7 @@ const DraggableResourceCard: React.FC = React.memo(( {name} - + @@ -398,6 +410,8 @@ export const LandscapeStructureBoard: React.FC = ( key={assignment.ResourceAssignmentId} assignment={assignment} rotationAfterMinutes={node.MaxTimeInRole} + amberAfterMinutes={node.WorkTimeAmberMinutes} + redAfterMinutes={node.WorkTimeRedMinutes} isSelected={selectedAssignmentId === assignment.ResourceAssignmentId} name={resolveResourceName(assignment.ResourceKind, assignment.ResourceId)} onDragEnd={() => setDraggingAssignmentId(null)} diff --git a/src/components/command/lane-details-sheet.tsx b/src/components/command/lane-details-sheet.tsx index 1d905fe..72cc1ee 100644 --- a/src/components/command/lane-details-sheet.tsx +++ b/src/components/command/lane-details-sheet.tsx @@ -55,6 +55,9 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl const [secondaryObjectiveId, setSecondaryObjectiveId] = useState(null); const [linkedNeedId, setLinkedNeedId] = useState(null); const [linkedMapId, setLinkedMapId] = useState(null); + /** Work-time (crew fatigue) thresholds in minutes; blank = that color disabled. */ + const [workTimeAmber, setWorkTimeAmber] = useState(''); + const [workTimeRed, setWorkTimeRed] = useState(''); /** Two-step delete: swaps the sheet body for the confirmation view. */ const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); @@ -67,6 +70,8 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl setSecondaryObjectiveId(node.SecondaryObjectiveId ?? null); setLinkedNeedId(node.LinkedNeedId ?? null); setLinkedMapId(node.LinkedMapId ?? null); + setWorkTimeAmber(node.WorkTimeAmberMinutes ? String(node.WorkTimeAmberMinutes) : ''); + setWorkTimeRed(node.WorkTimeRedMinutes ? String(node.WorkTimeRedMinutes) : ''); setIsConfirmingDelete(false); } }, [node, isOpen]); @@ -88,9 +93,11 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl SecondaryObjectiveId: secondaryObjectiveId, LinkedNeedId: linkedNeedId, LinkedMapId: linkedMapId, + WorkTimeAmberMinutes: parseInt(workTimeAmber, 10) || 0, + WorkTimeRedMinutes: parseInt(workTimeRed, 10) || 0, }); onClose(); - }, [node, primaryLead, secondaryLead, primaryObjectiveId, secondaryObjectiveId, linkedNeedId, linkedMapId, onSave, onClose]); + }, [node, primaryLead, secondaryLead, primaryObjectiveId, secondaryObjectiveId, linkedNeedId, linkedMapId, workTimeAmber, workTimeRed, onSave, onClose]); const openObjectives = objectives.filter((o) => o.Status !== TacticalObjectiveStatus.Complete); const openNeeds = needs.filter((n) => n.Status !== IncidentNeedStatus.Cancelled && n.Status !== IncidentNeedStatus.Met); @@ -291,6 +298,25 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl + + {t('command.worktime_thresholds_label')} + + + {t('command.limit_worktime_amber')} + + setWorkTimeAmber(v.replace(/[^0-9]/g, ''))} testID="lane-worktime-amber" /> + + + + {t('command.limit_worktime_red')} + + setWorkTimeRed(v.replace(/[^0-9]/g, ''))} testID="lane-worktime-red" /> + + + + {t('command.limit_worktime_help')} + + diff --git a/src/components/command/structure-section.tsx b/src/components/command/structure-section.tsx index c5b7df9..b82407e 100644 --- a/src/components/command/structure-section.tsx +++ b/src/components/command/structure-section.tsx @@ -160,7 +160,13 @@ export const StructureSection: React.FC = ({ nodes, assig ) : null} - + onViewResource(assignment)} className="p-1" testID={`lane-resource-view-${assignment.ResourceAssignmentId}`}> diff --git a/src/components/ui/bottom-sheet.tsx b/src/components/ui/bottom-sheet.tsx index 01d8c82..decd1a7 100644 --- a/src/components/ui/bottom-sheet.tsx +++ b/src/components/ui/bottom-sheet.tsx @@ -114,7 +114,16 @@ export function CustomBottomSheet({ if (!modalVisible) return null; return ( - + {/* KeyboardAvoidingView lifts the bottom-anchored sheet above the soft keyboard so text inputs low in the sheet stay visible while typing. */} diff --git a/src/components/ui/side-drawer.tsx b/src/components/ui/side-drawer.tsx index 0978918..ca73030 100644 --- a/src/components/ui/side-drawer.tsx +++ b/src/components/ui/side-drawer.tsx @@ -74,7 +74,16 @@ export function SideDrawer({ children, isOpen, onClose, testID }: SideDrawerProp if (!modalVisible) return null; return ( - + {/* Backdrop — tapping anywhere outside the panel dismisses the menu */} { name: string; nodeType: number; color?: string; - limits?: { minUnits?: number; maxUnits?: number; minUnitPersonnel?: number; maxUnitPersonnel?: number; minTimeInRole?: number; maxTimeInRole?: number }; + limits?: { minUnits?: number; maxUnits?: number; minUnitPersonnel?: number; maxUnitPersonnel?: number; minTimeInRole?: number; maxTimeInRole?: number; workTimeAmberMinutes?: number; workTimeRedMinutes?: number }; }; } diff --git a/src/models/v4/incidentCommand/commandStructureNode.ts b/src/models/v4/incidentCommand/commandStructureNode.ts index 67c43c3..4ef58af 100644 --- a/src/models/v4/incidentCommand/commandStructureNode.ts +++ b/src/models/v4/incidentCommand/commandStructureNode.ts @@ -12,6 +12,10 @@ export interface CommandStructureNode { /** Maps to CommandNodeType. */ NodeType: number; Name: string; + /** Minutes before the lane work-time indicator turns amber (0 = disabled; defaults to 20). */ + WorkTimeAmberMinutes?: number; + /** Minutes before the lane work-time indicator turns red (0 = disabled; defaults to 40). */ + WorkTimeRedMinutes?: number; /** Optional primary tactical objective this lane is working. */ PrimaryObjectiveId?: string | null; /** Optional secondary tactical objective this lane is working. */ diff --git a/src/models/v4/incidentCommand/incidentCommandModels.ts b/src/models/v4/incidentCommand/incidentCommandModels.ts index 42fafc0..0027cf2 100644 --- a/src/models/v4/incidentCommand/incidentCommandModels.ts +++ b/src/models/v4/incidentCommand/incidentCommandModels.ts @@ -170,6 +170,10 @@ export interface CommandStructureNode { MinTimeInRole?: number; /** Maximum minutes before a resource is rotation-due in this lane (0 = none). */ MaxTimeInRole?: number; + /** Minutes before the lane work-time indicator turns amber (0 = disabled; defaults to 20). */ + WorkTimeAmberMinutes?: number; + /** Minutes before the lane work-time indicator turns red (0 = disabled; defaults to 40). */ + WorkTimeRedMinutes?: number; /** When true, unmet lane requirements block assignment instead of warning. */ ForceRequirements?: boolean; /** Optional primary tactical objective this lane is working. */ diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index 6a9dcda..d1b8532 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -385,6 +385,8 @@ class OfflineEventManager { MaxUnitPersonnel: event.data.limits?.maxUnitPersonnel ?? 0, MinTimeInRole: event.data.limits?.minTimeInRole ?? 0, MaxTimeInRole: event.data.limits?.maxTimeInRole ?? 0, + WorkTimeAmberMinutes: event.data.limits?.workTimeAmberMinutes ?? 0, + WorkTimeRedMinutes: event.data.limits?.workTimeRedMinutes ?? 0, }); } diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index 2b253f5..51c900b 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -259,17 +259,8 @@ describe('LiveKit Store - Permission Management', () => { (Platform as any).OS = 'ios'; }); - it('should successfully request permissions on iOS', async () => { - // Mock initial permission check - not granted + it('should return true on iOS without requesting when permission already granted', async () => { mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: true, - expires: 'never', - status: 'undetermined', - } as any); - - // Mock permission request - granted - mockRequestRecordingPermissionsAsync.mockResolvedValueOnce({ granted: true, canAskAgain: true, expires: 'never', @@ -277,18 +268,17 @@ describe('LiveKit Store - Permission Management', () => { } as any); const { requestPermissions } = useLiveKitStore.getState(); - await requestPermissions(); + const result = await requestPermissions(); + expect(result).toBe(true); expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockLogger.info).toHaveBeenCalledWith({ - message: 'Microphone permission granted successfully', - context: { platform: 'ios' }, - }); + expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); }); - it('should handle iOS permission denial', async () => { - // Mock initial permission check - not granted + it('should never call session-activating request on iOS, even when not granted', async () => { + // expo-audio's requestRecordingPermissionsAsync activates AVAudioSession + // and deadlocks against expo-av — the store must only perform the + // non-activating check and let WebRTC prompt natively on publish. mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ granted: false, canAskAgain: false, @@ -296,21 +286,14 @@ describe('LiveKit Store - Permission Management', () => { status: 'denied', } as any); - // Mock permission request - still denied - mockRequestRecordingPermissionsAsync.mockResolvedValueOnce({ - granted: false, - canAskAgain: false, - expires: 'never', - status: 'denied', - } as any); - const { requestPermissions } = useLiveKitStore.getState(); - await requestPermissions(); + const result = await requestPermissions(); + expect(result).toBe(true); expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockRequestRecordingPermissionsAsync).toHaveBeenCalledTimes(1); - expect(mockLogger.error).toHaveBeenCalledWith({ - message: 'Microphone permission not granted', + expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith({ + message: 'Microphone permission not yet granted - WebRTC will prompt on publish', context: { platform: 'ios' }, }); }); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 459102c..d57a7c4 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -1,7 +1,7 @@ import { AudioSession } from '@livekit/react-native'; import { RTCAudioSession } from '@livekit/react-native-webrtc'; import notifee, { AndroidForegroundServiceType, AndroidImportance } from '@notifee/react-native'; -import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio'; +import { getRecordingPermissionsAsync } from 'expo-audio'; import { Audio, InterruptionModeIOS } from 'expo-av'; import * as Device from 'expo-device'; import { Room, RoomEvent } from 'livekit-client'; @@ -397,23 +397,20 @@ export const useLiveKitStore = create((set, get) => ({ }); return true; } else if (Platform.OS === 'ios') { + // NOTE: expo-audio's requestRecordingPermissionsAsync activates + // AVAudioSession in record mode, which deadlocks against expo-av's + // active session and freezes the app. Only perform the + // non-activating status check here; WebRTC triggers the native mic + // prompt itself when a track is published. const micPermission = await getRecordingPermissionsAsync(); if (!micPermission.granted) { - const result = await requestRecordingPermissionsAsync(); - if (!result.granted) { - logger.error({ - message: 'Microphone permission not granted', - context: { platform: Platform.OS }, - }); - return false; - } + logger.info({ + message: 'Microphone permission not yet granted - WebRTC will prompt on publish', + context: { platform: Platform.OS }, + }); } - logger.info({ - message: 'Microphone permission granted successfully', - context: { platform: Platform.OS }, - }); return true; } return true; // Web/other platforms don't need runtime permissions @@ -937,20 +934,14 @@ export const useLiveKitStore = create((set, get) => ({ } return true; } else if (Platform.OS === 'ios') { + // Non-activating check only — see requestPermissions for why + // requestRecordingPermissionsAsync must not be called on 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', + if (!mic.granted) { + logger.info({ + message: 'Microphone permission not yet granted - WebRTC will prompt on publish', context: { platform: Platform.OS }, }); - return false; } return true; } diff --git a/src/stores/command/store.ts b/src/stores/command/store.ts index cefe5ee..863f17b 100644 --- a/src/stores/command/store.ts +++ b/src/stores/command/store.ts @@ -637,6 +637,8 @@ export const useCommandStore = create()( MaxUnitPersonnel: limits?.maxUnitPersonnel ?? 0, MinTimeInRole: limits?.minTimeInRole ?? 0, MaxTimeInRole: limits?.maxTimeInRole ?? 0, + WorkTimeAmberMinutes: limits?.workTimeAmberMinutes ?? 0, + WorkTimeRedMinutes: limits?.workTimeRedMinutes ?? 0, SortOrder: current.board.Nodes.length, }, ], @@ -665,6 +667,8 @@ export const useCommandStore = create()( MaxUnitPersonnel: limits?.maxUnitPersonnel ?? 0, MinTimeInRole: limits?.minTimeInRole ?? 0, MaxTimeInRole: limits?.maxTimeInRole ?? 0, + WorkTimeAmberMinutes: limits?.workTimeAmberMinutes ?? 0, + WorkTimeRedMinutes: limits?.workTimeRedMinutes ?? 0, SortOrder: entry?.board?.Nodes.length ?? 0, }); await get().refreshBoard(callId); diff --git a/src/translations/ar.json b/src/translations/ar.json index bc1f5cb..1f7fc6a 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -508,6 +508,9 @@ "limit_riding_help": "عدد الأفراد الذين يجب أن يكونوا على متن الوحدة لتناسب هذا المسار. يُفحص عند الإسناد؛ يُحظر أو يُحذَّر حسب فرض المتطلبات.", "limit_time_help": "إرشادات التناوب بالدقائق. الخروج قبل الحد الأدنى يُعلِّم النقل؛ بعد الحد الأقصى يظهر شعار التناوب على المورد. لا يُزال شيء تلقائيًا.", "limit_units_help": "عدد الوحدات التي يجب أن يضمها هذا المسار. تحت الحد الأدنى يظهر المسار غير مكتمل (لا يحظر أبدًا)؛ عند الحد الأقصى تُحظر إضافة وحدات أو يُحذَّر منها.", + "limit_worktime_amber": "كهرماني بعد (دقيقة)", + "limit_worktime_help": "عتبات لون مؤشر وقت العمل لهذا المسار. فارغ = اللون معطل.", + "limit_worktime_red": "أحمر بعد (دقيقة)", "linked_map_label": "الخريطة المرفقة", "linked_need_label": "الاحتياج المرتبط", "location_geocode_hint": "لم يتم تحديد دبوس — سيتم تحويل النص إلى إحداثيات عند الحفظ", @@ -730,7 +733,8 @@ "view_details": "عرض التفاصيل", "view_log": "عرض السجل", "view_on_map": "عرض على الخريطة", - "voice_section": "قنوات الصوت" + "voice_section": "قنوات الصوت", + "worktime_thresholds_label": "عتبات وقت العمل" }, "common": { "add": "إضافة", diff --git a/src/translations/de.json b/src/translations/de.json index ea8646d..f66625e 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -508,6 +508,9 @@ "limit_riding_help": "Besatzung, die auf einer Einheit sein muss, damit sie in diese Spur passt. Wird bei Zuweisung geprüft; blockiert oder warnt je nach Anforderungen erzwingen.", "limit_time_help": "Rotationsrichtwerte in Minuten. Verlassen vor dem Minimum markiert den Wechsel; nach dem Maximum zeigt die Ressource das Ablösen-Badge. Nichts wird automatisch entfernt.", "limit_units_help": "Wie viele Einheiten diese Spur haben sollte. Unter dem Minimum gilt sie als unterbesetzt (blockiert nie); am Maximum werden weitere Einheiten blockiert oder gewarnt.", + "limit_worktime_amber": "Gelb nach (Min.)", + "limit_worktime_help": "Farbschwellen der Arbeitszeit-Anzeige für diese Lane. Leer = Farbe deaktiviert.", + "limit_worktime_red": "Rot nach (Min.)", "linked_map_label": "Angehängte Karte", "linked_need_label": "Verknüpfter Bedarf", "location_geocode_hint": "Kein Pin gesetzt — der Text wird beim Speichern geocodiert", @@ -730,7 +733,8 @@ "view_details": "Details anzeigen", "view_log": "Protokoll anzeigen", "view_on_map": "Auf Karte anzeigen", - "voice_section": "Sprachkanäle" + "voice_section": "Sprachkanäle", + "worktime_thresholds_label": "Arbeitszeit-Schwellen" }, "common": { "add": "Hinzufügen", diff --git a/src/translations/en.json b/src/translations/en.json index b6dd72d..1d6fbb1 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -508,6 +508,9 @@ "limit_riding_help": "Personnel that must be riding a unit for it to fit this lane. Checked when a unit is assigned; blocked or warned per Force Requirements.", "limit_time_help": "Rotation guidance in minutes. Leaving before the minimum flags the move; past the maximum the resource shows a Rotate badge. Nothing is removed automatically.", "limit_units_help": "How many units this lane should have. Below the minimum the lane shows as under-filled (never blocks); at the maximum, adding more units is blocked or warned.", + "limit_worktime_amber": "Amber after (min)", + "limit_worktime_help": "Work-time light color thresholds for this lane. Blank = that color disabled.", + "limit_worktime_red": "Red after (min)", "linked_map_label": "Attached Map", "linked_need_label": "Linked Need", "location_geocode_hint": "No pin set — the text will be geocoded on save", @@ -730,7 +733,8 @@ "view_details": "View Details", "view_log": "View Log", "view_on_map": "View on Map", - "voice_section": "Voice Channels" + "voice_section": "Voice Channels", + "worktime_thresholds_label": "Work-Time Thresholds" }, "common": { "add": "Add", diff --git a/src/translations/es.json b/src/translations/es.json index 8f0e1ee..abf52bc 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -508,6 +508,9 @@ "limit_riding_help": "Personal que debe ir a bordo de una unidad para que encaje en este carril. Se comprueba al asignar; bloquea o avisa según Forzar requisitos.", "limit_time_help": "Guía de rotación en minutos. Salir antes del mínimo marca el movimiento; superado el máximo el recurso muestra la insignia Rotar. Nada se elimina automáticamente.", "limit_units_help": "Cuántas unidades debería tener este carril. Por debajo del mínimo se muestra incompleto (nunca bloquea); en el máximo, añadir más unidades se bloquea o se avisa.", + "limit_worktime_amber": "Ámbar después de (min)", + "limit_worktime_help": "Umbrales de color del indicador de tiempo de trabajo para este carril. En blanco = color desactivado.", + "limit_worktime_red": "Rojo después de (min)", "linked_map_label": "Mapa adjunto", "linked_need_label": "Necesidad vinculada", "location_geocode_hint": "Sin pin: el texto se geocodificará al guardar", @@ -730,7 +733,8 @@ "view_details": "Ver detalles", "view_log": "Ver registro", "view_on_map": "Ver en el mapa", - "voice_section": "Canales de voz" + "voice_section": "Canales de voz", + "worktime_thresholds_label": "Umbrales de Tiempo de Trabajo" }, "common": { "add": "Añadir", diff --git a/src/translations/fr.json b/src/translations/fr.json index 1428c18..68f7db5 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -508,6 +508,9 @@ "limit_riding_help": "Personnel devant être à bord d’une unité pour qu’elle convienne à cette voie. Vérifié à l’affectation ; bloqué ou averti selon Forcer les exigences.", "limit_time_help": "Guide de rotation en minutes. Partir avant le minimum signale le mouvement ; au-delà du maximum la ressource affiche le badge Relève. Rien n’est retiré automatiquement.", "limit_units_help": "Nombre d’unités souhaité pour cette voie. Sous le minimum la voie apparaît incomplète (ne bloque jamais) ; au maximum, l’ajout d’unités est bloqué ou averti.", + "limit_worktime_amber": "Orange après (min)", + "limit_worktime_help": "Seuils de couleur de l’indicateur de temps de travail pour cette voie. Vide = couleur désactivée.", + "limit_worktime_red": "Rouge après (min)", "linked_map_label": "Carte attachée", "linked_need_label": "Besoin lié", "location_geocode_hint": "Aucun repère — le texte sera géocodé à l'enregistrement", @@ -730,7 +733,8 @@ "view_details": "Voir les détails", "view_log": "Voir le journal", "view_on_map": "Voir sur la carte", - "voice_section": "Canaux vocaux" + "voice_section": "Canaux vocaux", + "worktime_thresholds_label": "Seuils de Temps de Travail" }, "common": { "add": "Ajouter", diff --git a/src/translations/it.json b/src/translations/it.json index eeb8c2f..932d5a8 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -508,6 +508,9 @@ "limit_riding_help": "Personale che deve essere a bordo di un’unità perché sia adatta a questa corsia. Verificato all’assegnazione; bloccato o segnalato secondo Forza requisiti.", "limit_time_help": "Guida di rotazione in minuti. Uscire prima del minimo segnala lo spostamento; oltre il massimo la risorsa mostra il badge Ruotare. Nulla viene rimosso automaticamente.", "limit_units_help": "Quante unità dovrebbe avere questa corsia. Sotto il minimo appare incompleta (mai bloccante); al massimo, aggiungere unità viene bloccato o segnalato.", + "limit_worktime_amber": "Ambra dopo (min)", + "limit_worktime_help": "Soglie di colore dell’indicatore del tempo di lavoro per questa corsia. Vuoto = colore disattivato.", + "limit_worktime_red": "Rosso dopo (min)", "linked_map_label": "Mappa allegata", "linked_need_label": "Necessità collegata", "location_geocode_hint": "Nessun pin: il testo sarà geocodificato al salvataggio", @@ -730,7 +733,8 @@ "view_details": "Vedi dettagli", "view_log": "Visualizza registro", "view_on_map": "Vedi sulla mappa", - "voice_section": "Canali vocali" + "voice_section": "Canali vocali", + "worktime_thresholds_label": "Soglie Tempo di Lavoro" }, "common": { "add": "Aggiungi", diff --git a/src/translations/pl.json b/src/translations/pl.json index 12adf1f..6b2b627 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -508,6 +508,9 @@ "limit_riding_help": "Załoga wymagana na jednostce, aby pasowała do tego pasa. Sprawdzane przy przydziale; blokada lub ostrzeżenie zależnie od Wymuś wymagania.", "limit_time_help": "Wytyczne rotacji w minutach. Wyjście przed minimum oznacza ruch; po maksimum zasób pokazuje odznakę Zmiana. Nic nie jest usuwane automatycznie.", "limit_units_help": "Ile jednostek powinien mieć ten pas. Poniżej minimum pas jest niepełny (nigdy nie blokuje); przy maksimum dodanie jednostek jest blokowane lub ostrzegane.", + "limit_worktime_amber": "Bursztyn po (min)", + "limit_worktime_help": "Progi kolorów wskaźnika czasu pracy dla tego pasa. Puste = kolor wyłączony.", + "limit_worktime_red": "Czerwień po (min)", "linked_map_label": "Dołączona mapa", "linked_need_label": "Powiązana potrzeba", "location_geocode_hint": "Brak pinezki — tekst zostanie geokodowany przy zapisie", @@ -730,7 +733,8 @@ "view_details": "Zobacz szczegóły", "view_log": "Zobacz dziennik", "view_on_map": "Zobacz na mapie", - "voice_section": "Kanały głosowe" + "voice_section": "Kanały głosowe", + "worktime_thresholds_label": "Progi Czasu Pracy" }, "common": { "add": "Dodaj", diff --git a/src/translations/sv.json b/src/translations/sv.json index 684867c..9c2001a 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -508,6 +508,9 @@ "limit_riding_help": "Personal som måste vara ombord på en enhet för att den ska passa filen. Kontrolleras vid tilldelning; blockeras eller varnas enligt Tvinga krav.", "limit_time_help": "Rotationsguide i minuter. Att lämna före minimum flaggar flytten; efter maximum visar resursen Avlösning-märket. Inget tas bort automatiskt.", "limit_units_help": "Hur många enheter filen bör ha. Under minimum visas den som underbemannad (blockerar aldrig); vid maximum blockeras eller varnas fler enheter.", + "limit_worktime_amber": "Gul efter (min)", + "limit_worktime_help": "Färgtrösklar för arbetstidsindikatorn för denna fil. Tom = färgen avaktiverad.", + "limit_worktime_red": "Röd efter (min)", "linked_map_label": "Bifogad karta", "linked_need_label": "Kopplat behov", "location_geocode_hint": "Ingen nål — texten geokodas vid sparande", @@ -730,7 +733,8 @@ "view_details": "Visa detaljer", "view_log": "Visa logg", "view_on_map": "Visa på karta", - "voice_section": "Röstkanaler" + "voice_section": "Röstkanaler", + "worktime_thresholds_label": "Arbetstidströsklar" }, "common": { "add": "Lägg till", diff --git a/src/translations/uk.json b/src/translations/uk.json index 870039b..21b6fa9 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -508,6 +508,9 @@ "limit_riding_help": "Скільки людей має бути на підрозділі, щоб він підходив цій смузі. Перевіряється при призначенні; блокує або попереджає залежно від Примусових вимог.", "limit_time_help": "Орієнтири ротації у хвилинах. Вихід раніше мінімуму позначає переміщення; після максимуму ресурс показує значок Ротація. Нічого не видаляється автоматично.", "limit_units_help": "Скільки підрозділів має бути в цій смузі. Нижче мінімуму смуга неукомплектована (ніколи не блокує); на максимумі додавання підрозділів блокується або попереджається.", + "limit_worktime_amber": "Жовтий через (хв)", + "limit_worktime_help": "Кольорові пороги індикатора робочого часу для цієї смуги. Порожньо = колір вимкнено.", + "limit_worktime_red": "Червоний через (хв)", "linked_map_label": "Прикріплена мапа", "linked_need_label": "Пов'язана потреба", "location_geocode_hint": "Пін не встановлено — текст буде геокодовано під час збереження", @@ -730,7 +733,8 @@ "view_details": "Переглянути деталі", "view_log": "Переглянути журнал", "view_on_map": "Переглянути на карті", - "voice_section": "Голосові канали" + "voice_section": "Голосові канали", + "worktime_thresholds_label": "Пороги Робочого Часу" }, "common": { "add": "Додати", From ed11cbb88bf0a269cb1fd64c7c8a58d8ac0fc0ae Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 28 Jul 2026 10:06:17 -0700 Subject: [PATCH 2/2] RC-T40 PR#26 fixes --- .../command/landscape-structure-board.tsx | 4 +-- src/components/command/lane-details-sheet.tsx | 11 +++++--- .../app/__tests__/livekit-store.test.ts | 27 +++++++++++++++++-- src/stores/app/livekit-store.ts | 17 ++++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/components/command/landscape-structure-board.tsx b/src/components/command/landscape-structure-board.tsx index fee3093..f174134 100644 --- a/src/components/command/landscape-structure-board.tsx +++ b/src/components/command/landscape-structure-board.tsx @@ -27,8 +27,8 @@ const containerButtonRole = isWeb ? undefined : ('button' as const); const twoLine = isWeb ? {} : ({ numberOfLines: 2 } as const); /** Default work-time light thresholds (Tablet Command-style crew fatigue): green under 20m, amber under 40m, red past that. Lanes can override; 0 disables that color. */ -const DEFAULT_WORK_TIME_AMBER_MINUTES = 20; -const DEFAULT_WORK_TIME_RED_MINUTES = 40; +export const DEFAULT_WORK_TIME_AMBER_MINUTES = 20; +export const DEFAULT_WORK_TIME_RED_MINUTES = 40; export const workTimeColor = (minutes: number, amberAfterMinutes: number = DEFAULT_WORK_TIME_AMBER_MINUTES, redAfterMinutes: number = DEFAULT_WORK_TIME_RED_MINUTES) => redAfterMinutes > 0 && minutes >= redAfterMinutes ? '#ef4444' : amberAfterMinutes > 0 && minutes >= amberAfterMinutes ? '#f59e0b' : '#22c55e'; diff --git a/src/components/command/lane-details-sheet.tsx b/src/components/command/lane-details-sheet.tsx index 72cc1ee..d10172a 100644 --- a/src/components/command/lane-details-sheet.tsx +++ b/src/components/command/lane-details-sheet.tsx @@ -12,6 +12,8 @@ import { VStack } from '@/components/ui/vstack'; import { type CommandStructureNode, type IncidentMap, type IncidentNeed, IncidentNeedStatus, type TacticalObjective, TacticalObjectiveStatus } from '@/models/v4/incidentCommand/incidentCommandModels'; import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData'; +import { DEFAULT_WORK_TIME_AMBER_MINUTES, DEFAULT_WORK_TIME_RED_MINUTES } from './landscape-structure-board'; + /** One lead slot being edited: a Resgrid user OR an external contact. */ interface LeadDraft { userId: string | null; @@ -70,8 +72,8 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl setSecondaryObjectiveId(node.SecondaryObjectiveId ?? null); setLinkedNeedId(node.LinkedNeedId ?? null); setLinkedMapId(node.LinkedMapId ?? null); - setWorkTimeAmber(node.WorkTimeAmberMinutes ? String(node.WorkTimeAmberMinutes) : ''); - setWorkTimeRed(node.WorkTimeRedMinutes ? String(node.WorkTimeRedMinutes) : ''); + setWorkTimeAmber(node.WorkTimeAmberMinutes === undefined || node.WorkTimeAmberMinutes === null ? String(DEFAULT_WORK_TIME_AMBER_MINUTES) : String(node.WorkTimeAmberMinutes)); + setWorkTimeRed(node.WorkTimeRedMinutes === undefined || node.WorkTimeRedMinutes === null ? String(DEFAULT_WORK_TIME_RED_MINUTES) : String(node.WorkTimeRedMinutes)); setIsConfirmingDelete(false); } }, [node, isOpen]); @@ -93,8 +95,9 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl SecondaryObjectiveId: secondaryObjectiveId, LinkedNeedId: linkedNeedId, LinkedMapId: linkedMapId, - WorkTimeAmberMinutes: parseInt(workTimeAmber, 10) || 0, - WorkTimeRedMinutes: parseInt(workTimeRed, 10) || 0, + // Blank = unset (client defaults 20/40); explicit 0 = that color disabled. + WorkTimeAmberMinutes: workTimeAmber.trim() === '' ? undefined : parseInt(workTimeAmber, 10) || 0, + WorkTimeRedMinutes: workTimeRed.trim() === '' ? undefined : parseInt(workTimeRed, 10) || 0, }); onClose(); }, [node, primaryLead, secondaryLead, primaryObjectiveId, secondaryObjectiveId, linkedNeedId, linkedMapId, workTimeAmber, workTimeRed, onSave, onClose]); diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts index 51c900b..a984522 100644 --- a/src/stores/app/__tests__/livekit-store.test.ts +++ b/src/stores/app/__tests__/livekit-store.test.ts @@ -118,6 +118,7 @@ jest.mock('expo-audio', () => ({ jest.mock('../../../lib/logging', () => ({ logger: { info: jest.fn(), + warn: jest.fn(), error: jest.fn(), debug: jest.fn(), }, @@ -275,13 +276,35 @@ describe('LiveKit Store - Permission Management', () => { expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); }); - it('should never call session-activating request on iOS, even when not granted', async () => { + it('should return false on iOS when permission is permanently denied', async () => { + // canAskAgain=false means WebRTC cannot prompt either — fail so the caller + // can direct the user to Settings instead of joining without a mic. + mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ + granted: false, + canAskAgain: false, + expires: 'never', + status: 'denied', + } as any); + + const { requestPermissions } = useLiveKitStore.getState(); + const result = await requestPermissions(); + + expect(result).toBe(false); + expect(mockGetRecordingPermissionsAsync).toHaveBeenCalledTimes(1); + expect(mockRequestRecordingPermissionsAsync).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Microphone permission permanently denied - user must enable it in Settings', + context: { platform: 'ios' }, + }); + }); + + it('should never call session-activating request on iOS when permission can still be requested', async () => { // expo-audio's requestRecordingPermissionsAsync activates AVAudioSession // and deadlocks against expo-av — the store must only perform the // non-activating check and let WebRTC prompt natively on publish. mockGetRecordingPermissionsAsync.mockResolvedValueOnce({ granted: false, - canAskAgain: false, + canAskAgain: true, expires: 'never', status: 'denied', } as any); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index d57a7c4..75a19ca 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -404,6 +404,15 @@ export const useLiveKitStore = create((set, get) => ({ // prompt itself when a track is published. const micPermission = await getRecordingPermissionsAsync(); + if (!micPermission.granted && !micPermission.canAskAgain) { + // Permanently denied — WebRTC cannot prompt; the user must enable the mic in Settings. + logger.warn({ + message: 'Microphone permission permanently denied - user must enable it in Settings', + context: { platform: Platform.OS }, + }); + return false; + } + if (!micPermission.granted) { logger.info({ message: 'Microphone permission not yet granted - WebRTC will prompt on publish', @@ -937,6 +946,14 @@ export const useLiveKitStore = create((set, get) => ({ // Non-activating check only — see requestPermissions for why // requestRecordingPermissionsAsync must not be called on iOS. const mic = await getRecordingPermissionsAsync(); + if (!mic.granted && !mic.canAskAgain) { + // Permanently denied — WebRTC cannot prompt; the user must enable the mic in Settings. + logger.warn({ + message: 'Microphone permission permanently denied - user must enable it in Settings', + context: { platform: Platform.OS }, + }); + return false; + } if (!mic.granted) { logger.info({ message: 'Microphone permission not yet granted - WebRTC will prompt on publish',