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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,19 +154,51 @@ jobs:
sudo apt-get clean || true
sudo rm -rf /var/lib/apt/lists/*

# Remove swap
# Replace the image's swap with a larger one. The native (CMake/clang) and Kotlin
# compiles run alongside 4 GB Gradle and Node heaps; with no swap the runner starves
# and GitHub drops it ("hosted runner lost communication"). Prefer the /mnt temp disk
# so swap doesn't eat into the root disk freed above.
sudo swapoff -a || true
sudo rm -f /swapfile || true
sudo rm -f /swapfile /mnt/swapfile || true
SWAP_FILE=/swapfile
if mountpoint -q /mnt; then SWAP_FILE=/mnt/swapfile; fi
if sudo fallocate -l 8G "$SWAP_FILE" && sudo chmod 600 "$SWAP_FILE" && sudo mkswap "$SWAP_FILE" && sudo swapon "$SWAP_FILE"; then
echo "Enabled 8G swap at $SWAP_FILE"
else
echo "::warning::Could not enable swap at $SWAP_FILE"
fi

echo "Disk space after cleanup:"
df -h
free -h

# ubuntu-26.04 mounts /tmp as an ~8 GB RAM-backed tmpfs, and eas build --local works
# under /tmp by default, so the Gradle/CMake output fills it ("Disk quota exceeded").
# Move EAS's working dir and the general temp dir onto the runner's disk.
- name: 📂 Move build temp dirs off tmpfs (Android)
if: matrix.platform == 'android'
run: |
mkdir -p "${RUNNER_TEMP}/tmp" "${RUNNER_TEMP}/eas-build"
echo "TMPDIR=${RUNNER_TEMP}/tmp" >> "$GITHUB_ENV"
echo "EAS_LOCAL_BUILD_WORKINGDIR=${RUNNER_TEMP}/eas-build" >> "$GITHUB_ENV"
df -h /tmp "${RUNNER_TEMP}"

- name: 🏗 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'yarn'

# Pin the JDK instead of taking the runner default: ubuntu-latest (26.04) defaults to JDK 25,
# where AGP's prefab step emits "A restricted method in java.lang.System has been called" and
# fails configureCMake (react-native-worklets).
- name: ☕ Setup Java
if: matrix.platform == 'android'
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'

- name: Setup Expo
uses: expo/expo-github-action@v8
with:
Expand Down Expand Up @@ -283,6 +315,9 @@ jobs:
run: |
mkdir -p ~/.gradle/
printf '%s\n' "$GRADLE_PROPERTIES" > ~/.gradle/gradle.properties
# Each Gradle worker can drive its own CMake/ninja native build, so cap workers to
# keep concurrent clang processes within the runner's memory.
echo "org.gradle.workers.max=2" >> ~/.gradle/gradle.properties

- name: 📱 Build Development APK
if: (matrix.platform == 'android' && (github.event.inputs.buildType == 'all' || github.event_name == 'push' || github.event.inputs.buildType == 'dev'))
Expand Down
70 changes: 70 additions & 0 deletions src/api/mapping/__tests__/map-icon-legend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createCachedApiEndpoint } from '../../common/cached-client';
import { createApiEndpoint } from '../../common/client';
import { getMapIconLegend } from '../mapping';

jest.mock('../../common/client', () => {
const get = jest.fn();
return {
// Each endpoint reports its own path with the call, so a test can tell which endpoint was hit.
createApiEndpoint: jest.fn((endpoint: string) => ({ get: (...args: unknown[]) => get(endpoint, ...args) })),
__mockGet: get,
};
});

jest.mock('../../common/cached-client', () => ({
createCachedApiEndpoint: jest.fn(() => ({ get: jest.fn() })),
}));

jest.mock('@/lib/storage/app', () => ({
getBaseApiUrl: jest.fn(() => 'https://api.example.test/api/v4'),
}));

const { __mockGet: mockGet } = jest.requireMock('../../common/client') as { __mockGet: jest.Mock };

// Endpoints are created at module load; capture them before beforeEach clears the mocks.
const plainPaths = (createApiEndpoint as jest.Mock).mock.calls.map((call) => call[0]);
const cachedPaths = (createCachedApiEndpoint as jest.Mock).mock.calls.map((call) => call[0]);

describe('getMapIconLegend', () => {
beforeEach(() => {
mockGet.mockReset();
});

it('targets Mapping/GetMapIconLegend without the response cache, so every open asks the server', () => {
expect(plainPaths).toContain('/Mapping/GetMapIconLegend');
expect(cachedPaths).not.toContain('/Mapping/GetMapIconLegend');
});

it('GETs the endpoint with no parameters and returns the v4 envelope as sent', async () => {
const payload = {
PageSize: 3,
Status: 'success',
Environment: 'test',
RequestId: 'abc',
Version: 'v4',
Timestamp: '2026-10-25T00:00:00Z',
Node: 'n1',
Data: { StatusStyles: [], PriorityStyles: [], Calls: [{ Id: null, Name: 'Calls with no type', States: [] }], Units: [], Personnel: [], PoiTypes: [] },
};
mockGet.mockResolvedValueOnce({ data: payload });

await expect(getMapIconLegend()).resolves.toBe(payload);
expect(mockGet).toHaveBeenCalledTimes(1);
expect(mockGet).toHaveBeenCalledWith('/Mapping/GetMapIconLegend', undefined, undefined);
});

it('forwards the abort signal', async () => {
const controller = new AbortController();
mockGet.mockResolvedValueOnce({ data: { Data: null } });

await getMapIconLegend(controller.signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

The awaited getMapIconLegend operation can reject without a deterministic test assertion, causing unhandled promise failures. Guard it with an explicit promise assertion or try/catch; the same issue occurs in src/components/maps/tests/map-screen-legend.test.tsx:156, :170, :186, :194; src/stores/signalr/tests/signalr-store.test.ts:513 and :512; src/services/location.ts:108, :121, :721, :504, :796, :483, :743, :494, :788, :795, and :804; src/api/mapping/mapping.ts:80; and src/components/maps/tests/map-legend-sheet.test.tsx:230, :202, :434, :450, :458, :467, :283, :237, :270, :297, :310, :319, :330, :341, :351, :362, :371, :384, :393, :399, :409, :419, and :486.

Kody rule violation: Handle async operations with proper error handling

await expect(getMapIconLegend(controller.signal)).resolves.toBeDefined();
Prompt for LLM

File src/api/mapping/__tests__/map-icon-legend.test.ts:

Line 60:

The awaited getMapIconLegend operation can reject without a deterministic test assertion, causing unhandled promise failures. Guard it with an explicit promise assertion or try/catch; the same issue occurs in src/components/maps/__tests__/map-screen-legend.test.tsx:156, :170, :186, :194; src/stores/signalr/__tests__/signalr-store.test.ts:513 and :512; src/services/location.ts:108, :121, :721, :504, :796, :483, :743, :494, :788, :795, and :804; src/api/mapping/mapping.ts:80; and src/components/maps/__tests__/map-legend-sheet.test.tsx:230, :202, :434, :450, :458, :467, :283, :237, :270, :297, :310, :319, :330, :341, :351, :362, :371, :384, :393, :399, :409, :419, and :486.

Suggested Code:

    await expect(getMapIconLegend(controller.signal)).resolves.toBeDefined();

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


expect(mockGet).toHaveBeenCalledWith('/Mapping/GetMapIconLegend', undefined, controller.signal);
});

it('passes request failures to the caller', async () => {
mockGet.mockRejectedValueOnce(new Error('Request failed with status code 404'));

await expect(getMapIconLegend()).rejects.toThrow('404');
});
});
9 changes: 9 additions & 0 deletions src/api/mapping/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type FeatureCollection } from 'geojson';

import { getBaseApiUrl } from '@/lib/storage/app';
import { type GetMapDataAndMarkersResult } from '@/models/v4/mapping/getMapDataAndMarkersResult';
import { type GetMapIconLegendResult } from '@/models/v4/mapping/getMapIconLegendResult';
import { type GetMapLayersResult } from '@/models/v4/mapping/getMapLayersResult';
import {
type GetAllActiveLayersResult,
Expand All @@ -23,6 +24,8 @@ import { createApiEndpoint } from '../common/client';

const getMayLayersApi = createApiEndpoint('/Mapping/GetMayLayers');
const getMapDataAndMarkersApi = createApiEndpoint('/Mapping/GetMapDataAndMarkers');
// Not cached: the legend follows the department's types, statuses, priorities and uploads, so every open asks again.
const getMapIconLegendApi = createApiEndpoint('/Mapping/GetMapIconLegend');
const getPoiApi = createCachedApiEndpoint('/Mapping/GetPoi', {
ttl: 5 * 60 * 1000,
enabled: true,
Expand Down Expand Up @@ -72,6 +75,12 @@ export const getMapDataAndMarkers = async (signal?: AbortSignal) => {
return response.data;
};

/** The department's map icon legend: every call, unit, personnel and POI icon the map can draw, and the colour styles. */
export const getMapIconLegend = async (signal?: AbortSignal) => {
const response = await getMapIconLegendApi.get<GetMapIconLegendResult>(undefined, signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

The external getMapIconLegendApi.get call can propagate an uncontextualized failure from src/api/mapping/mapping.ts. Wrap it in try/catch and map failures to an application-level error with operation context; the same issue occurs in src/services/location.ts:108, :121, :788, and :795.

Kody rule violation: Add try-catch blocks for external calls

try {
  const response = await getMapIconLegendApi.get<GetMapIconLegendResult>(undefined, signal);
  return response.data;
} catch (error) {
  throw new Error('Failed to fetch map icon legend', { cause: error });
}
Prompt for LLM

File src/api/mapping/mapping.ts:

Line 80:

The external getMapIconLegendApi.get call can propagate an uncontextualized failure from src/api/mapping/mapping.ts. Wrap it in try/catch and map failures to an application-level error with operation context; the same issue occurs in src/services/location.ts:108, :121, :788, and :795.

Suggested Code:

  try {
    const response = await getMapIconLegendApi.get<GetMapIconLegendResult>(undefined, signal);
    return response.data;
  } catch (error) {
    throw new Error('Failed to fetch map icon legend', { cause: error });
  }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

return response.data;
};

export const getPoiTypes = async () => {
const response = await getPoiTypesApi.get<PoiTypesResult>();
return response.data;
Expand Down
35 changes: 33 additions & 2 deletions src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { router, Stack, useFocusEffect } from 'expo-router';
import { NavigationIcon } from 'lucide-react-native';
import { InfoIcon, NavigationIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { getMapDataAndMarkers } from '@/api/mapping/mapping';
import { Loading } from '@/components/common/loading';
import { MapLegendSheet } from '@/components/maps/map-legend-sheet';
import MapPins from '@/components/maps/map-pins';
import Mapbox from '@/components/maps/mapbox';
import PinDetailModal from '@/components/maps/pin-detail-modal';
Expand All @@ -23,7 +24,7 @@ import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner';
import { logger } from '@/lib/logging';
import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, normalizeHeading, normalizeSpeed, smoothSpeed, zoomForSpeed } from '@/lib/map-camera';
import { getDepartmentMapCenter } from '@/lib/map-center';
import { getPinEntityId } from '@/lib/map-pin-ids';
import { getPinEntityId, MapPinType } from '@/lib/map-pin-ids';
import { useDepartmentMapStyle } from '@/lib/map-style';
import { getMapboxAccessToken } from '@/lib/mapbox-token';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
Expand Down Expand Up @@ -84,6 +85,7 @@ function MapContent() {
const [mapPins, setMapPins] = useState<MapMakerInfoData[]>([]);
const [selectedPin, setSelectedPin] = useState<MapMakerInfoData | null>(null);
const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false);
const [isLegendOpen, setIsLegendOpen] = useState(false);
const { isActive } = useAppLifecycle();
const locationLatitude = useLocationStore((state) => state.latitude);
const locationLongitude = useLocationStore((state) => state.longitude);
Expand Down Expand Up @@ -536,6 +538,17 @@ function MapContent() {
setSelectedPin(null);
};

const handleOpenLegend = useCallback(() => {
setIsLegendOpen(true);
}, []);

const handleCloseLegend = useCallback(() => {
setIsLegendOpen(false);
}, []);

// The legend shows the hydrant marker only while the map is drawing hydrants.
const showHydrantsInLegend = useMemo(() => mapPins.some((pin) => pin.Type === MapPinType.Hydrant), [mapPins]);

// Show recenter button only when map is not locked and user has moved the map
const showRecenterButton = !isMapLocked && hasUserMovedMap && locationLatitude != null && locationLongitude != null;

Expand All @@ -562,6 +575,10 @@ function MapContent() {
shadowOpacity: isDark ? 0.1 : 0.25,
shadowRadius: 3.84,
},
// The legend button is the same control as the recenter button, one slot above it so the two never overlap.
legendButtonOffset: {
bottom: 20 + insets.bottom + 48 + 12,
},
};
}, [colorScheme, insets.bottom]);

Expand Down Expand Up @@ -682,10 +699,24 @@ function MapContent() {
<NavigationIcon size={20} color="#ffffff" />
</TouchableOpacity>
) : null}

{/* Map legend button - what each icon on the map means */}
<TouchableOpacity
style={[styles.recenterButton, themedStyles.recenterButton, themedStyles.legendButtonOffset]}
onPress={handleOpenLegend}
accessibilityRole="button"
accessibilityLabel={t('map.legend.button')}
testID="map-legend-button"
>
<InfoIcon size={20} color="#ffffff" />
</TouchableOpacity>
</View>

{/* Pin Detail Modal */}
<PinDetailModal pin={selectedPin} isOpen={isPinDetailModalOpen} onClose={handleClosePinDetail} onSetAsCurrentCall={handleSetAsCurrentCall} />

{/* Map Icon Legend */}
<MapLegendSheet isOpen={isLegendOpen} onClose={handleCloseLegend} showHydrants={showHydrantsInLegend} />
</>
);
}
Expand Down
31 changes: 20 additions & 11 deletions src/app/call/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { HtmlRenderer } from '@/components/ui/html-renderer';
import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { TypeIcon, UnitTypeIcon } from '@/components/units/unit-type-icon';
import { useAnalytics } from '@/hooks/use-analytics';
import { useRecordsContext } from '@/hooks/use-records-context';
import { getUnitTypeCheckInBadge } from '@/lib/check-in-timer-utils';
Expand Down Expand Up @@ -491,7 +492,11 @@ export default function CallDetail() {
<VStack className="space-y-3">
{callExtraData.Dispatches.map((dispatched, index) => (
<Box key={index} className="rounded-lg bg-gray-50 p-3 dark:bg-gray-700">
<Text className="font-semibold">{dispatched.Name}</Text>
<HStack space="sm" className="items-center">
{/* Unit rows carry the unit type's icon; other rows have none and render nothing here. */}
<UnitTypeIcon iconUrl={dispatched.TypeIconUrl} mapIcon={dispatched.TypeMapIcon} size={24} testID={`dispatched-unit-type-icon-${index}`} />
<Text className="shrink font-semibold">{dispatched.Name}</Text>
</HStack>
<HStack className="mt-1">
<Text className="mr-2 text-sm text-gray-600 dark:text-gray-400">
{t('call_detail.group')}: {dispatched.Group}
Expand Down Expand Up @@ -634,16 +639,20 @@ export default function CallDetail() {
{/* Header */}
<Box className="mx-4 mt-3 rounded-xl bg-white p-4 shadow-xs dark:bg-gray-800">
<HStack className="mb-2 items-center justify-between">
<Heading size="md">
{/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */}
{isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? (
<ProtectedText value={call.Name} fieldId={ProtectedFieldIds.callName} redactedFields={call.RedactedFields} />
) : (
<>
{call.Name} ({call.Number})
</>
)}
</Heading>
<HStack space="sm" className="shrink items-center">
{/* The call type's icon, when the department gave the type one. */}
<TypeIcon iconUrl={call.TypeIconUrl} mapIcon={call.TypeMapIcon} size={28} testID="call-detail-type-icon" />
<Heading size="md" className="shrink">
{/* The call NUMBER is not cataloged, so it stays visible and the record stays findable. */}
{isFieldRedacted(call.RedactedFields, ProtectedFieldIds.callName, call.Name) ? (
<ProtectedText value={call.Name} fieldId={ProtectedFieldIds.callName} redactedFields={call.RedactedFields} />
) : (
<>
{call.Name} ({call.Number})
</>
)}
</Heading>
</HStack>
{/* Show "Set Active" button if this call is not the active call and there is an active unit */}
{activeUnit && activeCall?.CallId !== call.CallId ? (
<Button variant="solid" size="sm" onPress={handleSetActive} disabled={isSettingActive} className={`${isSettingActive ? 'bg-primary-400 opacity-80' : 'bg-primary-500'} shadow-lg`}>
Expand Down
6 changes: 6 additions & 0 deletions src/app/call/__tests__/[id].security.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ jest.mock('react-native', () => ({
ActivityIndicator: (props: any) => <div {...props} />,
}));

jest.mock('expo-image', () => {
const React = require('react');
const { View } = require('react-native');
return { Image: (props: Record<string, unknown>) => React.createElement(View, props) };
});

// Mock expo-router
jest.mock('expo-router', () => ({
useFocusEffect: jest.fn((callback: () => void) => {
Expand Down
Loading
Loading