From 6c14c1f4890e0bcb5d04825507327f8aaf9ffb99 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 15:17:05 -0700 Subject: [PATCH 1/2] RU-T50 Bug and Security fixes, permission fix --- app.config.ts | 1 + electron/main.js | 8 +- src/api/common/client.tsx | 57 ++-- src/api/notes/notes.ts | 4 + src/app/(app)/__tests__/index.test.tsx | 2 +- src/app/(app)/_layout.tsx | 31 +- src/app/(app)/calls.tsx | 32 ++- src/app/(app)/contacts.tsx | 7 +- src/app/(app)/index.tsx | 36 ++- src/app/(app)/notes.tsx | 7 +- src/app/(app)/protocols.tsx | 7 +- src/app/(app)/settings.tsx | 13 +- src/app/(app)/weather-alerts.tsx | 24 +- src/app/_layout.tsx | 3 + src/app/call/[id].tsx | 4 +- src/app/call/[id]/edit.tsx | 2 - src/app/call/new/index.tsx | 2 - src/app/login/sso.tsx | 7 +- src/app/routes/active.tsx | 5 +- src/app/routes/stop/[id].tsx | 6 +- .../call-video-feeds/video-player-modal.tsx | 23 +- src/components/calls/call-card.tsx | 5 +- src/components/calls/call-images-modal.tsx | 15 +- .../calls/dispatch-selection-modal.tsx | 32 ++- .../check-in-timers/check-in-timer-card.tsx | 7 +- src/components/contacts/contact-card.tsx | 4 +- src/components/maps/static-map.tsx | 89 +++--- src/components/notes/note-card.tsx | 4 +- .../settings/server-url-bottom-sheet.tsx | 4 +- .../__tests__/status-bottom-sheet.test.tsx | 41 ++- .../status-gps-integration-working.test.tsx | 19 +- .../__tests__/status-gps-integration.test.tsx | 19 +- src/components/status/status-bottom-sheet.tsx | 27 +- src/components/ui/html-renderer/index.web.tsx | 21 +- src/components/ui/utils.tsx | 1 - .../livekit-call/store/useLiveKitCallStore.ts | 8 +- .../__tests__/use-map-signalr-updates.test.ts | 45 +-- src/hooks/__tests__/use-saml-login.test.ts | 157 +++++++++- .../use-status-signalr-updates.test.tsx | 221 +++++++------- src/hooks/use-map-signalr-updates.ts | 10 +- src/hooks/use-saml-login.ts | 56 +++- src/hooks/use-status-signalr-updates.ts | 85 +++--- src/lib/auth/api.tsx | 15 +- src/lib/auth/refresh-lock.ts | 31 ++ src/lib/auth/session-cleanup.ts | 25 ++ src/lib/auth/types.tsx | 2 +- src/lib/cache/cache-manager.ts | 65 ++++- src/lib/logging/index.tsx | 52 +++- src/lib/shared-ticker.ts | 39 +++ src/lib/storage/index.tsx | 11 +- src/lib/utils.ts | 83 ++++-- .../__tests__/app-reset.service.test.ts | 216 ++++++++++++++ .../location-foreground-permissions.test.ts | 51 +++- src/services/__tests__/location.test.ts | 73 +++++ .../signalr.service.enhanced.test.ts | 10 +- .../__tests__/signalr.service.test.ts | 132 ++++++--- src/services/app-reset.service.ts | 147 +++++++++- src/services/check-in-notification.service.ts | 9 +- src/services/location.ts | 26 +- src/services/offline-event-manager.service.ts | 11 +- src/services/push-notification.ts | 33 +++ src/services/signalr.service.ts | 271 ++++++++++-------- src/stores/app/core-store.ts | 77 +++-- src/stores/app/livekit-store.ts | 69 ++++- src/stores/app/server-url-store.ts | 9 + src/stores/auth/store.tsx | 131 +++++---- src/stores/calls/detail-store.ts | 8 +- src/stores/calls/store.ts | 39 ++- src/stores/check-in-timers/store.ts | 6 + src/stores/offline-queue/store.ts | 22 +- .../signalr/__tests__/signalr-store.test.ts | 208 +++++++++++++- src/stores/signalr/__tests__/zz-dbg.test.ts | 48 ++++ src/stores/signalr/signalr-store.ts | 176 ++++++++---- src/stores/status/__tests__/store.test.ts | 36 ++- src/stores/status/store.ts | 16 +- 75 files changed, 2556 insertions(+), 742 deletions(-) create mode 100644 src/lib/auth/refresh-lock.ts create mode 100644 src/lib/auth/session-cleanup.ts create mode 100644 src/lib/shared-ticker.ts create mode 100644 src/stores/signalr/__tests__/zz-dbg.test.ts diff --git a/app.config.ts b/app.config.ts index 5439fb68..9622edc1 100644 --- a/app.config.ts +++ b/app.config.ts @@ -108,6 +108,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ 'android.permission.READ_PHONE_NUMBERS', 'android.permission.MANAGE_OWN_CALLS', ], + blockedPermissions: ['android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_MEDIA_VIDEO'], }, web: { favicon: './assets/favicon.png', diff --git a/electron/main.js b/electron/main.js index c5957f9b..88b12d6c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -75,9 +75,13 @@ function createWindow() { mainWindow = null; }); - // Handle external links + // Handle external links — only http(s) may be opened externally. Other + // schemes (file:, javascript:, custom protocols) are denied: a crafted link + // inside rendered content could otherwise escape to the OS handler. mainWindow.webContents.setWindowOpenHandler(({ url }) => { - require('electron').shell.openExternal(url); + if (/^https?:\/\//i.test(url)) { + require('electron').shell.openExternal(url); + } return { action: 'deny' }; }); } diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index 32183eba..b3c145c2 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -1,7 +1,5 @@ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; -import { refreshTokenRequest } from '@/lib/auth/api'; -import { isRefreshCredentialRejection } from '@/lib/auth/token-refresh'; import { logger } from '@/lib/logging'; import { getBaseApiUrl } from '@/lib/storage/app'; import useAuthStore from '@/stores/auth/store'; @@ -61,6 +59,10 @@ axiosInstance.interceptors.response.use( } // Handle 401 errors if (error.response?.status === 401 && !(originalRequest as InternalAxiosRequestConfig & { _retry?: boolean })._retry) { + // Mark as retried immediately — also covers requests queued while a + // refresh is in flight, so a second 401 never triggers another refresh. + (originalRequest as InternalAxiosRequestConfig & { _retry: boolean })._retry = true; + if (isRefreshing) { // If refreshing, queue the request return new Promise((resolve, reject) => { @@ -74,26 +76,28 @@ axiosInstance.interceptors.response.use( }); } - // Add _retry property to request config type - (originalRequest as InternalAxiosRequestConfig & { _retry: boolean })._retry = true; isRefreshing = true; try { - const refreshToken = useAuthStore.getState().refreshToken; - if (!refreshToken) { + if (!useAuthStore.getState().refreshToken) { throw new Error('No refresh token available'); } - const response = await refreshTokenRequest(refreshToken); - const { access_token, refresh_token: newRefreshToken } = response; + // Delegate to the auth store's single-flight refresh. Concurrent 401s, + // the proactive refresh timer and SignalR reconnects all share one + // request, so server-side refresh-token rotation never invalidates a + // parallel caller. The store also reschedules the proactive timer and + // performs the full logout + data wipe when the server rejects the + // refresh token. + const refreshed = await useAuthStore.getState().refreshAccessToken(); + if (!refreshed) { + throw new Error('Token refresh failed'); + } - // Update tokens in store - useAuthStore.setState({ - accessToken: access_token, - refreshToken: newRefreshToken, - status: 'signedIn', - error: null, - }); + const access_token = useAuthStore.getState().accessToken; + if (!access_token) { + throw new Error('No access token available after refresh'); + } // Update Authorization header axiosInstance.defaults.headers.common.Authorization = `Bearer ${access_token}`; @@ -104,22 +108,13 @@ axiosInstance.interceptors.response.use( } catch (refreshError) { processQueue(refreshError as Error); - // Only log the user out when the token endpoint explicitly rejected the - // refresh token (400 invalid_grant / 401). Transient failures — no response - // (offline/timeout), 5xx server errors, or 429 — must preserve the session - // and retry, otherwise a backend incident logs out every active responder. - if (isRefreshCredentialRejection(refreshError)) { - logger.warn({ - message: 'Token refresh rejected by server (invalid/expired credentials), logging out user', - context: { error: refreshError }, - }); - useAuthStore.getState().logout(); - } else { - logger.warn({ - message: 'Token refresh failed transiently, preserving session for retry', - context: { error: refreshError }, - }); - } + // The store has already classified the failure: a credential rejection + // (400/401 from the token endpoint) triggered logout there; anything + // else is transient and the session is preserved for a later retry. + logger.warn({ + message: 'Request failed after token refresh attempt', + context: { error: refreshError }, + }); return Promise.reject(refreshError); } finally { diff --git a/src/api/notes/notes.ts b/src/api/notes/notes.ts index 01df0563..7fe92695 100644 --- a/src/api/notes/notes.ts +++ b/src/api/notes/notes.ts @@ -1,3 +1,4 @@ +import { cacheManager } from '@/lib/cache/cache-manager'; import { type NoteCategoryResult } from '@/models/v4/notes/noteCategoryResult'; import { type NoteResult } from '@/models/v4/notes/noteResult'; import { type NotesResult } from '@/models/v4/notes/notesResult'; @@ -62,5 +63,8 @@ export const saveNote = async (data: SaveNoteInput) => { const response = await saveNoteApi.post({ ...data, }); + // The notes list is cached for 2 days — invalidate so the new/updated note + // is visible immediately instead of after cache expiry. + cacheManager.remove('/Notes/GetAllNotes'); return response.data; }; diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/app/(app)/__tests__/index.test.tsx index 306791de..86d16dba 100644 --- a/src/app/(app)/__tests__/index.test.tsx +++ b/src/app/(app)/__tests__/index.test.tsx @@ -160,7 +160,7 @@ describe('Map Component - App Lifecycle', () => { }); mockLocationService.startLocationUpdates = jest.fn().mockResolvedValue(undefined); - mockLocationService.stopLocationUpdates = jest.fn(); + mockLocationService.stopLocationUpdates = jest.fn().mockResolvedValue(undefined); }); afterEach(() => { diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index f9c114c0..b6196818 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -23,6 +23,7 @@ import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle'; import { useAuthStore } from '@/lib/auth'; +import { cacheManager } from '@/lib/cache/cache-manager'; import { logger } from '@/lib/logging'; import { useIsFirstTime } from '@/lib/storage'; import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; @@ -157,22 +158,32 @@ export default function TabLayout() { }); try { - // Initialize core app data (init() calls fetchConfig() internally) + // Initialize core app data first (init() calls fetchConfig() internally) — + // config is required for SignalR hub URLs. await useCoreStore.getState().init(); - await useRolesStore.getState().init(); - await useCallsStore.getState().init(); - await useWeatherAlertsStore.getState().init(); - await securityStore.getState().getRights(); - await useSignalRStore.getState().connectUpdateHub(); - await useSignalRStore.getState().connectGeolocationHub(); + // These fetches are independent of each other — run in parallel to cut + // time-to-interactive (previously 8+ serial network hops). + await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights()]); + + // SignalR needs config (EventingUrl) and rights (DepartmentId) — both + // available now. The two hub connects are independent. + await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]); hasInitialized.current = true; - // Initialize Bluetooth and Audio services (native-only) + // Evict expired/capped API cache entries once per cold start. + cacheManager.prune(); + + // Initialize Bluetooth and Audio services (native-only) — deferred so they + // don't block first paint behind the loading overlay. if (Platform.OS !== 'web') { - await bluetoothAudioService.initialize(); - await audioService.initialize(); + void Promise.all([bluetoothAudioService.initialize(), audioService.initialize()]).catch((error) => { + logger.warn({ + message: 'Failed to initialize bluetooth/audio services', + context: { error }, + }); + }); } logger.info({ diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx index d9acaa30..a61a98a7 100644 --- a/src/app/(app)/calls.tsx +++ b/src/app/(app)/calls.tsx @@ -1,7 +1,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { router } from 'expo-router'; import { PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, RefreshControl, View } from 'react-native'; @@ -20,6 +20,7 @@ import { securityStore } from '@/stores/security/store'; export default function Calls() { const calls = useCallsStore((state) => state.calls); + const callPriorities = useCallsStore((state) => state.callPriorities); const isLoading = useCallsStore((state) => state.isLoading); const error = useCallsStore((state) => state.error); const fetchCalls = useCallsStore((state) => state.fetchCalls); @@ -69,8 +70,25 @@ export default function Calls() { router.push('/call/new/'); }; - // Filter calls based on search query - const filteredCalls = calls.filter((call) => call.CallId.toLowerCase().includes(searchQuery.toLowerCase()) || (call.Nature?.toLowerCase() || '').includes(searchQuery.toLowerCase())); + // Priority lookup map — O(1) per row instead of an O(n) find per row per render + const prioritiesById = useMemo(() => new Map(callPriorities.map((p) => [p.Id, p])), [callPriorities]); + + // Filter calls based on search query (memoized — previously recomputed every render) + const filteredCalls = useMemo(() => { + const query = searchQuery.toLowerCase(); + return calls.filter((call) => call.CallId.toLowerCase().includes(query) || (call.Nature?.toLowerCase() || '').includes(query)); + }, [calls, searchQuery]); + + const renderItem = useCallback( + ({ item }: { item: CallResultData }) => ( + router.push(`/call/${item.CallId}`)}> + + + ), + [prioritiesById, callDispatches] + ); + + const keyExtractor = useCallback((item: CallResultData) => item.CallId, []); // Render content based on loading, error, and data states const renderContent = () => { @@ -86,12 +104,8 @@ export default function Calls() { testID="calls-list" data={filteredCalls} - renderItem={({ item }: { item: CallResultData }) => ( - router.push(`/call/${item.CallId}`)}> - p.Id === item.Priority)} dispatches={callDispatches[item.CallId]} /> - - )} - keyExtractor={(item: CallResultData) => item.CallId} + renderItem={renderItem} + keyExtractor={keyExtractor} refreshControl={} ListEmptyComponent={} contentContainerStyle={{ paddingBottom: 20 }} diff --git a/src/app/(app)/contacts.tsx b/src/app/(app)/contacts.tsx index ca14dc27..99169fab 100644 --- a/src/app/(app)/contacts.tsx +++ b/src/app/(app)/contacts.tsx @@ -61,6 +61,9 @@ export default function Contacts() { ); }, [contacts, searchQuery]); + const renderContact = React.useCallback(({ item }: { item: (typeof filteredContacts)[number] }) => , [selectContact]); + const contactKeyExtractor = React.useCallback((item: (typeof filteredContacts)[number]) => item.ContactId, []); + // Show loading page during initial fetch (when no contacts are loaded yet) if (isLoading && contacts.length === 0) { return ( @@ -90,8 +93,8 @@ export default function Contacts() { item.ContactId} - renderItem={({ item }) => } + keyExtractor={contactKeyExtractor} + renderItem={renderContact} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 100 }} refreshControl={} diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 8a5fd919..fdcc6b33 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -31,6 +31,11 @@ import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +// Minimum interval between programmatic camera-follow updates. GPS fixes +// arrive every ~15s; without a throttle each fix drives a native camera +// animation plus a full MapContent re-render. +const CAMERA_FOLLOW_THROTTLE_MS = 5000; + export default function Map() { const { t } = useTranslation(); const isInitialized = useCoreStore((state) => state.isInitialized); @@ -102,6 +107,9 @@ function MapContent() { const pulseAnim = useRef(new Animated.Value(1)).current; useMapSignalRUpdates(setMapPins); + // Throttle state for programmatic camera follow (see effect below) + const lastCameraFollowRef = useRef(0); + // Stable initial camera settings so the native Camera renders at the // correct position from the very first frame (fixes Android/iOS centering). const initialCameraSettings = useMemo(() => { @@ -254,8 +262,10 @@ function MapContent() { message: 'Location tracking started successfully', }); } catch (error) { + // NOTE: do not JSON.stringify the error — axios errors carry circular + // refs and stringify throws inside the catch handler. logger.error({ - message: 'MapPage: Failed to start location tracking. ' + JSON.stringify(error), + message: 'MapPage: Failed to start location tracking', context: { error, }, @@ -265,10 +275,22 @@ function MapContent() { } }; - startLocationTracking(); + startLocationTracking().catch((error) => { + logger.error({ + message: 'MapPage: Unexpected error starting location tracking', + context: { error }, + }); + }); return () => { - locationService.stopLocationUpdates(); + // Async cleanup — swallow rejections so they don't surface as unhandled + // promise rejections after unmount. + locationService.stopLocationUpdates().catch((error) => { + logger.warn({ + message: 'MapPage: Failed to stop location tracking cleanly', + context: { error }, + }); + }); }; }, []); @@ -280,6 +302,14 @@ function MapContent() { // When map is locked, always follow the location // When map is unlocked, only follow if user hasn't moved the map if (isMapLocked || !hasUserMovedMap) { + // Throttle programmatic camera moves — GPS fixes arrive every ~15s and + // each setCamera triggers a native camera animation + re-render. + const now = Date.now(); + if (now - lastCameraFollowRef.current < CAMERA_FOLLOW_THROTTLE_MS) { + return; + } + lastCameraFollowRef.current = now; + const cameraConfig: any = { centerCoordinate: [locationLongitude, locationLatitude], zoomLevel: isMapLocked ? 16 : 12, diff --git a/src/app/(app)/notes.tsx b/src/app/(app)/notes.tsx index f51a2d4f..bef15bc0 100644 --- a/src/app/(app)/notes.tsx +++ b/src/app/(app)/notes.tsx @@ -51,6 +51,9 @@ export default function Notes() { return notes.filter((note) => note.Title.toLowerCase().includes(query) || note.Body.toLowerCase().includes(query) || note.Category?.toLowerCase().includes(query)); }, [notes, searchQuery]); + const renderNote = React.useCallback(({ item }: { item: (typeof filteredNotes)[number] }) => , [selectNote]); + const noteKeyExtractor = React.useCallback((item: (typeof filteredNotes)[number]) => item.NoteId, []); + return ( <> @@ -74,8 +77,8 @@ export default function Notes() { item.NoteId} - renderItem={({ item }) => } + keyExtractor={noteKeyExtractor} + renderItem={renderNote} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 100 }} refreshControl={} diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx index c9d5150d..fcd119e7 100644 --- a/src/app/(app)/protocols.tsx +++ b/src/app/(app)/protocols.tsx @@ -58,6 +58,9 @@ export default function Protocols() { return protocols.filter((protocol) => protocol.Name.toLowerCase().includes(query) || protocol.Description?.toLowerCase().includes(query) || protocol.Code?.toLowerCase().includes(query)); }, [protocols, searchQuery]); + const renderProtocol = React.useCallback(({ item }: { item: (typeof filteredProtocols)[number] }) => , [handleProtocolPress]); + const protocolKeyExtractor = React.useCallback((item: (typeof filteredProtocols)[number], index: number) => item.ProtocolId || `protocol-${index}`, []); + return ( <> @@ -81,8 +84,8 @@ export default function Protocols() { item.ProtocolId || `protocol-${index}`} - renderItem={({ item }) => } + keyExtractor={protocolKeyExtractor} + renderItem={renderProtocol} showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 100 }} refreshControl={} diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 426723d2..92d10a03 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -28,7 +28,6 @@ import { useAuth, useAuthStore } from '@/lib'; import { logger } from '@/lib/logging'; import { getBaseApiUrl } from '@/lib/storage/app'; import { openLinkInBrowser } from '@/lib/utils'; -import { clearAllAppData } from '@/services/app-reset.service'; import { useCoreStore } from '@/stores/app/core-store'; import { useServerUrlStore } from '@/stores/app/server-url-store'; import { useUnitsStore } from '@/stores/units/store'; @@ -53,7 +52,9 @@ export default function Settings() { }, [activeUnit, t]); /** - * Handles logout confirmation - clears all data and signs out + * Handles logout confirmation - signs out. logout() itself runs the full + * app-data wipe (storage, stores, SignalR/LiveKit/location teardown) for + * every logout path, manual or forced. */ const handleLogoutConfirm = useCallback(async () => { setShowLogoutConfirm(false); @@ -62,18 +63,14 @@ export default function Settings() { hadActiveUnit: !!activeUnit, }); - // Clear all app data first using the centralized service try { - await clearAllAppData(); + await signOut(); } catch (error) { logger.error({ - message: 'Error during app data cleanup on logout', + message: 'Error during logout', context: { error }, }); } - - // Then sign out - await signOut(); }, [signOut, trackEvent, activeUnit]); const handleLoginInfoSubmit = async (data: { username: string; password: string }) => { diff --git a/src/app/(app)/weather-alerts.tsx b/src/app/(app)/weather-alerts.tsx index 5d910741..f707e054 100644 --- a/src/app/(app)/weather-alerts.tsx +++ b/src/app/(app)/weather-alerts.tsx @@ -1,7 +1,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { router } from 'expo-router'; import { CloudOff, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, RefreshControl, View } from 'react-native'; @@ -40,15 +40,19 @@ export default function WeatherAlerts() { setRefreshing(false); }; - // Filter alerts - const filteredAlerts = alerts.filter((alert) => { - if (severityFilter !== null && alert.Severity !== severityFilter) return false; - if (searchQuery) { - const query = searchQuery.toLowerCase(); - return (alert.Event ?? '').toLowerCase().includes(query) || (alert.Headline ?? '').toLowerCase().includes(query) || (alert.AreaDescription ?? '').toLowerCase().includes(query); - } - return true; - }); + // Filter alerts (memoized — previously recomputed every render) + const filteredAlerts = useMemo( + () => + alerts.filter((alert) => { + if (severityFilter !== null && alert.Severity !== severityFilter) return false; + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return (alert.Event ?? '').toLowerCase().includes(query) || (alert.Headline ?? '').toLowerCase().includes(query) || (alert.AreaDescription ?? '').toLowerCase().includes(query); + } + return true; + }), + [alerts, severityFilter, searchQuery] + ); const renderItem = useCallback( ({ item }: { item: WeatherAlertResultData }) => ( diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 58de1ad1..8a045ee4 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,6 +1,9 @@ // Import global CSS file import '../../global.css'; import '../lib/i18n'; +// Side-effect import: registers the full app-data wipe as the session-cleanup +// handler for every logout path (manual, forced 401, refresh rejection). +import '@/services/app-reset.service'; import { Env } from '@env'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index bc151004..36d99875 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,4 +1,3 @@ -import { format } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { ClipboardListIcon, ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, MapPinIcon, NavigationIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; @@ -26,6 +25,7 @@ import { useAnalytics } from '@/hooks/use-analytics'; import { getUnitTypeCheckInBadge } from '@/lib/check-in-timer-utils'; import { logger } from '@/lib/logging'; import { openMapsWithDirections } from '@/lib/navigation'; +import { parseApiUtcDate, safeFormatDate } from '@/lib/utils'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -353,7 +353,7 @@ export default function CallDetail() { {t('call_detail.timestamp')} - {format(new Date(call.LoggedOn), 'MMM d, h:mm a')} + {safeFormatDate(parseApiUtcDate(call.LoggedOnUtc) ?? call.LoggedOn, 'MMM d, h:mm a')} {t('call_detail.type')} diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 8a79ef52..7702ffa6 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -216,8 +216,6 @@ export default function EditCall() { data.longitude = selectedLocation.longitude; } - console.log('Updating call with data:', data); - const priority = callPriorities.find((p) => p.Name === data.priority); const type = callTypes.find((t) => t.Name === data.type); diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index 1a26241f..f6b591f4 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -204,8 +204,6 @@ export default function NewCall() { return; } - console.log('Creating new call with data:', data); - const response = await createCall({ name: data.name, nature: data.nature, diff --git a/src/app/login/sso.tsx b/src/app/login/sso.tsx index d41cc555..5b1a64d7 100644 --- a/src/app/login/sso.tsx +++ b/src/app/login/sso.tsx @@ -46,7 +46,7 @@ export default function SsoLogin() { clientId: ssoConfig?.clientId ?? '', }); - const { startSamlLogin, isSamlCallback } = useSamlLogin(); + const { startSamlLogin, isSamlCallback, validateSamlCallback } = useSamlLogin(); const { control, @@ -97,8 +97,9 @@ export default function SsoLogin() { useEffect(() => { const subscription = Linking.addEventListener('url', async ({ url }: { url: string }) => { if (!isSamlCallback(url)) return; - const parsed = Linking.parse(url); - const samlResponse = parsed.queryParams?.saml_response as string | undefined; + // Validates the RelayState nonce — callbacks not belonging to a flow this + // app initiated (login CSRF) are rejected here. + const samlResponse = await validateSamlCallback(url); if (!samlResponse) { setIsSsoLoading(false); setIsErrorModalVisible(true); diff --git a/src/app/routes/active.tsx b/src/app/routes/active.tsx index 789da4aa..1ae6d427 100644 --- a/src/app/routes/active.tsx +++ b/src/app/routes/active.tsx @@ -2,7 +2,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { Compass, LogOut, Navigation, SkipForward } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Alert, Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity } from 'react-native'; +import { Alert, AppState, Modal, ScrollView, StyleSheet, TextInput, TouchableOpacity } from 'react-native'; import Mapbox from '@/components/maps/mapbox'; import { RouteDeviationBanner } from '@/components/routes/route-deviation-banner'; @@ -147,6 +147,9 @@ export default function ActiveRouteScreen() { useEffect(() => { if (!resolvedInstanceId) return; const interval = setInterval(() => { + // Skip while backgrounded — Android keeps JS timers alive and polling + // would burn network/battery for a screen nobody is looking at. + if (AppState.currentState !== 'active') return; fetchRouteProgress(resolvedInstanceId); fetchStopsForInstance(resolvedInstanceId); fetchDeviations(); diff --git a/src/app/routes/stop/[id].tsx b/src/app/routes/stop/[id].tsx index e928a171..7ea71509 100644 --- a/src/app/routes/stop/[id].tsx +++ b/src/app/routes/stop/[id].tsx @@ -1,4 +1,3 @@ -import { format } from 'date-fns'; import { router, Stack, useLocalSearchParams } from 'expo-router'; import { CheckCircleIcon, ClockIcon, LogInIcon, LogOutIcon, MapPinIcon, SkipForwardIcon, UserIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; @@ -18,6 +17,7 @@ import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; +import { safeFormatDate } from '@/lib/utils'; import { RouteStopStatus } from '@/models/v4/routes/routeInstanceStopResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; @@ -215,11 +215,11 @@ export default function StopDetailScreen() { {t('routes.planned_arrival')} - {stop.PlannedArrival ? format(new Date(stop.PlannedArrival), 'MMM d, h:mm a') : '--'} + {safeFormatDate(stop.PlannedArrival, 'MMM d, h:mm a')} {t('routes.planned_departure')} - {stop.PlannedDeparture ? format(new Date(stop.PlannedDeparture), 'MMM d, h:mm a') : '--'} + {safeFormatDate(stop.PlannedDeparture, 'MMM d, h:mm a')} {stop.DwellTimeMinutes > 0 && ( diff --git a/src/components/call-video-feeds/video-player-modal.tsx b/src/components/call-video-feeds/video-player-modal.tsx index bbfbe92c..c627de86 100644 --- a/src/components/call-video-feeds/video-player-modal.tsx +++ b/src/components/call-video-feeds/video-player-modal.tsx @@ -32,6 +32,10 @@ export const VideoPlayerModal: React.FC = ({ isOpen, onCl if (!feed) return null; + // Feed URLs are admin/server-configured — only ever load https/http in the + // WebView and restrict JS to embed-style formats that actually need it. + const safeWebUrl = /^https?:\/\//i.test(feed.Url) ? feed.Url : undefined; + const renderPlayer = () => { switch (feed.FeedFormat) { case FeedFormat.HLS: @@ -40,9 +44,20 @@ export const VideoPlayerModal: React.FC = ({ isOpen, onCl case FeedFormat.YouTubeLive: case FeedFormat.Embed: + return safeWebUrl ? ( + + ) : ( + {t('video_feeds.webrtc_not_supported')} + ); + case FeedFormat.MJPEG: case FeedFormat.Other: - return ; + // MJPEG/plain pages don't need JavaScript — keep it off. + return safeWebUrl ? ( + + ) : ( + {t('video_feeds.webrtc_not_supported')} + ); case FeedFormat.RTSP: return ( @@ -63,7 +78,11 @@ export const VideoPlayerModal: React.FC = ({ isOpen, onCl ); default: - return ; + return safeWebUrl ? ( + + ) : ( + {t('video_feeds.webrtc_not_supported')} + ); } }; diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx index e8209e3d..ff38e062 100644 --- a/src/components/calls/call-card.tsx +++ b/src/components/calls/call-card.tsx @@ -34,7 +34,7 @@ interface CallCardProps { dispatches?: DispatchedEventResultData[]; } -export const CallCard: React.FC = ({ call, priority, showTimerIcon = false, isTimerOverdue = false, dispatches }) => { +export const CallCard: React.FC = React.memo(({ call, priority, showTimerIcon = false, isTimerOverdue = false, dispatches }) => { const { t } = useTranslation(); const textColor = invertColor(getColor(call, priority), true); const pulseAnim = useRef(new Animated.Value(1)).current; @@ -181,8 +181,7 @@ export const CallCard: React.FC = ({ call, priority, showTimerIco )} ); -}; - +}); const styles = StyleSheet.create({ container: { width: '100%', diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index 7484dd8c..e4998453 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -13,6 +13,7 @@ import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib'; +import { isIOS } from '@/lib/platform'; import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData'; import { useLocationStore } from '@/stores/app/location-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -111,13 +112,19 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call const handleImageSelect = async () => { try { - const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); - if (permissionResult.status !== 'granted') { - alert(t('common.permission_denied')); - return; + // Android uses the system photo picker, which grants access only to the + // selected item and does not require broad media-library permission. + if (isIOS) { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (permissionResult.status !== 'granted') { + alert(t('common.permission_denied')); + return; + } } + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], + legacy: false, allowsEditing: true, quality: 0.8, }); diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx index 6ece8566..915af8b8 100644 --- a/src/components/calls/dispatch-selection-modal.tsx +++ b/src/components/calls/dispatch-selection-modal.tsx @@ -24,10 +24,36 @@ interface DispatchSelectionModalProps { export const DispatchSelectionModal: React.FC = ({ isVisible, onClose, onConfirm, initialSelection }) => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); - const { data, selection, isLoading, error, searchQuery, fetchDispatchData, setSelection, toggleEveryone, toggleUser, toggleGroup, toggleRole, toggleUnit, setSearchQuery, clearSelection, getFilteredData } = - useDispatchStore(); + // Selective subscriptions — a whole-store subscription re-rendered the + // entire modal (incl. every recipient card) on each search keystroke. + const data = useDispatchStore((state) => state.data); + const selection = useDispatchStore((state) => state.selection); + const isLoading = useDispatchStore((state) => state.isLoading); + const error = useDispatchStore((state) => state.error); + const searchQuery = useDispatchStore((state) => state.searchQuery); + const fetchDispatchData = useDispatchStore((state) => state.fetchDispatchData); + const setSelection = useDispatchStore((state) => state.setSelection); + const toggleEveryone = useDispatchStore((state) => state.toggleEveryone); + const toggleUser = useDispatchStore((state) => state.toggleUser); + const toggleGroup = useDispatchStore((state) => state.toggleGroup); + const toggleRole = useDispatchStore((state) => state.toggleRole); + const toggleUnit = useDispatchStore((state) => state.toggleUnit); + const setSearchQuery = useDispatchStore((state) => state.setSearchQuery); + const clearSelection = useDispatchStore((state) => state.clearSelection); - const filteredData = getFilteredData(); + // Memoized filtering instead of recomputing getFilteredData() every render + const filteredData = React.useMemo(() => { + if (!searchQuery.trim()) { + return data; + } + const query = searchQuery.toLowerCase(); + return { + users: data.users.filter((user) => user.Name.toLowerCase().includes(query)), + groups: data.groups.filter((group) => group.Name.toLowerCase().includes(query)), + roles: data.roles.filter((role) => role.Name.toLowerCase().includes(query)), + units: data.units.filter((unit) => unit.Name.toLowerCase().includes(query)), + }; + }, [data, searchQuery]); useEffect(() => { if (isVisible) { diff --git a/src/components/check-in-timers/check-in-timer-card.tsx b/src/components/check-in-timers/check-in-timer-card.tsx index fa778d6f..d44df672 100644 --- a/src/components/check-in-timers/check-in-timer-card.tsx +++ b/src/components/check-in-timers/check-in-timer-card.tsx @@ -10,6 +10,7 @@ import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { getCheckInTimerStatusColor, getCheckInTimerStatusTranslationKey, isCheckInTimerCritical } from '@/lib/check-in-timer-utils'; +import { subscribeToSharedTicker } from '@/lib/shared-ticker'; import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTimerStatusResultData'; interface CheckInTimerCardProps { @@ -29,10 +30,10 @@ export const CheckInTimerCard: React.FC = ({ timer, onChe }, [timer.ElapsedMinutes]); useEffect(() => { - const interval = setInterval(() => { + // Shared ticker: all visible timer cards run on ONE interval. + return subscribeToSharedTicker(() => { setLocalElapsed((prev) => prev + 1 / 60); - }, 1000); - return () => clearInterval(interval); + }); }, []); const isCritical = isCheckInTimerCritical(timer.Status); diff --git a/src/components/contacts/contact-card.tsx b/src/components/contacts/contact-card.tsx index de2673d3..bd3fb782 100644 --- a/src/components/contacts/contact-card.tsx +++ b/src/components/contacts/contact-card.tsx @@ -10,7 +10,7 @@ interface ContactCardProps { onPress: (id: string) => void; } -export const ContactCard: React.FC = ({ contact, onPress }) => { +export const ContactCard: React.FC = React.memo(({ contact, onPress }) => { const getInitials = (name: string) => { return name .split(' ') @@ -67,4 +67,4 @@ export const ContactCard: React.FC = ({ contact, onPress }) => ); -}; +}); diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index c6b48620..44b9630f 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -1,15 +1,12 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Platform, StyleSheet, View } from 'react-native'; +import { Image, StyleSheet } from 'react-native'; -import Mapbox from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; import { Text } from '@/components/ui/text'; import { Env } from '@/lib/env'; - -// Ensure Mapbox access token is set before using any Mapbox components -Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); +import { useLocationStore } from '@/stores/app/location-store'; interface StaticMapProps { latitude: number; @@ -20,14 +17,40 @@ interface StaticMapProps { showUserLocation?: boolean; } +/** + * Renders a map snapshot via the Mapbox Static Images API instead of mounting + * a full interactive Mapbox GL map (style load, tile downloads, GL context per + * instance) — call/POI detail screens open noticeably faster and hold far less + * native memory. The destination (and optionally the user's own position) are + * drawn as pins. + */ const StaticMap: React.FC = ({ latitude, longitude, address, zoom = 15, height = 200, showUserLocation = false }) => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); - // Get map style based on current theme - const mapStyle = colorScheme === 'dark' ? Mapbox.StyleURL.Dark : Mapbox.StyleURL.Street; + const imageUrl = React.useMemo(() => { + if (!latitude || !longitude) { + return null; + } + + const styleId = colorScheme === 'dark' ? 'mapbox/dark-v11' : 'mapbox/streets-v12'; + const pins: string[] = [`pin-s+E53E3E(${longitude},${latitude})`]; + + // Snapshot of the user's own position (no subscription needed for a static image) + let position = `${longitude},${latitude},${zoom}`; + if (showUserLocation) { + const { latitude: userLat, longitude: userLon } = useLocationStore.getState(); + if (userLat !== null && userLon !== null) { + pins.push(`pin-s+3B82F6(${userLon},${userLat})`); + // 'auto' fits the viewport to both pins + position = 'auto'; + } + } + + return `https://api.mapbox.com/styles/v1/${styleId}/static/${pins.join(',')}/${position}/800x${Math.round(height)}@2x?access_token=${Env.UNIT_MAPBOX_PUBKEY}&logo=false&attribution=false`; + }, [latitude, longitude, zoom, height, showUserLocation, colorScheme]); - if (!latitude || !longitude) { + if (!imageUrl) { return ( {t('call_detail.no_location')} @@ -37,19 +60,7 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo return ( - - - {/* Marker pin for the location */} - - - - - - - - {/* Show user location if requested */} - {showUserLocation && } - + {/* Address overlay */} {address && ( @@ -70,42 +81,6 @@ const styles = StyleSheet.create({ map: { flex: 1, }, - markerContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 30, - height: 40, - }, - markerPin: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: '#E53E3E', - borderWidth: 3, - borderColor: '#FFFFFF', - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 3, - }, - android: { - elevation: 4, - }, - }), - }, - markerDot: { - width: 0, - height: 0, - borderLeftWidth: 6, - borderRightWidth: 6, - borderTopWidth: 8, - borderLeftColor: 'transparent', - borderRightColor: 'transparent', - borderTopColor: '#E53E3E', - marginTop: -2, - }, addressContainer: { position: 'absolute', bottom: 0, diff --git a/src/components/notes/note-card.tsx b/src/components/notes/note-card.tsx index 55008774..f02d7321 100644 --- a/src/components/notes/note-card.tsx +++ b/src/components/notes/note-card.tsx @@ -14,7 +14,7 @@ interface NoteCardProps { onPress: (id: string) => void; } -export const NoteCard: React.FC = ({ note, onPress }) => { +export const NoteCard: React.FC = React.memo(({ note, onPress }) => { return ( onPress(note.NoteId)}> @@ -35,4 +35,4 @@ export const NoteCard: React.FC = ({ note, onPress }) => { ); -}; +}); diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx index b1de750c..0cff458c 100644 --- a/src/components/settings/server-url-bottom-sheet.tsx +++ b/src/components/settings/server-url-bottom-sheet.tsx @@ -25,7 +25,9 @@ interface ServerUrlBottomSheetProps { onClose: () => void; } -const URL_PATTERN = /^https?:\/\/.+/; +// Plain HTTP would send the OAuth2 password grant and bearer tokens in +// cleartext — only allow it in dev builds (local testing against localhost). +const URL_PATTERN = __DEV__ ? /^https?:\/\/.+/ : /^https:\/\/.+/; export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetProps) { const { t } = useTranslation(); diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx index 0f7478dc..55b70860 100644 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx @@ -197,8 +197,19 @@ jest.mock('@/stores/app/core-store', () => { return { useCoreStore: mockStore }; }); -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn(), +jest.mock('@/stores/app/location-store', () => { + const mockStore: any = jest.fn(); + mockStore.getState = jest.fn(); + return { useLocationStore: mockStore }; +}); + +jest.mock('@/lib/logging', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, })); jest.mock('@/stores/roles/store', () => ({ @@ -411,21 +422,25 @@ describe('StatusBottomSheet', () => { return defaultRolesStore; }); + const defaultLocationState = { + latitude: 37.7749, + longitude: -122.4194, + heading: 0, + accuracy: 10, + speed: 0, + altitude: 0, + timestamp: Date.now(), + }; + mockUseLocationStore.mockImplementation((selector: any) => { - const store = { - latitude: 37.7749, - longitude: -122.4194, - heading: 0, - accuracy: 10, - speed: 0, - altitude: 0, - timestamp: Date.now(), - }; if (selector) { - return selector(store); + return selector(defaultLocationState); } - return store; + return defaultLocationState; }); + + // handleSubmit reads location imperatively via useLocationStore.getState() + (mockUseLocationStore as any).getState.mockReturnValue(defaultLocationState); }); it('should be importable without error', () => { diff --git a/src/components/status/__tests__/status-gps-integration-working.test.tsx b/src/components/status/__tests__/status-gps-integration-working.test.tsx index 6eae2a5a..edece91e 100644 --- a/src/components/status/__tests__/status-gps-integration-working.test.tsx +++ b/src/components/status/__tests__/status-gps-integration-working.test.tsx @@ -134,6 +134,17 @@ const mockUseLocationStore = useLocationStore as jest.MockedFunction; const mockUseCoreStore = require('@/stores/app/core-store').useCoreStore as jest.MockedFunction; const mockSaveUnitStatus = require('@/api/units/unitStatuses').saveUnitStatus as jest.MockedFunction; +// The status store only queues offline when isNetworkError() is true — i.e. a +// genuine axios connectivity failure (no response received). Plain Errors are +// treated as server rejections and rethrown instead of queued. +const createNetworkError = (): Error => { + const error = new Error('Network Error') as any; + error.isAxiosError = true; + error.code = 'ERR_NETWORK'; + error.request = {}; + return error; +}; + describe('Status GPS Integration', () => { let mockLocationStore: any; let mockCoreStore: any; @@ -279,7 +290,7 @@ describe('Status GPS Integration', () => { mockLocationStore.speed = 25; mockLocationStore.heading = 90; - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; @@ -312,7 +323,7 @@ describe('Status GPS Integration', () => { it('should not include GPS data in offline queue when location is unavailable', async () => { const { result } = renderHook(() => useStatusesStore()); - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; @@ -474,7 +485,7 @@ describe('Status GPS Integration', () => { mockLocationStore.latitude = 35.6762; mockLocationStore.longitude = 139.6503; - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; @@ -512,7 +523,7 @@ describe('Status GPS Integration', () => { mockLocationStore.accuracy = 8; mockLocationStore.speed = 30; - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; diff --git a/src/components/status/__tests__/status-gps-integration.test.tsx b/src/components/status/__tests__/status-gps-integration.test.tsx index 5c27e759..cfc05eec 100644 --- a/src/components/status/__tests__/status-gps-integration.test.tsx +++ b/src/components/status/__tests__/status-gps-integration.test.tsx @@ -43,6 +43,17 @@ const mockUseLocationStore = useLocationStore as jest.MockedFunction; const mockUseCoreStore = require('@/stores/app/core-store').useCoreStore as jest.MockedFunction; const mockSaveUnitStatus = require('@/api/units/unitStatuses').saveUnitStatus as jest.MockedFunction; +// The status store only queues offline when isNetworkError() is true — i.e. a +// genuine axios connectivity failure (no response received). Plain Errors are +// treated as server rejections and rethrown instead of queued. +const createNetworkError = (): Error => { + const error = new Error('Network Error') as any; + error.isAxiosError = true; + error.code = 'ERR_NETWORK'; + error.request = {}; + return error; +}; + describe('Status GPS Integration', () => { let mockLocationStore: any; let mockCoreStore: any; @@ -187,7 +198,7 @@ describe('Status GPS Integration', () => { mockLocationStore.speed = 25; mockLocationStore.heading = 90; - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; @@ -220,7 +231,7 @@ describe('Status GPS Integration', () => { it('should not include GPS data in offline queue when location is unavailable', async () => { const { result } = renderHook(() => useStatusesStore()); - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; @@ -381,7 +392,7 @@ describe('Status GPS Integration', () => { mockLocationStore.latitude = 35.6762; mockLocationStore.longitude = 139.6503; - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; @@ -419,7 +430,7 @@ describe('Status GPS Integration', () => { mockLocationStore.accuracy = 8; mockLocationStore.speed = 30; - mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); + mockSaveUnitStatus.mockRejectedValue(createNetworkError()); const input = new SaveUnitStatusInput(); input.Id = 'unit1'; diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index 3205e801..8bc2c96b 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { ScrollView, TouchableOpacity } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { logger } from '@/lib/logging'; import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; import { invertColor } from '@/lib/utils'; import { CustomStateDetailTypes, statusDetailAllowsCalls, statusDetailAllowsPois, statusDetailAllowsStations } from '@/models/v4/customStatuses/customStateDetailTypes'; @@ -130,13 +131,10 @@ export const StatusBottomSheet = () => { const activeStatuses = useCoreStore((state) => state.activeStatuses); const unitRoleAssignments = useRolesStore((state) => state.unitRoleAssignments); const saveUnitStatus = useStatusesStore((state) => state.saveUnitStatus); - const latitude = useLocationStore((state) => state.latitude); - const longitude = useLocationStore((state) => state.longitude); - const heading = useLocationStore((state) => state.heading); - const accuracy = useLocationStore((state) => state.accuracy); - const speed = useLocationStore((state) => state.speed); - const altitude = useLocationStore((state) => state.altitude); - const timestamp = useLocationStore((state) => state.timestamp); + // NOTE: location is read via useLocationStore.getState() inside handleSubmit + // instead of subscribing — this sheet is mounted at the root and subscribing + // here re-rendered the whole 900-line component on every GPS fix, even when + // the sheet is closed. const poiTypesById = React.useMemo(() => createPoiTypeMap(availablePoiTypes), [availablePoiTypes]); @@ -410,6 +408,9 @@ export const StatusBottomSheet = () => { input.RespondingToType = DestinationEntityTypes.Poi; } + // Read the latest GPS fix imperatively (no render subscription) + const { latitude, longitude, heading, accuracy, speed, altitude, timestamp } = useLocationStore.getState(); + if (latitude !== null && longitude !== null) { input.Latitude = latitude.toString(); input.Longitude = longitude.toString(); @@ -441,21 +442,19 @@ export const StatusBottomSheet = () => { showToast('success', t('status.status_saved_successfully')); reset(); } catch (error) { - console.error('Failed to save unit status:', error); + logger.error({ + message: 'Failed to save unit status', + context: { error }, + }); showToast('error', t('status.failed_to_save_status')); } finally { setIsSubmitting(false); } }, [ - accuracy, activeCallId, activeUnit, - altitude, getStatusId, - heading, isSubmitting, - latitude, - longitude, note, reset, saveUnitStatus, @@ -466,9 +465,7 @@ export const StatusBottomSheet = () => { selectedStatus, setActiveCall, showToast, - speed, t, - timestamp, unitRoleAssignments, ]); diff --git a/src/components/ui/html-renderer/index.web.tsx b/src/components/ui/html-renderer/index.web.tsx index e71ecc58..8c58fe3c 100644 --- a/src/components/ui/html-renderer/index.web.tsx +++ b/src/components/ui/html-renderer/index.web.tsx @@ -109,12 +109,20 @@ export const HtmlRenderer: React.FC = ({ html, style, scrollE // Listen for link-click messages from the iframe const handleMessage = useCallback( (event: MessageEvent) => { - // Validate message origin/source - only accept messages from our trusted iframe + // Validate message source — only accept messages from our own iframe. + // The iframe is sandboxed WITHOUT allow-same-origin, so its origin is + // opaque ('null') and its scripts cannot reach parent storage/cookies. if (event.data?.type === 'html-renderer-link' && event.data.url && event.source === iframeRef.current?.contentWindow) { + const url = String(event.data.url); + // Only open safe schemes — a crafted anchor could otherwise hand us a + // javascript: or data: URL. + if (!/^(https?:|mailto:)/i.test(url)) { + return; + } if (onLinkPress) { - onLinkPress(event.data.url); + onLinkPress(url); } else { - Linking.openURL(event.data.url); + Linking.openURL(url); } } }, @@ -129,8 +137,11 @@ export const HtmlRenderer: React.FC = ({ html, style, scrollE const flatStyle = StyleSheet.flatten([styles.container, style]) as Record; - // When onLinkPress is provided we need allow-scripts so the click-interceptor runs - const sandboxValue = onLinkPress ? 'allow-same-origin allow-scripts' : 'allow-same-origin'; + // allow-scripts is needed for the click-interceptor, but allow-same-origin + // must NEVER be set: combined with srcDoc it would give server-supplied HTML + // full access to this origin's web storage (incl. auth tokens). Without it + // the iframe runs on an opaque origin, isolated from the app. + const sandboxValue = onLinkPress ? 'allow-scripts' : ''; const iframeStyle: React.CSSProperties = { border: 'none', diff --git a/src/components/ui/utils.tsx b/src/components/ui/utils.tsx index c57d9a6e..de3828aa 100644 --- a/src/components/ui/utils.tsx +++ b/src/components/ui/utils.tsx @@ -10,7 +10,6 @@ export const HEIGHT = height; // for onError react queries and mutations export const showError = (error: AxiosError) => { - console.log(JSON.stringify(error?.response?.data)); const description = extractError(error?.response?.data).trimEnd(); showMessage({ diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts index 0f3a2286..55c8a6a5 100644 --- a/src/features/livekit-call/store/useLiveKitCallStore.ts +++ b/src/features/livekit-call/store/useLiveKitCallStore.ts @@ -105,8 +105,12 @@ export const useLiveKitCallStore = create((set, get) => ({ error: null, }); get().actions._updateParticipants(); // Initial participant list - newRoom.localParticipant.setMicrophoneEnabled(true); - newRoom.localParticipant.setCameraEnabled(false); // No video + newRoom.localParticipant.setMicrophoneEnabled(true).catch((error) => { + logger.warn({ message: 'Failed to enable microphone on connect', context: { error, roomId } }); + }); + newRoom.localParticipant.setCameraEnabled(false).catch((error) => { + logger.warn({ message: 'Failed to disable camera on connect', context: { error, roomId } }); + }); // No video bluetoothAudioService.ensurePttInputMonitoring('useLiveKitCallStore connected'); diff --git a/src/hooks/__tests__/use-map-signalr-updates.test.ts b/src/hooks/__tests__/use-map-signalr-updates.test.ts index f5adb99b..23d0c044 100644 --- a/src/hooks/__tests__/use-map-signalr-updates.test.ts +++ b/src/hooks/__tests__/use-map-signalr-updates.test.ts @@ -17,6 +17,17 @@ const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction; const mockUseSignalRStore = useSignalRStore as jest.MockedFunction; +// The hook's selector reads state.lastUpdateTimestamps (per-event timestamps) +// and takes the max across map-relevant events. A single map-relevant event +// entry is enough to simulate an update. +const stateWithUpdate = (timestamp: number) => ({ + lastUpdateTimestamps: timestamp > 0 ? { callsUpdated: timestamp } : {}, +}); +const mockStoreWithTimestamp = (timestamp: number) => { + const state = stateWithUpdate(timestamp); + mockUseSignalRStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(state) : state)); +}; + describe('useMapSignalRUpdates', () => { beforeEach(() => { jest.useFakeTimers(); @@ -60,7 +71,7 @@ describe('useMapSignalRUpdates', () => { jest.clearAllTimers(); // Reset store state - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: 0 }) : { lastUpdateTimestamp: 0}); + mockStoreWithTimestamp(0); // Mock successful API response by default mockGetMapDataAndMarkers.mockResolvedValue(mockMapData); }); @@ -70,7 +81,7 @@ describe('useMapSignalRUpdates', () => { }); it('should not trigger API call when lastUpdateTimestamp is 0', () => { - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: 0 }) : { lastUpdateTimestamp: 0}); + mockStoreWithTimestamp(0); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); // Fast forward timers to ensure debounce completes @@ -82,7 +93,7 @@ describe('useMapSignalRUpdates', () => { it('should trigger API call when lastUpdateTimestamp changes', async () => { const timestamp = Date.now(); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); @@ -102,7 +113,7 @@ describe('useMapSignalRUpdates', () => { const { rerender } = renderHook( (props) => { - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp }); + mockStoreWithTimestamp(props.timestamp); return useMapSignalRUpdates(mockOnMarkersUpdate); }, { initialProps: { timestamp } } @@ -144,7 +155,7 @@ describe('useMapSignalRUpdates', () => { const { rerender } = renderHook( (props) => { - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp }); + mockStoreWithTimestamp(props.timestamp); return useMapSignalRUpdates(mockOnMarkersUpdate); }, { initialProps: { timestamp } } @@ -201,7 +212,7 @@ describe('useMapSignalRUpdates', () => { const { rerender } = renderHook( (props) => { - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp }); + mockStoreWithTimestamp(props.timestamp); return useMapSignalRUpdates(mockOnMarkersUpdate); }, { initialProps: { timestamp: timestamp1 } } @@ -249,7 +260,7 @@ describe('useMapSignalRUpdates', () => { const timestamp = Date.now(); const error = new Error('API Error'); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); mockGetMapDataAndMarkers.mockRejectedValue(error); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); @@ -274,7 +285,7 @@ describe('useMapSignalRUpdates', () => { const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); mockGetMapDataAndMarkers.mockRejectedValue(abortError); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); @@ -300,7 +311,7 @@ describe('useMapSignalRUpdates', () => { const timestamp = Date.now(); const cancelError = new Error('canceled'); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); mockGetMapDataAndMarkers.mockRejectedValue(cancelError); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); @@ -339,7 +350,7 @@ describe('useMapSignalRUpdates', () => { }) as any; renderHook(() => { - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp1 }) : { lastUpdateTimestamp: timestamp1 }); + mockStoreWithTimestamp(timestamp1); return useMapSignalRUpdates(mockOnMarkersUpdate); }); @@ -369,7 +380,7 @@ describe('useMapSignalRUpdates', () => { }, }; - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); mockGetMapDataAndMarkers.mockResolvedValue(emptyMapData); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); @@ -386,7 +397,7 @@ describe('useMapSignalRUpdates', () => { it('should handle null API response', async () => { const timestamp = Date.now(); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); mockGetMapDataAndMarkers.mockResolvedValue(undefined as any); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); @@ -406,7 +417,7 @@ describe('useMapSignalRUpdates', () => { const { rerender } = renderHook( (props) => { - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: props.timestamp }) : { lastUpdateTimestamp: props.timestamp }); + mockStoreWithTimestamp(props.timestamp); return useMapSignalRUpdates(mockOnMarkersUpdate); }, { initialProps: { timestamp } } @@ -433,7 +444,7 @@ describe('useMapSignalRUpdates', () => { it('should cleanup timers and abort requests on unmount', () => { const timestamp = Date.now(); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); // Mock AbortController const mockAbort = jest.fn(); @@ -461,7 +472,7 @@ describe('useMapSignalRUpdates', () => { it('should maintain stable callback reference', async () => { const timestamp = Date.now(); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); const secondCallback = jest.fn(); const { rerender } = renderHook( @@ -488,7 +499,7 @@ describe('useMapSignalRUpdates', () => { it('should log debug information for debouncing', () => { const timestamp = Date.now(); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); @@ -504,7 +515,7 @@ describe('useMapSignalRUpdates', () => { it('should log successful marker updates', async () => { const timestamp = Date.now(); - mockUseSignalRStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ lastUpdateTimestamp: timestamp }) : { lastUpdateTimestamp: timestamp }); + mockStoreWithTimestamp(timestamp); renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate)); diff --git a/src/hooks/__tests__/use-saml-login.test.ts b/src/hooks/__tests__/use-saml-login.test.ts index c7521d60..0b96cdb8 100644 --- a/src/hooks/__tests__/use-saml-login.test.ts +++ b/src/hooks/__tests__/use-saml-login.test.ts @@ -1,16 +1,27 @@ import { renderHook } from '@testing-library/react-native'; import axios from 'axios'; +import * as Crypto from 'expo-crypto'; import * as Linking from 'expo-linking'; import * as WebBrowser from 'expo-web-browser'; +import { getItem, removeItem, setItem } from '@/lib/storage'; + import { useSamlLogin } from '../use-saml-login'; jest.mock('expo-web-browser'); +jest.mock('expo-crypto', () => ({ + randomUUID: jest.fn(), +})); jest.mock('expo-linking', () => ({ parse: jest.fn(), addEventListener: jest.fn(() => ({ remove: jest.fn() })), })); jest.mock('axios'); +jest.mock('@/lib/storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: jest.fn(() => 'https://api.resgrid.com/api/v4'), })); @@ -21,10 +32,20 @@ jest.mock('@/lib/logging', () => ({ const mockedWebBrowser = WebBrowser as jest.Mocked; const mockedLinking = Linking as jest.Mocked; const mockedAxios = axios as jest.Mocked; +const mockedCrypto = Crypto as jest.Mocked; +const mockedGetItem = getItem as jest.Mock; +const mockedSetItem = setItem as jest.Mock; +const mockedRemoveItem = removeItem as jest.Mock; + +const TEST_NONCE = 'test-nonce-123'; describe('useSamlLogin', () => { beforeEach(() => { jest.clearAllMocks(); + (mockedCrypto.randomUUID as jest.Mock).mockReturnValue(TEST_NONCE); + mockedSetItem.mockResolvedValue(undefined); + mockedRemoveItem.mockResolvedValue(undefined); + mockedGetItem.mockReturnValue(null); }); it('renders without error', () => { @@ -32,16 +53,29 @@ describe('useSamlLogin', () => { expect(result.current.startSamlLogin).toBeDefined(); expect(result.current.handleDeepLink).toBeDefined(); expect(result.current.isSamlCallback).toBeDefined(); + expect(result.current.validateSamlCallback).toBeDefined(); }); - it('startSamlLogin opens browser with the given URL', async () => { + it('startSamlLogin stores a RelayState nonce and opens browser with it appended', async () => { (mockedWebBrowser.openBrowserAsync as jest.Mock).mockResolvedValueOnce({ type: 'dismiss' }); const { result } = renderHook(() => useSamlLogin()); await result.current.startSamlLogin('https://idp.example.com/saml/sso'); + expect(mockedSetItem).toHaveBeenCalledWith('saml_pending_relay_state', TEST_NONCE); expect(mockedWebBrowser.openBrowserAsync).toHaveBeenCalledWith( - 'https://idp.example.com/saml/sso', + `https://idp.example.com/saml/sso?RelayState=${TEST_NONCE}`, + ); + }); + + it('startSamlLogin uses & separator when the URL already has a query string', async () => { + (mockedWebBrowser.openBrowserAsync as jest.Mock).mockResolvedValueOnce({ type: 'dismiss' }); + + const { result } = renderHook(() => useSamlLogin()); + await result.current.startSamlLogin('https://idp.example.com/saml/sso?foo=bar'); + + expect(mockedWebBrowser.openBrowserAsync).toHaveBeenCalledWith( + `https://idp.example.com/saml/sso?foo=bar&RelayState=${TEST_NONCE}`, ); }); @@ -61,12 +95,32 @@ describe('useSamlLogin', () => { expect(tokenResult).toBeNull(); }); + it('handleDeepLink returns null when relay state validation fails', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse', relay_state: 'attacker-state' }, + }); + mockedGetItem.mockReturnValue(TEST_NONCE); + + const { result } = renderHook(() => useSamlLogin()); + const tokenResult = await result.current.handleDeepLink( + 'resgridunit://auth/callback?saml_response=base64SamlResponse&relay_state=attacker-state', + 'john.doe', + ); + + expect(tokenResult).toBeNull(); + expect(mockedAxios.post).not.toHaveBeenCalled(); + expect(mockedRemoveItem).not.toHaveBeenCalled(); + }); + it('handleDeepLink exchanges SAMLResponse for Resgrid token on success', async () => { (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ scheme: 'resgridunit', path: 'auth/callback', - queryParams: { saml_response: 'base64SamlResponse' }, + queryParams: { saml_response: 'base64SamlResponse', relay_state: TEST_NONCE }, }); + mockedGetItem.mockReturnValue(TEST_NONCE); mockedAxios.post = jest.fn().mockResolvedValueOnce({ data: { @@ -79,7 +133,7 @@ describe('useSamlLogin', () => { const { result } = renderHook(() => useSamlLogin()); const tokenResult = await result.current.handleDeepLink( - 'resgridunit://auth/callback?saml_response=base64SamlResponse', + `resgridunit://auth/callback?saml_response=base64SamlResponse&relay_state=${TEST_NONCE}`, 'john.doe', ); @@ -95,26 +149,117 @@ describe('useSamlLogin', () => { expect.stringContaining('provider=saml2'), expect.any(Object), ); + + // Nonce consumed on success + expect(mockedRemoveItem).toHaveBeenCalledWith('saml_pending_relay_state'); }); it('handleDeepLink returns null when Resgrid API call fails', async () => { (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ scheme: 'resgridunit', path: 'auth/callback', - queryParams: { saml_response: 'base64SamlResponse' }, + queryParams: { saml_response: 'base64SamlResponse', relay_state: TEST_NONCE }, }); + mockedGetItem.mockReturnValue(TEST_NONCE); mockedAxios.post = jest.fn().mockRejectedValueOnce(new Error('API Error')); const { result } = renderHook(() => useSamlLogin()); const tokenResult = await result.current.handleDeepLink( - 'resgridunit://auth/callback?saml_response=base64SamlResponse', + `resgridunit://auth/callback?saml_response=base64SamlResponse&relay_state=${TEST_NONCE}`, 'john.doe', ); expect(tokenResult).toBeNull(); }); + describe('validateSamlCallback', () => { + it('returns the saml_response and consumes the nonce when relay state matches', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse', relay_state: TEST_NONCE }, + }); + mockedGetItem.mockReturnValue(TEST_NONCE); + + const { result } = renderHook(() => useSamlLogin()); + const samlResponse = await result.current.validateSamlCallback( + `resgridunit://auth/callback?saml_response=base64SamlResponse&relay_state=${TEST_NONCE}`, + ); + + expect(samlResponse).toBe('base64SamlResponse'); + expect(mockedRemoveItem).toHaveBeenCalledWith('saml_pending_relay_state'); + }); + + it('returns null when no SAML flow is pending', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse', relay_state: TEST_NONCE }, + }); + mockedGetItem.mockReturnValue(null); + + const { result } = renderHook(() => useSamlLogin()); + const samlResponse = await result.current.validateSamlCallback( + `resgridunit://auth/callback?saml_response=base64SamlResponse&relay_state=${TEST_NONCE}`, + ); + + expect(samlResponse).toBeNull(); + expect(mockedRemoveItem).not.toHaveBeenCalled(); + }); + + it('returns null when relay_state does not match the pending nonce', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse', relay_state: 'wrong-state' }, + }); + mockedGetItem.mockReturnValue(TEST_NONCE); + + const { result } = renderHook(() => useSamlLogin()); + const samlResponse = await result.current.validateSamlCallback( + 'resgridunit://auth/callback?saml_response=base64SamlResponse&relay_state=wrong-state', + ); + + expect(samlResponse).toBeNull(); + expect(mockedRemoveItem).not.toHaveBeenCalled(); + }); + + it('returns null when relay_state param is missing', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse' }, + }); + mockedGetItem.mockReturnValue(TEST_NONCE); + + const { result } = renderHook(() => useSamlLogin()); + const samlResponse = await result.current.validateSamlCallback( + 'resgridunit://auth/callback?saml_response=base64SamlResponse', + ); + + expect(samlResponse).toBeNull(); + expect(mockedRemoveItem).not.toHaveBeenCalled(); + }); + + it('returns null when saml_response param is missing', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { relay_state: TEST_NONCE }, + }); + mockedGetItem.mockReturnValue(TEST_NONCE); + + const { result } = renderHook(() => useSamlLogin()); + const samlResponse = await result.current.validateSamlCallback( + `resgridunit://auth/callback?relay_state=${TEST_NONCE}`, + ); + + expect(samlResponse).toBeNull(); + expect(mockedRemoveItem).not.toHaveBeenCalled(); + }); + }); + describe('isSamlCallback', () => { it('returns true for SAML callback URLs', () => { const { result } = renderHook(() => useSamlLogin()); diff --git a/src/hooks/__tests__/use-status-signalr-updates.test.tsx b/src/hooks/__tests__/use-status-signalr-updates.test.tsx index 6dbf2425..c13d24b8 100644 --- a/src/hooks/__tests__/use-status-signalr-updates.test.tsx +++ b/src/hooks/__tests__/use-status-signalr-updates.test.tsx @@ -1,39 +1,41 @@ -import { renderHook, waitFor } from '@testing-library/react-native'; +import { act, renderHook } from '@testing-library/react-native'; -import { getUnitStatus } from '@/api/units/unitStatuses'; +import { logger } from '@/lib/logging'; import { useCoreStore } from '@/stores/app/core-store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; import { useStatusSignalRUpdates } from '../use-status-signalr-updates'; // Mock the dependencies -jest.mock('@/api/units/unitStatuses'); +jest.mock('@/lib/logging'); jest.mock('@/stores/app/core-store'); jest.mock('@/stores/signalr/signalr-store'); -const mockGetUnitStatus = getUnitStatus as jest.MockedFunction; +const mockLogger = logger as jest.Mocked; const mockUseCoreStore = useCoreStore as jest.MockedFunction; const mockUseSignalRStore = useSignalRStore as jest.MockedFunction; describe('useStatusSignalRUpdates', () => { - const mockSetActiveUnitWithFetch = jest.fn(); + const mockRefreshActiveUnitStatus = jest.fn(); const mockCoreState = { - activeUnitId: '123', - setActiveUnitWithFetch: mockSetActiveUnitWithFetch, + activeUnitId: '123' as string | null, + refreshActiveUnitStatus: mockRefreshActiveUnitStatus, } as any; const mockSignalRState = { - lastUpdateTimestamp: 0, - lastUpdateMessage: null, + lastUnitStatusTimestamp: 0, + lastUnitStatusMessage: null as unknown, } as any; beforeEach(() => { + jest.useFakeTimers(); jest.clearAllMocks(); // Reset state to default values mockCoreState.activeUnitId = '123'; - mockCoreState.setActiveUnitWithFetch = mockSetActiveUnitWithFetch; - mockSignalRState.lastUpdateTimestamp = 0; - mockSignalRState.lastUpdateMessage = null; + mockCoreState.refreshActiveUnitStatus = mockRefreshActiveUnitStatus; + mockRefreshActiveUnitStatus.mockResolvedValue(undefined); + mockSignalRState.lastUnitStatusTimestamp = 0; + mockSignalRState.lastUnitStatusMessage = null; // Mock core store with selector support mockUseCoreStore.mockImplementation((selector) => { @@ -52,177 +54,200 @@ describe('useStatusSignalRUpdates', () => { }); }); + afterEach(() => { + jest.useRealTimers(); + }); + + const advancePastDebounce = () => { + act(() => { + jest.advanceTimersByTime(2000); + }); + }; + it('should not process updates when no active unit', () => { mockCoreState.activeUnitId = null; - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); }); it('should not process updates when timestamp is 0', () => { - mockSignalRState.lastUpdateTimestamp = 0; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); + mockSignalRState.lastUnitStatusTimestamp = 0; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); }); it('should not process updates when message is null', () => { - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = null; + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = null; renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); }); - it('should process unit status update for active unit', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; + it('should process unit status update for active unit after the debounce delay', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; renderHook(useStatusSignalRUpdates); - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); - }); + // Debounced — not called before the delay elapses + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); - it('should not process updates for different unit', async () => { - const mockMessage = JSON.stringify({ UnitId: '456', State: 'Available' }); + advancePastDebounce(); - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledWith('123'); + }); + + it('should not process updates for different unit', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '456', State: 'Available' }; renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); }); - it('should handle invalid JSON message gracefully', async () => { - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = 'invalid json'; + it('should handle non-object message gracefully', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = 'invalid json'; renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); }); - it('should not process the same timestamp twice', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; + it('should not process the same timestamp twice', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; const { rerender } = renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledWith('123'); - mockSetActiveUnitWithFetch.mockClear(); + mockRefreshActiveUnitStatus.mockClear(); // Rerender with same timestamp rerender({}); + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); }); - it('should process new timestamp after initial one', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; + it('should process new timestamp after initial one', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; const { rerender } = renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledWith('123'); - mockSetActiveUnitWithFetch.mockClear(); + mockRefreshActiveUnitStatus.mockClear(); // Update with new timestamp - mockSignalRState.lastUpdateTimestamp = 12346; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '123', State: 'Busy' }); + mockSignalRState.lastUnitStatusTimestamp = 12346; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Busy' }; rerender({}); + advancePastDebounce(); - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledWith('123'); }); it('should handle API errors gracefully', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; - - mockSetActiveUnitWithFetch.mockRejectedValue(new Error('API Error')); + mockRefreshActiveUnitStatus.mockRejectedValue(new Error('API Error')); renderHook(useStatusSignalRUpdates); - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); + await act(async () => { + jest.advanceTimersByTime(2000); + await Promise.resolve(); }); // Should not crash the hook - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledTimes(1); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ + message: 'Failed to process unit status update', + context: { error: expect.any(Error) }, + }); }); - it('should handle activeUnitId changes', async () => { - const mockMessage = JSON.stringify({ UnitId: '123', State: 'Available' }); - - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; + it('should handle activeUnitId changes', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; const { rerender } = renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('123'); - }); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledWith('123'); - mockSetActiveUnitWithFetch.mockClear(); + mockRefreshActiveUnitStatus.mockClear(); // Change active unit mockCoreState.activeUnitId = '456'; - // Same timestamp but different unit in message - mockSignalRState.lastUpdateTimestamp = 12346; - mockSignalRState.lastUpdateMessage = JSON.stringify({ UnitId: '456', State: 'Available' }); + // New timestamp with a message for the new unit + mockSignalRState.lastUnitStatusTimestamp = 12346; + mockSignalRState.lastUnitStatusMessage = { UnitId: '456', State: 'Available' }; rerender({}); + advancePastDebounce(); - await waitFor(() => { - expect(mockSetActiveUnitWithFetch).toHaveBeenCalledWith('456'); - }); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledWith('456'); }); - it('should handle message with no UnitId', async () => { - const mockMessage = JSON.stringify({ State: 'Available' }); + it('should handle message with no UnitId', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { State: 'Available' }; + + renderHook(useStatusSignalRUpdates); + advancePastDebounce(); + + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); + }); - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; + it('should handle empty message object', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = {}; renderHook(useStatusSignalRUpdates); + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).not.toHaveBeenCalled(); }); - it('should handle empty message object', async () => { - const mockMessage = JSON.stringify({}); + it('should coalesce a burst of updates into a single refresh', () => { + mockSignalRState.lastUnitStatusTimestamp = 12345; + mockSignalRState.lastUnitStatusMessage = { UnitId: '123', State: 'Available' }; - mockSignalRState.lastUpdateTimestamp = 12345; - mockSignalRState.lastUpdateMessage = mockMessage; + const { rerender } = renderHook(useStatusSignalRUpdates); - renderHook(useStatusSignalRUpdates); + // Burst of updates before the debounce elapses + mockSignalRState.lastUnitStatusTimestamp = 12346; + rerender({}); + mockSignalRState.lastUnitStatusTimestamp = 12347; + rerender({}); + + advancePastDebounce(); - expect(mockSetActiveUnitWithFetch).not.toHaveBeenCalled(); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledTimes(1); + expect(mockRefreshActiveUnitStatus).toHaveBeenCalledWith('123'); }); -}); \ No newline at end of file +}); diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts index 2a8ac416..d241e6b0 100644 --- a/src/hooks/use-map-signalr-updates.ts +++ b/src/hooks/use-map-signalr-updates.ts @@ -3,11 +3,15 @@ import { useCallback, useEffect, useRef } from 'react'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; import { logger } from '@/lib/logging'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; -import { useSignalRStore } from '@/stores/signalr/signalr-store'; +import { type UpdateHubEvent, useSignalRStore } from '@/stores/signalr/signalr-store'; // Debounce delay in milliseconds to prevent rapid consecutive API calls const DEBOUNCE_DELAY = 1000; +// Only these events can change what the map renders. Weather-alert and other +// update-hub events must NOT trigger a full marker refetch. +const MAP_RELEVANT_EVENTS: UpdateHubEvent[] = ['personnelStatusUpdated', 'personnelStaffingUpdated', 'unitStatusUpdated', 'callsUpdated', 'callAdded', 'callClosed']; + export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData[]) => void) => { const lastProcessedTimestamp = useRef(0); const isUpdating = useRef(false); @@ -15,7 +19,9 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData const debounceTimer = useRef | null>(null); const abortController = useRef(null); - const lastUpdateTimestamp = useSignalRStore((state) => state.lastUpdateTimestamp); + // Latest timestamp across map-relevant events only (primitive, so the + // selector is safe). Weather alerts etc. no longer trigger marker refetches. + const lastUpdateTimestamp = useSignalRStore((state) => Math.max(0, ...MAP_RELEVANT_EVENTS.map((event) => state.lastUpdateTimestamps[event] ?? 0))); // Use a ref so the callback doesn't need lastUpdateTimestamp in its deps const lastUpdateTimestampRef = useRef(lastUpdateTimestamp); diff --git a/src/hooks/use-saml-login.ts b/src/hooks/use-saml-login.ts index ea1cc9c2..41a05ae2 100644 --- a/src/hooks/use-saml-login.ts +++ b/src/hooks/use-saml-login.ts @@ -1,8 +1,10 @@ import axios from 'axios'; +import * as Crypto from 'expo-crypto'; import * as Linking from 'expo-linking'; import * as WebBrowser from 'expo-web-browser'; import { logger } from '@/lib/logging'; +import { getItem, removeItem, setItem } from '@/lib/storage'; import { getBaseApiUrl } from '@/lib/storage/app'; export interface SamlExchangeResult { @@ -14,6 +16,8 @@ export interface SamlExchangeResult { expiration_date?: string; } +const SAML_RELAY_STATE_KEY = 'saml_pending_relay_state'; + /** * SAML 2.0 SSO flow: * 1. Open the IdP-initiated SSO URL in the system browser. @@ -22,6 +26,12 @@ export interface SamlExchangeResult { * 4. The app intercepts the deep link and calls handleDeepLink() to exchange * the SAMLResponse for Resgrid access/refresh tokens. * + * Login-CSRF hardening: a random RelayState nonce is generated when the flow + * starts and appended to the IdP URL. The backend must echo it back as the + * `relay_state` query param on the app callback. Callbacks without a matching + * pending nonce are ignored, so an attacker cannot plant their own SAML + * response on a victim's device. + * * NOTE: The backend must expose a * GET/POST /api/v4/connect/saml-mobile-callback endpoint that accepts the * SAMLResponse and issues a 302 redirect to the app scheme (see plan Step 8). @@ -33,7 +43,13 @@ export function useSamlLogin() { */ async function startSamlLogin(idpSsoUrl: string): Promise { try { - await WebBrowser.openBrowserAsync(idpSsoUrl); + // Generate a one-time RelayState nonce for this flow. The backend ACS + // endpoint must round-trip it back to the app callback. + const relayState = Crypto.randomUUID(); + await setItem(SAML_RELAY_STATE_KEY, relayState); + + const separator = idpSsoUrl.includes('?') ? '&' : '?'; + await WebBrowser.openBrowserAsync(`${idpSsoUrl}${separator}RelayState=${encodeURIComponent(relayState)}`); } catch (error) { logger.error({ message: 'Failed to open SAML SSO browser', @@ -42,6 +58,38 @@ export function useSamlLogin() { } } + /** + * Validate that a deep-link SAML callback belongs to a flow this app started. + * Returns the SAMLResponse when valid, null otherwise. The nonce is consumed + * on use so a captured callback URL cannot be replayed. + */ + async function validateSamlCallback(url: string): Promise { + const parsed = Linking.parse(url); + const samlResponse = parsed.queryParams?.saml_response as string | undefined; + const relayState = parsed.queryParams?.relay_state as string | undefined; + + if (!samlResponse) { + logger.warn({ message: 'SAML deep-link missing saml_response param' }); + return null; + } + + const pendingRelayState = getItem(SAML_RELAY_STATE_KEY); + if (!pendingRelayState) { + // No SAML flow is pending — this callback was not initiated by this app. + logger.warn({ message: 'Ignoring SAML callback with no pending flow' }); + return null; + } + + if (!relayState || relayState !== pendingRelayState) { + logger.warn({ message: 'Ignoring SAML callback with mismatched relay state' }); + return null; + } + + // Consume the nonce — one-time use. + await removeItem(SAML_RELAY_STATE_KEY); + return samlResponse; + } + /** * Handle the deep-link callback that carries the base64-encoded SAMLResponse. * Returns the Resgrid token pair on success, or null on failure. @@ -51,11 +99,9 @@ export function useSamlLogin() { */ async function handleDeepLink(url: string, username: string): Promise { try { - const parsed = Linking.parse(url); - const samlResponse = parsed.queryParams?.saml_response as string | undefined; + const samlResponse = await validateSamlCallback(url); if (!samlResponse) { - logger.warn({ message: 'SAML deep-link missing saml_response param', context: { url } }); return null; } @@ -86,5 +132,5 @@ export function useSamlLogin() { return url.includes('auth/callback') && url.includes('saml_response'); } - return { startSamlLogin, handleDeepLink, isSamlCallback }; + return { startSamlLogin, handleDeepLink, isSamlCallback, validateSamlCallback }; } diff --git a/src/hooks/use-status-signalr-updates.ts b/src/hooks/use-status-signalr-updates.ts index 6893a4c4..804413ea 100644 --- a/src/hooks/use-status-signalr-updates.ts +++ b/src/hooks/use-status-signalr-updates.ts @@ -4,63 +4,62 @@ import { logger } from '@/lib/logging'; import { useCoreStore } from '@/stores/app/core-store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; +// Coalesce bursts of unitStatusUpdated messages into a single status fetch. +const DEBOUNCE_DELAY = 2000; + +interface UnitStatusSignalRMessage { + UnitId?: string; +} + export const useStatusSignalRUpdates = () => { const lastProcessedTimestamp = useRef(0); + const debounceTimer = useRef | null>(null); const activeUnitId = useCoreStore((state) => state.activeUnitId); - const setActiveUnitWithFetch = useCoreStore((state) => state.setActiveUnitWithFetch); + const refreshActiveUnitStatus = useCoreStore((state) => state.refreshActiveUnitStatus); - const lastUpdateTimestamp = useSignalRStore((state) => state.lastUpdateTimestamp); - const lastUpdateMessage = useSignalRStore((state) => state.lastUpdateMessage); + const lastUnitStatusTimestamp = useSignalRStore((state) => state.lastUnitStatusTimestamp); + const lastUnitStatusMessage = useSignalRStore((state) => state.lastUnitStatusMessage); useEffect(() => { - const handleStatusUpdate = async () => { - try { - if (!activeUnitId) { - logger.info({ - message: 'No active unit, skipping status update', - }); - return; - } + if (lastUnitStatusTimestamp <= 0 || lastUnitStatusTimestamp === lastProcessedTimestamp.current || !activeUnitId) { + return; + } - // Parse the SignalR message to check if it's a unit status update - if (lastUpdateMessage && typeof lastUpdateMessage === 'string') { - try { - const parsedMessage = JSON.parse(lastUpdateMessage); + // Message arrives as a raw object — no JSON round-trip needed. + const message = lastUnitStatusMessage as UnitStatusSignalRMessage | null; + if (!message || typeof message !== 'object' || message.UnitId !== activeUnitId) { + lastProcessedTimestamp.current = lastUnitStatusTimestamp; + return; + } - // Check if this is a unit status update message - if (parsedMessage && parsedMessage.UnitId === activeUnitId) { - logger.info({ - message: 'Processing unit status update for active unit', - context: { - unitId: activeUnitId, - timestamp: lastUpdateTimestamp, - message: parsedMessage, - }, - }); + // Debounce so a burst of status events yields ONE lightweight status fetch + // (previously each message refetched the entire fleet via fetchUnits()). + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } - // Refresh the active unit status - await setActiveUnitWithFetch(activeUnitId); + debounceTimer.current = setTimeout(() => { + debounceTimer.current = null; + lastProcessedTimestamp.current = lastUnitStatusTimestamp; - // Update the last processed timestamp - lastProcessedTimestamp.current = lastUpdateTimestamp; - } - } catch (parseError) { - logger.error({ - message: 'Failed to parse SignalR message', - context: { error: parseError, message: lastUpdateMessage }, - }); - } - } - } catch (error) { + logger.info({ + message: 'Refreshing active unit status from SignalR update', + context: { unitId: activeUnitId, timestamp: lastUnitStatusTimestamp }, + }); + + refreshActiveUnitStatus(activeUnitId).catch((error) => { logger.error({ message: 'Failed to process unit status update', context: { error }, }); + }); + }, DEBOUNCE_DELAY); + + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + debounceTimer.current = null; } }; - - if (lastUpdateTimestamp > 0 && lastUpdateTimestamp !== lastProcessedTimestamp.current && activeUnitId) { - handleStatusUpdate(); - } - }, [lastUpdateTimestamp, lastUpdateMessage, activeUnitId, setActiveUnitWithFetch]); + }, [lastUnitStatusTimestamp, lastUnitStatusMessage, activeUnitId, refreshActiveUnitStatus]); }; diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index a0076ee2..b429f059 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -35,7 +35,6 @@ export const loginRequest = async (credentials: LoginCredentials): Promise | null = null; + +export const refreshTokenSingleFlight = (refreshToken: string): Promise => { + if (inFlightRefresh) { + return inFlightRefresh; + } + + inFlightRefresh = refreshTokenRequest(refreshToken).finally(() => { + inFlightRefresh = null; + }); + + return inFlightRefresh; +}; + +/** Test hook: drop any pending in-flight reference. */ +export const resetInFlightRefresh = (): void => { + inFlightRefresh = null; +}; diff --git a/src/lib/auth/session-cleanup.ts b/src/lib/auth/session-cleanup.ts new file mode 100644 index 00000000..19238424 --- /dev/null +++ b/src/lib/auth/session-cleanup.ts @@ -0,0 +1,25 @@ +/** + * Session-cleanup registry. The auth store must trigger a full app-data wipe on + * EVERY logout path (manual, forced 401, refresh rejection), but the reset + * service imports stores that import the api client that imports the auth + * store — a static import would be a module cycle. This leaf module (zero + * imports) breaks the cycle: the reset service registers its handler here at + * module load, and the auth store invokes it. + */ + +export type SessionCleanupHandler = () => Promise; + +let sessionCleanupHandler: SessionCleanupHandler | null = null; + +export const registerSessionCleanupHandler = (handler: SessionCleanupHandler): void => { + sessionCleanupHandler = handler; +}; + +export const runSessionCleanup = async (): Promise => { + if (sessionCleanupHandler) { + await sessionCleanupHandler(); + } +}; + +/** Test hook. */ +export const _getSessionCleanupHandler = (): SessionCleanupHandler | null => sessionCleanupHandler; diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx index 2b3f2d28..140c86ac 100644 --- a/src/lib/auth/types.tsx +++ b/src/lib/auth/types.tsx @@ -57,7 +57,7 @@ export interface AuthState { login: (credentials: LoginCredentials) => Promise; ssoLogin: (credentials: SsoLoginCredentials) => Promise; logout: () => Promise; - refreshAccessToken: () => Promise; + refreshAccessToken: () => Promise; hydrate: () => void; isFirstTime: boolean; isAuthenticated: () => boolean; diff --git a/src/lib/cache/cache-manager.ts b/src/lib/cache/cache-manager.ts index e6690351..6215cdfc 100644 --- a/src/lib/cache/cache-manager.ts +++ b/src/lib/cache/cache-manager.ts @@ -1,4 +1,6 @@ +import { logger } from '@/lib/logging'; import { storage } from '@/lib/storage'; +import { getBaseApiUrl } from '@/lib/storage/app'; interface CacheItem { data: T; @@ -6,6 +8,10 @@ interface CacheItem { expiresIn: number; } +// Hard cap on cached entries — MMKV growth is otherwise unbounded because +// endpoints with varying params (ids, dates) create a key per combination. +const MAX_CACHE_ENTRIES = 200; + export class CacheManager { private static instance: CacheManager; private defaultTTL = 5 * 60 * 1000; // 5 minutes default @@ -21,7 +27,10 @@ export class CacheManager { private getCacheKey(endpoint: string, params?: Record): string { const queryString = params ? `?${new URLSearchParams(params as Record)}` : ''; - return `api_cache_${endpoint}${queryString}`; + // Scope by server base URL so cached data from one environment is never + // served against another after a server-URL switch. + const scope = getBaseApiUrl(); + return `api_cache_${scope}_${endpoint}${queryString}`; } private isExpired(timestamp: number, expiresIn: number): boolean { @@ -46,7 +55,15 @@ export class CacheManager { return null; } - const cacheItem: CacheItem = JSON.parse(cached); + let cacheItem: CacheItem; + try { + cacheItem = JSON.parse(cached); + } catch { + // Corrupted/truncated entry — evict it or every call for this key throws + // forever and the endpoint is permanently broken. + storage.delete(key); + return null; + } if (this.isExpired(cacheItem.timestamp, cacheItem.expiresIn)) { storage.delete(key); @@ -61,6 +78,50 @@ export class CacheManager { storage.delete(key); } + /** + * Deletes all expired cache entries and enforces the max-entry cap (oldest + * first). Call periodically, e.g. once during app init. + */ + prune(): void { + try { + const now = Date.now(); + const entries: { key: string; timestamp: number }[] = []; + + storage.getAllKeys().forEach((key) => { + if (!key.startsWith('api_cache_')) { + return; + } + const raw = storage.getString(key); + if (!raw) { + return; + } + try { + const item = JSON.parse(raw) as CacheItem; + if (now - item.timestamp > item.expiresIn) { + storage.delete(key); + } else { + entries.push({ key, timestamp: item.timestamp }); + } + } catch { + storage.delete(key); + } + }); + + if (entries.length > MAX_CACHE_ENTRIES) { + entries.sort((a, b) => a.timestamp - b.timestamp); + const toDelete = entries.length - MAX_CACHE_ENTRIES; + for (let i = 0; i < toDelete; i++) { + storage.delete(entries[i].key); + } + } + } catch (error) { + logger.warn({ + message: 'Cache prune failed', + context: { error }, + }); + } + } + clear(): void { const allKeys = storage.getAllKeys(); allKeys.forEach((key) => { diff --git a/src/lib/logging/index.tsx b/src/lib/logging/index.tsx index 60475690..0410fc15 100644 --- a/src/lib/logging/index.tsx +++ b/src/lib/logging/index.tsx @@ -4,12 +4,54 @@ import { consoleTransport, logger as rnLogger } from 'react-native-logs'; import type { LogContext, LogEntry, Logger, LogLevel } from './types'; -const SENSITIVE_KEYS = new Set(['token', 'password', 'passwd', 'secret', 'apikey', 'authorization', 'auth', 'cred', 'credentials', 'email', 'ssn']); +// Substring match: any context key CONTAINING one of these fragments is redacted. +// This catches camelCase/snake_case variants (accessToken, refresh_token, id_token, ...) +// without having to enumerate every possible spelling. +const SENSITIVE_KEY_PARTS = ['token', 'password', 'passwd', 'secret', 'apikey', 'api_key', 'authorization', 'cred', 'email', 'ssn', 'saml', 'cookie', 'session', 'username', 'grant']; + +const isSensitiveKey = (key: string): boolean => { + const lower = key.toLowerCase(); + return SENSITIVE_KEY_PARTS.some((part) => lower.includes(part)); +}; + +// Strip query string and hash — URLs can carry credentials (e.g. access_token params). +const sanitizeUrl = (url: unknown): string | undefined => { + if (typeof url !== 'string' || url.length === 0) return undefined; + return url.split('?')[0].split('#')[0]; +}; -const isSensitiveKey = (key: string): boolean => SENSITIVE_KEYS.has(key.toLowerCase()); +interface AxiosErrorLike { + isAxiosError?: boolean; + name?: string; + message?: string; + code?: string; + config?: { method?: string; url?: string; baseURL?: string }; + response?: { status?: number }; +} + +// Axios errors carry the full request (incl. urlencoded password/token bodies in +// config.data) and response objects with circular refs. Never forward them raw — +// reduce to a safe summary instead. +const isAxiosErrorLike = (value: unknown): value is AxiosErrorLike => typeof value === 'object' && value !== null && ((value as AxiosErrorLike).isAxiosError === true || ('config' in value && 'message' in value)); + +const summarizeAxiosError = (error: AxiosErrorLike): Record => ({ + name: error.name, + message: error.message, + code: error.code, + status: error.response?.status, + method: error.config?.method, + url: sanitizeUrl(error.config?.url), + baseURL: sanitizeUrl(error.config?.baseURL), + isAxiosError: true, +}); const sanitizeValue = (key: string, value: unknown, depth: number): unknown => { if (isSensitiveKey(key)) return '[REDACTED]'; + if (isAxiosErrorLike(value)) return summarizeAxiosError(value); + // Error instances have non-enumerable props; Object.keys() would yield {}. + if (value instanceof Error) { + return { name: value.name, message: value.message, stack: value.stack }; + } if (depth > 0 && value !== null && typeof value === 'object' && !Array.isArray(value)) { return sanitizeObject(value as Record, depth - 1); } @@ -62,6 +104,9 @@ const config = { enabled: !isJest, }; +const LEVEL_VALUES: Record = { debug: 0, info: 1, warn: 2, error: 3 }; +const MIN_SEVERITY: number = LEVEL_VALUES[(config.severity as LogLevel) ?? 'warn'] ?? 2; + class LogService { private static instance: LogService; private logger: any; @@ -79,6 +124,9 @@ class LogService { } private log(level: LogLevel, { message, context = {} }: LogEntry): void { + // Bail before allocating the context object on hot paths (SignalR messages, + // GPS fixes) when the level would be filtered out anyway. + if (isJest || LEVEL_VALUES[level] < MIN_SEVERITY) return; this.logger[level](message, { ...this.globalContext, ...context, diff --git a/src/lib/shared-ticker.ts b/src/lib/shared-ticker.ts new file mode 100644 index 00000000..f4122121 --- /dev/null +++ b/src/lib/shared-ticker.ts @@ -0,0 +1,39 @@ +/** + * Shared 1-second ticker. Components that tick every second (e.g. countdown + * cards) subscribe here instead of each running their own setInterval, so N + * visible cards cost ONE interval + one wakeup per second instead of N. + */ + +type TickCallback = () => void; + +const listeners = new Set(); +let interval: ReturnType | null = null; + +const tick = (): void => { + listeners.forEach((callback) => { + try { + callback(); + } catch { + // A throwing listener must not kill the shared ticker for everyone else. + } + }); +}; + +export const subscribeToSharedTicker = (callback: TickCallback): (() => void) => { + listeners.add(callback); + + if (!interval) { + interval = setInterval(tick, 1000); + } + + return () => { + listeners.delete(callback); + if (listeners.size === 0 && interval) { + clearInterval(interval); + interval = null; + } + }; +}; + +/** Test hook: current subscriber count. */ +export const getSharedTickerListenerCount = (): number => listeners.size; diff --git a/src/lib/storage/index.tsx b/src/lib/storage/index.tsx index 318426bf..7863c38d 100644 --- a/src/lib/storage/index.tsx +++ b/src/lib/storage/index.tsx @@ -17,7 +17,16 @@ const IS_FIRST_TIME = 'IS_FIRST_TIME'; export function getItem(key: string): T | null { const value = storage.getString(key); - return value ? JSON.parse(value) : null; + if (!value) { + return null; + } + try { + return JSON.parse(value); + } catch { + // Corrupted value — drop it so callers get a clean miss instead of a crash. + storage.delete(key); + return null; + } } export async function setItem(key: string, value: T) { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 6633492f..40b275df 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,33 @@ +import { format } from 'date-fns'; import { Linking } from 'react-native'; import { Platform } from 'react-native'; import type { StoreApi, UseBoundStore } from 'zustand'; +/** + * Parses an API UTC timestamp. Server "*Utc" fields arrive WITHOUT a timezone + * designator (e.g. "2025-08-06T17:30:00") and `new Date()` would parse those as + * DEVICE-local time, shifting the value by the timezone offset. Treat + * designator-less inputs as UTC. Returns null for unparseable input. + */ +export function parseApiUtcDate(input: unknown): Date | null { + if (input === null || input === undefined || input === '') { + return null; + } + + if (input instanceof Date) { + return Number.isNaN(input.getTime()) ? null : input; + } + + let value = String(input).trim(); + // Append Z only when the string carries no timezone info (no Z, no ±hh:mm) + if (!/[zZ]$/.test(value) && !/[+-]\d{2}:?\d{2}$/.test(value)) { + value = `${value}Z`; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + export function openLinkInBrowser(url: string) { Linking.canOpenURL(url).then((canOpen) => canOpen && Linking.openURL(url)); } @@ -282,6 +308,29 @@ export function subtractDaysFromDate(date: string, days: number): Date { return result; } +/** + * date-fns format() that never throws. API date fields can be empty strings or + * malformed — `new Date('')` is an Invalid Date and date-fns format() throws + * RangeError during render, crashing the whole screen. Returns the fallback + * for unparseable input. + */ +export function safeFormatDate(input: unknown, formatStr: string, fallback: string = '--'): string { + if (input === null || input === undefined || input === '') { + return fallback; + } + + const date = input instanceof Date ? input : new Date(input as string | number); + if (Number.isNaN(date.getTime())) { + return fallback; + } + + try { + return format(date, formatStr); + } catch { + return fallback; + } +} + export function getTimeAgo(time: any, floor: number = 0): string { if (!time) { return 'Unknown'; @@ -302,6 +351,11 @@ export function getTimeAgo(time: any, floor: number = 0): string { time = +new Date(); } + // Unparseable input → NaN — render a safe string instead of "NaN". + if (typeof time !== 'number' || !Number.isFinite(time)) { + return 'Unknown'; + } + const timeFormats = [ [60, 'seconds', 1], // 60 [120, '1 minute ago', '1 minute from now'], // 60*2 @@ -346,7 +400,7 @@ export function getTimeAgo(time: any, floor: number = 0): string { } } } - return time; + return String(time); } export function getTimeAgoUtc(time: any): string { @@ -354,23 +408,14 @@ export function getTimeAgoUtc(time: any): string { return 'Unknown'; } - switch (typeof time) { - case 'number': - break; - case 'string': - time = +new Date(time); - break; - case 'object': - if (time.constructor === Date) { - time = time.getTime(); - } - break; - default: - time = +new Date(); + // Parse UTC-naive API strings correctly instead of relying on device-local + // parsing plus timezone-offset math (which breaks if the server ever sends + // a real Z designator). + const parsed = parseApiUtcDate(time); + if (!parsed) { + return 'Unknown'; } - - const currentDate = new Date(); - time = Number(new Date(time).getTime() + 0 * 60 * 1000); + time = parsed.getTime(); const timeFormats = [ [60, 'seconds', 1], // 60 @@ -389,7 +434,7 @@ export function getTimeAgoUtc(time: any): string { [5806080000, 'Last century', 'Next century'], // 60*60*24*7*4*12*100*2 [58060800000, 'centuries', 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100 ]; - let seconds = (Number(new Date(currentDate).getTime() + new Date(currentDate).getTimezoneOffset() * 60 * 1000) - time) / 1000, + let seconds = (Date.now() - time) / 1000, token = 'ago', listChoice = 1; @@ -412,5 +457,5 @@ export function getTimeAgoUtc(time: any): string { } } } - return time; + return String(time); } diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 304d0d5b..12f59674 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -23,6 +23,37 @@ jest.mock('@/lib/storage/app', () => ({ removeDeviceUuid: jest.fn(), })); +// Mock react-query client +jest.mock('@/api/common/api-provider', () => ({ + queryClient: { + clear: jest.fn(), + }, +})); + +// Mock session cleanup registration (module registers a handler at import time) +jest.mock('@/lib/auth/session-cleanup', () => ({ + registerSessionCleanupHandler: jest.fn(), +})); + +// Mock services used by teardownServices +jest.mock('@/services/location', () => ({ + locationService: { + stopLocationUpdates: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('@/services/push-notification', () => ({ + pushNotificationService: { + unregisterFromPushNotifications: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('@/services/signalr.service', () => ({ + signalRService: { + disconnectAll: jest.fn().mockResolvedValue(undefined), + }, +})); + // Mock all the store imports jest.mock('@/stores/app/core-store', () => ({ useCoreStore: { @@ -151,6 +182,48 @@ jest.mock('@/stores/units/store', () => ({ }, })); +jest.mock('@/stores/maps/store', () => ({ + useMapsStore: { + setState: jest.fn(), + getState: jest.fn(() => ({})), + }, +})); + +jest.mock('@/stores/pois/store', () => ({ + usePoisStore: { + setState: jest.fn(), + getState: jest.fn(() => ({})), + }, +})); + +jest.mock('@/stores/routes/store', () => ({ + useRoutesStore: { + setState: jest.fn(), + getState: jest.fn(() => ({})), + }, +})); + +jest.mock('@/stores/weather-alerts/store', () => ({ + useWeatherAlertsStore: { + setState: jest.fn(), + getState: jest.fn(), + }, +})); + +jest.mock('@/stores/check-in-timers/store', () => ({ + useCheckInTimerStore: { + setState: jest.fn(), + getState: jest.fn(), + }, +})); + +jest.mock('@/stores/signalr/signalr-store', () => ({ + useSignalRStore: { + setState: jest.fn(), + getState: jest.fn(() => ({})), + }, +})); + import { clearAllAppData, clearAppStorageItems, @@ -163,13 +236,17 @@ import { INITIAL_DISPATCH_STATE, INITIAL_LIVEKIT_STATE, INITIAL_LOCATION_STATE, + INITIAL_MAPS_STATE, INITIAL_NOTES_STATE, + INITIAL_POIS_STATE, INITIAL_PROTOCOLS_STATE, INITIAL_PUSH_NOTIFICATION_MODAL_STATE, INITIAL_ROLES_STATE, + INITIAL_ROUTES_STATE, INITIAL_SECURITY_STATE, INITIAL_UNITS_STATE, resetAllStores, + teardownServices, } from '../app-reset.service'; // Get mock references after imports @@ -182,6 +259,8 @@ const mockOfflineQueueClear = jest.fn(); const mockLoadingReset = jest.fn(); const mockAudioCleanup = jest.fn().mockResolvedValue(undefined); const mockLiveKitDisconnect = jest.fn().mockResolvedValue(undefined); +const mockWeatherAlertsReset = jest.fn(); +const mockCheckInTimerReset = jest.fn(); describe('app-reset.service', () => { beforeEach(() => { @@ -193,6 +272,10 @@ describe('app-reset.service', () => { const { useLoadingStore } = jest.requireMock('@/stores/app/loading-store'); const { useOfflineQueueStore } = jest.requireMock('@/stores/offline-queue/store'); const { useStatusBottomSheetStore } = jest.requireMock('@/stores/status/store'); + const { useWeatherAlertsStore } = jest.requireMock('@/stores/weather-alerts/store'); + const { useCheckInTimerStore } = jest.requireMock('@/stores/check-in-timers/store'); + const { locationService } = jest.requireMock('@/services/location'); + const { signalRService } = jest.requireMock('@/services/signalr.service'); useLiveKitStore.getState.mockReturnValue({ isConnected: false, @@ -214,6 +297,17 @@ describe('app-reset.service', () => { useStatusBottomSheetStore.getState.mockReturnValue({ reset: mockStatusReset, }); + + useWeatherAlertsStore.getState.mockReturnValue({ + reset: mockWeatherAlertsReset, + }); + + useCheckInTimerStore.getState.mockReturnValue({ + reset: mockCheckInTimerReset, + }); + + locationService.stopLocationUpdates.mockResolvedValue(undefined); + signalRService.disconnectAll.mockResolvedValue(undefined); }); describe('Initial State Constants', () => { @@ -240,8 +334,15 @@ describe('app-reset.service', () => { calls: [], callPriorities: [], callTypes: [], + destinationPois: [], + poiTypes: [], + callDispatches: {}, + callDispatchesFetchedAt: {}, isLoading: false, + isInitialized: false, + isCallFormDataLoaded: false, error: null, + lastFetchedAt: 0, }); }); @@ -284,6 +385,7 @@ describe('app-reset.service', () => { unitRoleAssignments: [], users: [], isLoading: false, + isInitialized: false, error: null, }); }); @@ -379,6 +481,59 @@ describe('app-reset.service', () => { notification: null, }); }); + + it('should export INITIAL_MAPS_STATE with correct shape', () => { + expect(INITIAL_MAPS_STATE).toEqual({ + activeLayers: [], + layerToggles: {}, + cachedGeoJSON: {}, + indoorMaps: [], + currentIndoorMap: null, + currentFloorId: null, + currentFloor: null, + currentZonesGeoJSON: null, + customMaps: [], + currentCustomMap: null, + searchResults: [], + searchQuery: '', + isLoading: false, + isLoadingLayers: false, + isLoadingGeoJSON: false, + error: null, + }); + }); + + it('should export INITIAL_POIS_STATE with correct shape', () => { + expect(INITIAL_POIS_STATE).toEqual({ + poiTypes: [], + pois: [], + destinationPois: [], + poiDetails: {}, + selectedPoi: null, + isLoading: false, + isLoadingDetail: false, + error: null, + lastFetchedAt: 0, + }); + }); + + it('should export INITIAL_ROUTES_STATE with correct shape', () => { + expect(INITIAL_ROUTES_STATE).toEqual({ + routePlans: [], + activePlan: null, + activeInstance: null, + instanceStops: [], + directions: null, + deviations: [], + routeHistory: [], + isTracking: false, + isLoading: false, + isLoadingStops: false, + isLoadingDirections: false, + isInitialized: false, + error: null, + }); + }); }); describe('clearAppStorageItems', () => { @@ -407,6 +562,9 @@ describe('app-reset.service', () => { const { useCoreStore } = jest.requireMock('@/stores/app/core-store'); const { useCallsStore } = jest.requireMock('@/stores/calls/store'); const { useUnitsStore } = jest.requireMock('@/stores/units/store'); + const { useMapsStore } = jest.requireMock('@/stores/maps/store'); + const { usePoisStore } = jest.requireMock('@/stores/pois/store'); + const { useRoutesStore } = jest.requireMock('@/stores/routes/store'); await resetAllStores(); @@ -417,6 +575,11 @@ describe('app-reset.service', () => { expect(mockOfflineQueueClear).toHaveBeenCalled(); expect(mockLoadingReset).toHaveBeenCalled(); expect(mockAudioCleanup).toHaveBeenCalled(); + expect(useMapsStore.setState).toHaveBeenCalledWith(INITIAL_MAPS_STATE); + expect(usePoisStore.setState).toHaveBeenCalledWith(INITIAL_POIS_STATE); + expect(useRoutesStore.setState).toHaveBeenCalledWith(INITIAL_ROUTES_STATE); + expect(mockWeatherAlertsReset).toHaveBeenCalled(); + expect(mockCheckInTimerReset).toHaveBeenCalled(); }); it('should disconnect from LiveKit room if connected', async () => { @@ -449,10 +612,60 @@ describe('app-reset.service', () => { }); }); + describe('teardownServices', () => { + it('should disconnect SignalR, reset signalr store, stop location and unregister push', async () => { + const { signalRService } = jest.requireMock('@/services/signalr.service'); + const { useSignalRStore } = jest.requireMock('@/stores/signalr/signalr-store'); + const { locationService } = jest.requireMock('@/services/location'); + const { pushNotificationService } = jest.requireMock('@/services/push-notification'); + + await teardownServices(); + + expect(signalRService.disconnectAll).toHaveBeenCalled(); + expect(useSignalRStore.setState).toHaveBeenCalledWith( + expect.objectContaining({ + isUpdateHubConnected: false, + isGeolocationHubConnected: false, + }) + ); + expect(locationService.stopLocationUpdates).toHaveBeenCalled(); + expect(pushNotificationService.unregisterFromPushNotifications).toHaveBeenCalled(); + }); + + it('should tolerate a missing unregisterFromPushNotifications method', async () => { + const { pushNotificationService } = jest.requireMock('@/services/push-notification'); + const original = pushNotificationService.unregisterFromPushNotifications; + delete pushNotificationService.unregisterFromPushNotifications; + + await expect(teardownServices()).resolves.toBeUndefined(); + + pushNotificationService.unregisterFromPushNotifications = original; + }); + + it('should register clearAllAppData as the session cleanup handler at import time', () => { + const { registerSessionCleanupHandler } = jest.requireMock('@/lib/auth/session-cleanup'); + registerSessionCleanupHandler.mockClear(); + + jest.isolateModules(() => { + require('../app-reset.service'); + }); + + expect(registerSessionCleanupHandler).toHaveBeenCalledTimes(1); + }); + }); + describe('clearAllAppData', () => { it('should call all clearing functions in sequence', async () => { + const { signalRService } = jest.requireMock('@/services/signalr.service'); + const { locationService } = jest.requireMock('@/services/location'); + const { queryClient } = jest.requireMock('@/api/common/api-provider'); + await clearAllAppData(); + // Should tear down live services first + expect(signalRService.disconnectAll).toHaveBeenCalled(); + expect(locationService.stopLocationUpdates).toHaveBeenCalled(); + // Should clear app storage items expect(mockStorageApp.removeActiveUnitId).toHaveBeenCalled(); expect(mockStorageApp.removeActiveCallId).toHaveBeenCalled(); @@ -461,6 +674,9 @@ describe('app-reset.service', () => { // Should clear persisted storage expect(mockStorage.getAllKeys).toHaveBeenCalled(); expect(mockStorage.delete).toHaveBeenCalled(); + + // Should clear react-query cache + expect(queryClient.clear).toHaveBeenCalled(); }); it('should throw error if clearing fails', async () => { diff --git a/src/services/__tests__/location-foreground-permissions.test.ts b/src/services/__tests__/location-foreground-permissions.test.ts index cd77b716..31e4c0d1 100644 --- a/src/services/__tests__/location-foreground-permissions.test.ts +++ b/src/services/__tests__/location-foreground-permissions.test.ts @@ -32,6 +32,16 @@ jest.mock('@/lib/storage/background-geolocation', () => ({ loadBackgroundGeolocationState: jest.fn(), })); +jest.mock('@/services/offline-event-manager.service', () => ({ + offlineEventManager: { + queueLocationUpdateEvent: jest.fn(), + }, +})); + +jest.mock('@/utils/network', () => ({ + isNetworkError: jest.fn(), +})); + // Create mock store states const mockCoreStoreState = { activeUnitId: 'unit-123' as string | null, @@ -101,6 +111,8 @@ import { setUnitLocation } from '@/api/units/unitLocation'; import { logger } from '@/lib/logging'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; +import { offlineEventManager } from '@/services/offline-event-manager.service'; +import { isNetworkError } from '@/utils/network'; // Import the service after mocks are set up let locationService: any; @@ -109,6 +121,8 @@ let locationService: any; const mockSetUnitLocation = setUnitLocation as jest.MockedFunction; const mockLogger = logger as jest.Mocked; const mockLoadBackgroundGeolocationState = loadBackgroundGeolocationState as jest.MockedFunction; +const mockIsNetworkError = isNetworkError as jest.MockedFunction; +const mockQueueLocationUpdateEvent = offlineEventManager.queueLocationUpdateEvent as jest.Mock; const mockTaskManager = TaskManager as jest.Mocked; const mockLocation = Location as jest.Mocked; @@ -196,6 +210,9 @@ describe('LocationService - Foreground-Only Permissions', () => { // Setup API mock mockSetUnitLocation.mockResolvedValue(mockApiResponse); + // Default: errors are not network errors (no offline queueing) + mockIsNetworkError.mockReturnValue(false); + // Reset core store state mockCoreStoreState.activeUnitId = 'unit-123'; @@ -406,7 +423,7 @@ describe('LocationService - Foreground-Only Permissions', () => { }); await locationService.startLocationUpdates(); - + // Should register background task expect(mockLocation.startLocationUpdatesAsync).toHaveBeenCalledWith( 'location-updates', @@ -418,4 +435,36 @@ describe('LocationService - Foreground-Only Permissions', () => { ); }); }); + + describe('Offline Queueing', () => { + it('should queue location update for offline replay on network errors in foreground-only mode', async () => { + const networkError = new Error('Network Error'); + mockSetUnitLocation.mockRejectedValue(networkError); + mockIsNetworkError.mockReturnValue(true); + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockQueueLocationUpdateEvent).toHaveBeenCalledWith( + 'unit-123', + mockLocationObject.coords.latitude, + mockLocationObject.coords.longitude, + mockLocationObject.coords.accuracy, + mockLocationObject.coords.heading, + mockLocationObject.coords.speed + ); + }); + + it('should not queue location update for non-network errors in foreground-only mode', async () => { + mockSetUnitLocation.mockRejectedValue(new Error('Server rejected')); + mockIsNetworkError.mockReturnValue(false); + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockQueueLocationUpdateEvent).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts index 1adbf4c6..e4bd9ca9 100644 --- a/src/services/__tests__/location.test.ts +++ b/src/services/__tests__/location.test.ts @@ -16,6 +16,16 @@ jest.mock('@/lib/storage/background-geolocation', () => ({ loadBackgroundGeolocationState: jest.fn(), })); +jest.mock('@/services/offline-event-manager.service', () => ({ + offlineEventManager: { + queueLocationUpdateEvent: jest.fn(), + }, +})); + +jest.mock('@/utils/network', () => ({ + isNetworkError: jest.fn(), +})); + // Create mock store states const mockCoreStoreState = { activeUnitId: 'unit-123' as string | null, @@ -88,6 +98,8 @@ import { registerLocationServiceUpdater } from '@/lib/hooks/use-background-geolo import { logger } from '@/lib/logging'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; +import { offlineEventManager } from '@/services/offline-event-manager.service'; +import { isNetworkError } from '@/utils/network'; // Import the service after mocks are set up let locationService: any; @@ -97,6 +109,8 @@ const mockSetUnitLocation = setUnitLocation as jest.MockedFunction; const mockLogger = logger as jest.Mocked; const mockLoadBackgroundGeolocationState = loadBackgroundGeolocationState as jest.MockedFunction; +const mockIsNetworkError = isNetworkError as jest.MockedFunction; +const mockQueueLocationUpdateEvent = offlineEventManager.queueLocationUpdateEvent as jest.Mock; const mockTaskManager = TaskManager as jest.Mocked; const mockAppState = AppState as jest.Mocked; const mockLocation = Location as jest.Mocked; @@ -186,6 +200,9 @@ describe('LocationService', () => { // Setup API mock mockSetUnitLocation.mockResolvedValue(mockApiResponse); + // Default: errors are not network errors (no offline queueing) + mockIsNetworkError.mockReturnValue(false); + // Reset core store state mockCoreStoreState.activeUnitId = 'unit-123'; @@ -571,6 +588,62 @@ describe('LocationService', () => { }); }); + it('should queue location update for offline replay on network errors', async () => { + const networkError = new Error('Network Error'); + mockSetUnitLocation.mockRejectedValue(networkError); + mockIsNetworkError.mockReturnValue(true); + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: 'Failed to send location to API', + context: { + error: 'Network Error', + latitude: mockLocationObject.coords.latitude, + longitude: mockLocationObject.coords.longitude, + }, + }); + + expect(mockQueueLocationUpdateEvent).toHaveBeenCalledWith( + 'unit-123', + mockLocationObject.coords.latitude, + mockLocationObject.coords.longitude, + mockLocationObject.coords.accuracy, + mockLocationObject.coords.heading, + mockLocationObject.coords.speed + ); + }); + + it('should not queue location update for non-network errors', async () => { + const serverError = new Error('Server rejected'); + mockSetUnitLocation.mockRejectedValue(serverError); + mockIsNetworkError.mockReturnValue(false); + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockQueueLocationUpdateEvent).not.toHaveBeenCalled(); + }); + + it('should not queue location update on network error when no active unit', async () => { + mockCoreStoreState.activeUnitId = null; + const networkError = new Error('Network Error'); + mockSetUnitLocation.mockRejectedValue(networkError); + mockIsNetworkError.mockReturnValue(true); + + await locationService.startLocationUpdates(); + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1] as Function; + await locationCallback(mockLocationObject); + + expect(mockSetUnitLocation).not.toHaveBeenCalled(); + expect(mockQueueLocationUpdateEvent).not.toHaveBeenCalled(); + + mockCoreStoreState.activeUnitId = 'unit-123'; + }); + it('should log successful API calls', async () => { // Reset mock to resolved value mockSetUnitLocation.mockResolvedValue(mockApiResponse); diff --git a/src/services/__tests__/signalr.service.enhanced.test.ts b/src/services/__tests__/signalr.service.enhanced.test.ts index 9bd63fa2..54e5ec4c 100644 --- a/src/services/__tests__/signalr.service.enhanced.test.ts +++ b/src/services/__tests__/signalr.service.enhanced.test.ts @@ -236,7 +236,7 @@ describe('SignalRService - Enhanced Features', () => { // Should log the reconnection attempt scheduling expect(mockLogger.info).toHaveBeenCalledWith({ - message: `Scheduling reconnection attempt 1/5 for hub: ${mockConfig.name}`, + message: `Scheduling reconnection attempt 1/10 for hub: ${mockConfig.name} in 5000ms`, }); }); @@ -250,14 +250,14 @@ describe('SignalRService - Enhanced Features', () => { // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - // Trigger connection close multiple times to exceed max attempts - for (let i = 0; i < 6; i++) { + // Trigger connection close enough times to exceed max attempts (10) + for (let i = 0; i < 11; i++) { onCloseCallback(); } // Should log max attempts reached and cleanup expect(mockLogger.error).toHaveBeenCalledWith({ - message: `Max reconnection attempts (5) reached for hub: ${mockConfig.name}`, + message: `Max reconnection attempts (10) reached for hub: ${mockConfig.name}`, }); }); @@ -312,7 +312,7 @@ describe('SignalRService - Enhanced Features', () => { // Should log the reconnection attempt scheduling expect(mockLogger.info).toHaveBeenCalledWith({ - message: `Scheduling reconnection attempt 1/5 for hub: ${mockConfig.name}`, + message: `Scheduling reconnection attempt 1/10 for hub: ${mockConfig.name} in 5000ms`, }); // Clear previous logs to isolate subsequent logging diff --git a/src/services/__tests__/signalr.service.test.ts b/src/services/__tests__/signalr.service.test.ts index a55dd674..f180a9a6 100644 --- a/src/services/__tests__/signalr.service.test.ts +++ b/src/services/__tests__/signalr.service.test.ts @@ -153,22 +153,27 @@ describe('SignalRService', () => { ); }); - it('should use URL parameter for geolocation hub authentication', async () => { + it('should use accessTokenFactory for geolocation hub authentication (no token in URL)', async () => { // Create a geolocation config const geoConfig: SignalRHubConnectConfig = { name: 'geoHub', eventingUrl: 'https://api.example.com/', - hubName: 'geolocationHub', // This should match REALTIME_GEO_HUB_NAME from env + hubName: 'geolocationHub', methods: ['onPersonnelLocationUpdated'], }; await signalRService.connectToHubWithEventingUrl(geoConfig); - // Should connect with URL parameter instead of header auth + // Token must not appear in the URL — auth goes via accessTokenFactory expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( - 'https://api.example.com/geolocationHub?access_token=mock-token', - {} + 'https://api.example.com/geolocationHub', + expect.objectContaining({ + accessTokenFactory: expect.any(Function), + }) ); + + const options = mockBuilderInstance.withUrl.mock.calls[0][1] as any; + expect(options.accessTokenFactory()).toBe('mock-token'); }); it('should use header authentication for non-geolocation hubs', async () => { @@ -190,7 +195,7 @@ describe('SignalRService', () => { ); }); - it('should properly encode access token in URL for geolocation hub', async () => { + it('should not put access token with special chars in URL for geolocation hub', async () => { // Set up a token that needs encoding mockGetState.mockReturnValue({ accessToken: 'token with spaces & special chars', @@ -200,20 +205,25 @@ describe('SignalRService', () => { const geoConfig: SignalRHubConnectConfig = { name: 'geoHub', eventingUrl: 'https://api.example.com/', - hubName: 'geolocationHub', // This should match REALTIME_GEO_HUB_NAME from env + hubName: 'geolocationHub', methods: ['onPersonnelLocationUpdated'], }; await signalRService.connectToHubWithEventingUrl(geoConfig); - // Should properly encode the token in the URL (URLSearchParams uses + for spaces, which is correct) + // URL must stay clean regardless of token content expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( - 'https://api.example.com/geolocationHub?access_token=token+with+spaces+%26+special+chars', - {} + 'https://api.example.com/geolocationHub', + expect.objectContaining({ + accessTokenFactory: expect.any(Function), + }) ); + + const options = mockBuilderInstance.withUrl.mock.calls[0][1] as any; + expect(options.accessTokenFactory()).toBe('token with spaces & special chars'); }); - it('should properly URI encode complex access tokens for geolocation hub', async () => { + it('should not put complex access tokens in URL for geolocation hub', async () => { // Set up a complex token with various characters that need encoding mockGetState.mockReturnValue({ accessToken: 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9+/=?#&', @@ -229,27 +239,34 @@ describe('SignalRService', () => { await signalRService.connectToHubWithEventingUrl(geoConfig); - // Should properly encode all special characters in the token (URLSearchParams uses + for spaces, which is correct) + // URL must stay clean regardless of token content expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( - 'https://api.example.com/geolocationHub?access_token=Bearer+eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9%2B%2F%3D%3F%23%26', - {} + 'https://api.example.com/geolocationHub', + expect.objectContaining({ + accessTokenFactory: expect.any(Function), + }) ); + + const options = mockBuilderInstance.withUrl.mock.calls[0][1] as any; + expect(options.accessTokenFactory()).toBe('Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9+/=?#&'); }); - it('should handle URL with existing query parameters for geolocation hub', async () => { + it('should preserve existing query parameters without adding access_token for geolocation hub', async () => { const geoConfig: SignalRHubConnectConfig = { name: 'geoHub', eventingUrl: 'https://api.example.com/path?existing=param', - hubName: 'geolocationHub', // This should match REALTIME_GEO_HUB_NAME from env + hubName: 'geolocationHub', methods: ['onPersonnelLocationUpdated'], }; await signalRService.connectToHubWithEventingUrl(geoConfig); - // Should append the hub to the path and merge access_token with existing query parameters + // Hub appended to path, existing query string preserved, no access_token added expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( - 'https://api.example.com/path/geolocationHub?existing=param&access_token=mock-token', - {} + 'https://api.example.com/path/geolocationHub?existing=param', + expect.objectContaining({ + accessTokenFactory: expect.any(Function), + }) ); }); @@ -844,18 +861,20 @@ describe('SignalRService', () => { // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - // Simulate multiple failed reconnection attempts - for (let i = 0; i < 6; i++) { - onCloseCallback(); - jest.advanceTimersByTime(5000); - await jest.runAllTicks(); - // Simulate each attempt failing by removing the connection - (signalRService as any).connections.delete(mockConfig.name); - } + // Trigger the initial close — schedules attempt 1 + onCloseCallback(); + + // Each failed attempt now reschedules automatically (retry-until-cap). + // One large advance covers the full linear backoff schedule + // (5s, 10s, ..., 50s — sum is 275s). + await jest.advanceTimersByTimeAsync(600000); + + // Should have retried 10 times before giving up + expect(connectSpy).toHaveBeenCalledTimes(10); // Should log max attempts reached error expect(mockLogger.error).toHaveBeenCalledWith({ - message: `Max reconnection attempts (5) reached for hub: ${mockConfig.name}`, + message: `Max reconnection attempts (10) reached for hub: ${mockConfig.name}`, }); jest.useRealTimers(); @@ -903,23 +922,66 @@ describe('SignalRService', () => { onCloseCallback(); // Fast-forward time to trigger the setTimeout callback - jest.advanceTimersByTime(5000); - - // Wait for all promises to resolve - await jest.runAllTicks(); + await jest.advanceTimersByTimeAsync(5000); // Should have attempted to refresh token expect(mockRefreshAccessToken).toHaveBeenCalled(); - // Should have logged the failure + // Should have logged the failure and rescheduled (retry-until-cap semantics) expect(mockLogger.error).toHaveBeenCalledWith({ - message: `Failed to refresh token or reconnect to hub: ${mockConfig.name}`, - context: { error: expect.any(Error), attempts: 1, maxAttempts: 5 }, + message: `Reconnection attempt 1/10 failed for hub: ${mockConfig.name}`, + context: { error: expect.any(Error) }, }); // Should NOT have called connectToHubWithEventingUrl due to token refresh failure expect(connectSpy).not.toHaveBeenCalled(); + // A retry should have been scheduled (attempt counter advanced) + expect((signalRService as any).reconnectAttempts.get(mockConfig.name)).toBe(2); + + jest.useRealTimers(); + connectSpy.mockRestore(); + }); + + it('should abort reconnection and clean up when no token remains after refresh', async () => { + jest.useFakeTimers(); + + // Refresh succeeds but yields no access token (logged out) + mockRefreshAccessToken.mockImplementation(async () => { + mockGetState.mockReturnValue({ + accessToken: null, + refreshAccessToken: mockRefreshAccessToken, + }); + }); + + // Connect to hub + await signalRService.connectToHubWithEventingUrl(mockConfig); + + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; + + const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); + connectSpy.mockResolvedValue(); + + // Remove the connection to simulate it being closed + (signalRService as any).connections.delete(mockConfig.name); + + // Trigger connection close + onCloseCallback(); + + await jest.advanceTimersByTimeAsync(5000); + + // Should abort with a warn log + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: `No valid authentication token after refresh, aborting reconnect for hub: ${mockConfig.name}`, + }); + + // Should NOT attempt to reconnect + expect(connectSpy).not.toHaveBeenCalled(); + + // Hub config and attempts should be cleaned up — no further retries + expect((signalRService as any).hubConfigs.has(mockConfig.name)).toBe(false); + expect((signalRService as any).reconnectAttempts.has(mockConfig.name)).toBe(false); + jest.useRealTimers(); connectSpy.mockRestore(); }); diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index e274a8b5..714a1e96 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -6,9 +6,14 @@ * It's designed to be reusable and testable. */ +import { queryClient } from '@/api/common/api-provider'; +import { registerSessionCleanupHandler } from '@/lib/auth/session-cleanup'; import { logger } from '@/lib/logging'; import { storage } from '@/lib/storage'; import { removeActiveCallId, removeActiveUnitId, removeDeviceUuid } from '@/lib/storage/app'; +import { locationService } from '@/services/location'; +import { pushNotificationService } from '@/services/push-notification'; +import { signalRService } from '@/services/signalr.service'; import { useAudioStreamStore } from '@/stores/app/audio-stream-store'; import { INITIAL_STATE as BLUETOOTH_INITIAL_STATE, useBluetoothAudioStore } from '@/stores/app/bluetooth-audio-store'; import { useCoreStore } from '@/stores/app/core-store'; @@ -16,16 +21,22 @@ import { useLiveKitStore } from '@/stores/app/livekit-store'; import { useLoadingStore } from '@/stores/app/loading-store'; import { useLocationStore } from '@/stores/app/location-store'; import { useCallsStore } from '@/stores/calls/store'; +import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; import { useContactsStore } from '@/stores/contacts/store'; import { useDispatchStore } from '@/stores/dispatch/store'; +import { useMapsStore } from '@/stores/maps/store'; import { useNotesStore } from '@/stores/notes/store'; import { useOfflineQueueStore } from '@/stores/offline-queue/store'; +import { usePoisStore } from '@/stores/pois/store'; import { useProtocolsStore } from '@/stores/protocols/store'; import { usePushNotificationModalStore } from '@/stores/push-notification/store'; import { useRolesStore } from '@/stores/roles/store'; +import { useRoutesStore } from '@/stores/routes/store'; import { securityStore } from '@/stores/security/store'; +import { useSignalRStore } from '@/stores/signalr/signalr-store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import { useUnitsStore } from '@/stores/units/store'; +import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; // ============================================================================ // Initial State Constants @@ -52,8 +63,15 @@ export const INITIAL_CALLS_STATE = { calls: [] as never[], callPriorities: [] as never[], callTypes: [] as never[], + destinationPois: [] as never[], + poiTypes: [] as never[], + callDispatches: {}, + callDispatchesFetchedAt: {}, isLoading: false, + isInitialized: false, + isCallFormDataLoaded: false, error: null, + lastFetchedAt: 0, }; export const INITIAL_UNITS_STATE = { @@ -88,6 +106,7 @@ export const INITIAL_ROLES_STATE = { unitRoleAssignments: [] as never[], users: [] as never[], isLoading: false, + isInitialized: false, error: null, }; @@ -160,6 +179,53 @@ export const INITIAL_PUSH_NOTIFICATION_MODAL_STATE = { notification: null, }; +export const INITIAL_MAPS_STATE = { + activeLayers: [] as never[], + layerToggles: {}, + cachedGeoJSON: {}, + indoorMaps: [] as never[], + currentIndoorMap: null, + currentFloorId: null, + currentFloor: null, + currentZonesGeoJSON: null, + customMaps: [] as never[], + currentCustomMap: null, + searchResults: [] as never[], + searchQuery: '', + isLoading: false, + isLoadingLayers: false, + isLoadingGeoJSON: false, + error: null, +}; + +export const INITIAL_POIS_STATE = { + poiTypes: [] as never[], + pois: [] as never[], + destinationPois: [] as never[], + poiDetails: {}, + selectedPoi: null, + isLoading: false, + isLoadingDetail: false, + error: null, + lastFetchedAt: 0, +}; + +export const INITIAL_ROUTES_STATE = { + routePlans: [] as never[], + activePlan: null, + activeInstance: null, + instanceStops: [] as never[], + directions: null, + deviations: [] as never[], + routeHistory: [] as never[], + isTracking: false, + isLoading: false, + isLoadingStops: false, + isLoadingDirections: false, + isInitialized: false, + error: null, +}; + // Keys to preserve during storage clear (e.g., first-time flags) const STORAGE_KEYS_TO_PRESERVE = ['IS_FIRST_TIME']; @@ -239,6 +305,70 @@ export const resetAllStores = async (): Promise => { // Push notification modal store - reset usePushNotificationModalStore.setState(INITIAL_PUSH_NOTIFICATION_MODAL_STATE); + + // Department-scoped data stores that previously survived logout + useMapsStore.setState(INITIAL_MAPS_STATE); + usePoisStore.setState(INITIAL_POIS_STATE); + useRoutesStore.setState(INITIAL_ROUTES_STATE); + useWeatherAlertsStore.getState().reset(); + + // Check-in timer store — reset() also stops the 30s polling interval that + // would otherwise keep fetching the OLD call's timers under the NEW user's + // credentials after re-login. + useCheckInTimerStore.getState().reset(); +}; + +/** + * Tears down live services and realtime connections. MUST run on every logout + * path — otherwise the previous user's hub connections keep receiving events + * and stale connected-flags block the next user's SignalR session. + */ +export const teardownServices = async (): Promise => { + // SignalR: disconnect both hubs and clear the store's connected flags + try { + await signalRService.disconnectAll(); + } catch (error) { + logger.error({ + message: 'Error disconnecting SignalR hubs during reset', + context: { error }, + }); + } + useSignalRStore.setState({ + isUpdateHubConnected: false, + isGeolocationHubConnected: false, + lastUpdateMessage: null, + lastUpdateTimestamp: 0, + lastUpdateTimestamps: {}, + lastUnitStatusMessage: null, + lastUnitStatusTimestamp: 0, + lastGeolocationMessage: null, + lastGeolocationTimestamp: 0, + error: null, + }); + + // Location tracking: stop foreground/background updates (battery + privacy) + try { + await locationService.stopLocationUpdates(); + } catch (error) { + logger.error({ + message: 'Error stopping location updates during reset', + context: { error }, + }); + } + + // Push notifications: clear local token/badge/delivered notifications. + // (Method only exists on the native variant — web/Electron have no-op push.) + try { + const unregister = (pushNotificationService as { unregisterFromPushNotifications?: () => Promise }).unregisterFromPushNotifications; + if (unregister) { + await unregister.call(pushNotificationService); + } + } catch (error) { + logger.error({ + message: 'Error clearing push notification state during reset', + context: { error }, + }); + } }; /** @@ -253,6 +383,10 @@ export const clearAllAppData = async (): Promise => { }); try { + // Tear down realtime connections and background services first so nothing + // keeps writing into stores while they are being reset. + await teardownServices(); + // Clear persisted storage items clearAppStorageItems(); @@ -262,6 +396,9 @@ export const clearAllAppData = async (): Promise => { // Reset all zustand stores to their initial states await resetAllStores(); + // Drop all react-query cached data — query keys are not user-scoped. + queryClient.clear(); + logger.info({ message: 'Successfully cleared all app data', }); @@ -280,13 +417,13 @@ export default { clearAppStorageItems, clearPersistedStorage, resetAllStores, + teardownServices, // Export initial states for testing and external use INITIAL_CORE_STATE, INITIAL_CALLS_STATE, INITIAL_UNITS_STATE, INITIAL_CONTACTS_STATE, INITIAL_NOTES_STATE, - INITIAL_ROLES_STATE, INITIAL_PROTOCOLS_STATE, INITIAL_DISPATCH_STATE, INITIAL_SECURITY_STATE, @@ -295,4 +432,12 @@ export default { INITIAL_AUDIO_STREAM_STATE, INITIAL_BLUETOOTH_AUDIO_STATE, INITIAL_PUSH_NOTIFICATION_MODAL_STATE, + INITIAL_MAPS_STATE, + INITIAL_POIS_STATE, + INITIAL_ROUTES_STATE, }; + +// Register the full wipe as THE session-cleanup handler so every logout path +// in the auth store (manual, forced 401, refresh rejection) runs it. This +// module must be imported at app startup for the registration to take effect. +registerSessionCleanupHandler(clearAllAppData); diff --git a/src/services/check-in-notification.service.ts b/src/services/check-in-notification.service.ts index 36b10e0e..1c886ecf 100644 --- a/src/services/check-in-notification.service.ts +++ b/src/services/check-in-notification.service.ts @@ -5,6 +5,7 @@ import { logger } from '@/lib/logging'; const CHANNEL_ID = 'check-in-timers'; const NOTIFICATION_ID = 'check-in-timer-notification'; +const COUNTDOWN_TICK_SECONDS = 15; export interface NotificationLabels { statusLabels: Record; @@ -50,12 +51,14 @@ class CheckInNotificationService { await this.displayNotification(callName, callNumber, timerName); - // Local 1s countdown for smooth updates + // Local countdown. Updates every 15s (not 1s) — re-posting a notification + // to the Android NotificationManager every second for hours drains battery + // and churns the system notification service. this.stopCountdown(); this.countdownInterval = setInterval(async () => { - this.currentSeconds = Math.max(0, this.currentSeconds - 1); + this.currentSeconds = Math.max(0, this.currentSeconds - COUNTDOWN_TICK_SECONDS); await this.displayNotification(callName, callNumber, timerName); - }, 1000); + }, COUNTDOWN_TICK_SECONDS * 1000); } async updateNotification(secondsRemaining: number, status: string, statusLabels: Record): Promise { diff --git a/src/services/location.ts b/src/services/location.ts index b6997a65..1b58565a 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -8,16 +8,17 @@ import { logger } from '@/lib/logging'; import { isWeb } from '@/lib/platform'; import { loadBackgroundGeolocationState } from '@/lib/storage/background-geolocation'; import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; +import { offlineEventManager } from '@/services/offline-event-manager.service'; import { useCoreStore } from '@/stores/app/core-store'; import { useLocationStore } from '@/stores/app/location-store'; +import { isNetworkError } from '@/utils/network'; const LOCATION_TASK_NAME = 'location-updates'; // Helper function to send location to API const sendLocationToAPI = async (location: Location.LocationObject): Promise => { + const { activeUnitId } = useCoreStore.getState(); try { - const { activeUnitId } = useCoreStore.getState(); - if (!activeUnitId) { logger.warn({ message: 'No active unit selected, skipping location API call', @@ -56,6 +57,27 @@ const sendLocationToAPI = async (location: Location.LocationObject): Promise this.processEvent(event)); + // Process events SEQUENTIALLY in creation order. Concurrent batches could + // land two UNIT_STATUS/LOCATION events for the same unit out of order on + // the server, showing a stale status/position as current. + const eventsToProcess = [...pendingEvents].sort((a, b) => a.createdAt - b.createdAt).slice(0, this.MAX_CONCURRENT_EVENTS); try { - await Promise.allSettled(processingPromises); + for (const event of eventsToProcess) { + await this.processEvent(event); + } } catch (error) { logger.error({ message: 'Error during batch event processing', diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 7f9cf5af..ccb73bf2 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -238,6 +238,13 @@ class PushNotificationService { return; } if (identifier) { + // Cap the dedupe set — one entry per tap, never pruned otherwise. + if (this.handledResponseIds.size >= 200) { + const oldest = this.handledResponseIds.values().next().value; + if (oldest !== undefined) { + this.handledResponseIds.delete(oldest); + } + } this.handledResponseIds.add(identifier); } @@ -467,6 +474,32 @@ class PushNotificationService { return this.pushToken; } + /** + * Best-effort local push teardown on logout: clears the in-memory token, + * badge and delivered notifications so the previous user's alerts don't + * linger for the next user of the device. NOTE: the backend has no + * unregister-device endpoint yet — server-side token invalidation should be + * added there and called from here when available. + */ + public async unregisterFromPushNotifications(): Promise { + try { + this.pushToken = null; + await Notifications.setBadgeCountAsync(0).catch(() => {}); + await Notifications.dismissAllNotificationsAsync().catch(() => {}); + if (Platform.OS === 'android') { + await notifee.cancelAllNotifications().catch(() => {}); + } + logger.info({ + message: 'Push notification local state cleared on logout', + }); + } catch (error) { + logger.warn({ + message: 'Error clearing push notification state on logout', + context: { error }, + }); + } + } + public cleanup(): void { if (this.notificationListener) { this.notificationListener.remove(); diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index cb140b01..5d3ab778 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -1,7 +1,6 @@ import { type HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr'; import { Platform } from 'react-native'; -import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; import useAuthStore from '@/stores/auth/store'; @@ -36,8 +35,21 @@ class SignalRService { private connectionLocks: Map> = new Map(); private reconnectingHubs: Set = new Set(); private hubStates: Map = new Map(); - private readonly MAX_RECONNECT_ATTEMPTS = 5; - private readonly RECONNECT_INTERVAL = 5000; // 5 seconds + private readonly MAX_RECONNECT_ATTEMPTS = 10; + private readonly RECONNECT_INTERVAL = 5000; // 5 seconds base (linear backoff to 60s cap) + + /** Internal lifecycle events emitted so stores can react to connection state. */ + public static readonly HUB_DISCONNECTED_EVENT = '__hubDisconnected'; + public static readonly HUB_RECONNECTING_EVENT = '__hubReconnecting'; + public static readonly HUB_RECONNECTED_EVENT = '__hubReconnected'; + + /** Emit a lifecycle event for a specific hub — both a generic form (data is + * the hub name) and a hub-scoped form so listeners for one hub can be + * removed without wiping another hub's listeners. */ + private emitHubLifecycle(event: string, hubName: string): void { + this.emit(event, hubName); + this.emit(`${event}:${hubName}`, hubName); + } private static instance: SignalRService | null = null; @@ -160,26 +172,18 @@ class SignalRService { // Append the hub name to the path (ensuring a single slash) const pathWithHub = url.pathname.endsWith('/') ? `${url.pathname}${config.hubName}` : `${url.pathname}/${config.hubName}`; - // Reassemble the URL with the hub in the path + // Reassemble the URL with the hub in the path, preserving any existing + // query parameters. Auth goes via the accessTokenFactory (Authorization + // header / SignalR negotiate) — never as an access_token query param, + // which would leak bearer tokens into server/proxy access logs. let fullUrl = `${url.protocol}//${url.host}${pathWithHub}`; - - // For geolocation hub, add token as URL parameter instead of header - const isGeolocationHub = config.hubName === Env.REALTIME_GEO_HUB_NAME; - - // Merge existing query parameters with access_token if needed - const queryParams = new URLSearchParams(url.search); - if (isGeolocationHub) { - queryParams.set('access_token', token); - } - - // Add query string if there are any parameters - if (queryParams.toString()) { - fullUrl = `${fullUrl}?${queryParams.toString()}`; + if (url.search) { + fullUrl = `${fullUrl}${url.search}`; } logger.info({ message: `Connecting to hub: ${config.name}`, - context: { config, fullUrl: isGeolocationHub ? fullUrl.replace(/access_token=[^&]+/, 'access_token=***') : fullUrl }, + context: { config, fullUrl }, }); // Store the config for potential reconnections @@ -189,14 +193,9 @@ class SignalRService { const signalRLogLevel = Platform.OS === 'web' ? LogLevel.Warning : LogLevel.Information; const connectionBuilder = new HubConnectionBuilder() - .withUrl( - fullUrl, - isGeolocationHub - ? {} - : { - accessTokenFactory: () => token, - } - ) + .withUrl(fullUrl, { + accessTokenFactory: () => useAuthStore.getState().accessToken ?? token, + }) .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) .configureLogging(signalRLogLevel); @@ -204,10 +203,12 @@ class SignalRService { // Set up event handlers connection.onclose(() => { + this.emitHubLifecycle(SignalRService.HUB_DISCONNECTED_EVENT, config.name); this.handleConnectionClose(config.name); }); connection.onreconnecting((error) => { + this.emitHubLifecycle(SignalRService.HUB_RECONNECTING_EVENT, config.name); logger.warn({ message: `Reconnecting to hub: ${config.name}`, context: { error }, @@ -220,6 +221,9 @@ class SignalRService { context: { connectionId }, }); this.reconnectAttempts.set(config.name, 0); + // Group membership is per-connectionId — subscribers must re-join + // their groups and resync state after every reconnect. + this.emitHubLifecycle(SignalRService.HUB_RECONNECTED_EVENT, config.name); }); // Register all methods @@ -325,7 +329,7 @@ class SignalRService { const connection = new HubConnectionBuilder() .withUrl(config.url, { - accessTokenFactory: () => token, + accessTokenFactory: () => useAuthStore.getState().accessToken ?? token, }) .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) .configureLogging(signalRLogLevel) @@ -333,10 +337,12 @@ class SignalRService { // Set up event handlers connection.onclose(() => { + this.emitHubLifecycle(SignalRService.HUB_DISCONNECTED_EVENT, config.name); this.handleConnectionClose(config.name); }); connection.onreconnecting((error) => { + this.emitHubLifecycle(SignalRService.HUB_RECONNECTING_EVENT, config.name); logger.warn({ message: `Reconnecting to hub: ${config.name}`, context: { error }, @@ -349,6 +355,9 @@ class SignalRService { context: { connectionId }, }); this.reconnectAttempts.set(config.name, 0); + // Group membership is per-connectionId — subscribers must re-join + // their groups and resync state after every reconnect. + this.emitHubLifecycle(SignalRService.HUB_RECONNECTED_EVENT, config.name); }); // Register all methods @@ -391,102 +400,7 @@ class SignalRService { private handleConnectionClose(hubName: string): void { const attempts = this.reconnectAttempts.get(hubName) || 0; - if (attempts < this.MAX_RECONNECT_ATTEMPTS) { - this.reconnectAttempts.set(hubName, attempts + 1); - const currentAttempts = attempts + 1; - - const hubConfig = this.hubConfigs.get(hubName); - if (hubConfig) { - logger.info({ - message: `Scheduling reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} for hub: ${hubName}`, - }); - - setTimeout(async () => { - try { - // Check if the hub config was removed (e.g., by explicit disconnect) - const currentHubConfig = this.hubConfigs.get(hubName); - if (!currentHubConfig) { - logger.debug({ - message: `Hub ${hubName} config was removed, skipping reconnection attempt`, - }); - return; - } - - // If a live connection exists, skip; if it's stale/closed, drop it - const existingConn = this.connections.get(hubName); - if (existingConn && existingConn.state === HubConnectionState.Connected) { - logger.debug({ - message: `Hub ${hubName} is already connected, skipping reconnection attempt`, - }); - return; - } - - // Mark as reconnecting and remove stale entry (if any) to allow a fresh connect - this.setHubState(hubName, HubConnectingState.RECONNECTING); - if (existingConn) { - this.connections.delete(hubName); - } - - try { - // Refresh authentication token before reconnecting - logger.info({ - message: `Refreshing authentication token before reconnecting to hub: ${hubName}`, - }); - - await useAuthStore.getState().refreshAccessToken(); - - // Verify we have a valid token after refresh - const token = useAuthStore.getState().accessToken; - if (!token) { - throw new Error('No valid authentication token available after refresh'); - } - - logger.info({ - message: `Token refreshed successfully, attempting to reconnect to hub: ${hubName} (attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS})`, - }); - - // Remove the connection from our maps to allow fresh connection - // This is now safe because we have the reconnecting flag set - this.connections.delete(hubName); - - await this.connectToHubWithEventingUrl(currentHubConfig); - - // Clear reconnecting state on successful reconnection - this.setHubState(hubName, HubConnectingState.IDLE); - - logger.info({ - message: `Successfully reconnected to hub: ${hubName} after ${currentAttempts} attempts`, - }); - } catch (reconnectionError) { - // Clear reconnecting state on failed reconnection - this.setHubState(hubName, HubConnectingState.IDLE); - - logger.error({ - message: `Failed to refresh token or reconnect to hub: ${hubName}`, - context: { error: reconnectionError, attempts: currentAttempts, maxAttempts: this.MAX_RECONNECT_ATTEMPTS }, - }); - - // Re-throw to trigger the outer catch block - throw reconnectionError; - } - } catch (error) { - // This catch block handles the overall reconnection attempt failure - // The reconnecting flag has already been cleared in the inner catch block - logger.error({ - message: `Reconnection attempt failed for hub: ${hubName}`, - context: { error, attempts: currentAttempts, maxAttempts: this.MAX_RECONNECT_ATTEMPTS }, - }); - - // Don't immediately retry; let the next connection close event trigger another attempt - // This prevents rapid retry loops that could overwhelm the server - } - }, this.RECONNECT_INTERVAL); - } else { - logger.error({ - message: `No stored config found for hub: ${hubName}, cannot attempt reconnection`, - }); - } - } else { + if (attempts >= this.MAX_RECONNECT_ATTEMPTS) { logger.error({ message: `Max reconnection attempts (${this.MAX_RECONNECT_ATTEMPTS}) reached for hub: ${hubName}`, }); @@ -496,7 +410,105 @@ class SignalRService { this.reconnectAttempts.delete(hubName); this.hubConfigs.delete(hubName); this.setHubState(hubName, HubConnectingState.IDLE); + return; } + + const hubConfig = this.hubConfigs.get(hubName); + if (!hubConfig) { + logger.error({ + message: `No stored config found for hub: ${hubName}, cannot attempt reconnection`, + }); + return; + } + + const currentAttempts = attempts + 1; + this.reconnectAttempts.set(hubName, currentAttempts); + + // Exponential backoff capped at 60s: 5s, 10s, 20s, 40s, 60s, ... + const delay = Math.min(this.RECONNECT_INTERVAL * currentAttempts, 60000); + + logger.info({ + message: `Scheduling reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} for hub: ${hubName} in ${delay}ms`, + }); + + setTimeout(async () => { + try { + // Check if the hub config was removed (e.g., by explicit disconnect) + const currentHubConfig = this.hubConfigs.get(hubName); + if (!currentHubConfig) { + logger.debug({ + message: `Hub ${hubName} config was removed, skipping reconnection attempt`, + }); + return; + } + + // If a live connection exists, skip; if it's stale/closed, drop it + const existingConn = this.connections.get(hubName); + if (existingConn && existingConn.state === HubConnectionState.Connected) { + logger.debug({ + message: `Hub ${hubName} is already connected, skipping reconnection attempt`, + }); + this.reconnectAttempts.set(hubName, 0); + return; + } + + // Mark as reconnecting and remove stale entry (if any) to allow a fresh connect + this.setHubState(hubName, HubConnectingState.RECONNECTING); + if (existingConn) { + this.connections.delete(hubName); + } + + // Refresh authentication token before reconnecting + logger.info({ + message: `Refreshing authentication token before reconnecting to hub: ${hubName}`, + }); + + await useAuthStore.getState().refreshAccessToken(); + + // Verify we have a valid token after refresh — if the session is gone + // (refresh rejected), stop reconnecting instead of retrying forever. + const token = useAuthStore.getState().accessToken; + if (!token) { + logger.warn({ + message: `No valid authentication token after refresh, aborting reconnect for hub: ${hubName}`, + }); + this.setHubState(hubName, HubConnectingState.IDLE); + this.reconnectAttempts.delete(hubName); + this.hubConfigs.delete(hubName); + return; + } + + logger.info({ + message: `Token refreshed successfully, attempting to reconnect to hub: ${hubName} (attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS})`, + }); + + this.connections.delete(hubName); + + await this.connectToHubWithEventingUrl(currentHubConfig); + + // Clear reconnecting state on successful reconnection + this.setHubState(hubName, HubConnectingState.IDLE); + this.reconnectAttempts.set(hubName, 0); + + logger.info({ + message: `Successfully reconnected to hub: ${hubName} after ${currentAttempts} attempts`, + }); + } catch (error) { + // Attempt failed — the old connection object is gone, so no further + // onclose event will fire. We MUST reschedule here or the hub stays + // dead until the next app background/resume cycle. + this.setHubState(hubName, HubConnectingState.IDLE); + + logger.error({ + message: `Reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} failed for hub: ${hubName}`, + context: { error }, + }); + + // Reschedule (attempt count was already incremented when this attempt + // was scheduled); handleConnectionClose enforces the max-attempt cap. + this.handleConnectionClose(hubName); + } + }, delay); } private handleMessage(_hubName: string, method: string, data: unknown): void { @@ -618,7 +630,18 @@ class SignalRService { } private emit(event: string, data: unknown): void { - this.eventListeners.get(event)?.forEach((callback) => callback(data)); + // Isolate listener failures — one throwing callback must not prevent the + // remaining listeners from receiving the event. + this.eventListeners.get(event)?.forEach((callback) => { + try { + callback(data); + } catch (error) { + logger.error({ + message: `SignalR event listener threw for event: ${event}`, + context: { error }, + }); + } + }); } } diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index d7d92b42..62d5b61b 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -8,7 +8,7 @@ import { getAllUnitStatuses } from '@/api/satuses/statuses'; import { getUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; import { zustandStorage } from '@/lib/storage'; -import { getActiveCallId, getActiveUnitId, setActiveCallId, setActiveUnitId } from '@/lib/storage/app'; +import { getActiveCallId, getActiveUnitId, removeActiveCallId, removeActiveUnitId, setActiveCallId, setActiveUnitId } from '@/lib/storage/app'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; @@ -42,6 +42,7 @@ interface CoreState { init: () => Promise; setActiveUnit: (unitId: string) => void; setActiveUnitWithFetch: (unitId: string) => Promise; + refreshActiveUnitStatus: (unitId: string) => Promise; setActiveCall: (callId: string | null) => Promise; fetchConfig: () => Promise; } @@ -129,7 +130,7 @@ export const useCoreStore = create()( }); } else { logger.error({ - message: `Failed to init core app data: ${JSON.stringify(error)}`, + message: 'Failed to init core app data', context: { error }, }); } @@ -166,6 +167,23 @@ export const useCoreStore = create()( activeStatuses: activeStatuses, isLoading: false, }); + } else { + // Persisted unit no longer exists (deleted/decommissioned) — clear + // the stale id and ALWAYS reset isLoading or consumers spin forever. + logger.warn({ + message: 'Active unit not found in fetched units, clearing stale selection', + context: { unitId }, + }); + await removeActiveUnitId(); + set({ + activeUnitId: null, + activeUnit: null, + activeUnitStatus: null, + activeUnitStatusType: null, + activeStatuses: null, + isLoading: false, + }); + return; } const unitStatus = await getUnitStatus(unitId); @@ -190,7 +208,7 @@ export const useCoreStore = create()( } catch (error) { set({ error: 'Failed to set active unit', isLoading: false }); logger.error({ - message: `Failed to set active unit: ${JSON.stringify(error)}`, + message: 'Failed to set active unit', context: { error }, }); } @@ -216,14 +234,31 @@ export const useCoreStore = create()( isLoading: false, }); logger.error({ - message: `Failed to fetch and set active unit: ${JSON.stringify(error)}`, + message: 'Failed to fetch and set active unit', + context: { error }, + }); + } + }, + // Lightweight status-only refresh — used by the SignalR status hook so a + // unitStatusUpdated event does NOT refetch the entire fleet. + refreshActiveUnitStatus: async (unitId: string) => { + try { + const unitStatus = await getUnitStatus(unitId); + if (unitStatus?.Data) { + set({ activeUnitStatus: unitStatus.Data }); + } + } catch (error) { + logger.error({ + message: 'Failed to refresh active unit status', context: { error }, }); } }, setActiveCall: async (callId: string | null) => { if (!callId) { - // Deselect the call + // Deselect the call — also drop the persisted id so a stale value is + // not re-attempted on every cold start. + await removeActiveCallId(); set({ activeCall: null, activePriority: null, @@ -240,6 +275,20 @@ export const useCoreStore = create()( await callStore.fetchCallPriorities(); const activeCall = callStore.calls.find((call) => call.CallId === callId); const activePriority = callStore.callPriorities.find((priority) => priority.Id === activeCall?.Priority); + + if (!activeCall) { + // Call no longer active (e.g. closed between persist and init) — + // clear the stale id instead of re-attempting it every cold start. + await removeActiveCallId(); + set({ + activeCall: null, + activePriority: null, + activeCallId: null, + isLoading: false, + }); + return; + } + set({ activeCall: activeCall, activePriority: activePriority, @@ -248,7 +297,7 @@ export const useCoreStore = create()( } catch (error) { set({ error: 'Failed to set active call', isLoading: false }); logger.error({ - message: `Failed to set active call: ${JSON.stringify(error)}`, + message: 'Failed to set active call', context: { error }, }); } @@ -277,7 +326,7 @@ export const useCoreStore = create()( }); } else { logger.error({ - message: `Failed to fetch config: ${JSON.stringify(error)}`, + message: 'Failed to fetch config', context: { error }, }); } @@ -288,18 +337,14 @@ export const useCoreStore = create()( { name: 'core-storage', storage: createJSONStorage(() => zustandStorage), + // Persist ONLY the selection ids. init() refetches everything else from + // the server (and the standalone activeUnitId/activeCallId MMKV keys are + // the real restore mechanism), so persisting full objects just meant + // JSON.stringify + a synchronous MMKV write of heavy config/unit/call + // payloads on EVERY store set. partialize: (state) => ({ activeUnitId: state.activeUnitId, - activeUnit: state.activeUnit, - activeUnitStatus: state.activeUnitStatus, - activeUnitStatusType: state.activeUnitStatusType, activeCallId: state.activeCallId, - activeCall: state.activeCall, - activePriority: state.activePriority, - config: state.config, - activeStatuses: state.activeStatuses, - // Exclude: isLoading, isInitialized, isInitializing, error - // These are transient flags that must NOT persist across reloads }), } ) diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 0fda9bb2..1c70c42f 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -565,6 +565,54 @@ export const useLiveKitStore = create((set, get) => ({ set({ isTalking }); }); + room.on(RoomEvent.Reconnecting, () => { + logger.warn({ + message: 'LiveKit room connection lost, reconnecting', + context: { roomName: roomInfo.Name }, + }); + }); + + room.on(RoomEvent.Reconnected, () => { + logger.info({ + message: 'LiveKit room reconnected', + context: { roomName: roomInfo.Name }, + }); + }); + + room.on(RoomEvent.Disconnected, () => { + // Fired on network drop AND on intentional room.disconnect(). The + // intentional path sets isConnected=false BEFORE disconnecting, so a + // truthy value here means an unexpected drop — reset state so the UI + // never gets stuck showing "in call" with a dead room. + if (!get().isConnected) { + return; + } + + logger.warn({ + message: 'LiveKit room disconnected unexpectedly, resetting call state', + context: { roomName: roomInfo.Name }, + }); + + callKeepService.setMuteStateCallback(null); + callKeepService.setEndCallCallback(null); + callKeepService.endCall().catch((error) => { + logger.warn({ message: 'Failed to end CallKeep call after unexpected disconnect', context: { error } }); + }); + if (Platform.OS === 'android') { + notifee.stopForegroundService().catch((error) => { + logger.warn({ message: 'Failed to stop foreground service after unexpected disconnect', context: { error } }); + }); + } + + set({ + currentRoom: null, + currentRoomInfo: null, + isConnected: false, + isMicrophoneEnabled: false, + isTalking: false, + }); + }); + // Connect to the room logger.info({ message: 'Connecting to LiveKit room', @@ -660,10 +708,18 @@ export const useLiveKitStore = create((set, get) => ({ // We attach these listeners to the local participant if needed for other UI sync // Setup audio routing based on selected devices - // This may change audio modes/focus, so it comes after media button init - await setupAudioRouting(room); - - await audioService.playConnectToAudioRoomSound(); + // This may change audio modes/focus, so it comes after media button init. + // Guarded: a failure here must NOT fall into the outer catch, which would + // report "Voice Connection Failed" while the room is actually connected. + try { + await setupAudioRouting(room); + await audioService.playConnectToAudioRoomSound(); + } catch (postConnectError) { + logger.warn({ + message: 'Post-connect audio setup failed - room is still connected', + context: { error: postConnectError }, + }); + } // Android foreground service for background audio. // Only needed on Android - iOS uses CallKeep, web browsers handle audio natively. @@ -762,6 +818,11 @@ export const useLiveKitStore = create((set, get) => ({ disconnectFromRoom: async () => { const { currentRoom } = get(); if (currentRoom) { + // Mark disconnected FIRST — room.disconnect() fires RoomEvent.Disconnected + // and the unexpected-drop handler must not treat this intentional + // teardown as a connection failure. + set({ isConnected: false }); + await currentRoom.disconnect(); await audioService.playDisconnectedFromAudioRoomSound(); diff --git a/src/stores/app/server-url-store.ts b/src/stores/app/server-url-store.ts index 9fefa622..d9195f35 100644 --- a/src/stores/app/server-url-store.ts +++ b/src/stores/app/server-url-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; +import { cacheManager } from '@/lib/cache/cache-manager'; import { getBaseApiUrl, setBaseApiUrl } from '@/lib/storage/app'; interface ServerUrlState { @@ -11,8 +12,16 @@ interface ServerUrlState { export const useServerUrlStore = create((set) => ({ url: '', setUrl: async (url: string) => { + const previousUrl = getBaseApiUrl(); await setBaseApiUrl(url); set({ url }); + + // Environment switch — drop all cached API data so content from the + // previous server is never served against the new one. (Cache keys are + // also scoped by base URL as a second layer of defense.) + if (previousUrl !== url) { + cacheManager.clear(); + } }, getUrl: async () => { const url = await getBaseApiUrl(); diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 5b2a670f..2255c612 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -5,11 +5,14 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { logger } from '@/lib/logging'; -import { loginRequest, refreshTokenRequest, ssoExternalTokenRequest } from '../../lib/auth/api'; +import { loginRequest, ssoExternalTokenRequest } from '../../lib/auth/api'; +import { refreshTokenSingleFlight } from '../../lib/auth/refresh-lock'; +import { runSessionCleanup } from '../../lib/auth/session-cleanup'; +import { isRefreshCredentialRejection } from '../../lib/auth/token-refresh'; import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types'; import { type ProfileModel } from '../../lib/auth/types'; import { getAuth } from '../../lib/auth/utils'; -import { setItem, zustandStorage } from '../../lib/storage'; +import { removeItem, setItem, zustandStorage } from '../../lib/storage'; const useAuthStore = create()( persist( @@ -153,20 +156,49 @@ const useAuthStore = create()( refreshTimeoutId: null, }); Sentry.setUser(null); + + // Remove the standalone stored auth response so no valid refresh + // token is left on the device after logout. + try { + await removeItem('authResponse'); + } catch (error) { + logger.warn({ + message: 'Failed to remove stored auth response on logout', + context: { error }, + }); + } + + // Route EVERY logout (manual, forced by the 401 interceptor, refresh + // credential rejection) through the full app-data reset so a different + // user logging in on the same device never sees the previous user's + // data — and the previous user's queued offline events never replay + // under the new account. The reset service registers its handler in + // the leaf session-cleanup module (avoids a static import cycle). + try { + await runSessionCleanup(); + } catch (error) { + logger.error({ + message: 'Failed to clear app data on logout', + context: { error }, + }); + } }, - refreshAccessToken: async () => { + refreshAccessToken: async (): Promise => { try { const { refreshToken } = get(); if (!refreshToken) { logger.warn({ message: 'No refresh token available, logging out user', }); - get().logout(); - return; + await get().logout(); + return false; } - const response = await refreshTokenRequest(refreshToken); + // Single-flight: concurrent refresh triggers (proactive timer, axios + // 401 interceptor, SignalR reconnect) share one request so server-side + // refresh-token rotation never invalidates a parallel caller. + const response = await refreshTokenSingleFlight(refreshToken); // Update the stored auth response for hydration setItem('authResponse', response); @@ -192,32 +224,35 @@ const useAuthStore = create()( } const timeoutId = setTimeout(() => get().refreshAccessToken(), refreshDelayMs); set({ refreshTimeoutId: timeoutId }); + return true; } catch (error) { - // Check if it's a network error vs an invalid refresh token - const isNetworkError = error instanceof Error && (error.message.includes('Network Error') || error.message.includes('timeout') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')); - - if (isNetworkError) { - // Network error - retry after a delay, don't logout - logger.warn({ - message: 'Token refresh failed due to network error, will retry', - context: { error: error instanceof Error ? error.message : String(error) }, - }); - // Clear any existing refresh timer before scheduling retry - const existingTimeoutId = get().refreshTimeoutId; - if (existingTimeoutId !== null) { - clearTimeout(existingTimeoutId); - } - // Retry after 30 seconds for network errors - const retryTimeoutId = setTimeout(() => get().refreshAccessToken(), 30000); - set({ refreshTimeoutId: retryTimeoutId }); - } else { - // Invalid refresh token or server rejected it - logout user + if (isRefreshCredentialRejection(error)) { + // The token endpoint explicitly rejected the refresh token + // (400 invalid_grant / 401) — credentials are known-bad, log out. logger.error({ - message: 'Token refresh failed with non-recoverable error, logging out user', - context: { error: error instanceof Error ? error.message : String(error) }, + message: 'Token refresh rejected by server, logging out user', + context: { error }, }); - get().logout(); + await get().logout(); + return false; } + + // Everything else is transient (offline, timeout, 5xx, 429) — keep the + // session and retry, otherwise a backend incident logs out every + // active responder at once. + logger.warn({ + message: 'Token refresh failed transiently, will retry', + context: { error }, + }); + // Clear any existing refresh timer before scheduling retry + const existingTimeoutId = get().refreshTimeoutId; + if (existingTimeoutId !== null) { + clearTimeout(existingTimeoutId); + } + // Retry after 30 seconds for transient errors + const retryTimeoutId = setTimeout(() => get().refreshAccessToken(), 30000); + set({ refreshTimeoutId: retryTimeoutId }); + return false; } }, hydrate: () => { @@ -301,6 +336,18 @@ const useAuthStore = create()( { name: 'auth-storage', storage: createJSONStorage(() => zustandStorage), + // Only persist what is needed to restore a session. Transient fields — + // status, error, refreshTimeoutId — must never be persisted: an app kill + // mid-login used to rehydrate `status: 'loading'` with no way out, + // permanently locking the user behind a spinner. + partialize: (state) => ({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + refreshTokenExpiresOn: state.refreshTokenExpiresOn, + profile: state.profile, + userId: state.userId, + isFirstTime: state.isFirstTime, + }), onRehydrateStorage: () => { return (state, error) => { if (error) { @@ -313,30 +360,12 @@ const useAuthStore = create()( // Defer execution to ensure useAuthStore is fully initialized setTimeout(() => { - if (state && state.refreshToken && state.status === 'signedIn') { - // We have a stored refresh token and were previously signed in - // Schedule an immediate token refresh to ensure we have a valid access token - logger.info({ - message: 'Auth state rehydrated from storage, scheduling token refresh', - context: { hasAccessToken: !!state.accessToken, hasRefreshToken: !!state.refreshToken }, - }); - - // Clear any existing refresh timer before scheduling a new one - const existingTimeoutId = useAuthStore.getState().refreshTimeoutId; - if (existingTimeoutId !== null) { - clearTimeout(existingTimeoutId); - } - // Use a small delay to allow the app to fully initialize - const timeoutId = setTimeout(() => { - useAuthStore.getState().refreshAccessToken(); - }, 2000); - useAuthStore.setState({ refreshTimeoutId: timeoutId }); - } else if (state && state.refreshToken && state.status !== 'signedIn') { - // We have a refresh token but status is not signedIn (maybe was idle/error) - // Try to refresh and restore the session + // status/error are no longer persisted, so a stored refresh token is + // the only signal that a session existed — always try to restore it. + if (state && state.refreshToken) { logger.info({ - message: 'Found refresh token in storage with non-signedIn status, attempting to restore session', - context: { status: state.status }, + message: 'Found refresh token in storage, attempting to restore session', + context: { hasAccessToken: !!state.accessToken }, }); // Clear any existing refresh timer before scheduling a new one diff --git a/src/stores/calls/detail-store.ts b/src/stores/calls/detail-store.ts index 199ae6d8..976a1ff0 100644 --- a/src/stores/calls/detail-store.ts +++ b/src/stores/calls/detail-store.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import { getCallFiles, getCallImages, saveCallImage } from '@/api/calls/callFiles'; import { getCallNotes, saveCallNote } from '@/api/calls/callNotes'; import { closeCall, type CloseCallRequest, getCall, getCallExtraData, updateCall, type UpdateCallRequest } from '@/api/calls/calls'; +import { logger } from '@/lib/logging'; import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData'; import { type CallNoteResultData } from '@/models/v4/callNotes/callNoteResultData'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; @@ -99,7 +100,7 @@ export const useCallDetailStore = create((set, get) => ({ }); } else { set({ - error: callResult.Message || callExtraDataResult.Message || 'Failed to fetch call details', + error: callResult?.Message || callExtraDataResult?.Message || 'Failed to fetch call details', isLoading: false, }); } @@ -167,7 +168,10 @@ export const useCallDetailStore = create((set, get) => ({ // After successful upload, refresh the images list useCallDetailStore.getState().fetchCallImages(callId); } catch (error) { - console.error('Error uploading image:', error); + logger.error({ + message: 'Error uploading image', + context: { error }, + }); throw error; } }, diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index 7f3b077f..4d8866a6 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -4,12 +4,15 @@ import { getCallPriorities } from '@/api/calls/callPriorities'; import { getCallExtraData, getCalls } from '@/api/calls/calls'; import { getCallTypes } from '@/api/calls/callTypes'; import { getNewCallData } from '@/api/dispatch/dispatch'; +import { logger } from '@/lib/logging'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; import { type CallResultData } from '@/models/v4/calls/callResultData'; import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; import { type CallTypeResultData } from '@/models/v4/callTypes/callTypeResultData'; import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/poiResultData'; +const DISPATCHES_TTL_MS = 5 * 60 * 1000; // refetch per-call dispatches after 5 min + interface CallsState { calls: CallResultData[]; callPriorities: CallPriorityResultData[]; @@ -17,6 +20,7 @@ interface CallsState { destinationPois: PoiResultData[]; poiTypes: PoiTypeResultData[]; callDispatches: Record; + callDispatchesFetchedAt: Record; isLoading: boolean; isInitialized: boolean; isCallFormDataLoaded: boolean; @@ -37,6 +41,7 @@ export const useCallsStore = create((set, get) => ({ destinationPois: [], poiTypes: [], callDispatches: {}, + callDispatchesFetchedAt: {}, isLoading: false, isInitialized: false, isCallFormDataLoaded: false, @@ -69,14 +74,17 @@ export const useCallsStore = create((set, get) => ({ // Evict dispatches for calls no longer in the active list to prevent unbounded memory growth const activeIds = new Set(newCalls.map((c) => c.CallId)); const existing = get().callDispatches; + const existingFetchedAt = get().callDispatchesFetchedAt; const pruned: Record = {}; + const prunedFetchedAt: Record = {}; for (const id in existing) { if (activeIds.has(id)) { pruned[id] = existing[id]; + prunedFetchedAt[id] = existingFetchedAt[id] ?? 0; } } - set({ calls: newCalls, callDispatches: pruned, isLoading: false, lastFetchedAt: Date.now() }); + set({ calls: newCalls, callDispatches: pruned, callDispatchesFetchedAt: prunedFetchedAt, isLoading: false, lastFetchedAt: Date.now() }); } catch (error) { set({ error: 'Failed to fetch calls', isLoading: false }); } @@ -128,8 +136,13 @@ export const useCallsStore = create((set, get) => ({ }, fetchCallDispatches: async (callIds: string[]) => { const existing = get().callDispatches; - // Only fetch for call IDs that aren't already cached - const uncachedIds = callIds.filter((id) => !(id in existing)); + const fetchedAt = get().callDispatchesFetchedAt; + const now = Date.now(); + + // Only fetch for call IDs that aren't cached or whose cache is stale. + // Dispatches for an active call change over time, so an eternal cache + // hides newly dispatched units/personnel. + const uncachedIds = callIds.filter((id) => !(id in existing) || now - (fetchedAt[id] ?? 0) > DISPATCHES_TTL_MS); if (uncachedIds.length === 0) return; try { @@ -140,18 +153,30 @@ export const useCallsStore = create((set, get) => ({ const dispatches = result?.Data?.Dispatches ?? []; return { callId, dispatches: dispatches as DispatchedEventResultData[] }; } catch { - return { callId, dispatches: [] as DispatchedEventResultData[] }; + // Failed fetches must NOT be cached as empty — that would suppress + // retries and hide dispatches for the life of the call. + return { callId, dispatches: null }; } }) ); const newDispatches: Record = {}; + const newFetchedAt: Record = {}; for (const { callId, dispatches } of results) { - newDispatches[callId] = dispatches; + if (dispatches !== null) { + newDispatches[callId] = dispatches; + newFetchedAt[callId] = now; + } } - set({ callDispatches: { ...get().callDispatches, ...newDispatches } }); + set({ + callDispatches: { ...get().callDispatches, ...newDispatches }, + callDispatchesFetchedAt: { ...get().callDispatchesFetchedAt, ...newFetchedAt }, + }); } catch (error) { - console.warn('Failed to fetch call dispatches:', error); + logger.warn({ + message: 'Failed to fetch call dispatches', + context: { error }, + }); } }, })); diff --git a/src/stores/check-in-timers/store.ts b/src/stores/check-in-timers/store.ts index 6c5f432a..c0d555ab 100644 --- a/src/stores/check-in-timers/store.ts +++ b/src/stores/check-in-timers/store.ts @@ -1,4 +1,5 @@ import { isAxiosError } from 'axios'; +import { AppState } from 'react-native'; import { create } from 'zustand'; import { getCheckInHistory, getTimersForCall, getTimerStatuses, performCheckIn, type PerformCheckInInput } from '@/api/check-in-timers/check-in-timers'; @@ -120,6 +121,11 @@ export const useCheckInTimerStore = create((set, get) => ({ get().fetchTimerStatuses(callId); const interval = setInterval(() => { + // Skip while backgrounded — JS timers keep firing on Android and polling + // would burn network/battery with no UI visible. + if (AppState.currentState !== 'active') { + return; + } get().fetchTimerStatuses(callId); }, intervalMs); diff --git a/src/stores/offline-queue/store.ts b/src/stores/offline-queue/store.ts index f5b99382..1f305a7c 100644 --- a/src/stores/offline-queue/store.ts +++ b/src/stores/offline-queue/store.ts @@ -44,6 +44,9 @@ interface OfflineQueueState { const DEFAULT_MAX_RETRIES = 3; const RETRY_DELAY_BASE = 1000; // 1 second base delay +// Module-level handle so initialize() never stacks duplicate NetInfo listeners. +let netInfoUnsubscribe: (() => void) | null = null; + export const useOfflineQueueStore = create()( persist( (set, get) => ({ @@ -59,7 +62,12 @@ export const useOfflineQueueStore = create()( // Initialize network state listener initializeNetworkListener: () => { - NetInfo.addEventListener((state: NetInfoState) => { + if (netInfoUnsubscribe) { + // Already listening — a second listener would double-fire handlers. + return; + } + + netInfoUnsubscribe = NetInfo.addEventListener((state: NetInfoState) => { const isConnected = state.isConnected ?? false; const isReachable = state.isInternetReachable ?? false; @@ -271,6 +279,18 @@ export const useOfflineQueueStore = create()( failedEvents: state.failedEvents, completedEvents: state.completedEvents, }), + onRehydrateStorage: () => { + return (state) => { + // Events mid-PROCESSING when the app was killed would otherwise be + // stuck forever (getPendingEvents excludes PROCESSING) — recover + // them to PENDING so they are retried. + if (state?.queuedEvents?.some((event) => event.status === QueuedEventStatus.PROCESSING)) { + useOfflineQueueStore.setState((current) => ({ + queuedEvents: current.queuedEvents.map((event) => (event.status === QueuedEventStatus.PROCESSING ? { ...event, status: QueuedEventStatus.PENDING } : event)), + })); + } + }; + }, } ) ); diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts index f8c2decd..ac9f5f46 100644 --- a/src/stores/signalr/__tests__/signalr-store.test.ts +++ b/src/stores/signalr/__tests__/signalr-store.test.ts @@ -7,14 +7,6 @@ const mockCoreStoreGetState = jest.fn(() => ({ }, })); -const mockSecurityStore = { - getState: jest.fn(() => ({ - rights: { - DepartmentId: '123', - }, - })), -}; - // Mock all dependencies before importing anything jest.mock('@/services/signalr.service', () => { const mockInstance = { @@ -26,7 +18,13 @@ jest.mock('@/services/signalr.service', () => { connectToHub: jest.fn().mockResolvedValue(undefined), disconnectAll: jest.fn().mockResolvedValue(undefined), }; + class MockSignalRService { + static readonly HUB_DISCONNECTED_EVENT = '__hubDisconnected'; + static readonly HUB_RECONNECTING_EVENT = '__hubReconnecting'; + static readonly HUB_RECONNECTED_EVENT = '__hubReconnected'; + } return { + SignalRService: MockSignalRService, signalRService: mockInstance, default: mockInstance, }; @@ -50,13 +48,14 @@ jest.mock('../../app/core-store', () => { }; }); -jest.mock('@/stores/security/store', () => ({ - securityStore: mockSecurityStore, -})); - jest.mock('../../security/store', () => ({ - securityStore: mockSecurityStore, - useSecurityStore: mockSecurityStore, + securityStore: { + getState: jest.fn(() => ({ + rights: { + DepartmentId: '123', + }, + })), + }, })); jest.mock('@/lib/logging', () => ({ @@ -89,6 +88,9 @@ jest.mock('@/lib', () => ({ import { useSignalRStore } from '../signalr-store'; import { logger } from '@/lib/logging'; import { signalRService } from '@/services/signalr.service'; +import { securityStore } from '../../security/store'; + +const mockSecurityStoreGetState = securityStore.getState as jest.Mock; describe('useSignalRStore', () => { const mockEventingUrl = 'https://eventing.example.com/'; @@ -97,6 +99,21 @@ describe('useSignalRStore', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset store state — the store is a module-level singleton and connection + // flags persist across tests otherwise. + useSignalRStore.setState({ + isUpdateHubConnected: false, + lastUpdateMessage: null, + lastUpdateTimestamp: 0, + lastUpdateTimestamps: {}, + lastUnitStatusMessage: null, + lastUnitStatusTimestamp: 0, + isGeolocationHubConnected: false, + lastGeolocationMessage: null, + lastGeolocationTimestamp: 0, + error: null, + }); + // Reset the mock function to default behavior mockCoreStoreGetState.mockReturnValue({ config: { @@ -105,7 +122,7 @@ describe('useSignalRStore', () => { }); // Mock security store - mockSecurityStore.getState.mockReturnValue({ + mockSecurityStoreGetState.mockReturnValue({ rights: { DepartmentId: mockDepartmentId, }, @@ -134,6 +151,9 @@ describe('useSignalRStore', () => { expect(result.current.lastGeolocationMessage).toBeNull(); expect(result.current.lastUpdateTimestamp).toBe(0); expect(result.current.lastGeolocationTimestamp).toBe(0); + expect(result.current.lastUpdateTimestamps).toEqual({}); + expect(result.current.lastUnitStatusMessage).toBeNull(); + expect(result.current.lastUnitStatusTimestamp).toBe(0); expect(result.current.error).toBeNull(); }); }); @@ -197,6 +217,143 @@ describe('useSignalRStore', () => { context: { error: connectionError }, }); }); + + it('should join the department group with the parsed DepartmentId', async () => { + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + expect(signalRService.invoke).toHaveBeenCalledWith('eventingHub', 'connect', 123); + }); + + it.each([ + ['missing', undefined], + ['non-numeric', 'abc'], + ['zero', '0'], + ['negative', '-5'], + ])('should not join the department group when DepartmentId is %s', async (_label, departmentId) => { + mockSecurityStoreGetState.mockReturnValue({ + rights: { + DepartmentId: departmentId, + }, + } as any); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + expect(signalRService.invoke).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith({ + message: 'Cannot join SignalR department group: invalid or missing DepartmentId', + context: { rawDepartmentId: departmentId }, + }); + }); + + it('should register per-event handlers that record raw messages and per-event timestamps', async () => { + const handlers: Record void> = {}; + (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => { + handlers[event] = handler; + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + const callsMessage = { CallId: '42' }; + act(() => { + handlers['callsUpdated'](callsMessage); + }); + + expect(result.current.lastUpdateMessage).toBe(callsMessage); + expect(result.current.lastUpdateTimestamp).toBeGreaterThan(0); + expect(result.current.lastUpdateTimestamps.callsUpdated).toBe(result.current.lastUpdateTimestamp); + + const unitMessage = { UnitId: '123' }; + act(() => { + handlers['unitStatusUpdated'](unitMessage); + }); + + expect(result.current.lastUnitStatusMessage).toBe(unitMessage); + expect(result.current.lastUnitStatusTimestamp).toBeGreaterThan(0); + expect(result.current.lastUpdateTimestamps.unitStatusUpdated).toBe(result.current.lastUnitStatusTimestamp); + }); + + it('should clear isUpdateHubConnected on hub disconnect and reconnecting events', async () => { + const handlers: Record void> = {}; + (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => { + handlers[event] = handler; + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + act(() => { + handlers['onConnected'](); + }); + expect(result.current.isUpdateHubConnected).toBe(true); + + act(() => { + handlers['__hubDisconnected:eventingHub'](); + }); + expect(result.current.isUpdateHubConnected).toBe(false); + + act(() => { + handlers['onConnected'](); + }); + act(() => { + handlers['__hubReconnecting:eventingHub'](); + }); + expect(result.current.isUpdateHubConnected).toBe(false); + }); + + it('should re-join the department group and bump all timestamps on hub reconnect', async () => { + const handlers: Record void> = {}; + (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => { + handlers[event] = handler; + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + (signalRService.invoke as jest.Mock).mockClear(); + + await act(async () => { + handlers['__hubReconnected:eventingHub'](); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(signalRService.invoke).toHaveBeenCalledWith('eventingHub', 'connect', 123); + expect(result.current.isUpdateHubConnected).toBe(true); + expect(result.current.lastUpdateTimestamp).toBeGreaterThan(0); + expect(result.current.lastUpdateTimestamps.callsUpdated).toBe(result.current.lastUpdateTimestamp); + expect(result.current.lastUpdateTimestamps.unitStatusUpdated).toBe(result.current.lastUpdateTimestamp); + expect(result.current.lastUpdateTimestamps.personnelStatusUpdated).toBe(result.current.lastUpdateTimestamp); + expect(result.current.lastUpdateTimestamps.weatherAlertReceived).toBe(result.current.lastUpdateTimestamp); + }); + + it('should remove hub-scoped lifecycle listeners before connecting', async () => { + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + expect(signalRService.removeAllListeners).toHaveBeenCalledWith('__hubDisconnected:eventingHub'); + expect(signalRService.removeAllListeners).toHaveBeenCalledWith('__hubReconnecting:eventingHub'); + expect(signalRService.removeAllListeners).toHaveBeenCalledWith('__hubReconnected:eventingHub'); + }); }); describe('disconnectUpdateHub', () => { @@ -250,6 +407,27 @@ describe('useSignalRStore', () => { new Error('EventingUrl not available in config. Please ensure config is loaded first.') ); }); + + it('should register no-op location handlers that do not write to the store', async () => { + const handlers: Record void> = {}; + (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => { + handlers[event] = handler; + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectGeolocationHub(); + }); + + act(() => { + handlers['onPersonnelLocationUpdated']({ Latitude: 1, Longitude: 2 }); + handlers['onUnitLocationUpdated']({ Latitude: 3, Longitude: 4 }); + }); + + expect(result.current.lastGeolocationMessage).toBeNull(); + expect(result.current.lastGeolocationTimestamp).toBe(0); + }); }); describe('disconnectGeolocationHub', () => { diff --git a/src/stores/signalr/__tests__/zz-dbg.test.ts b/src/stores/signalr/__tests__/zz-dbg.test.ts new file mode 100644 index 00000000..d57ebc64 --- /dev/null +++ b/src/stores/signalr/__tests__/zz-dbg.test.ts @@ -0,0 +1,48 @@ +import { act, renderHook } from '@testing-library/react-native'; + +const mockCoreStoreGetState = jest.fn(() => ({ config: { EventingUrl: 'https://eventing.example.com/' } })); +const mockSecurityStore = { getState: jest.fn(() => ({ rights: { DepartmentId: '123' } })) }; + +jest.mock('@/services/signalr.service', () => { + const mockInstance = { + connectToHubWithEventingUrl: jest.fn().mockResolvedValue(undefined), + disconnectFromHub: jest.fn().mockResolvedValue(undefined), + invoke: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + removeAllListeners: jest.fn(), + }; + class MockSignalRService { + static readonly HUB_DISCONNECTED_EVENT = '__hubDisconnected'; + static readonly HUB_RECONNECTING_EVENT = '__hubReconnecting'; + static readonly HUB_RECONNECTED_EVENT = '__hubReconnected'; + } + return { SignalRService: MockSignalRService, signalRService: mockInstance, default: mockInstance }; +}); +jest.mock('../../app/core-store', () => { + const mockStore: any = () => mockCoreStoreGetState(); + mockStore.getState = () => mockCoreStoreGetState(); + return { useCoreStore: mockStore }; +}); +jest.mock('../../security/store', () => { + console.log('RELATIVE security mock factory ran'); + return { securityStore: mockSecurityStore, useSecurityStore: mockSecurityStore }; +}); +jest.mock('@/stores/security/store', () => { + console.log('ALIAS security mock factory ran'); + return { securityStore: mockSecurityStore }; +}); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), fatal: jest.fn() } })); +jest.mock('@/lib/env', () => ({ Env: { CHANNEL_HUB_NAME: 'eventingHub', REALTIME_GEO_HUB_NAME: 'geolocationHub' } })); +jest.mock('@/lib', () => ({ useAuthStore: { getState: jest.fn(() => ({ accessToken: 'mock-token' })) } })); + +import { useSignalRStore } from '../signalr-store'; +import { signalRService } from '@/services/signalr.service'; + +it('dbg join', async () => { + const { result } = renderHook(() => useSignalRStore()); + await act(async () => { + await result.current.connectUpdateHub(); + }); + console.log('error:', result.current.error); + console.log('invoke calls:', (signalRService.invoke as jest.Mock).mock.calls); +}); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 8dae5794..d439091c 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -3,10 +3,10 @@ import { create } from 'zustand'; import { useAuthStore } from '@/lib'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; -import { signalRService } from '@/services/signalr.service'; +import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; -import { securityStore, useSecurityStore } from '../security/store'; +import { securityStore } from '../security/store'; import { useWeatherAlertsStore } from '../weather-alerts/store'; /** Minimal shape of the SignalR weather alert payload. The server sends @@ -25,10 +25,32 @@ function extractAlertId(message: unknown): string | undefined { return undefined; } +/** Update-hub events that carry a per-event timestamp for targeted refetches. */ +export const UPDATE_HUB_EVENTS = [ + 'personnelStatusUpdated', + 'personnelStaffingUpdated', + 'unitStatusUpdated', + 'callsUpdated', + 'callAdded', + 'callClosed', + 'weatherAlertReceived', + 'weatherAlertUpdated', + 'weatherAlertExpired', +] as const; + +export type UpdateHubEvent = (typeof UPDATE_HUB_EVENTS)[number]; + interface SignalRState { isUpdateHubConnected: boolean; + /** @deprecated Kept for backward compatibility — mirrors the latest update-hub message. */ lastUpdateMessage: unknown; + /** @deprecated Kept for backward compatibility — mirrors the latest update-hub timestamp. */ lastUpdateTimestamp: number; + /** Per-event timestamps — consumers should subscribe to the events they care about. */ + lastUpdateTimestamps: Record; + /** Raw payload of the latest unitStatusUpdated message (no JSON round-trip). */ + lastUnitStatusMessage: unknown; + lastUnitStatusTimestamp: number; isGeolocationHubConnected: boolean; lastGeolocationMessage: unknown; lastGeolocationTimestamp: number; @@ -39,10 +61,30 @@ interface SignalRState { disconnectGeolocationHub: () => Promise; } +/** Join the department group on the update hub. Group membership is per- + * connectionId, so this must run after every (re)connect. */ +const joinDepartmentGroup = async (): Promise => { + const rawDepartmentId = securityStore.getState().rights?.DepartmentId; + const departmentId = parseInt(rawDepartmentId ?? '', 10); + + if (!Number.isFinite(departmentId) || departmentId <= 0) { + logger.error({ + message: 'Cannot join SignalR department group: invalid or missing DepartmentId', + context: { rawDepartmentId }, + }); + return; + } + + await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', departmentId); +}; + export const useSignalRStore = create((set, get) => ({ isUpdateHubConnected: false, lastUpdateMessage: null, lastUpdateTimestamp: 0, + lastUpdateTimestamps: {}, + lastUnitStatusMessage: null, + lastUnitStatusTimestamp: 0, isGeolocationHubConnected: false, lastGeolocationMessage: null, lastGeolocationTimestamp: 0, @@ -69,19 +111,12 @@ export const useSignalRStore = create((set, get) => ({ } // Remove any previously registered handlers to prevent accumulation - // across reconnections or repeated connectUpdateHub calls - const updateEvents = [ - 'personnelStatusUpdated', - 'personnelStaffingUpdated', - 'unitStatusUpdated', - 'callsUpdated', - 'callAdded', - 'callClosed', - 'weatherAlertReceived', - 'weatherAlertUpdated', - 'weatherAlertExpired', - 'onConnected', - ]; + // across reconnections or repeated connectUpdateHub calls. Lifecycle + // events use hub-scoped names so this never wipes the geo hub's listeners. + const updateHubDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`; + const updateHubReconnecting = `${SignalRService.HUB_RECONNECTING_EVENT}:${Env.CHANNEL_HUB_NAME}`; + const updateHubReconnected = `${SignalRService.HUB_RECONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`; + const updateEvents = [...UPDATE_HUB_EVENTS, 'onConnected', updateHubDisconnected, updateHubReconnecting, updateHubReconnected]; updateEvents.forEach((event) => signalRService.removeAllListeners(event)); // Connect to the eventing hub @@ -89,49 +124,82 @@ export const useSignalRStore = create((set, get) => ({ name: Env.CHANNEL_HUB_NAME, eventingUrl: eventingUrl, hubName: Env.CHANNEL_HUB_NAME, - methods: [ - 'personnelStatusUpdated', - 'personnelStaffingUpdated', - 'unitStatusUpdated', - 'callsUpdated', - 'callAdded', - 'callClosed', - 'weatherAlertReceived', - 'weatherAlertUpdated', - 'weatherAlertExpired', - 'onConnected', - ], + methods: [...UPDATE_HUB_EVENTS, 'onConnected'], }); - await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', parseInt(securityStore.getState().rights?.DepartmentId ?? '0')); + await joinDepartmentGroup(); - signalRService.on('personnelStatusUpdated', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + // Connection lifecycle: clear the connected flag when the hub drops so + // connectUpdateHub() can recover, and re-join the department group + + // trigger a full state resync after every reconnect (group membership + // is per-connectionId and events are missed while disconnected). + signalRService.on(updateHubDisconnected, () => { + set({ isUpdateHubConnected: false }); }); - signalRService.on('personnelStaffingUpdated', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + signalRService.on(updateHubReconnecting, () => { + set({ isUpdateHubConnected: false }); }); - signalRService.on('unitStatusUpdated', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + signalRService.on(updateHubReconnected, () => { + void (async () => { + try { + await joinDepartmentGroup(); + set({ isUpdateHubConnected: true, error: null }); + + // Bump every event timestamp so subscribed hooks refetch their + // data — events were missed while the connection was down. + const now = Date.now(); + const timestamps: Record = {}; + UPDATE_HUB_EVENTS.forEach((event) => { + timestamps[event] = now; + }); + set({ lastUpdateTimestamps: timestamps, lastUpdateTimestamp: now }); + + logger.info({ + message: 'Re-joined department group and triggered state resync after SignalR reconnect', + }); + } catch (error) { + logger.error({ + message: 'Failed to re-join department group after SignalR reconnect', + context: { error }, + }); + } + })(); }); - signalRService.on('callsUpdated', (message) => { + // One handler per event: record a per-event timestamp (no JSON.stringify + // on the hot path) and keep the deprecated aggregate fields in sync for + // legacy consumers. + const recordEvent = (event: UpdateHubEvent) => (message: unknown) => { const now = Date.now(); - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: now }); - }); + set((state) => ({ + lastUpdateMessage: message, + lastUpdateTimestamp: now, + lastUpdateTimestamps: { ...state.lastUpdateTimestamps, [event]: now }, + })); + }; - signalRService.on('callAdded', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); - }); + signalRService.on('personnelStatusUpdated', recordEvent('personnelStatusUpdated')); + signalRService.on('personnelStaffingUpdated', recordEvent('personnelStaffingUpdated')); + signalRService.on('callsUpdated', recordEvent('callsUpdated')); + signalRService.on('callAdded', recordEvent('callAdded')); + signalRService.on('callClosed', recordEvent('callClosed')); - signalRService.on('callClosed', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + // unitStatusUpdated additionally keeps its raw payload for the status hook + signalRService.on('unitStatusUpdated', (message) => { + const now = Date.now(); + set((state) => ({ + lastUpdateMessage: message, + lastUpdateTimestamp: now, + lastUpdateTimestamps: { ...state.lastUpdateTimestamps, unitStatusUpdated: now }, + lastUnitStatusMessage: message, + lastUnitStatusTimestamp: now, + })); }); signalRService.on('weatherAlertReceived', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + recordEvent('weatherAlertReceived')(message); const alertId = extractAlertId(message); if (alertId) { useWeatherAlertsStore.getState().handleAlertReceived(alertId); @@ -141,7 +209,7 @@ export const useSignalRStore = create((set, get) => ({ }); signalRService.on('weatherAlertUpdated', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + recordEvent('weatherAlertUpdated')(message); const alertId = extractAlertId(message); if (alertId) { useWeatherAlertsStore.getState().handleAlertUpdated(alertId); @@ -151,7 +219,7 @@ export const useSignalRStore = create((set, get) => ({ }); signalRService.on('weatherAlertExpired', (message) => { - set({ lastUpdateMessage: JSON.stringify(message), lastUpdateTimestamp: Date.now() }); + recordEvent('weatherAlertExpired')(message); const alertId = extractAlertId(message); if (alertId) { useWeatherAlertsStore.getState().handleAlertExpired(alertId); @@ -210,7 +278,8 @@ export const useSignalRStore = create((set, get) => ({ } // Remove any previously registered handlers to prevent accumulation - const geoEvents = ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect']; + const geoHubDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.REALTIME_GEO_HUB_NAME}`; + const geoEvents = ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect', geoHubDisconnected]; geoEvents.forEach((event) => signalRService.removeAllListeners(event)); // Connect to the geolocation hub @@ -221,13 +290,16 @@ export const useSignalRStore = create((set, get) => ({ methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'], }); - // Set up message handler - signalRService.on('onPersonnelLocationUpdated', (message) => { - set({ lastGeolocationMessage: JSON.stringify(message), lastGeolocationTimestamp: Date.now() }); - }); + // NOTE: no per-message store writes here. Geolocation messages fire per + // unit per location cycle and nothing in the app consumes them — writing + // them to the store (previously JSON.stringify'd on every message) was + // pure CPU/render churn. Register no-op listeners so the hub methods stay + // subscribed without store updates. + signalRService.on('onPersonnelLocationUpdated', () => {}); + signalRService.on('onUnitLocationUpdated', () => {}); - signalRService.on('onUnitLocationUpdated', (message) => { - set({ lastGeolocationMessage: JSON.stringify(message), lastGeolocationTimestamp: Date.now() }); + signalRService.on(geoHubDisconnected, () => { + set({ isGeolocationHubConnected: false }); }); signalRService.on('onGeolocationConnect', () => { diff --git a/src/stores/status/__tests__/store.test.ts b/src/stores/status/__tests__/store.test.ts index 141ceedd..61f3ded1 100644 --- a/src/stores/status/__tests__/store.test.ts +++ b/src/stores/status/__tests__/store.test.ts @@ -27,6 +27,7 @@ import { UnitTypeStatusesResult } from '@/models/v4/statuses/unitTypeStatusesRes import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { offlineEventManager } from '@/services/offline-event-manager.service'; import { useCoreStore } from '@/stores/app/core-store'; +import { isNetworkError } from '@/utils/network'; import { useStatusBottomSheetStore, useStatusesStore } from '../store'; @@ -67,6 +68,9 @@ jest.mock('@/services/offline-event-manager.service', () => ({ queueUnitStatusEvent: jest.fn(), }, })); +jest.mock('@/utils/network', () => ({ + isNetworkError: jest.fn(), +})); jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), @@ -79,6 +83,7 @@ const mockGetSetUnitStatusData = getSetUnitStatusData as jest.MockedFunction; const mockUseCoreStore = useCoreStore as jest.MockedFunction; const mockOfflineEventManager = offlineEventManager as jest.Mocked; +const mockIsNetworkError = isNetworkError as jest.MockedFunction; describe('StatusBottomSheetStore', () => { beforeEach(() => { @@ -259,7 +264,10 @@ describe('StatusesStore', () => { beforeEach(() => { jest.clearAllMocks(); - + + // Default: failures are network errors so offline queueing kicks in + mockIsNetworkError.mockReturnValue(true); + // Mock the zustand store pattern const mockStore = { activeUnit: mockActiveUnit, @@ -302,7 +310,7 @@ describe('StatusesStore', () => { expect(result.current.error).toBe(null); }); - it('should queue unit status event when direct save fails', async () => { + it('should queue unit status event when direct save fails due to a network error', async () => { const { result } = renderHook(() => useStatusesStore()); mockSaveUnitStatus.mockRejectedValue(new Error('Network error')); @@ -370,6 +378,30 @@ describe('StatusesStore', () => { expect(result.current.error).toBe(null); }); + it('should not queue and should rethrow when the server rejects the save', async () => { + const { result } = renderHook(() => useStatusesStore()); + + const serverError = new Error('Request failed with status code 400'); + mockSaveUnitStatus.mockRejectedValue(serverError); + mockIsNetworkError.mockReturnValue(false); + mockUseCoreStore.mockReturnValue({ + activeUnit: { UnitId: 'unit1' }, + setActiveUnitWithFetch: jest.fn(), + } as any); + + const input = new SaveUnitStatusInput(); + input.Id = 'unit1'; + input.Type = '1'; + + await act(async () => { + await expect(result.current.saveUnitStatus(input)).rejects.toThrow('Request failed with status code 400'); + }); + + expect(mockOfflineEventManager.queueUnitStatusEvent).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe('Failed to save unit status'); + }); + it('should handle input without roles when queueing', async () => { const { result } = renderHook(() => useStatusesStore()); diff --git a/src/stores/status/store.ts b/src/stores/status/store.ts index 0dbaf731..aa9c1965 100644 --- a/src/stores/status/store.ts +++ b/src/stores/status/store.ts @@ -10,6 +10,7 @@ import { type PoiResultData, type PoiTypeResultData } from '@/models/v4/mapping/ import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData'; import { type SaveUnitStatusInput, type SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; import { offlineEventManager } from '@/services/offline-event-manager.service'; +import { isNetworkError } from '@/utils/network'; import { useCoreStore } from '../app/core-store'; import { useLocationStore } from '../app/location-store'; @@ -240,8 +241,21 @@ export const useStatusesStore = create((set) => ({ } } } catch (error) { + // Only queue the status when the failure is genuinely network-related + // (offline/timeout, no response). Server rejections (400 validation, + // 403 permission, 5xx) must surface as errors — queuing them would + // tell the user the save succeeded and replay a stale status later. + if (!isNetworkError(error)) { + logger.error({ + message: 'Unit status save rejected by server', + context: { unitId: input.Id, statusType: input.Type, error }, + }); + set({ error: 'Failed to save unit status', isLoading: false }); + throw error; + } + logger.warn({ - message: 'Direct unit status save failed, queuing for offline processing', + message: 'Direct unit status save failed due to network, queuing for offline processing', context: { unitId: input.Id, statusType: input.Type, error }, }); From 9362012e66d630f2dabe514da124b0939132bdac Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 17:27:24 -0700 Subject: [PATCH 2/2] RU-T50 PR#256 fixes --- src/api/common/__tests__/client.test.ts | 90 +++++++ src/api/common/client.tsx | 2 + src/app/(app)/__tests__/protocols.test.tsx | 2 +- src/app/(app)/calls.tsx | 26 +- src/app/(app)/protocols.tsx | 4 +- src/app/call/[id].tsx | 50 ++-- src/app/call/__tests__/[id].test.tsx | 28 ++ .../dispatch-selection-modal.test.tsx | 13 +- .../calls/dispatch-selection-modal.tsx | 10 +- .../__tests__/check-in-timer-card.test.tsx | 31 +++ .../check-in-timers/check-in-timer-card.tsx | 18 +- .../maps/__tests__/full-screen-map.test.tsx | 94 +++++++ src/components/maps/full-screen-map.tsx | 112 ++++++++ src/hooks/__tests__/use-saml-login.test.ts | 20 +- src/hooks/use-saml-login.ts | 2 +- src/lib/__tests__/protocol-utils.test.ts | 31 +++ src/lib/__tests__/shared-ticker.test.ts | 30 +++ src/lib/cache/__tests__/cache-manager.test.ts | 64 +++++ src/lib/cache/cache-manager.ts | 24 +- src/lib/logging/index.tsx | 10 +- src/lib/logging/types.tsx | 2 + src/lib/protocol-utils.ts | 3 + src/lib/shared-ticker.ts | 24 +- .../signalr.service.enhanced.test.ts | 32 ++- src/services/signalr.service.ts | 37 ++- .../livekit-store-room-switch.test.ts | 249 ++++++++++++++++++ src/stores/app/livekit-store.ts | 3 + 27 files changed, 954 insertions(+), 57 deletions(-) create mode 100644 src/api/common/__tests__/client.test.ts create mode 100644 src/components/maps/__tests__/full-screen-map.test.tsx create mode 100644 src/components/maps/full-screen-map.tsx create mode 100644 src/lib/__tests__/protocol-utils.test.ts create mode 100644 src/lib/__tests__/shared-ticker.test.ts create mode 100644 src/lib/cache/__tests__/cache-manager.test.ts create mode 100644 src/lib/protocol-utils.ts create mode 100644 src/stores/app/__tests__/livekit-store-room-switch.test.ts diff --git a/src/api/common/__tests__/client.test.ts b/src/api/common/__tests__/client.test.ts new file mode 100644 index 00000000..236910ae --- /dev/null +++ b/src/api/common/__tests__/client.test.ts @@ -0,0 +1,90 @@ +const mockRequestInterceptorUse = jest.fn(); +const mockResponseInterceptorUse = jest.fn(); +const mockLoggerWarn = jest.fn(); +const mockGetAuthState = jest.fn(); +const mockAxiosInstance = Object.assign(jest.fn(), { + defaults: { + headers: { + common: {}, + }, + }, + interceptors: { + request: { + use: mockRequestInterceptorUse, + }, + response: { + use: mockResponseInterceptorUse, + }, + }, +}); + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: jest.fn(() => mockAxiosInstance), + }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + warn: mockLoggerWarn, + }, +})); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://example.test'), +})); + +jest.mock('@/stores/auth/store', () => ({ + __esModule: true, + default: { + getState: mockGetAuthState, + }, +})); + +let rejectResponse: (error: unknown) => Promise; + +describe('API client token refresh logging', () => { + beforeAll(() => { + jest.isolateModules(() => { + require('@/api/common/client'); + }); + rejectResponse = mockResponseInterceptorUse.mock.calls[0]?.[1] as (error: unknown) => Promise; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('logs the token refresh operation and original request trace ID when refresh fails', async () => { + const refreshError = new Error('Token refresh failed'); + const refreshAccessToken = jest.fn().mockRejectedValue(refreshError); + const getHeader = jest.fn((name: string) => (name === 'x-trace-id' ? 'trace-123' : undefined)); + + mockGetAuthState.mockReturnValue({ + refreshAccessToken, + refreshToken: 'refresh-token', + }); + + const requestError = { + config: { + headers: { + get: getHeader, + }, + }, + response: { + status: 401, + }, + }; + + await expect(rejectResponse(requestError)).rejects.toBe(refreshError); + + expect(mockLoggerWarn).toHaveBeenCalledWith({ + message: 'Request failed after token refresh attempt', + operation: 'token_refresh', + trace_id: 'trace-123', + context: { error: refreshError }, + }); + expect(getHeader).toHaveBeenCalledWith('x-trace-id'); + }); +}); diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index b3c145c2..ae98e69e 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -113,6 +113,8 @@ axiosInstance.interceptors.response.use( // else is transient and the session is preserved for a later retry. logger.warn({ message: 'Request failed after token refresh attempt', + operation: 'token_refresh', + trace_id: originalRequest.headers.get('x-trace-id')?.toString(), context: { error: refreshError }, }); diff --git a/src/app/(app)/__tests__/protocols.test.tsx b/src/app/(app)/__tests__/protocols.test.tsx index 9878c604..3595da9a 100644 --- a/src/app/(app)/__tests__/protocols.test.tsx +++ b/src/app/(app)/__tests__/protocols.test.tsx @@ -431,4 +431,4 @@ describe('Protocols Page', () => { expect(screen.getByTestId('protocol-card-3')).toBeTruthy(); }); }); -}); \ No newline at end of file +}); diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx index a61a98a7..62492e9c 100644 --- a/src/app/(app)/calls.tsx +++ b/src/app/(app)/calls.tsx @@ -14,10 +14,30 @@ import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { useAnalytics } from '@/hooks/use-analytics'; +import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; import { type CallResultData } from '@/models/v4/calls/callResultData'; +import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; import { useCallsStore } from '@/stores/calls/store'; import { securityStore } from '@/stores/security/store'; +interface CallListItemProps { + call: CallResultData; + dispatches?: DispatchedEventResultData[]; + priority?: CallPriorityResultData; +} + +const CallListItem: React.FC = React.memo(({ call, dispatches, priority }) => { + const handlePress = useCallback(() => { + router.push(`/call/${call.CallId}`); + }, [call.CallId]); + + return ( + + + + ); +}); + export default function Calls() { const calls = useCallsStore((state) => state.calls); const callPriorities = useCallsStore((state) => state.callPriorities); @@ -80,11 +100,7 @@ export default function Calls() { }, [calls, searchQuery]); const renderItem = useCallback( - ({ item }: { item: CallResultData }) => ( - router.push(`/call/${item.CallId}`)}> - - - ), + ({ item }: { item: CallResultData }) => , [prioritiesById, callDispatches] ); diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx index fcd119e7..e57408fe 100644 --- a/src/app/(app)/protocols.tsx +++ b/src/app/(app)/protocols.tsx @@ -13,6 +13,7 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { Input } from '@/components/ui/input'; import { InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { useAnalytics } from '@/hooks/use-analytics'; +import { getProtocolKey } from '@/lib/protocol-utils'; import { useProtocolsStore } from '@/stores/protocols/store'; export default function Protocols() { @@ -59,7 +60,6 @@ export default function Protocols() { }, [protocols, searchQuery]); const renderProtocol = React.useCallback(({ item }: { item: (typeof filteredProtocols)[number] }) => , [handleProtocolPress]); - const protocolKeyExtractor = React.useCallback((item: (typeof filteredProtocols)[number], index: number) => item.ProtocolId || `protocol-${index}`, []); return ( <> @@ -84,7 +84,7 @@ export default function Protocols() { ('call'); + const [isFullScreenMapOpen, setIsFullScreenMapOpen] = useState(false); const showToast = useToastStore((state) => state.showToast); const timerStatuses = useCheckInTimerStore((state) => state.timerStatuses); const startPolling = useCheckInTimerStore((state) => state.startPolling); @@ -120,6 +122,14 @@ export default function CallDetail() { setMapTarget('destination'); }, []); + const handleOpenFullScreenMap = useCallback(() => { + setIsFullScreenMapOpen(true); + }, []); + + const handleCloseFullScreenMap = useCallback(() => { + setIsFullScreenMapOpen(false); + }, []); + const handleSetActive = async () => { if (!call) return; @@ -534,6 +544,7 @@ export default function CallDetail() { const mapLatitude = showingDestination ? destinationLatitude : coordinates.latitude; const mapLongitude = showingDestination ? destinationLongitude : coordinates.longitude; const mapAddress = showingDestination ? call.DestinationAddress || call.DestinationName || '' : call.Address; + const mapTitle = showingDestination ? call.DestinationName || t('call_detail.destination') : call.Name || t('call_detail.call_location'); return ( <> @@ -569,22 +580,27 @@ export default function CallDetail() { {/* Map - only show when valid coordinates exist */} {mapLatitude !== null && mapLongitude !== null ? ( - - - {/* Toggle the map between the call (dispatch) location and the destination POI */} - {hasDestinationCoordinates ? ( - - - - - ) : null} - + <> + + + + + {/* Toggle the map between the call (dispatch) location and the destination POI */} + {hasDestinationCoordinates ? ( + + + + + ) : null} + + + ) : null} {/* Action Buttons */} diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index ba243947..8c18fc7d 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -282,6 +282,13 @@ jest.mock('@/components/maps/static-map', () => { }; }); +jest.mock('@/components/maps/full-screen-map', () => ({ + FullScreenMap: ({ isOpen, ...props }: any) => { + const React = require('react'); + return isOpen ? React.createElement('full-screen-map', { ...props, testID: 'full-screen-call-map' }) : null; + }, +})); + jest.mock('@/components/check-in-timers/check-in-tab-content', () => ({ CheckInTabContent: () => null, })); @@ -316,6 +323,10 @@ jest.mock('react-native', () => ({ View: jest.fn().mockImplementation(({ children, ...props }) => children), Text: jest.fn().mockImplementation(({ children }) => children), ScrollView: jest.fn().mockImplementation(({ children }) => children), + Pressable: jest.fn().mockImplementation(({ children, ...props }) => { + const React = require('react'); + return React.createElement('button', props, children); + }), ActivityIndicator: jest.fn().mockImplementation(() => null), StatusBar: { setBackgroundColor: jest.fn(), @@ -1345,6 +1356,23 @@ describe('CallDetail', () => { expect(queryByTestId('call-detail-route-destination-button')).toBeNull(); }); + it('should open the call location in the full-screen map when the static map is pressed', async () => { + mockDetailStore(callWithoutDestination); + + const { getByTestId, queryByTestId } = render(); + + expect(queryByTestId('full-screen-call-map')).toBeNull(); + fireEvent.press(await waitFor(() => getByTestId('call-detail-static-map'))); + + const fullScreenMap = getByTestId('full-screen-call-map'); + expect(fullScreenMap.props).toMatchObject({ + latitude: 40.7128, + longitude: -74.006, + title: 'Test Call', + address: '123 Main St', + }); + }); + it('should route to the destination POI coordinates when the destination route button is pressed', async () => { mockDetailStore(callWithDestination); diff --git a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx index ccc28128..fdec18a4 100644 --- a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx +++ b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx @@ -172,6 +172,8 @@ describe('DispatchSelectionModal', () => { beforeEach(() => { jest.clearAllMocks(); + mockDispatchStore.data.users[0].Name = 'John Doe'; + mockDispatchStore.searchQuery = ''; }); it('should render when visible', () => { @@ -226,6 +228,15 @@ describe('DispatchSelectionModal', () => { }); }); + it.each([undefined, null])('should not crash while filtering when a user name is %s', (missingName) => { + mockDispatchStore.data.users[0].Name = missingName as unknown as string; + mockDispatchStore.searchQuery = 'john'; + + const { queryByText } = render(); + + expect(queryByText('calls.users (1)')).toBeNull(); + }); + it('should call clearSelection and onClose when cancel button is pressed', async () => { const { getByText } = render(); @@ -244,4 +255,4 @@ describe('DispatchSelectionModal', () => { // Should show 0 selected by default expect(getByText('0 calls.selected')).toBeTruthy(); }); -}); \ No newline at end of file +}); diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx index 915af8b8..a3129889 100644 --- a/src/components/calls/dispatch-selection-modal.tsx +++ b/src/components/calls/dispatch-selection-modal.tsx @@ -21,6 +21,8 @@ interface DispatchSelectionModalProps { initialSelection?: DispatchSelection; } +const nameIncludesQuery = (name: string | null | undefined, query: string): boolean => name?.toLowerCase().includes(query) ?? false; + export const DispatchSelectionModal: React.FC = ({ isVisible, onClose, onConfirm, initialSelection }) => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); @@ -48,10 +50,10 @@ export const DispatchSelectionModal: React.FC = ({ } const query = searchQuery.toLowerCase(); return { - users: data.users.filter((user) => user.Name.toLowerCase().includes(query)), - groups: data.groups.filter((group) => group.Name.toLowerCase().includes(query)), - roles: data.roles.filter((role) => role.Name.toLowerCase().includes(query)), - units: data.units.filter((unit) => unit.Name.toLowerCase().includes(query)), + users: data.users.filter((user) => nameIncludesQuery(user.Name, query)), + groups: data.groups.filter((group) => nameIncludesQuery(group.Name, query)), + roles: data.roles.filter((role) => nameIncludesQuery(role.Name, query)), + units: data.units.filter((unit) => nameIncludesQuery(unit.Name, query)), }; }, [data, searchQuery]); diff --git a/src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx b/src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx index 8151e752..f7e046e0 100644 --- a/src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx +++ b/src/components/check-in-timers/__tests__/check-in-timer-card.test.tsx @@ -1,10 +1,22 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; +import { logger } from '@/lib/logging'; +import { subscribeToSharedTicker } from '@/lib/shared-ticker'; import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTimerStatusResultData'; import { CheckInTimerCard } from '../check-in-timer-card'; +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + }, +})); + +jest.mock('@/lib/shared-ticker', () => ({ + subscribeToSharedTicker: jest.fn(() => jest.fn()), +})); + jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -80,6 +92,10 @@ const createMockTimer = (overrides: Partial = {}): }); describe('CheckInTimerCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render timer info', () => { const timer = createMockTimer(); const onCheckIn = jest.fn(); @@ -113,6 +129,21 @@ describe('CheckInTimerCard', () => { unmount(); }); + it('should report shared ticker listener errors', () => { + const error = new Error('tick failed'); + const { unmount } = render(); + const subscriptionOptions = jest.mocked(subscribeToSharedTicker).mock.calls[0][1]; + + subscriptionOptions.onError(error); + + expect(logger.error).toHaveBeenCalledWith({ + message: 'Shared check-in timer ticker listener failed', + operation: 'check_in_timer_tick', + context: { error }, + }); + unmount(); + }); + it('should render warning status', () => { const timer = createMockTimer({ Status: 'Warning', ElapsedMinutes: 22 }); const onCheckIn = jest.fn(); diff --git a/src/components/check-in-timers/check-in-timer-card.tsx b/src/components/check-in-timers/check-in-timer-card.tsx index d44df672..65b5c0b9 100644 --- a/src/components/check-in-timers/check-in-timer-card.tsx +++ b/src/components/check-in-timers/check-in-timer-card.tsx @@ -10,6 +10,7 @@ import { HStack } from '@/components/ui/hstack'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { getCheckInTimerStatusColor, getCheckInTimerStatusTranslationKey, isCheckInTimerCritical } from '@/lib/check-in-timer-utils'; +import { logger } from '@/lib/logging'; import { subscribeToSharedTicker } from '@/lib/shared-ticker'; import type { CheckInTimerStatusResultData } from '@/models/v4/checkIn/checkInTimerStatusResultData'; @@ -31,9 +32,20 @@ export const CheckInTimerCard: React.FC = ({ timer, onChe useEffect(() => { // Shared ticker: all visible timer cards run on ONE interval. - return subscribeToSharedTicker(() => { - setLocalElapsed((prev) => prev + 1 / 60); - }); + return subscribeToSharedTicker( + () => { + setLocalElapsed((prev) => prev + 1 / 60); + }, + { + onError: (error) => { + logger.error({ + message: 'Shared check-in timer ticker listener failed', + operation: 'check_in_timer_tick', + context: { error }, + }); + }, + } + ); }, []); const isCritical = isCheckInTimerCritical(timer.Status); diff --git a/src/components/maps/__tests__/full-screen-map.test.tsx b/src/components/maps/__tests__/full-screen-map.test.tsx new file mode 100644 index 00000000..76349427 --- /dev/null +++ b/src/components/maps/__tests__/full-screen-map.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { FullScreenMap } from '@/components/maps/full-screen-map'; + +interface MockMapComponentProps { + children?: React.ReactNode; + [key: string]: unknown; +} + +jest.mock('@env', () => ({ + Env: { + UNIT_MAPBOX_PUBKEY: 'test-mapbox-key', + }, +})); + +jest.mock('lucide-react-native', () => { + const { View } = require('react-native'); + return { + MapPinIcon: () => , + XIcon: () => , + }; +}); + +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: 'light' }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); + +jest.mock('@/components/ui/text', () => { + const { Text } = require('react-native'); + return { + Text: ({ children, ...props }: MockMapComponentProps) => {children}, + }; +}); + +jest.mock('@/components/maps/mapbox', () => { + const { View } = require('react-native'); + const MapView = ({ children, ...props }: MockMapComponentProps) => ( + + {children} + + ); + const Camera = (props: MockMapComponentProps) => ; + const PointAnnotation = ({ children, ...props }: MockMapComponentProps) => ( + + {children} + + ); + + return { + __esModule: true, + default: { + Camera, + MapView, + PointAnnotation, + setAccessToken: jest.fn(), + StyleURL: { + Dark: 'dark', + Street: 'street', + }, + }, + }; +}); + +describe('FullScreenMap', () => { + it('centers the camera and marker on the supplied location', () => { + const onClose = jest.fn(); + const { unmount } = render(); + + expect(screen.getByTestId('map-camera').props).toMatchObject({ + centerCoordinate: [-74.006, 40.7128], + zoomLevel: 15, + }); + expect(screen.getByTestId('map-point-annotation').props).toMatchObject({ + coordinate: [-74.006, 40.7128], + title: 'Test Call', + }); + expect(screen.getByText('123 Main St')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('full-screen-call-map-close')); + expect(onClose).toHaveBeenCalledTimes(1); + unmount(); + }); +}); diff --git a/src/components/maps/full-screen-map.tsx b/src/components/maps/full-screen-map.tsx new file mode 100644 index 00000000..80813cc9 --- /dev/null +++ b/src/components/maps/full-screen-map.tsx @@ -0,0 +1,112 @@ +import { MapPinIcon, XIcon } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import Mapbox from '@/components/maps/mapbox'; +import { Text } from '@/components/ui/text'; +import colors from '@/constants/colors'; +import { Env } from '@/lib/env'; + +Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); + +interface FullScreenMapProps { + isOpen: boolean; + latitude: number; + longitude: number; + onClose: () => void; + title?: string; + address?: string; +} + +export const FullScreenMap: React.FC = ({ isOpen, latitude, longitude, onClose, title, address }) => { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const insets = useSafeAreaInsets(); + const markerTitle = title || t('call_detail.call_location'); + const coordinate: [number, number] = [longitude, latitude]; + + return ( + + + + + + + + + + + + + + {markerTitle} + + + + + + + {address ? ( + + {address} + + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + position: 'relative', + }, + map: { + flex: 1, + }, + marker: { + alignItems: 'center', + justifyContent: 'center', + }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + minHeight: 56, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 16, + paddingBottom: 8, + backgroundColor: 'rgba(0, 0, 0, 0.72)', + }, + closeButton: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + addressContainer: { + position: 'absolute', + left: 16, + right: 16, + borderRadius: 8, + backgroundColor: 'rgba(0, 0, 0, 0.72)', + paddingHorizontal: 12, + paddingVertical: 10, + }, +}); diff --git a/src/hooks/__tests__/use-saml-login.test.ts b/src/hooks/__tests__/use-saml-login.test.ts index 0b96cdb8..fe98ef02 100644 --- a/src/hooks/__tests__/use-saml-login.test.ts +++ b/src/hooks/__tests__/use-saml-login.test.ts @@ -173,6 +173,24 @@ describe('useSamlLogin', () => { expect(tokenResult).toBeNull(); }); + it('handleDeepLink catches RelayState storage read failures', async () => { + (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ + scheme: 'resgridunit', + path: 'auth/callback', + queryParams: { saml_response: 'base64SamlResponse', relay_state: TEST_NONCE }, + }); + mockedGetItem.mockRejectedValueOnce(new Error('Storage read failed')); + + const { result } = renderHook(() => useSamlLogin()); + const tokenResult = await result.current.handleDeepLink( + `resgridunit://auth/callback?saml_response=base64SamlResponse&relay_state=${TEST_NONCE}`, + 'john.doe', + ); + + expect(tokenResult).toBeNull(); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + describe('validateSamlCallback', () => { it('returns the saml_response and consumes the nonce when relay state matches', async () => { (mockedLinking.parse as jest.Mock).mockReturnValueOnce({ @@ -180,7 +198,7 @@ describe('useSamlLogin', () => { path: 'auth/callback', queryParams: { saml_response: 'base64SamlResponse', relay_state: TEST_NONCE }, }); - mockedGetItem.mockReturnValue(TEST_NONCE); + mockedGetItem.mockResolvedValue(TEST_NONCE); const { result } = renderHook(() => useSamlLogin()); const samlResponse = await result.current.validateSamlCallback( diff --git a/src/hooks/use-saml-login.ts b/src/hooks/use-saml-login.ts index 41a05ae2..ad5bcb10 100644 --- a/src/hooks/use-saml-login.ts +++ b/src/hooks/use-saml-login.ts @@ -73,7 +73,7 @@ export function useSamlLogin() { return null; } - const pendingRelayState = getItem(SAML_RELAY_STATE_KEY); + const pendingRelayState = await getItem(SAML_RELAY_STATE_KEY); if (!pendingRelayState) { // No SAML flow is pending — this callback was not initiated by this app. logger.warn({ message: 'Ignoring SAML callback with no pending flow' }); diff --git a/src/lib/__tests__/protocol-utils.test.ts b/src/lib/__tests__/protocol-utils.test.ts new file mode 100644 index 00000000..fa25d7d0 --- /dev/null +++ b/src/lib/__tests__/protocol-utils.test.ts @@ -0,0 +1,31 @@ +import { getProtocolKey } from '@/lib/protocol-utils'; +import { CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData'; + +const createProtocol = (overrides: Partial): CallProtocolsResultData => Object.assign(new CallProtocolsResultData(), overrides); + +describe('getProtocolKey', () => { + it('uses ProtocolId when it is available', () => { + expect(getProtocolKey(createProtocol({ ProtocolId: 'protocol-42' }))).toBe('protocol-42'); + }); + + it('creates stable, distinct keys for protocols without IDs', () => { + const firstProtocol = createProtocol({ + DepartmentId: 'department-1', + Name: 'First Protocol', + Code: 'FIRST', + CreatedOn: '2026-01-01T00:00:00Z', + }); + const secondProtocol = createProtocol({ + DepartmentId: 'department-1', + Name: 'Second Protocol', + Code: 'SECOND', + CreatedOn: '2026-01-01T00:00:00Z', + }); + const firstKey = getProtocolKey(firstProtocol); + const secondKey = getProtocolKey(secondProtocol); + + expect(firstKey).toBe(getProtocolKey(firstProtocol)); + expect(firstKey).not.toBe(secondKey); + expect([secondProtocol, firstProtocol].map(getProtocolKey)).toEqual([secondKey, firstKey]); + }); +}); diff --git a/src/lib/__tests__/shared-ticker.test.ts b/src/lib/__tests__/shared-ticker.test.ts new file mode 100644 index 00000000..046893fd --- /dev/null +++ b/src/lib/__tests__/shared-ticker.test.ts @@ -0,0 +1,30 @@ +import { getSharedTickerListenerCount, subscribeToSharedTicker } from '@/lib/shared-ticker'; + +describe('shared ticker', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('reports listener errors and keeps ticking other listeners', () => { + jest.useFakeTimers(); + const error = new Error('tick failed'); + const onError = jest.fn(); + const healthyListener = jest.fn(); + const unsubscribeFailingListener = subscribeToSharedTicker( + () => { + throw error; + }, + { onError } + ); + const unsubscribeHealthyListener = subscribeToSharedTicker(healthyListener, { onError: jest.fn() }); + + jest.advanceTimersByTime(1000); + + expect(onError).toHaveBeenCalledWith(error); + expect(healthyListener).toHaveBeenCalledTimes(1); + + unsubscribeFailingListener(); + unsubscribeHealthyListener(); + expect(getSharedTickerListenerCount()).toBe(0); + }); +}); diff --git a/src/lib/cache/__tests__/cache-manager.test.ts b/src/lib/cache/__tests__/cache-manager.test.ts new file mode 100644 index 00000000..5e426958 --- /dev/null +++ b/src/lib/cache/__tests__/cache-manager.test.ts @@ -0,0 +1,64 @@ +import { cacheManager } from '@/lib/cache/cache-manager'; +import { storage } from '@/lib/storage'; + +jest.mock('@/lib/storage', () => ({ + storage: { + delete: jest.fn(), + getAllKeys: jest.fn(), + getString: jest.fn(), + set: jest.fn(), + }, +})); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://api.test'), +})); + +const mockedDelete = storage.delete as jest.Mock; +const mockedGetAllKeys = storage.getAllKeys as jest.Mock; +const mockedGetString = storage.getString as jest.Mock; + +const CACHE_KEY = 'api_cache_https://api.test_/test'; + +describe('CacheManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetAllKeys.mockReturnValue([]); + }); + + it('returns data from a valid unexpired cache entry', () => { + const data = { id: 'cached-result' }; + mockedGetString.mockReturnValue( + JSON.stringify({ + data, + timestamp: Date.now(), + expiresIn: 60_000, + }) + ); + + expect(cacheManager.get('/test')).toEqual(data); + expect(mockedDelete).not.toHaveBeenCalled(); + }); + + it.each([ + ['an empty object', {}], + ['a null value', null], + ['a missing data field', { timestamp: Date.now(), expiresIn: 60_000 }], + ['a nonnumeric timestamp', { data: 'value', timestamp: 'now', expiresIn: 60_000 }], + ['a nonnumeric expiry', { data: 'value', timestamp: Date.now(), expiresIn: 'later' }], + ])('evicts valid JSON with an invalid cache shape: %s', (_description, malformedEntry) => { + mockedGetString.mockReturnValue(JSON.stringify(malformedEntry)); + + expect(cacheManager.get('/test')).toBeNull(); + expect(mockedDelete).toHaveBeenCalledWith(CACHE_KEY); + }); + + it('evicts malformed cache shapes while pruning', () => { + mockedGetAllKeys.mockReturnValue([CACHE_KEY]); + mockedGetString.mockReturnValue('{}'); + + cacheManager.prune(); + + expect(mockedDelete).toHaveBeenCalledWith(CACHE_KEY); + }); +}); diff --git a/src/lib/cache/cache-manager.ts b/src/lib/cache/cache-manager.ts index 6215cdfc..6fee1a2d 100644 --- a/src/lib/cache/cache-manager.ts +++ b/src/lib/cache/cache-manager.ts @@ -8,6 +8,15 @@ interface CacheItem { expiresIn: number; } +const isCacheItem = (value: unknown): value is CacheItem => { + if (typeof value !== 'object' || value === null) { + return false; + } + + const item = value as Partial>; + return Object.prototype.hasOwnProperty.call(item, 'data') && typeof item.timestamp === 'number' && Number.isFinite(item.timestamp) && typeof item.expiresIn === 'number' && Number.isFinite(item.expiresIn); +}; + // Hard cap on cached entries — MMKV growth is otherwise unbounded because // endpoints with varying params (ids, dates) create a key per combination. const MAX_CACHE_ENTRIES = 200; @@ -55,7 +64,7 @@ export class CacheManager { return null; } - let cacheItem: CacheItem; + let cacheItem: unknown; try { cacheItem = JSON.parse(cached); } catch { @@ -65,12 +74,17 @@ export class CacheManager { return null; } + if (!isCacheItem(cacheItem)) { + storage.delete(key); + return null; + } + if (this.isExpired(cacheItem.timestamp, cacheItem.expiresIn)) { storage.delete(key); return null; } - return cacheItem.data; + return cacheItem.data as T; } remove(endpoint: string, params?: Record): void { @@ -96,7 +110,11 @@ export class CacheManager { return; } try { - const item = JSON.parse(raw) as CacheItem; + const item: unknown = JSON.parse(raw); + if (!isCacheItem(item)) { + storage.delete(key); + return; + } if (now - item.timestamp > item.expiresIn) { storage.delete(key); } else { diff --git a/src/lib/logging/index.tsx b/src/lib/logging/index.tsx index 0410fc15..881210ff 100644 --- a/src/lib/logging/index.tsx +++ b/src/lib/logging/index.tsx @@ -123,13 +123,15 @@ class LogService { return LogService.instance; } - private log(level: LogLevel, { message, context = {} }: LogEntry): void { + private log(level: LogLevel, { message, operation, trace_id, context = {} }: LogEntry): void { // Bail before allocating the context object on hot paths (SignalR messages, // GPS fixes) when the level would be filtered out anyway. if (isJest || LEVEL_VALUES[level] < MIN_SEVERITY) return; this.logger[level](message, { ...this.globalContext, ...context, + ...(operation ? { operation } : {}), + ...(trace_id ? { trace_id } : {}), timestamp: new Date().toISOString(), }); } @@ -157,7 +159,11 @@ class LogService { public error(entry: LogEntry): void { this.log('error', entry); if (!isJest) { - const sanitized = sanitizeLogContext(entry.context); + const sanitized = sanitizeLogContext({ + ...entry.context, + ...(entry.operation ? { operation: entry.operation } : {}), + ...(entry.trace_id ? { trace_id: entry.trace_id } : {}), + }); const err = sanitized.error; if (err instanceof Error) { Sentry.captureException(err, { extra: { message: entry.message, ...sanitized } }); diff --git a/src/lib/logging/types.tsx b/src/lib/logging/types.tsx index 9066d20d..3a2a8ce3 100644 --- a/src/lib/logging/types.tsx +++ b/src/lib/logging/types.tsx @@ -6,6 +6,8 @@ export interface LogContext { export interface LogEntry { message: string; + operation?: string; + trace_id?: string; context?: LogContext; } diff --git a/src/lib/protocol-utils.ts b/src/lib/protocol-utils.ts new file mode 100644 index 00000000..d0e22384 --- /dev/null +++ b/src/lib/protocol-utils.ts @@ -0,0 +1,3 @@ +import { type CallProtocolsResultData } from '@/models/v4/callProtocols/callProtocolsResultData'; + +export const getProtocolKey = (protocol: CallProtocolsResultData): string => protocol.ProtocolId || JSON.stringify([protocol.DepartmentId, protocol.Code, protocol.Name, protocol.CreatedOn]); diff --git a/src/lib/shared-ticker.ts b/src/lib/shared-ticker.ts index f4122121..8f23a947 100644 --- a/src/lib/shared-ticker.ts +++ b/src/lib/shared-ticker.ts @@ -6,28 +6,38 @@ type TickCallback = () => void; -const listeners = new Set(); +export interface SharedTickerSubscriptionOptions { + onError: (error: unknown) => void; +} + +interface TickListener { + callback: TickCallback; + onError: SharedTickerSubscriptionOptions['onError']; +} + +const listeners = new Set(); let interval: ReturnType | null = null; const tick = (): void => { - listeners.forEach((callback) => { + listeners.forEach(({ callback, onError }) => { try { callback(); - } catch { - // A throwing listener must not kill the shared ticker for everyone else. + } catch (error) { + onError(error); } }); }; -export const subscribeToSharedTicker = (callback: TickCallback): (() => void) => { - listeners.add(callback); +export const subscribeToSharedTicker = (callback: TickCallback, options: SharedTickerSubscriptionOptions): (() => void) => { + const listener = { callback, onError: options.onError }; + listeners.add(listener); if (!interval) { interval = setInterval(tick, 1000); } return () => { - listeners.delete(callback); + listeners.delete(listener); if (listeners.size === 0 && interval) { clearInterval(interval); interval = null; diff --git a/src/services/__tests__/signalr.service.enhanced.test.ts b/src/services/__tests__/signalr.service.enhanced.test.ts index 54e5ec4c..022fa80c 100644 --- a/src/services/__tests__/signalr.service.enhanced.test.ts +++ b/src/services/__tests__/signalr.service.enhanced.test.ts @@ -314,23 +314,19 @@ describe('SignalRService - Enhanced Features', () => { expect(mockLogger.info).toHaveBeenCalledWith({ message: `Scheduling reconnection attempt 1/10 for hub: ${mockConfig.name} in 5000ms`, }); + expect((service as any).reconnectTimers.has(mockConfig.name)).toBe(true); // Clear previous logs to isolate subsequent logging jest.clearAllMocks(); // Simulate explicit disconnect after the onclose handler await service.disconnectFromHub(mockConfig.name); + expect((service as any).reconnectTimers.has(mockConfig.name)).toBe(false); - // Advance timers to trigger the scheduled reconnect + // Advancing time must not run the cancelled reconnect callback. jest.advanceTimersByTime(5000); - // Assert that no reconnection attempt occurs - // The reconnect logic should not be called because the hub was explicitly disconnected - expect(mockLogger.debug).toHaveBeenCalledWith({ - message: `Hub ${mockConfig.name} config was removed, skipping reconnection attempt`, - }); - - // Ensure no actual reconnection attempt was made + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); expect(mockLogger.info).not.toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('attempting to reconnect to hub'), @@ -344,5 +340,25 @@ describe('SignalRService - Enhanced Features', () => { }); }); + + it('should cancel pending reconnect timers during teardown when no live connection remains', async () => { + const service = SignalRService.getInstance(); + + await service.connectToHubWithEventingUrl(mockConfig); + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; + + onCloseCallback(); + (service as any).connections.delete(mockConfig.name); + expect((service as any).reconnectTimers.has(mockConfig.name)).toBe(true); + + jest.clearAllMocks(); + await service.disconnectAll(); + + expect((service as any).reconnectTimers.has(mockConfig.name)).toBe(false); + expect((service as any).hubConfigs.has(mockConfig.name)).toBe(false); + + jest.advanceTimersByTime(5000); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index 5d3ab778..395801fa 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -35,6 +35,7 @@ class SignalRService { private connectionLocks: Map> = new Map(); private reconnectingHubs: Set = new Set(); private hubStates: Map = new Map(); + private reconnectTimers: Map> = new Map(); private readonly MAX_RECONNECT_ATTEMPTS = 10; private readonly RECONNECT_INTERVAL = 5000; // 5 seconds base (linear backoff to 60s cap) @@ -106,7 +107,17 @@ class SignalRService { } } + private clearReconnectTimer(hubName: string): void { + const reconnectTimer = this.reconnectTimers.get(hubName); + if (reconnectTimer !== undefined) { + clearTimeout(reconnectTimer); + this.reconnectTimers.delete(hubName); + } + } + public async connectToHubWithEventingUrl(config: SignalRHubConnectConfig): Promise { + this.clearReconnectTimer(config.name); + // Check for existing lock to prevent concurrent connections to the same hub const existingLock = this.connectionLocks.get(config.name); if (existingLock) { @@ -216,6 +227,7 @@ class SignalRService { }); connection.onreconnected((connectionId) => { + this.clearReconnectTimer(config.name); logger.info({ message: `Reconnected to hub: ${config.name}`, context: { connectionId }, @@ -243,6 +255,7 @@ class SignalRService { }); await connection.start(); + this.clearReconnectTimer(config.name); this.connections.set(config.name, connection); this.reconnectAttempts.set(config.name, 0); @@ -265,6 +278,8 @@ class SignalRService { } public async connectToHub(config: SignalRHubConfig): Promise { + this.clearReconnectTimer(config.name); + // Check for existing lock to prevent concurrent connections to the same hub const existingLock = this.connectionLocks.get(config.name); if (existingLock) { @@ -350,6 +365,7 @@ class SignalRService { }); connection.onreconnected((connectionId) => { + this.clearReconnectTimer(config.name); logger.info({ message: `Reconnected to hub: ${config.name}`, context: { connectionId }, @@ -377,6 +393,7 @@ class SignalRService { }); await connection.start(); + this.clearReconnectTimer(config.name); this.connections.set(config.name, connection); this.reconnectAttempts.set(config.name, 0); @@ -401,6 +418,7 @@ class SignalRService { private handleConnectionClose(hubName: string): void { const attempts = this.reconnectAttempts.get(hubName) || 0; if (attempts >= this.MAX_RECONNECT_ATTEMPTS) { + this.clearReconnectTimer(hubName); logger.error({ message: `Max reconnection attempts (${this.MAX_RECONNECT_ATTEMPTS}) reached for hub: ${hubName}`, }); @@ -431,7 +449,12 @@ class SignalRService { message: `Scheduling reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} for hub: ${hubName} in ${delay}ms`, }); - setTimeout(async () => { + this.clearReconnectTimer(hubName); + const reconnectTimer = setTimeout(async () => { + if (this.reconnectTimers.get(hubName) === reconnectTimer) { + this.reconnectTimers.delete(hubName); + } + try { // Check if the hub config was removed (e.g., by explicit disconnect) const currentHubConfig = this.hubConfigs.get(hubName); @@ -509,6 +532,7 @@ class SignalRService { this.handleConnectionClose(hubName); } }, delay); + this.reconnectTimers.set(hubName, reconnectTimer); } private handleMessage(_hubName: string, method: string, data: unknown): void { @@ -517,6 +541,8 @@ class SignalRService { } public async disconnectFromHub(hubName: string): Promise { + this.clearReconnectTimer(hubName); + // Wait for any ongoing connection attempt to complete const existingLock = this.connectionLocks.get(hubName); if (existingLock) { @@ -534,10 +560,15 @@ class SignalRService { } } + // A connection attempt may have scheduled a reconnect while we waited. + this.clearReconnectTimer(hubName); + const connection = this.connections.get(hubName); if (connection) { try { await connection.stop(); + // stop() may invoke onclose and schedule a reconnect before resolving. + this.clearReconnectTimer(hubName); this.connections.delete(hubName); this.reconnectAttempts.delete(hubName); this.hubConfigs.delete(hubName); @@ -546,6 +577,7 @@ class SignalRService { message: `Disconnected from hub: ${hubName}`, }); } catch (error) { + this.clearReconnectTimer(hubName); logger.error({ message: `Error disconnecting from hub: ${hubName}`, context: { error }, @@ -607,7 +639,8 @@ class SignalRService { } public async disconnectAll(): Promise { - const disconnectPromises = Array.from(this.connections.keys()).map((hubName) => this.disconnectFromHub(hubName)); + const hubNames = new Set([...this.connections.keys(), ...this.hubConfigs.keys(), ...this.reconnectTimers.keys()]); + const disconnectPromises = Array.from(hubNames).map((hubName) => this.disconnectFromHub(hubName)); await Promise.all(disconnectPromises); } diff --git a/src/stores/app/__tests__/livekit-store-room-switch.test.ts b/src/stores/app/__tests__/livekit-store-room-switch.test.ts new file mode 100644 index 00000000..2141f7db --- /dev/null +++ b/src/stores/app/__tests__/livekit-store-room-switch.test.ts @@ -0,0 +1,249 @@ +import notifee from '@notifee/react-native'; +import { Room, RoomEvent } from 'livekit-client'; + +import { DepartmentVoiceChannelResultData } from '@/models/v4/voice/departmentVoiceResultData'; +import { callKeepService } from '@/services/callkeep.service'; + +import { useLiveKitStore } from '../livekit-store'; + +jest.mock('@livekit/react-native', () => ({ + AudioSession: { + startAudioSession: jest.fn(), + stopAudioSession: jest.fn(), + }, +})); + +jest.mock('@livekit/react-native-webrtc', () => ({ + RTCAudioSession: {}, +})); + +jest.mock('@notifee/react-native', () => ({ + __esModule: true, + default: { + displayNotification: jest.fn(), + stopForegroundService: jest.fn(), + }, + AndroidForegroundServiceType: { + FOREGROUND_SERVICE_TYPE_MICROPHONE: 1, + }, + AndroidImportance: { + DEFAULT: 3, + }, +})); + +jest.mock('expo-audio', () => ({ + getRecordingPermissionsAsync: jest.fn(), + requestRecordingPermissionsAsync: jest.fn(), +})); + +jest.mock('expo-av', () => ({ + Audio: { + setAudioModeAsync: jest.fn(), + }, + InterruptionModeIOS: { + MixWithOthers: 0, + }, +})); + +jest.mock('expo-device', () => ({ + hasPlatformFeatureAsync: jest.fn(), +})); + +jest.mock('livekit-client', () => ({ + Room: jest.fn(), + RoomEvent: { + ActiveSpeakersChanged: 'activeSpeakersChanged', + Disconnected: 'disconnected', + ParticipantConnected: 'participantConnected', + ParticipantDisconnected: 'participantDisconnected', + Reconnected: 'reconnected', + Reconnecting: 'reconnecting', + }, +})); + +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, + Linking: { + openSettings: jest.fn(), + }, + NativeModules: {}, + PermissionsAndroid: { + check: jest.fn(), + request: jest.fn(), + requestMultiple: jest.fn(), + PERMISSIONS: { + READ_PHONE_NUMBERS: 'android.permission.READ_PHONE_NUMBERS', + READ_PHONE_STATE: 'android.permission.READ_PHONE_STATE', + RECORD_AUDIO: 'android.permission.RECORD_AUDIO', + }, + RESULTS: { + DENIED: 'denied', + GRANTED: 'granted', + }, + }, + Platform: { + OS: 'web', + }, +})); + +jest.mock('@/api/voice', () => ({ + getCanConnectToVoiceSession: jest.fn(), + getDepartmentVoiceSettings: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock('@/services/audio.service', () => ({ + audioService: { + playConnectToAudioRoomSound: jest.fn().mockResolvedValue(undefined), + playDisconnectedFromAudioRoomSound: jest.fn().mockResolvedValue(undefined), + playStartTransmittingSound: jest.fn().mockResolvedValue(undefined), + playStopTransmittingSound: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('@/services/bluetooth-audio.service', () => ({ + bluetoothAudioService: { + ensurePttInputMonitoring: jest.fn(), + }, +})); + +jest.mock('@/services/callkeep.service', () => ({ + callKeepService: { + endCall: jest.fn().mockResolvedValue(undefined), + setEndCallCallback: jest.fn(), + setMuteStateCallback: jest.fn(), + startCall: jest.fn().mockResolvedValue('call-uuid'), + }, +})); + +jest.mock('../bluetooth-audio-store', () => ({ + useBluetoothAudioStore: { + getState: jest.fn(() => ({ + connectedDevice: null, + selectedAudioDevices: { + microphone: null, + speaker: null, + }, + setLastButtonAction: jest.fn(), + setSelectedMicrophone: jest.fn(), + setSelectedSpeaker: jest.fn(), + })), + }, +})); + +type RoomEventHandler = (...args: unknown[]) => void; + +interface MockRoom { + connect: jest.Mock; + disconnect: jest.Mock; + handlers: Map; + localParticipant: { + audioTracks: Map; + identity: string; + isMicrophoneEnabled: boolean; + setCameraEnabled: jest.Mock; + setMicrophoneEnabled: jest.Mock; + sid: string; + videoTracks: Map; + }; + on: jest.Mock; + remoteParticipants: Map; +} + +const createMockRoom = (identity: string): MockRoom => { + const handlers = new Map(); + const room: MockRoom = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + handlers, + localParticipant: { + audioTracks: new Map(), + identity, + isMicrophoneEnabled: false, + setCameraEnabled: jest.fn().mockResolvedValue(undefined), + setMicrophoneEnabled: jest.fn().mockResolvedValue(undefined), + sid: `${identity}-sid`, + videoTracks: new Map(), + }, + on: jest.fn(), + remoteParticipants: new Map(), + }; + room.on.mockImplementation((event: string, handler: RoomEventHandler) => { + handlers.set(event, handler); + return room; + }); + room.disconnect.mockImplementation(async () => { + handlers.get(RoomEvent.Disconnected)?.(); + }); + return room; +}; + +const createRoomInfo = (id: string, name: string): DepartmentVoiceChannelResultData => + Object.assign(new DepartmentVoiceChannelResultData(), { + Id: id, + Name: name, + }); + +describe('LiveKit store room switching', () => { + beforeEach(() => { + jest.clearAllMocks(); + useLiveKitStore.setState({ + currentRoom: null, + currentRoomInfo: null, + isConnected: false, + isConnecting: false, + isMicrophoneEnabled: false, + isTalking: false, + requestPermissions: jest.fn().mockResolvedValue(true), + voipServerWebsocketSslAddress: 'wss://voice.example.com', + }); + }); + + it('marks the old room disconnected before switching rooms', async () => { + const oldRoom = createMockRoom('old-participant'); + const newRoom = createMockRoom('new-participant'); + const mockedRoomConstructor = Room as unknown as jest.Mock; + mockedRoomConstructor.mockImplementationOnce(() => oldRoom).mockImplementationOnce(() => newRoom); + + await useLiveKitStore.getState().connectToRoom(createRoomInfo('old', 'Old Room'), 'old-token'); + expect(useLiveKitStore.getState().currentRoom).toBe(oldRoom); + + let connectedStateDuringOldRoomDisconnect: boolean | undefined; + oldRoom.disconnect.mockImplementation(async () => { + connectedStateDuringOldRoomDisconnect = useLiveKitStore.getState().isConnected; + oldRoom.handlers.get(RoomEvent.Disconnected)?.(); + }); + + useLiveKitStore.setState({ + isConnected: false, + requestPermissions: jest.fn().mockImplementation(async () => { + // Simulate a concurrent state update after the entry guard but before + // the old room is intentionally disconnected. + useLiveKitStore.setState({ isConnected: true }); + return true; + }), + }); + jest.mocked(callKeepService.endCall).mockClear(); + jest.mocked(notifee.stopForegroundService).mockClear(); + + await useLiveKitStore.getState().connectToRoom(createRoomInfo('new', 'New Room'), 'new-token'); + + expect(connectedStateDuringOldRoomDisconnect).toBe(false); + expect(callKeepService.endCall).not.toHaveBeenCalled(); + expect(notifee.stopForegroundService).not.toHaveBeenCalled(); + expect(useLiveKitStore.getState()).toMatchObject({ + currentRoom: newRoom, + isConnected: true, + }); + }); +}); diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 1c70c42f..da7d1bad 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -513,6 +513,9 @@ export const useLiveKitStore = create((set, get) => ({ // Disconnect from current room if connected if (currentRoom) { logger.debug({ message: 'connectToRoom: disconnecting existing room' }); + // RoomEvent.Disconnected also fires for intentional room switches. + // Clear the flag first so that handler does not run unexpected-drop cleanup. + set({ isConnected: false }); await currentRoom.disconnect(); }