diff --git a/scripts/__tests__/extract-release-notes.test.ts b/scripts/__tests__/extract-release-notes.test.ts
new file mode 100644
index 0000000..27e3afa
--- /dev/null
+++ b/scripts/__tests__/extract-release-notes.test.ts
@@ -0,0 +1,32 @@
+import { spawnSync } from 'node:child_process';
+import path from 'node:path';
+
+const scriptPath = path.join(process.cwd(), 'scripts/extract-release-notes.sh');
+
+const extractReleaseNotes = (body: string): string => {
+ const result = spawnSync('bash', [scriptPath, '--extract-only'], {
+ encoding: 'utf8',
+ input: body,
+ });
+
+ if (result.status !== 0) {
+ throw new Error(result.stderr);
+ }
+
+ return result.stdout.trim();
+};
+
+describe('extract-release-notes', () => {
+ it.each(['##', '###'])('normalizes a %s PR Description heading', (heading) => {
+ const notes = extractReleaseNotes(`${heading} PR Description\n\nAdds the release change.`);
+
+ expect(notes).toBe(`${heading} Description\nAdds the release change.`);
+ expect(notes).not.toContain('PR Description');
+ });
+
+ it('continues to prefer the dedicated Release Notes section', () => {
+ const notes = extractReleaseNotes('## PR Description\nInternal PR context\n\n## Release Notes\nUser-facing change\n\n## Testing\nPassed');
+
+ expect(notes).toBe('User-facing change');
+ });
+});
diff --git a/scripts/extract-release-notes.sh b/scripts/extract-release-notes.sh
index 50014e6..671a68f 100755
--- a/scripts/extract-release-notes.sh
+++ b/scripts/extract-release-notes.sh
@@ -11,11 +11,12 @@ export GH_TOKEN=$5
# Function to extract release notes from PR body
extract_release_notes() {
local body="$1"
-
+
# First pass: Remove everything between CodeRabbit comment markers using sed
- local cleaned_body="$(printf '%s\n' "$body" \
+ local cleaned_body
+ cleaned_body="$(printf '%s\n' "$body" \
| sed '//,//d')"
-
+
# Second pass: Remove the "Summary by CodeRabbit" section
cleaned_body="$(printf '%s\n' "$cleaned_body" \
| awk '
@@ -24,34 +25,49 @@ extract_release_notes() {
/^## / && skip==1 { skip=0 }
skip==0 { print }
')"
-
- # Third pass: Remove any remaining HTML comment lines
+
+ # Third pass: Replace PR-only description headings with release-friendly headings
+ cleaned_body="$(printf '%s\n' "$cleaned_body" \
+ | awk '
+ /^#+[[:space:]]+PR Description[[:space:]]*$/ {
+ sub(/PR Description[[:space:]]*$/, "Description")
+ }
+ { print }
+ ')"
+
+ # Fourth pass: Remove any remaining HTML comment lines
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^$/d' | sed '/^$/d')"
-
- # Fourth pass: Remove specific CodeRabbit lines
+
+ # Fifth pass: Remove specific CodeRabbit lines
cleaned_body="$(printf '%s\n' "$cleaned_body" \
| (grep -v '✏️ Tip: You can customize this high-level summary in your review settings\.' || true) \
| (grep -v '' || true) \
| (grep -v '' || true))"
-
- # Fifth pass: Trim leading and trailing whitespace/empty lines
+
+ # Sixth pass: Trim leading and trailing whitespace/empty lines
cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^$/d' | awk 'NF {p=1} p')"
-
+
# Try to extract content under "## Release Notes" heading if it exists
- local notes="$(printf '%s\n' "$cleaned_body" \
+ local notes
+ notes="$(printf '%s\n' "$cleaned_body" \
| awk 'f && /^## /{exit} /^## Release Notes/{f=1; next} f')"
-
+
# If no specific "Release Notes" section found, use the entire cleaned body
if [ -z "$notes" ]; then
notes="$cleaned_body"
fi
-
+
# Final trim
notes="$(printf '%s\n' "$notes" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
-
+
printf '%s\n' "$notes"
}
+if [ "${1:-}" = "--extract-only" ]; then
+ extract_release_notes "$(cat)"
+ exit 0
+fi
+
# Determine source of release notes
NOTES=""
diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx
index 92d2005..e614c0e 100644
--- a/src/api/common/client.tsx
+++ b/src/api/common/client.tsx
@@ -1,6 +1,6 @@
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, isAxiosError } from 'axios';
-import { refreshTokenRequest } from '@/lib/auth/api';
+import { refreshTokenSingleFlight } from '@/lib/auth/api';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '@/lib/storage/app';
import useAuthStore from '@/stores/auth/store';
@@ -8,6 +8,7 @@ import useAuthStore from '@/stores/auth/store';
// Create axios instance with default config
const axiosInstance: AxiosInstance = axios.create({
baseURL: getBaseApiUrl(),
+ timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
@@ -83,7 +84,7 @@ axiosInstance.interceptors.response.use(
throw new Error('No refresh token available');
}
- const response = await refreshTokenRequest(refreshToken);
+ const response = await refreshTokenSingleFlight(refreshToken);
const { access_token, refresh_token: newRefreshToken } = response;
// Update tokens in store
diff --git a/src/app/(app)/__tests__/incidents.test.tsx b/src/app/(app)/__tests__/incidents.test.tsx
new file mode 100644
index 0000000..cbcf08d
--- /dev/null
+++ b/src/app/(app)/__tests__/incidents.test.tsx
@@ -0,0 +1,151 @@
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import { type IncidentCommandSummary } from '@/models/v4/incidentCommand/incidentCommandModels';
+import { useIncidentsStore } from '@/stores/command/incidents-store';
+
+jest.mock('@react-navigation/native', () => ({
+ useFocusEffect: jest.fn(),
+}));
+
+jest.mock('expo-router', () => ({
+ router: { push: jest.fn() },
+}));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/components/ui/focus-aware-status-bar', () => ({
+ FocusAwareStatusBar: () => null,
+}));
+
+jest.mock('@/components/command/incident-card', () => {
+ const React = require('react');
+ const { Text } = require('react-native');
+
+ return {
+ IncidentCard: ({ summary }: { summary: { CallName?: string | null; IncidentCommandId: string; Name?: string | null } }) =>
+ React.createElement(Text, { testID: `incident-summary-${summary.IncidentCommandId}` }, summary.Name ?? summary.CallName),
+ };
+});
+
+jest.mock('@/components/common/loading', () => {
+ const React = require('react');
+ const { Text } = require('react-native');
+
+ return {
+ Loading: ({ text }: { text: string }) => React.createElement(Text, { testID: 'loading' }, text),
+ };
+});
+
+jest.mock('@/components/common/zero-state', () => {
+ const React = require('react');
+ const { Text, View } = require('react-native');
+
+ return {
+ __esModule: true,
+ default: ({ description, heading }: { description: string; heading: string }) =>
+ React.createElement(View, { testID: 'zero-state' }, React.createElement(Text, null, heading), React.createElement(Text, null, description)),
+ };
+});
+
+jest.mock('@/components/ui/flat-list', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+
+ return {
+ FlatList: ({
+ data,
+ keyExtractor,
+ ListEmptyComponent,
+ renderItem,
+ testID,
+ }: {
+ data: { IncidentCommandId: string }[];
+ keyExtractor: (item: { IncidentCommandId: string }) => string;
+ ListEmptyComponent?: unknown;
+ renderItem: (info: { item: { IncidentCommandId: string } }) => unknown;
+ testID?: string;
+ }) => React.createElement(View, { testID }, data.length > 0 ? data.map((item) => React.createElement(View, { key: keyExtractor(item) }, renderItem({ item }) as never)) : (ListEmptyComponent as never)),
+ };
+});
+
+import IncidentsScreen from '../incidents';
+
+const summaries: IncidentCommandSummary[] = [
+ {
+ IncidentCommandId: 'ic-1',
+ DepartmentId: 1,
+ CallId: 101,
+ Name: 'Warehouse Fire',
+ CallName: 'Commercial Structure Fire',
+ CallNumber: 'C-1001',
+ CallAddress: '100 Industrial Way',
+ Status: 0,
+ EstablishedOn: '2026-07-25T10:00:00Z',
+ CommanderName: 'Alex Rivera',
+ CommandPostLocationText: 'North entrance',
+ AssignedPersonnelCount: 8,
+ AssignedUnitCount: 3,
+ },
+ {
+ IncidentCommandId: 'ic-2',
+ DepartmentId: 1,
+ CallId: 202,
+ Name: 'Flood Response',
+ CallName: 'Flooding',
+ CallNumber: 'C-2002',
+ CallAddress: '200 River Road',
+ Status: 0,
+ EstablishedOn: '2026-07-25T11:00:00Z',
+ CommanderName: 'Jordan Lee',
+ CommandPostLocationText: 'Community center',
+ AssignedPersonnelCount: 5,
+ AssignedUnitCount: 2,
+ },
+];
+
+describe('IncidentsScreen search', () => {
+ beforeEach(() => {
+ useIncidentsStore.setState({
+ summaries,
+ includeClosed: false,
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it('filters incidents by searchable summary fields', () => {
+ const { unmount } = render();
+
+ fireEvent.changeText(screen.getByTestId('incidents-search'), 'C-2002');
+
+ expect(screen.queryByTestId('incident-summary-ic-1')).toBeNull();
+ expect(screen.getByTestId('incident-summary-ic-2')).toBeTruthy();
+ unmount();
+ });
+
+ it('clears the search and restores the incident list', () => {
+ const { unmount } = render();
+
+ fireEvent.changeText(screen.getByTestId('incidents-search'), 'warehouse');
+ expect(screen.queryByTestId('incident-summary-ic-2')).toBeNull();
+
+ fireEvent.press(screen.getByTestId('incidents-search-clear'));
+
+ expect(screen.getByTestId('incident-summary-ic-1')).toBeTruthy();
+ expect(screen.getByTestId('incident-summary-ic-2')).toBeTruthy();
+ unmount();
+ });
+
+ it('shows a search-specific empty state when no incidents match', () => {
+ const { unmount } = render();
+
+ fireEvent.changeText(screen.getByTestId('incidents-search'), 'not-a-real-incident');
+
+ expect(screen.getByText('common.no_results_found')).toBeTruthy();
+ expect(screen.getByText('incidents.no_search_results_description')).toBeTruthy();
+ unmount();
+ });
+});
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 34a77c5..a4c8b3c 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -3,7 +3,7 @@
import { NovuProvider } from '@novu/react-native';
import Countly from 'countly-sdk-react-native-bridge';
import * as NavigationBar from 'expo-navigation-bar';
-import { Redirect, router, SplashScreen, Tabs } from 'expo-router';
+import { Redirect, router, SplashScreen, Tabs, usePathname } from 'expo-router';
import { ArrowLeft, ClipboardList, CloudAlert, LayoutDashboard, Map, Megaphone, Menu, Navigation, Settings } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -26,6 +26,7 @@ import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle';
import { getAppHeaderHeight } from '@/lib/app-shell-layout';
import { useAuthStore } from '@/lib/auth';
import { logger } from '@/lib/logging';
+import { getMapsHeaderState } from '@/lib/maps-route';
import { useIsFirstTime } from '@/lib/storage';
import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData';
import { audioService } from '@/services/audio.service';
@@ -51,6 +52,8 @@ export default function TabLayout() {
const { width, height } = useWindowDimensions();
const insets = useSafeAreaInsets();
const isLandscape = width > height;
+ const pathname = usePathname();
+ const mapsHeaderState = useMemo(() => getMapsHeaderState(pathname), [pathname]);
const { isActive, appState } = useAppLifecycle();
const { trackEvent } = useAnalytics();
@@ -429,6 +432,20 @@ export default function TabLayout() {
[t, weatherAlertsIcon, headerLeftMap, headerRightNotification]
);
+ // The Maps browser is a hidden tab route so its entire nested stack retains the
+ // authenticated app shell. The landing page gets the drawer menu; child routes
+ // get a back button while the tab bar remains available.
+ const mapsOptions = useMemo(
+ () => ({
+ href: null,
+ title: t(mapsHeaderState.titleKey),
+ headerShown: true as const,
+ headerLeft: mapsHeaderState.showMenu ? headerLeftMap : headerLeftBack,
+ headerRight: headerRightNotification,
+ }),
+ [t, mapsHeaderState, headerLeftMap, headerLeftBack, headerRightNotification]
+ );
+
// POI detail renders inside the tab shell (app header + tab bar stay visible) but has no tab
// bar entry of its own; the header shows a back button instead of the drawer menu.
const poiDetailOptions = useMemo(
@@ -504,6 +521,8 @@ export default function TabLayout() {
tab bar (href: null). IC shell shows: Map, Calls, Settings. */}
+
+
diff --git a/src/app/(app)/incidents.tsx b/src/app/(app)/incidents.tsx
index 2e2f9bb..a724cb8 100644
--- a/src/app/(app)/incidents.tsx
+++ b/src/app/(app)/incidents.tsx
@@ -1,7 +1,7 @@
import { useFocusEffect } from '@react-navigation/native';
import { router } from 'expo-router';
-import { LayoutDashboard } from 'lucide-react-native';
-import React, { useCallback } from 'react';
+import { LayoutDashboard, Search, X } from 'lucide-react-native';
+import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshControl, View } from 'react-native';
@@ -12,11 +12,18 @@ import { Box } from '@/components/ui/box';
import { FlatList } from '@/components/ui/flat-list';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { HStack } from '@/components/ui/hstack';
+import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { type IncidentCommandSummary } from '@/models/v4/incidentCommand/incidentCommandModels';
import { useIncidentsStore } from '@/stores/command/incidents-store';
+const matchesIncidentSearch = (summary: IncidentCommandSummary, normalizedSearch: string): boolean => {
+ const searchableValues = [summary.CallId.toString(), summary.CallNumber, summary.Name, summary.CallName, summary.CallAddress, summary.CommanderName, summary.CommandPostLocationText];
+
+ return searchableValues.some((value) => value?.toLocaleLowerCase().includes(normalizedSearch));
+};
+
export default function Incidents() {
const { t } = useTranslation();
const summaries = useIncidentsStore((state) => state.summaries);
@@ -25,6 +32,10 @@ export default function Incidents() {
const error = useIncidentsStore((state) => state.error);
const fetchIncidents = useIncidentsStore((state) => state.fetchIncidents);
const setIncludeClosed = useIncidentsStore((state) => state.setIncludeClosed);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const normalizedSearch = searchQuery.trim().toLocaleLowerCase();
+ const filteredSummaries = useMemo(() => (normalizedSearch ? summaries.filter((summary) => matchesIncidentSearch(summary, normalizedSearch)) : summaries), [normalizedSearch, summaries]);
useFocusEffect(
useCallback(() => {
@@ -53,11 +64,17 @@ export default function Incidents() {
return (
testID="incidents-list"
- data={summaries}
+ data={filteredSummaries}
keyExtractor={(item: IncidentCommandSummary) => item.IncidentCommandId}
renderItem={({ item }: { item: IncidentCommandSummary }) => handleOpen(item)} />}
refreshControl={}
- ListEmptyComponent={}
+ ListEmptyComponent={
+ normalizedSearch ? (
+
+ ) : (
+
+ )
+ }
contentContainerStyle={{ paddingBottom: 20 }}
/>
);
@@ -67,6 +84,18 @@ export default function Incidents() {
+
+
+
+
+
+ {searchQuery ? (
+ setSearchQuery('')} accessibilityLabel={t('common.clear')} accessibilityRole="button" testID="incidents-search-clear" hitSlop={8}>
+
+
+ ) : null}
+
+
{/* Active-only by default; the second chip adds ended incidents (read-only history) */}
setIncludeClosed(false)} className={`rounded-full px-4 py-2 ${!includeClosed ? 'bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'}`} testID="incidents-filter-active">
diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx
index ebc8647..e6042a6 100644
--- a/src/app/(app)/index.tsx
+++ b/src/app/(app)/index.tsx
@@ -254,7 +254,7 @@ function MapContent() {
const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal);
if (mapDataAndMarkers && mapDataAndMarkers.Data) {
- setMapPins(mapDataAndMarkers.Data.MapMakerInfos);
+ setMapPins(mapDataAndMarkers.Data.MapMakerInfos ?? []);
}
} catch (error) {
// Don't log aborted requests as errors
diff --git a/src/app/(app)/maps/_layout.tsx b/src/app/(app)/maps/_layout.tsx
new file mode 100644
index 0000000..d34551d
--- /dev/null
+++ b/src/app/(app)/maps/_layout.tsx
@@ -0,0 +1,17 @@
+import { Stack } from 'expo-router';
+import React from 'react';
+
+export default function MapsLayout() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/maps/custom/[id].tsx b/src/app/(app)/maps/custom/[id].tsx
similarity index 99%
rename from src/app/maps/custom/[id].tsx
rename to src/app/(app)/maps/custom/[id].tsx
index fc6f0ad..0ce228f 100644
--- a/src/app/maps/custom/[id].tsx
+++ b/src/app/(app)/maps/custom/[id].tsx
@@ -17,7 +17,7 @@ import { VStack } from '@/components/ui/vstack';
import { type CustomMapLayerResultData } from '@/models/v4/mapping/customMapResultData';
import { useMapsStore } from '@/stores/maps/store';
-export default function CustomMapViewer() {
+export default function CustomMapViewer(): React.JSX.Element {
const { t } = useTranslation();
const { id } = useLocalSearchParams<{ id: string }>();
const currentCustomMap = useMapsStore((state) => state.currentCustomMap);
diff --git a/src/app/maps/index.tsx b/src/app/(app)/maps/index.tsx
similarity index 99%
rename from src/app/maps/index.tsx
rename to src/app/(app)/maps/index.tsx
index e464021..a67cfd9 100644
--- a/src/app/maps/index.tsx
+++ b/src/app/(app)/maps/index.tsx
@@ -21,7 +21,7 @@ import { type IndoorMapResultData } from '@/models/v4/mapping/indoorMapResultDat
import { type ActiveLayerSummary } from '@/models/v4/mapping/mappingResults';
import { useMapsStore } from '@/stores/maps/store';
-export default function MapsHome() {
+export default function MapsHome(): React.JSX.Element {
const { t } = useTranslation();
const getCustomMapTypeLabel = (type: number): string => {
diff --git a/src/app/maps/indoor/[id].tsx b/src/app/(app)/maps/indoor/[id].tsx
similarity index 99%
rename from src/app/maps/indoor/[id].tsx
rename to src/app/(app)/maps/indoor/[id].tsx
index be33824..037ba3b 100644
--- a/src/app/maps/indoor/[id].tsx
+++ b/src/app/(app)/maps/indoor/[id].tsx
@@ -20,7 +20,7 @@ import { VStack } from '@/components/ui/vstack';
import { type IndoorMapFloorResultData } from '@/models/v4/mapping/indoorMapResultData';
import { useMapsStore } from '@/stores/maps/store';
-export default function IndoorMapViewer() {
+export default function IndoorMapViewer(): React.JSX.Element {
const { t } = useTranslation();
const { id } = useLocalSearchParams<{ id: string }>();
const currentIndoorMap = useMapsStore((state) => state.currentIndoorMap);
diff --git a/src/app/maps/search.tsx b/src/app/(app)/maps/search.tsx
similarity index 99%
rename from src/app/maps/search.tsx
rename to src/app/(app)/maps/search.tsx
index d9038d7..dfa7449 100644
--- a/src/app/maps/search.tsx
+++ b/src/app/(app)/maps/search.tsx
@@ -25,7 +25,7 @@ const SEGMENTS: { labelKey: string; value: SearchFilter }[] = [
{ labelKey: 'maps.custom', value: 'custom' },
];
-export default function MapSearch() {
+export default function MapSearch(): React.JSX.Element {
const { t } = useTranslation();
const searchResults = useMapsStore((state) => state.searchResults);
const isLoading = useMapsStore((state) => state.isLoading);
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 3dc1296..1d74d6b 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -190,7 +190,6 @@ function RootLayout() {
-
);
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index b884ba6..1d7ef15 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -1,4 +1,4 @@
-import { format } from 'date-fns';
+import { format, isValid } from 'date-fns';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
@@ -348,7 +348,7 @@ export default function CallDetail() {
{t('call_detail.timestamp')}
- {format(new Date(call.LoggedOn), 'MMM d, h:mm a')}
+ {isValid(new Date(call.LoggedOn)) ? format(new Date(call.LoggedOn), 'MMM d, h:mm a') : call.LoggedOn}
{t('call_detail.type')}
@@ -471,7 +471,7 @@ export default function CallDetail() {
{event.Name} - {event.Group}
- {new Date(event.Timestamp).toLocaleString()}
+ {isValid(new Date(event.Timestamp)) ? new Date(event.Timestamp).toLocaleString() : ''}
{event.Note}
))}
diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx
index fb91f43..08b1066 100644
--- a/src/app/call/__tests__/[id].security.test.tsx
+++ b/src/app/call/__tests__/[id].security.test.tsx
@@ -310,6 +310,7 @@ jest.mock('@/components/ui/html-renderer', () => ({
// Mock date-fns
jest.mock('date-fns', () => ({
format: (date: any, formatStr: string) => `formatted-${formatStr}`,
+ isValid: (date: any) => date instanceof Date && !Number.isNaN(date.getTime()),
}));
// Mock lucide-react-native icons
diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx
index abf8d8d..3f76b06 100644
--- a/src/app/call/__tests__/[id].test.tsx
+++ b/src/app/call/__tests__/[id].test.tsx
@@ -213,6 +213,7 @@ jest.mock('react-native-svg', () => ({
// Mock date-fns
jest.mock('date-fns', () => ({
format: jest.fn(() => '2024-01-01 12:00'),
+ isValid: jest.fn(() => true),
}));
// Mock HtmlRenderer
diff --git a/src/app/command-map/[callId].tsx b/src/app/command-map/[callId].tsx
index 3f04cfb..2665a7d 100644
--- a/src/app/command-map/[callId].tsx
+++ b/src/app/command-map/[callId].tsx
@@ -67,6 +67,10 @@ export default function CommandMapScreen() {
// Latest camera framing, captured from map movement so "Save View" pins exactly what's on screen.
const cameraStateRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
+ const cameraRef = useRef(null); // Using any due to imperative handle
+ // Tracks what the camera was auto-centered on so higher-priority targets
+ // arriving later (saved view > ICP > first pin) can still recenter.
+ const autoCenterPriorityRef = useRef<0 | 1 | 2>(0);
useEffect(() => {
let cancelled = false;
@@ -112,8 +116,43 @@ export default function CommandMapScreen() {
return { centerCoordinate: [first.Longitude, first.Latitude] as [number, number], zoomLevel: 13 };
}
return { centerCoordinate: DEFAULT_CENTER, zoomLevel: 4 };
+ // incidentPins intentionally omitted: pins arrive async and are applied via the recenter effect below
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [command?.MapCenterLatitude, command?.MapCenterLongitude, command?.MapZoomLevel, namedMap?.CenterLatitude, namedMap?.CenterLongitude, namedMap?.ZoomLevel]);
+ }, [
+ command?.MapCenterLatitude,
+ command?.MapCenterLongitude,
+ command?.MapZoomLevel,
+ command?.CommandPostLatitude,
+ command?.CommandPostLongitude,
+ namedMap?.CenterLatitude,
+ namedMap?.CenterLongitude,
+ namedMap?.ZoomLevel,
+ ]);
+
+ // initialCamera is only applied at mount (defaultSettings). Pins and the ICP
+ // arrive asynchronously after mount, so recenter imperatively once they load.
+ useEffect(() => {
+ const hasSavedView = Boolean(namedMap?.CenterLatitude && namedMap?.CenterLongitude) || Boolean(command?.MapCenterLatitude && command?.MapCenterLongitude);
+ if (hasSavedView) {
+ return;
+ }
+
+ const icpLat = parseFloat(command?.CommandPostLatitude ?? '');
+ const icpLon = parseFloat(command?.CommandPostLongitude ?? '');
+ if (Number.isFinite(icpLat) && Number.isFinite(icpLon)) {
+ if (autoCenterPriorityRef.current < 2) {
+ autoCenterPriorityRef.current = 2;
+ cameraRef.current?.setCamera({ centerCoordinate: [icpLon, icpLat], zoomLevel: 14, animationDuration: 500 });
+ }
+ return;
+ }
+
+ const first = incidentPins[0];
+ if (first && autoCenterPriorityRef.current < 1) {
+ autoCenterPriorityRef.current = 1;
+ cameraRef.current?.setCamera({ centerCoordinate: [first.Longitude, first.Latitude], zoomLevel: 13, animationDuration: 500 });
+ }
+ }, [incidentPins, namedMap, command]);
const handleCameraChanged = useCallback((state: { properties?: { center?: number[]; zoom?: number } }) => {
const center = state?.properties?.center;
@@ -233,7 +272,7 @@ export default function CommandMapScreen() {
-
+
(mode === 'none' ? setPendingDeleteId(annotationId) : undefined)} />
{draftShape.features.length > 0 ? (
diff --git a/src/app/maps/_layout.tsx b/src/app/maps/_layout.tsx
deleted file mode 100644
index 55afee3..0000000
--- a/src/app/maps/_layout.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Stack } from 'expo-router';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-export default function MapsLayout() {
- const { t } = useTranslation();
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/src/components/contacts/contact-notes-list.tsx b/src/components/contacts/contact-notes-list.tsx
index ed49df1..25eebf4 100644
--- a/src/components/contacts/contact-notes-list.tsx
+++ b/src/components/contacts/contact-notes-list.tsx
@@ -33,11 +33,8 @@ const ContactNoteCard: React.FC = ({ note }) => {
const { colorScheme } = useColorScheme();
const formatDate = (dateString: string) => {
- try {
- return new Date(dateString).toLocaleDateString();
- } catch {
- return dateString;
- }
+ const date = new Date(dateString);
+ return Number.isNaN(date.getTime()) ? dateString : date.toLocaleDateString();
};
// Fallback display for empty or plain text notes
@@ -239,9 +236,9 @@ export const ContactNotesList: React.FC = ({ contactId })
// Sort notes by date (newest first)
const sortedNotes = [...notes].sort((a, b) => {
- const dateA = new Date(a.AddedOnUtc || a.AddedOn);
- const dateB = new Date(b.AddedOnUtc || b.AddedOn);
- return dateB.getTime() - dateA.getTime();
+ const dateA = new Date(a.AddedOnUtc || a.AddedOn).getTime() || 0;
+ const dateB = new Date(b.AddedOnUtc || b.AddedOn).getTime() || 0;
+ return dateB - dateA;
});
return (
diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts
index 0f3a228..f738165 100644
--- a/src/features/livekit-call/store/useLiveKitCallStore.ts
+++ b/src/features/livekit-call/store/useLiveKitCallStore.ts
@@ -226,8 +226,10 @@ export const useLiveKitCallStore = create((set, get) => ({
autoSubscribe: true, // Subscribe to all tracks by default
};
- //await newRoom.connect(LIVEKIT_URL, token, connectOptions);
- // Connection success is handled by the ConnectionStateChanged event listener
+ void connectOptions;
+ // Token fetching is not implemented: no LiveKit token endpoint exists in the API layer.
+ // Fail fast instead of leaving isConnecting=true forever.
+ throw new Error('LiveKit room connection is not available.');
} catch (err: any) {
logger.error({
message: 'Failed to connect to LiveKit room',
diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts
index 2a8ac41..0401011 100644
--- a/src/hooks/use-map-signalr-updates.ts
+++ b/src/hooks/use-map-signalr-updates.ts
@@ -62,15 +62,16 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
}
if (mapDataAndMarkers && mapDataAndMarkers.Data) {
+ const markers = mapDataAndMarkers.Data.MapMakerInfos ?? [];
logger.info({
message: 'Updating map markers from SignalR update',
context: {
- markerCount: mapDataAndMarkers.Data.MapMakerInfos.length,
+ markerCount: markers.length,
timestamp: timestampToProcess,
},
});
- onMarkersUpdate(mapDataAndMarkers.Data.MapMakerInfos);
+ onMarkersUpdate(markers);
}
// Update the last processed timestamp after successful API call
diff --git a/src/lib/__tests__/maps-route.test.ts b/src/lib/__tests__/maps-route.test.ts
new file mode 100644
index 0000000..af33e83
--- /dev/null
+++ b/src/lib/__tests__/maps-route.test.ts
@@ -0,0 +1,25 @@
+import { getMapsHeaderState } from '@/lib/maps-route';
+
+describe('getMapsHeaderState', () => {
+ it('shows the drawer menu on the Maps landing page', () => {
+ expect(getMapsHeaderState('/maps')).toEqual({
+ showMenu: true,
+ titleKey: 'maps.title',
+ });
+ expect(getMapsHeaderState('/maps/')).toEqual({
+ showMenu: true,
+ titleKey: 'maps.title',
+ });
+ });
+
+ it.each([
+ ['/maps/search', 'maps.search_maps'],
+ ['/maps/custom/custom-map-id', 'maps.custom_maps'],
+ ['/maps/indoor/indoor-map-id', 'maps.indoor_maps'],
+ ] as const)('shows a back button and the correct title for %s', (pathname, titleKey) => {
+ expect(getMapsHeaderState(pathname)).toEqual({
+ showMenu: false,
+ titleKey,
+ });
+ });
+});
diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx
index bc4b930..fab35bb 100644
--- a/src/lib/auth/api.tsx
+++ b/src/lib/auth/api.tsx
@@ -9,6 +9,7 @@ import type { AuthResponse, LoginCredentials, LoginResponse, SsoLoginCredentials
const authApi = axios.create({
baseURL: getBaseApiUrl(),
+ timeout: 15000,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
@@ -88,6 +89,23 @@ export const refreshTokenRequest = async (refreshToken: string): Promise | null = null;
+
+export const refreshTokenSingleFlight = (refreshToken: string): Promise => {
+ if (!inFlightRefresh) {
+ inFlightRefresh = refreshTokenRequest(refreshToken).finally(() => {
+ inFlightRefresh = null;
+ });
+ }
+ return inFlightRefresh;
+};
+
/**
* Exchange an external IdP token (OIDC id_token or SAML SAMLResponse) for
* Resgrid access/refresh tokens via POST /api/v4/connect/external-token.
diff --git a/src/lib/cache/cache-manager.ts b/src/lib/cache/cache-manager.ts
index 69cb746..b37ec7c 100644
--- a/src/lib/cache/cache-manager.ts
+++ b/src/lib/cache/cache-manager.ts
@@ -46,7 +46,13 @@ export class CacheManager {
return null;
}
- const cacheItem: CacheItem = JSON.parse(cached);
+ let cacheItem: CacheItem;
+ try {
+ cacheItem = JSON.parse(cached);
+ } catch {
+ storage.delete(key);
+ return null;
+ }
if (this.isExpired(cacheItem.timestamp, cacheItem.expiresIn)) {
// Keep the entry on disk — getStale() serves it as an offline fallback.
@@ -68,8 +74,13 @@ export class CacheManager {
return null;
}
- const cacheItem: CacheItem = JSON.parse(cached);
- return cacheItem.data;
+ try {
+ const cacheItem: CacheItem = JSON.parse(cached);
+ return cacheItem.data;
+ } catch {
+ storage.delete(key);
+ return null;
+ }
}
remove(endpoint: string, params?: Record): void {
diff --git a/src/lib/maps-route.ts b/src/lib/maps-route.ts
new file mode 100644
index 0000000..6f6758a
--- /dev/null
+++ b/src/lib/maps-route.ts
@@ -0,0 +1,43 @@
+export type MapsHeaderTitleKey = 'maps.custom_maps' | 'maps.indoor_maps' | 'maps.search_maps' | 'maps.title';
+
+interface MapsHeaderState {
+ showMenu: boolean;
+ titleKey: MapsHeaderTitleKey;
+}
+
+export const getMapsHeaderState = (pathname: string): MapsHeaderState => {
+ const normalizedPathname = pathname.replace(/\/+$/, '') || '/';
+
+ if (normalizedPathname === '/maps') {
+ return {
+ showMenu: true,
+ titleKey: 'maps.title',
+ };
+ }
+
+ if (normalizedPathname.startsWith('/maps/search')) {
+ return {
+ showMenu: false,
+ titleKey: 'maps.search_maps',
+ };
+ }
+
+ if (normalizedPathname.startsWith('/maps/custom/')) {
+ return {
+ showMenu: false,
+ titleKey: 'maps.custom_maps',
+ };
+ }
+
+ if (normalizedPathname.startsWith('/maps/indoor/')) {
+ return {
+ showMenu: false,
+ titleKey: 'maps.indoor_maps',
+ };
+ }
+
+ return {
+ showMenu: false,
+ titleKey: 'maps.title',
+ };
+};
diff --git a/src/lib/storage/app.tsx b/src/lib/storage/app.tsx
index 30d8051..b56e3ab 100644
--- a/src/lib/storage/app.tsx
+++ b/src/lib/storage/app.tsx
@@ -22,10 +22,7 @@ export const setActiveCallId = (value: string) => setItem(ACTIVE_CALL_ID
export const getActiveCallId = () => {
const activeCallId = getItem(ACTIVE_CALL_ID);
- if (!activeCallId) {
- return activeCallId;
- }
- return '';
+ return activeCallId ?? null;
};
export const removeDeviceUuid = () => removeItem(DEVICE_UUID);
diff --git a/src/lib/storage/index.tsx b/src/lib/storage/index.tsx
index 8f26a9e..ce9eb21 100644
--- a/src/lib/storage/index.tsx
+++ b/src/lib/storage/index.tsx
@@ -17,7 +17,15 @@ 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) as T;
+ } catch {
+ storage.delete(key);
+ return null;
+ }
}
export async function setItem(key: string, value: T) {
diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts
index eaba99f..1e8a434 100644
--- a/src/services/__tests__/location.test.ts
+++ b/src/services/__tests__/location.test.ts
@@ -484,6 +484,27 @@ describe('LocationService', () => {
expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject);
expect(mockSetUnitLocation).not.toHaveBeenCalled();
});
+
+ it('should catch and log rejected background location API sends', async () => {
+ await locationService.startBackgroundUpdates();
+
+ const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1];
+ const catchSpy = jest.spyOn(Promise.prototype, 'catch');
+
+ locationCallback(mockLocationObject);
+
+ const rejectionHandler = catchSpy.mock.calls[0]?.[0];
+ catchSpy.mockRestore();
+
+ const networkError = new Error('Location API request failed');
+ expect(rejectionHandler).toEqual(expect.any(Function));
+ rejectionHandler?.(networkError);
+
+ expect(mockLogger.error).toHaveBeenCalledWith({
+ message: 'Failed to send background location update to API',
+ context: { error: networkError },
+ });
+ });
});
describe('API Integration', () => {
diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts
index 7bdb98f..c6bdaa3 100644
--- a/src/services/__tests__/push-notification.test.ts
+++ b/src/services/__tests__/push-notification.test.ts
@@ -170,7 +170,8 @@ describe('PushNotificationService (expo-notifications transport)', () => {
expect(mockAddNotificationReceivedListener).toHaveBeenCalledTimes(1);
expect(mockAddNotificationResponseReceivedListener).toHaveBeenCalledTimes(1);
expect(mockOnForegroundEvent).toHaveBeenCalledTimes(1);
- expect(mockOnBackgroundEvent).toHaveBeenCalledTimes(1);
+ // Background handler is registered at module scope (see test below), not in initialize()
+ expect(mockOnBackgroundEvent).not.toHaveBeenCalled();
// iOS platform mock: no Android channels, but categories set
expect(mockSetNotificationCategories).toHaveBeenCalledTimes(1);
expect(mockCreateChannel).not.toHaveBeenCalled();
@@ -342,6 +343,17 @@ describe('PushNotificationService (expo-notifications transport)', () => {
});
});
+ describe('module scope', () => {
+ it('registers the notifee background handler at module load (headless/killed state)', () => {
+ jest.isolateModules(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ require('../push-notification');
+ });
+
+ expect(mockOnBackgroundEvent).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('cleanup', () => {
it('removes all listeners', async () => {
await pushNotificationService.initialize();
diff --git a/src/services/__tests__/signalr.service.enhanced.test.ts b/src/services/__tests__/signalr.service.enhanced.test.ts
index 9bd63fa..ebcfc77 100644
--- a/src/services/__tests__/signalr.service.enhanced.test.ts
+++ b/src/services/__tests__/signalr.service.enhanced.test.ts
@@ -323,14 +323,9 @@ describe('SignalRService - Enhanced Features', () => {
// Advance timers to trigger the scheduled reconnect
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
+
+ // The pending reconnect timer is cancelled by disconnectFromHub, so the
+ // timer callback never runs and no reconnection is attempted
expect(mockLogger.info).not.toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('attempting to reconnect to hub'),
diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts
index aa011d1..0c25e74 100644
--- a/src/services/bluetooth-audio.service.ts
+++ b/src/services/bluetooth-audio.service.ts
@@ -185,7 +185,8 @@ class BluetoothAudioService {
});
// If direct connection fails, start scanning to find the device
- this.startScanning(5000); // 5 second scan
+ // Await so scan failures (BT off, permission denied) stay inside this try/catch
+ await this.startScanning(5000); // 5 second scan
}
} else {
logger.info({
diff --git a/src/services/callkeep.service.ts b/src/services/callkeep.service.ts
index 7f70445..d6d9278 100644
--- a/src/services/callkeep.service.ts
+++ b/src/services/callkeep.service.ts
@@ -1,12 +1,9 @@
-import { Platform } from 'react-native';
+// TypeScript-facing barrel for './callkeep.service' imports. Metro and jest
+// resolve the platform-specific files (callkeep.service.ios.ts /
+// callkeep.service.android.ts / callkeep.service.web.ts) BEFORE this file, so
+// at runtime this module is shadowed and never bundled. It exists so tsc can
+// type-check those imports. It re-exports the web implementation because it
+// has no native dependencies.
-import { callKeepService as androidCallKeepService } from './callkeep.service.android';
-import { callKeepService as iosCallKeepService } from './callkeep.service.ios';
-import { callKeepService as webCallKeepService } from './callkeep.service.web';
-
-// Export the appropriate platform-specific implementation
-export const callKeepService = Platform.OS === 'ios' ? iosCallKeepService : Platform.OS === 'web' ? webCallKeepService : androidCallKeepService;
-
-// Re-export types from iOS (they're the same in both)
-export type { CallKeepConfig } from './callkeep.service.ios';
-export { CallKeepService } from './callkeep.service.ios';
+export type { CallKeepConfig } from './callkeep.service.web';
+export { CallKeepService, callKeepService } from './callkeep.service.web';
diff --git a/src/services/location.ts b/src/services/location.ts
index d40e187..9b6e40f 100644
--- a/src/services/location.ts
+++ b/src/services/location.ts
@@ -79,10 +79,18 @@ class LocationService {
context: { nextAppState, backgroundEnabled: this.isBackgroundGeolocationEnabled },
});
- if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) {
- await this.startBackgroundUpdates();
- } else if (nextAppState === 'active') {
- await this.stopBackgroundUpdates();
+ // AppState event handlers don't handle promise rejections — catch everything
+ try {
+ if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) {
+ await this.startBackgroundUpdates();
+ } else if (nextAppState === 'active') {
+ await this.stopBackgroundUpdates();
+ }
+ } catch (error) {
+ logger.error({
+ message: 'Location service failed to handle app state change',
+ context: { error, nextAppState },
+ });
}
};
@@ -133,7 +141,12 @@ class LocationService {
timestamp: pos.timestamp,
};
useLocationStore.getState().setLocation(loc);
- sendLocationToAPI(loc);
+ void sendLocationToAPI(loc).catch((error) => {
+ logger.error({
+ message: 'Failed to send web location update to API',
+ context: { error },
+ });
+ });
},
(err) => {
logger.warn({ message: 'Web geolocation error', context: { code: err.code, msg: err.message } });
@@ -208,7 +221,12 @@ class LocationService {
},
});
useLocationStore.getState().setLocation(location);
- sendLocationToAPI(location); // Send to API for foreground updates
+ void sendLocationToAPI(location).catch((error) => {
+ logger.error({
+ message: 'Failed to send foreground location update to API',
+ context: { error },
+ });
+ });
}
);
} else {
@@ -243,31 +261,55 @@ class LocationService {
return;
}
+ // Re-check background permission: starting updates while backgrounded
+ // without background permission throws
+ const { status: backgroundStatus } = await Location.getBackgroundPermissionsAsync();
+ if (backgroundStatus !== 'granted') {
+ logger.warn({
+ message: 'Skipping background location updates: background permission not granted',
+ context: { backgroundStatus },
+ });
+ return;
+ }
+
logger.info({
message: 'Starting background location updates',
});
- this.backgroundSubscription = await Location.watchPositionAsync(
- {
- accuracy: Location.Accuracy.Balanced,
- timeInterval: 60000,
- distanceInterval: 20,
- },
- (location) => {
- logger.info({
- message: 'Background location update received',
- context: {
- latitude: location.coords.latitude,
- longitude: location.coords.longitude,
- heading: location.coords.heading,
- },
- });
- useLocationStore.getState().setLocation(location);
- sendLocationToAPI(location); // Send to API for background updates
- }
- );
+ try {
+ this.backgroundSubscription = await Location.watchPositionAsync(
+ {
+ accuracy: Location.Accuracy.Balanced,
+ timeInterval: 60000,
+ distanceInterval: 20,
+ },
+ (location) => {
+ logger.info({
+ message: 'Background location update received',
+ context: {
+ latitude: location.coords.latitude,
+ longitude: location.coords.longitude,
+ heading: location.coords.heading,
+ },
+ });
+ useLocationStore.getState().setLocation(location);
+ void sendLocationToAPI(location).catch((error) => {
+ logger.error({
+ message: 'Failed to send background location update to API',
+ context: { error },
+ });
+ });
+ }
+ );
- useLocationStore.getState().setBackgroundEnabled(true);
+ useLocationStore.getState().setBackgroundEnabled(true);
+ } catch (error) {
+ this.backgroundSubscription = null;
+ logger.error({
+ message: 'Failed to start background location updates',
+ context: { error },
+ });
+ }
}
async stopBackgroundUpdates(): Promise {
diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts
index 84d246b..e7b4224 100644
--- a/src/services/push-notification.ts
+++ b/src/services/push-notification.ts
@@ -234,42 +234,10 @@ class PushNotificationService {
this.showModalForData(detail.notification.data, detail.notification.title, detail.notification.body);
}
});
-
- notifee.onBackgroundEvent(async ({ type, detail }) => {
- logger.info({
- message: 'Notifee background event',
- context: { type, detail: { id: detail.notification?.id, data: detail.notification?.data } },
- });
-
- if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'check-in') {
- await this.handleCheckInAction();
- }
-
- if (type === EventType.PRESS && detail.notification) {
- this.showModalForData(detail.notification.data, detail.notification.title, detail.notification.body);
- }
- });
}
- private async handleCheckInAction(): Promise {
- logger.info({ message: 'Check-in action pressed from notification' });
- const activeCall = useCoreStore.getState().activeCall;
- if (!activeCall) {
- return;
- }
-
- const callId = parseInt(activeCall.CallId, 10);
- if (Number.isNaN(callId)) {
- logger.error({ message: 'Check-in action aborted: invalid CallId', context: { CallId: activeCall.CallId } });
- return;
- }
-
- await useCheckInTimerStore.getState().performCheckIn({
- CallId: callId,
- CheckInType: CHECK_IN_TYPE_PERSONNEL,
- Latitude: useLocationStore.getState().latitude?.toString(),
- Longitude: useLocationStore.getState().longitude?.toString(),
- });
+ public async handleCheckInAction(): Promise {
+ await handleCheckInActionFromEvent();
}
async initialize(): Promise {
@@ -432,6 +400,52 @@ class PushNotificationService {
}
}
+/**
+ * Shared check-in action handler for foreground and background notifee events.
+ * Never throws: a rejected promise inside a notifee event handler is an
+ * unhandled rejection that can kill the headless background task.
+ */
+const handleCheckInActionFromEvent = async (): Promise => {
+ try {
+ logger.info({ message: 'Check-in action pressed from notification' });
+ const activeCall = useCoreStore.getState().activeCall;
+ if (!activeCall) {
+ return;
+ }
+
+ const callId = parseInt(activeCall.CallId, 10);
+ if (Number.isNaN(callId)) {
+ logger.error({ message: 'Check-in action aborted: invalid CallId', context: { CallId: activeCall.CallId } });
+ return;
+ }
+
+ // performCheckIn queues the event offline when the network request fails
+ await useCheckInTimerStore.getState().performCheckIn({
+ CallId: callId,
+ CheckInType: CHECK_IN_TYPE_PERSONNEL,
+ Latitude: useLocationStore.getState().latitude?.toString(),
+ Longitude: useLocationStore.getState().longitude?.toString(),
+ });
+ } catch (error) {
+ logger.error({
+ message: 'Check-in action failed',
+ context: { error },
+ });
+ }
+};
+
+// Notifee requires the background event handler to be registered at module
+// scope: when the app is killed, only the headless JS task runs and
+// initialize() is never called, so a registration inside initialize() would
+// never fire for action presses from the killed state.
+if (Platform.OS !== 'web') {
+ notifee.onBackgroundEvent(async ({ type, detail }) => {
+ if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'check-in') {
+ await handleCheckInActionFromEvent();
+ }
+ });
+}
+
export const pushNotificationService = PushNotificationService.getInstance();
// React hook for component usage
diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts
index 0864528..1b4f3cc 100644
--- a/src/services/signalr.service.ts
+++ b/src/services/signalr.service.ts
@@ -36,6 +36,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 = 5;
private readonly RECONNECT_INTERVAL = 5000; // 5 seconds
@@ -194,7 +195,8 @@ class SignalRService {
isGeolocationHub
? {}
: {
- accessTokenFactory: () => token,
+ // Read the token lazily so automatic reconnects use the current access token
+ accessTokenFactory: () => useAuthStore.getState().accessToken ?? token,
}
)
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
@@ -325,7 +327,8 @@ class SignalRService {
const connection = new HubConnectionBuilder()
.withUrl(config.url, {
- accessTokenFactory: () => token,
+ // Read the token lazily so automatic reconnects use the current access token
+ accessTokenFactory: () => useAuthStore.getState().accessToken ?? token,
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalRLogLevel)
@@ -401,7 +404,15 @@ class SignalRService {
message: `Scheduling reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} for hub: ${hubName}`,
});
- setTimeout(async () => {
+ // Cancel any pending reconnect timer before scheduling a new one
+ const pendingTimer = this.reconnectTimers.get(hubName);
+ if (pendingTimer) {
+ clearTimeout(pendingTimer);
+ this.reconnectTimers.delete(hubName);
+ }
+
+ const reconnectTimer = setTimeout(async () => {
+ this.reconnectTimers.delete(hubName);
try {
// Check if the hub config was removed (e.g., by explicit disconnect)
const currentHubConfig = this.hubConfigs.get(hubName);
@@ -481,6 +492,7 @@ class SignalRService {
// This prevents rapid retry loops that could overwhelm the server
}
}, this.RECONNECT_INTERVAL);
+ this.reconnectTimers.set(hubName, reconnectTimer);
} else {
logger.error({
message: `No stored config found for hub: ${hubName}, cannot attempt reconnection`,
@@ -505,6 +517,13 @@ class SignalRService {
}
public async disconnectFromHub(hubName: string): Promise {
+ // Cancel any pending reconnection attempt so it doesn't fire after teardown
+ const pendingTimer = this.reconnectTimers.get(hubName);
+ if (pendingTimer) {
+ clearTimeout(pendingTimer);
+ this.reconnectTimers.delete(hubName);
+ }
+
// Wait for any ongoing connection attempt to complete
const existingLock = this.connectionLocks.get(hubName);
if (existingLock) {
@@ -620,7 +639,16 @@ class SignalRService {
}
private emit(event: string, data: unknown): void {
- this.eventListeners.get(event)?.forEach((callback) => callback(data));
+ this.eventListeners.get(event)?.forEach((callback) => {
+ try {
+ callback(data);
+ } catch (error) {
+ logger.error({
+ message: `Error in SignalR event listener for event: ${event}`,
+ context: { error },
+ });
+ }
+ });
}
}
diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts
index 36c5a9d..459102c 100644
--- a/src/stores/app/livekit-store.ts
+++ b/src/stores/app/livekit-store.ts
@@ -565,6 +565,29 @@ export const useLiveKitStore = create((set, get) => ({
set({ isTalking });
});
+ room.on(RoomEvent.Reconnecting, () => {
+ logger.warn({
+ message: 'LiveKit room connection lost, attempting to reconnect',
+ context: { roomName: roomInfo.Name },
+ });
+ });
+
+ room.on(RoomEvent.Disconnected, () => {
+ logger.warn({
+ message: 'LiveKit room disconnected (server kick or unrecoverable network loss)',
+ context: { roomName: roomInfo.Name },
+ });
+ // Only clean up if this room is still the active one (avoids racing a manual disconnect)
+ if (get().currentRoom === room) {
+ Promise.resolve(get().disconnectFromRoom()).catch((cleanupError: unknown) => {
+ logger.error({
+ message: 'Failed to clean up after LiveKit room disconnect',
+ context: { error: cleanupError },
+ });
+ });
+ }
+ });
+
// Connect to the room
logger.info({
message: 'Connecting to LiveKit room',
@@ -762,8 +785,23 @@ export const useLiveKitStore = create((set, get) => ({
disconnectFromRoom: async () => {
const { currentRoom } = get();
if (currentRoom) {
- await currentRoom.disconnect();
- await audioService.playDisconnectedFromAudioRoomSound();
+ // If disconnect throws, CallKeep/foreground-service/state cleanup must still run
+ try {
+ await currentRoom.disconnect();
+ } catch (disconnectError) {
+ logger.warn({
+ message: 'Failed to disconnect LiveKit room cleanly, continuing cleanup',
+ context: { error: disconnectError },
+ });
+ }
+ try {
+ await audioService.playDisconnectedFromAudioRoomSound();
+ } catch (soundError) {
+ logger.warn({
+ message: 'Failed to play disconnect sound, continuing cleanup',
+ context: { error: soundError },
+ });
+ }
// Stop the native audio session that was started during connectToRoom
if (Platform.OS !== 'web') {
diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx
index 5b2a670..743dbb9 100644
--- a/src/stores/auth/store.tsx
+++ b/src/stores/auth/store.tsx
@@ -5,7 +5,7 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import { logger } from '@/lib/logging';
-import { loginRequest, refreshTokenRequest, ssoExternalTokenRequest } from '../../lib/auth/api';
+import { loginRequest, refreshTokenSingleFlight, ssoExternalTokenRequest } from '../../lib/auth/api';
import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types';
import { type ProfileModel } from '../../lib/auth/types';
import { getAuth } from '../../lib/auth/utils';
@@ -32,8 +32,9 @@ const useAuthStore = create()(
const payload = sanitizeJson(base64.decode(response.authResponse!.id_token!.split('.')[1]));
setItem('authResponse', response.authResponse!);
+ const expiresInSeconds = response.authResponse?.expires_in ?? 3600;
const now = new Date();
- const expiresOn = new Date(now.getTime() + response.authResponse?.expires_in! * 1000).getTime().toString();
+ const expiresOn = new Date(now.getTime() + expiresInSeconds * 1000).getTime().toString();
const profileData = JSON.parse(payload) as ProfileModel;
@@ -63,10 +64,10 @@ const useAuthStore = create()(
// Schedule proactive token refresh before expiry
// expires_in is in seconds, so convert to milliseconds and refresh 1 minute before expiry
- const refreshDelayMs = Math.max((response.authResponse!.expires_in - 60) * 1000, 60000);
+ const refreshDelayMs = Math.max((expiresInSeconds - 60) * 1000, 60000);
logger.info({
message: 'Login successful, scheduling token refresh',
- context: { refreshDelayMs, expiresInSeconds: response.authResponse!.expires_in },
+ context: { refreshDelayMs, expiresInSeconds },
});
// Clear any existing refresh timer before scheduling a new one
const existingTimeoutId = get().refreshTimeoutId;
@@ -98,7 +99,8 @@ const useAuthStore = create()(
const payload = sanitizeJson(base64.decode(response.authResponse.id_token!.split('.')[1]));
setItem('authResponse', response.authResponse);
- const expiresOn = new Date(Date.now() + response.authResponse.expires_in * 1000).getTime().toString();
+ const expiresInSeconds = response.authResponse.expires_in ?? 3600;
+ const expiresOn = new Date(Date.now() + expiresInSeconds * 1000).getTime().toString();
const profileData = JSON.parse(payload) as ProfileModel;
@@ -114,7 +116,7 @@ const useAuthStore = create()(
Sentry.setUser({ id: profileData.sub, username: profileData.name });
- const refreshDelayMs = Math.max((response.authResponse.expires_in - 60) * 1000, 60000);
+ const refreshDelayMs = Math.max((expiresInSeconds - 60) * 1000, 60000);
logger.info({
message: 'SSO login successful, scheduling token refresh',
context: { refreshDelayMs, provider: credentials.provider },
@@ -166,7 +168,7 @@ const useAuthStore = create()(
return;
}
- const response = await refreshTokenRequest(refreshToken);
+ const response = await refreshTokenSingleFlight(refreshToken);
// Update the stored auth response for hydration
setItem('authResponse', response);
@@ -180,10 +182,11 @@ const useAuthStore = create()(
// Set up next token refresh - refresh 1 minute before expiry
// expires_in is in seconds, so convert to milliseconds
- const refreshDelayMs = Math.max((response.expires_in - 60) * 1000, 60000); // At least 1 minute
+ const expiresInSeconds = response.expires_in ?? 3600;
+ const refreshDelayMs = Math.max((expiresInSeconds - 60) * 1000, 60000); // At least 1 minute
logger.info({
message: 'Token refreshed successfully, scheduling next refresh',
- context: { refreshDelayMs, expiresInSeconds: response.expires_in },
+ context: { refreshDelayMs, expiresInSeconds },
});
// Clear any existing refresh timer before scheduling a new one
const existingTimeoutId = get().refreshTimeoutId;
diff --git a/src/stores/dispatch/store.ts b/src/stores/dispatch/store.ts
index 3de4005..aa6c8d2 100644
--- a/src/stores/dispatch/store.ts
+++ b/src/stores/dispatch/store.ts
@@ -76,7 +76,7 @@ export const useDispatchStore = create((set, get) => ({
const categorizedUnits: RecipientsResultData[] = [];
// Categorize recipients based on Type field
- recipients.Data.forEach((recipient) => {
+ (recipients.Data ?? []).forEach((recipient) => {
if (recipient.Type === 'Personnel') {
categorizedUsers.push(recipient);
} else if (recipient.Type === 'Groups') {
diff --git a/src/stores/units/store.ts b/src/stores/units/store.ts
index 1b802cb..fd117f4 100644
--- a/src/stores/units/store.ts
+++ b/src/stores/units/store.ts
@@ -27,7 +27,7 @@ export const useUnitsStore = create((set) => ({
set({ isLoading: true, error: null });
try {
const [unitsResponse, unitStatusesResponse, currentStatusesResponse] = await Promise.all([getUnits(), getAllUnitStatuses(), getUnitCurrentStatuses()]);
- set({ units: unitsResponse.Data, unitStatuses: unitStatusesResponse.Data, unitCurrentStatuses: currentStatusesResponse.Data ?? [], isLoading: false });
+ set({ units: unitsResponse.Data ?? [], unitStatuses: unitStatusesResponse.Data ?? [], unitCurrentStatuses: currentStatusesResponse.Data ?? [], isLoading: false });
} catch (error) {
set({ error: 'Failed to fetch units', isLoading: false });
}
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 628f974..14c70ca 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -36,6 +36,7 @@
"applied": "مطبق",
"audio": "صوت",
"audioActive": "الصوت نشط",
+ "audio_capable": "يدعم الصوت",
"audio_device": "سماعة BT",
"audio_output": "مخرج الصوت",
"availableDevices": "الأجهزة المتاحة",
@@ -47,6 +48,8 @@
"connect": "اتصال",
"connected": "متصل",
"connectionError": "خطأ في الاتصال",
+ "connection_error_message": "تعذر الاتصال بالجهاز",
+ "connection_error_title": "فشل الاتصال",
"current_selection": "الاختيار الحالي",
"device_disconnected": "تم فصل الجهاز",
"disconnect": "قطع الاتصال",
@@ -243,8 +246,12 @@
"edit_call_description": "تحديث معلومات المكالمة",
"everyone": "الجميع",
"files": {
+ "error": "خطأ في جلب الملفات",
+ "file_name": "اسم الملف",
"no_files": "لا توجد ملفات متاحة",
"no_files_description": "لم تتم إضافة أي ملفات إلى هذه المكالمة بعد",
+ "open_error": "خطأ في فتح الملف",
+ "share_error": "خطأ في مشاركة الملف",
"title": "ملفات المكالمة"
},
"geocoding_error": "فشل البحث عن العنوان، يرجى المحاولة مرة أخرى",
@@ -696,6 +703,7 @@
"add": "إضافة",
"back": "رجوع",
"cancel": "إلغاء",
+ "clear": "مسح",
"close": "إغلاق",
"confirm": "تأكيد",
"confirm_location": "تأكيد الموقع",
@@ -722,6 +730,7 @@
"optional": "اختياري",
"permission_denied": "تم رفض الإذن",
"previous": "السابق",
+ "refresh": "تحديث",
"remove": "إزالة",
"retry": "إعادة المحاولة",
"route": "مسار",
@@ -846,6 +855,7 @@
"no_lanes": "لا يوجد هيكل قيادة بعد",
"no_needs": "لا توجد احتياجات",
"no_objectives": "لا توجد أهداف بعد",
+ "no_search_results_description": "لا توجد حوادث مطابقة لبحثك.",
"objectives": "الأهداف",
"ongoing": "جارٍ",
"par_critical": "حرج",
@@ -853,6 +863,7 @@
"personnel": "الأفراد",
"read_only_banner": "انتهى هذا الحادث — المعلومات للقراءة فقط.",
"resources": "الموارد",
+ "search": "البحث في الحوادث...",
"title": "الحوادث",
"unassigned": "غير معيّن",
"units": "الوحدات",
diff --git a/src/translations/de.json b/src/translations/de.json
index 1a5b808..aaa612b 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -36,6 +36,7 @@
"applied": "Angewendet",
"audio": "Audio",
"audioActive": "Audio aktiv",
+ "audio_capable": "Audiofähig",
"audio_device": "BT-Handset",
"audio_output": "Audioausgabe",
"availableDevices": "Verfügbare Geräte",
@@ -47,6 +48,8 @@
"connect": "Verbinden",
"connected": "Verbunden",
"connectionError": "Verbindungsfehler",
+ "connection_error_message": "Verbindung zum Gerät fehlgeschlagen",
+ "connection_error_title": "Verbindung fehlgeschlagen",
"current_selection": "Aktuelle Auswahl",
"device_disconnected": "Gerät getrennt",
"disconnect": "Trennen",
@@ -243,8 +246,12 @@
"edit_call_description": "Anrufinformationen aktualisieren",
"everyone": "Alle",
"files": {
+ "error": "Fehler beim Abrufen der Dateien",
+ "file_name": "Dateiname",
"no_files": "Keine Dateien verfügbar",
"no_files_description": "Diesem Anruf wurden noch keine Dateien hinzugefügt",
+ "open_error": "Fehler beim Öffnen der Datei",
+ "share_error": "Fehler beim Teilen der Datei",
"title": "Anrufdateien"
},
"geocoding_error": "Adresssuche fehlgeschlagen, bitte erneut versuchen",
@@ -696,6 +703,7 @@
"add": "Hinzufügen",
"back": "Zurück",
"cancel": "Abbrechen",
+ "clear": "Löschen",
"close": "Schließen",
"confirm": "Bestätigen",
"confirm_location": "Standort bestätigen",
@@ -722,6 +730,7 @@
"optional": "optional",
"permission_denied": "Berechtigung verweigert",
"previous": "Zurück",
+ "refresh": "Aktualisieren",
"remove": "Entfernen",
"retry": "Erneut versuchen",
"route": "Route",
@@ -846,6 +855,7 @@
"no_lanes": "Noch keine Führungsstruktur",
"no_needs": "Keine Bedarfe",
"no_objectives": "Noch keine Ziele",
+ "no_search_results_description": "Keine Einsätze entsprechen Ihrer Suche.",
"objectives": "Ziele",
"ongoing": "Laufend",
"par_critical": "Kritisch",
@@ -853,6 +863,7 @@
"personnel": "Personal",
"read_only_banner": "Dieser Einsatz ist beendet — die Informationen sind schreibgeschützt.",
"resources": "Ressourcen",
+ "search": "Einsätze suchen...",
"title": "Einsätze",
"unassigned": "Nicht zugewiesen",
"units": "Einheiten",
diff --git a/src/translations/en.json b/src/translations/en.json
index 9f7de2a..abc8024 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -36,6 +36,7 @@
"applied": "Applied",
"audio": "Audio",
"audioActive": "Audio Active",
+ "audio_capable": "Audio capable",
"audio_device": "BT Handset",
"audio_output": "Audio Output",
"availableDevices": "Available Devices",
@@ -47,6 +48,8 @@
"connect": "Connect",
"connected": "Connected",
"connectionError": "Connection Error",
+ "connection_error_message": "Could not connect to device",
+ "connection_error_title": "Connection Failed",
"current_selection": "Current Selection",
"device_disconnected": "Device disconnected",
"disconnect": "Disconnect",
@@ -243,8 +246,12 @@
"edit_call_description": "Update call information",
"everyone": "Everyone",
"files": {
+ "error": "Error getting files",
+ "file_name": "File Name",
"no_files": "No files available",
"no_files_description": "No files have been added to this call yet",
+ "open_error": "Error opening file",
+ "share_error": "Error sharing file",
"title": "Call Files"
},
"geocoding_error": "Failed to search for address, please try again",
@@ -696,6 +703,7 @@
"add": "Add",
"back": "Back",
"cancel": "Cancel",
+ "clear": "Clear",
"close": "Close",
"confirm": "Confirm",
"confirm_location": "Confirm Location",
@@ -722,6 +730,7 @@
"optional": "optional",
"permission_denied": "Permission denied",
"previous": "Previous",
+ "refresh": "Refresh",
"remove": "Remove",
"retry": "Retry",
"route": "Route",
@@ -846,6 +855,7 @@
"no_lanes": "No command structure yet",
"no_needs": "No needs",
"no_objectives": "No objectives yet",
+ "no_search_results_description": "No incidents match your search.",
"objectives": "Objectives",
"ongoing": "Ongoing",
"par_critical": "Critical",
@@ -853,6 +863,7 @@
"personnel": "Personnel",
"read_only_banner": "This incident has ended — information is read-only.",
"resources": "Resources",
+ "search": "Search incidents...",
"title": "Incidents",
"unassigned": "Unassigned",
"units": "Units",
diff --git a/src/translations/es.json b/src/translations/es.json
index 67f6c9e..4075404 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -36,6 +36,7 @@
"applied": "Aplicado",
"audio": "Audio",
"audioActive": "Audio Activo",
+ "audio_capable": "Compatible con audio",
"audio_device": "Auricular BT",
"audio_output": "Salida de audio",
"availableDevices": "Dispositivos Disponibles",
@@ -47,6 +48,8 @@
"connect": "Conectar",
"connected": "Conectado",
"connectionError": "Error de Conexión",
+ "connection_error_message": "No se pudo conectar al dispositivo",
+ "connection_error_title": "Error de conexión",
"current_selection": "Selección Actual",
"device_disconnected": "Dispositivo desconectado",
"disconnect": "Desconectar",
@@ -243,8 +246,12 @@
"edit_call_description": "Actualizar información de la llamada",
"everyone": "Todos",
"files": {
+ "error": "Error al obtener los archivos",
+ "file_name": "Nombre de archivo",
"no_files": "No hay archivos disponibles",
"no_files_description": "No se han agregado archivos a esta llamada aún",
+ "open_error": "Error al abrir el archivo",
+ "share_error": "Error al compartir el archivo",
"title": "Archivos de llamada"
},
"geocoding_error": "Error al buscar la dirección, por favor inténtelo de nuevo",
@@ -696,6 +703,7 @@
"add": "Añadir",
"back": "Atrás",
"cancel": "Cancelar",
+ "clear": "Limpiar",
"close": "Cerrar",
"confirm": "Confirmar",
"confirm_location": "Confirmar ubicación",
@@ -722,6 +730,7 @@
"optional": "opcional",
"permission_denied": "Permiso denegado",
"previous": "Anterior",
+ "refresh": "Actualizar",
"remove": "Eliminar",
"retry": "Reintentar",
"route": "Ruta",
@@ -846,6 +855,7 @@
"no_lanes": "Aún no hay estructura de comando",
"no_needs": "Sin necesidades",
"no_objectives": "Aún no hay objetivos",
+ "no_search_results_description": "Ningún incidente coincide con tu búsqueda.",
"objectives": "Objetivos",
"ongoing": "En curso",
"par_critical": "Crítico",
@@ -853,6 +863,7 @@
"personnel": "Personal",
"read_only_banner": "Este incidente ha finalizado; la información es de solo lectura.",
"resources": "Recursos",
+ "search": "Buscar incidentes...",
"title": "Incidentes",
"unassigned": "Sin asignar",
"units": "Unidades",
diff --git a/src/translations/fr.json b/src/translations/fr.json
index 9b2854e..30db513 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -36,6 +36,7 @@
"applied": "Appliqué",
"audio": "Audio",
"audioActive": "Audio actif",
+ "audio_capable": "Compatible audio",
"audio_device": "Combiné BT",
"audio_output": "Sortie audio",
"availableDevices": "Appareils disponibles",
@@ -47,6 +48,8 @@
"connect": "Connecter",
"connected": "Connecté",
"connectionError": "Erreur de connexion",
+ "connection_error_message": "Impossible de se connecter à l'appareil",
+ "connection_error_title": "Échec de la connexion",
"current_selection": "Sélection actuelle",
"device_disconnected": "Appareil déconnecté",
"disconnect": "Déconnecter",
@@ -243,8 +246,12 @@
"edit_call_description": "Mettre à jour les informations de l'appel",
"everyone": "Tout le monde",
"files": {
+ "error": "Erreur lors de la récupération des fichiers",
+ "file_name": "Nom du fichier",
"no_files": "Aucun fichier disponible",
"no_files_description": "Aucun fichier n'a encore été ajouté à cet appel",
+ "open_error": "Erreur lors de l'ouverture du fichier",
+ "share_error": "Erreur lors du partage du fichier",
"title": "Fichiers de l'appel"
},
"geocoding_error": "Échec de la recherche d'adresse, veuillez réessayer",
@@ -696,6 +703,7 @@
"add": "Ajouter",
"back": "Retour",
"cancel": "Annuler",
+ "clear": "Effacer",
"close": "Fermer",
"confirm": "Confirmer",
"confirm_location": "Confirmer la localisation",
@@ -722,6 +730,7 @@
"optional": "optionnel",
"permission_denied": "Permission refusée",
"previous": "Précédent",
+ "refresh": "Actualiser",
"remove": "Supprimer",
"retry": "Réessayer",
"route": "Itinéraire",
@@ -846,6 +855,7 @@
"no_lanes": "Pas encore de structure de commandement",
"no_needs": "Aucun besoin",
"no_objectives": "Pas encore d'objectifs",
+ "no_search_results_description": "Aucun incident ne correspond à votre recherche.",
"objectives": "Objectifs",
"ongoing": "En cours",
"par_critical": "Critique",
@@ -853,6 +863,7 @@
"personnel": "Personnel",
"read_only_banner": "Cet incident est terminé — les informations sont en lecture seule.",
"resources": "Ressources",
+ "search": "Rechercher des incidents...",
"title": "Incidents",
"unassigned": "Non assigné",
"units": "Unités",
diff --git a/src/translations/it.json b/src/translations/it.json
index b1fc910..da60d5a 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -36,6 +36,7 @@
"applied": "Applicato",
"audio": "Audio",
"audioActive": "Audio attivo",
+ "audio_capable": "Compatibile audio",
"audio_device": "Auricolare BT",
"audio_output": "Uscita audio",
"availableDevices": "Dispositivi disponibili",
@@ -47,6 +48,8 @@
"connect": "Connetti",
"connected": "Connesso",
"connectionError": "Errore di connessione",
+ "connection_error_message": "Impossibile connettersi al dispositivo",
+ "connection_error_title": "Connessione non riuscita",
"current_selection": "Selezione corrente",
"device_disconnected": "Dispositivo disconnesso",
"disconnect": "Disconnetti",
@@ -243,8 +246,12 @@
"edit_call_description": "Aggiorna le informazioni della chiamata",
"everyone": "Tutti",
"files": {
+ "error": "Errore durante il recupero dei file",
+ "file_name": "Nome file",
"no_files": "Nessun file disponibile",
"no_files_description": "Nessun file aggiunto a questa chiamata",
+ "open_error": "Errore durante l'apertura del file",
+ "share_error": "Errore durante la condivisione del file",
"title": "File chiamata"
},
"geocoding_error": "Ricerca indirizzo fallita, riprovare",
@@ -696,6 +703,7 @@
"add": "Aggiungi",
"back": "Indietro",
"cancel": "Annulla",
+ "clear": "Cancella",
"close": "Chiudi",
"confirm": "Conferma",
"confirm_location": "Conferma posizione",
@@ -722,6 +730,7 @@
"optional": "opzionale",
"permission_denied": "Autorizzazione negata",
"previous": "Precedente",
+ "refresh": "Aggiorna",
"remove": "Rimuovi",
"retry": "Riprova",
"route": "Percorso",
@@ -846,6 +855,7 @@
"no_lanes": "Ancora nessuna struttura di comando",
"no_needs": "Nessuna necessità",
"no_objectives": "Ancora nessun obiettivo",
+ "no_search_results_description": "Nessun incidente corrisponde alla ricerca.",
"objectives": "Obiettivi",
"ongoing": "In corso",
"par_critical": "Critico",
@@ -853,6 +863,7 @@
"personnel": "Personale",
"read_only_banner": "Questo incidente è terminato: le informazioni sono di sola lettura.",
"resources": "Risorse",
+ "search": "Cerca incidenti...",
"title": "Incidenti",
"unassigned": "Non assegnato",
"units": "Unità",
diff --git a/src/translations/pl.json b/src/translations/pl.json
index 6d37725..6f7f79d 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -36,6 +36,7 @@
"applied": "Zastosowano",
"audio": "Audio",
"audioActive": "Audio aktywne",
+ "audio_capable": "Obsługuje dźwięk",
"audio_device": "Słuchawka BT",
"audio_output": "Wyjście audio",
"availableDevices": "Dostępne urządzenia",
@@ -47,6 +48,8 @@
"connect": "Połącz",
"connected": "Połączono",
"connectionError": "Błąd połączenia",
+ "connection_error_message": "Nie można połączyć się z urządzeniem",
+ "connection_error_title": "Połączenie nieudane",
"current_selection": "Bieżący wybór",
"device_disconnected": "Urządzenie rozłączone",
"disconnect": "Rozłącz",
@@ -243,8 +246,12 @@
"edit_call_description": "Zaktualizuj informacje o zgłoszeniu",
"everyone": "Wszyscy",
"files": {
+ "error": "Błąd podczas pobierania plików",
+ "file_name": "Nazwa pliku",
"no_files": "Brak dostępnych plików",
"no_files_description": "Do tego zgłoszenia nie dodano jeszcze żadnych plików",
+ "open_error": "Błąd podczas otwierania pliku",
+ "share_error": "Błąd podczas udostępniania pliku",
"title": "Pliki zgłoszenia"
},
"geocoding_error": "Wyszukiwanie adresu nie powiodło się, spróbuj ponownie",
@@ -696,6 +703,7 @@
"add": "Dodaj",
"back": "Wstecz",
"cancel": "Anuluj",
+ "clear": "Wyczyść",
"close": "Zamknij",
"confirm": "Potwierdź",
"confirm_location": "Potwierdź lokalizację",
@@ -722,6 +730,7 @@
"optional": "opcjonalne",
"permission_denied": "Odmówiono dostępu",
"previous": "Poprzedni",
+ "refresh": "Odśwież",
"remove": "Usuń",
"retry": "Spróbuj ponownie",
"route": "Trasa",
@@ -846,6 +855,7 @@
"no_lanes": "Brak struktury dowodzenia",
"no_needs": "Brak potrzeb",
"no_objectives": "Brak celów",
+ "no_search_results_description": "Żadne zdarzenia nie pasują do wyszukiwania.",
"objectives": "Cele",
"ongoing": "W toku",
"par_critical": "Krytyczny",
@@ -853,6 +863,7 @@
"personnel": "Personel",
"read_only_banner": "Ten incydent został zakończony — informacje są tylko do odczytu.",
"resources": "Zasoby",
+ "search": "Szukaj zdarzeń...",
"title": "Zdarzenia",
"unassigned": "Nieprzypisane",
"units": "Jednostki",
diff --git a/src/translations/sv.json b/src/translations/sv.json
index 9ca6582..c30c5b3 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -36,6 +36,7 @@
"applied": "Tillämpad",
"audio": "Ljud",
"audioActive": "Ljud aktivt",
+ "audio_capable": "Ljudkompatibel",
"audio_device": "BT-handset",
"audio_output": "Ljudutgång",
"availableDevices": "Tillgängliga enheter",
@@ -47,6 +48,8 @@
"connect": "Anslut",
"connected": "Ansluten",
"connectionError": "Anslutningsfel",
+ "connection_error_message": "Kunde inte ansluta till enheten",
+ "connection_error_title": "Anslutningen misslyckades",
"current_selection": "Aktuellt val",
"device_disconnected": "Enhet frånkopplad",
"disconnect": "Koppla från",
@@ -243,8 +246,12 @@
"edit_call_description": "Uppdatera samtalsinformation",
"everyone": "Alla",
"files": {
+ "error": "Fel vid hämtning av filer",
+ "file_name": "Filnamn",
"no_files": "Inga filer tillgängliga",
"no_files_description": "Inga filer har lagts till i detta samtal ännu",
+ "open_error": "Fel vid öppning av fil",
+ "share_error": "Fel vid delning av fil",
"title": "Samtalsfiler"
},
"geocoding_error": "Det gick inte att söka efter adressen, försök igen",
@@ -696,6 +703,7 @@
"add": "Lägg till",
"back": "Tillbaka",
"cancel": "Avbryt",
+ "clear": "Rensa",
"close": "Stäng",
"confirm": "Bekräfta",
"confirm_location": "Bekräfta plats",
@@ -722,6 +730,7 @@
"optional": "valfritt",
"permission_denied": "Behörighet nekad",
"previous": "Föregående",
+ "refresh": "Uppdatera",
"remove": "Ta bort",
"retry": "Försök igen",
"route": "Rutt",
@@ -846,6 +855,7 @@
"no_lanes": "Ingen ledningsstruktur ännu",
"no_needs": "Inga behov",
"no_objectives": "Inga mål ännu",
+ "no_search_results_description": "Inga händelser matchar din sökning.",
"objectives": "Mål",
"ongoing": "Pågående",
"par_critical": "Kritisk",
@@ -853,6 +863,7 @@
"personnel": "Personal",
"read_only_banner": "Denna händelse har avslutats — informationen är skrivskyddad.",
"resources": "Resurser",
+ "search": "Sök händelser...",
"title": "Händelser",
"unassigned": "Ej tilldelad",
"units": "Enheter",
diff --git a/src/translations/uk.json b/src/translations/uk.json
index fbbe739..78b59c9 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -36,6 +36,7 @@
"applied": "Застосовано",
"audio": "Аудіо",
"audioActive": "Аудіо активне",
+ "audio_capable": "Підтримує аудіо",
"audio_device": "BT-гарнітура",
"audio_output": "Виведення звуку",
"availableDevices": "Доступні пристрої",
@@ -47,6 +48,8 @@
"connect": "Підключити",
"connected": "Підключено",
"connectionError": "Помилка підключення",
+ "connection_error_message": "Не вдалося підключитися до пристрою",
+ "connection_error_title": "Помилка підключення",
"current_selection": "Поточний вибір",
"device_disconnected": "Пристрій відключено",
"disconnect": "Відключити",
@@ -243,8 +246,12 @@
"edit_call_description": "Оновити інформацію про виклик",
"everyone": "Всі",
"files": {
+ "error": "Помилка отримання файлів",
+ "file_name": "Назва файлу",
"no_files": "Немає доступних файлів",
"no_files_description": "До цього виклику ще не додано файлів",
+ "open_error": "Помилка відкриття файлу",
+ "share_error": "Помилка надсилання файлу",
"title": "Файли виклику"
},
"geocoding_error": "Пошук адреси не вдався, спробуйте ще раз",
@@ -696,6 +703,7 @@
"add": "Додати",
"back": "Назад",
"cancel": "Скасувати",
+ "clear": "Очистити",
"close": "Закрити",
"confirm": "Підтвердити",
"confirm_location": "Підтвердити місцезнаходження",
@@ -722,6 +730,7 @@
"optional": "необов'язково",
"permission_denied": "Доступ заборонено",
"previous": "Попередній",
+ "refresh": "Оновити",
"remove": "Видалити",
"retry": "Спробувати ще раз",
"route": "Маршрут",
@@ -846,6 +855,7 @@
"no_lanes": "Ще немає структури управління",
"no_needs": "Немає потреб",
"no_objectives": "Ще немає цілей",
+ "no_search_results_description": "Жоден інцидент не відповідає вашому пошуку.",
"objectives": "Цілі",
"ongoing": "Триває",
"par_critical": "Критично",
@@ -853,6 +863,7 @@
"personnel": "Персонал",
"read_only_banner": "Цей інцидент завершено — інформація доступна лише для читання.",
"resources": "Ресурси",
+ "search": "Пошук інцидентів...",
"title": "Інциденти",
"unassigned": "Не призначено",
"units": "Одиниці",