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
1 change: 1 addition & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'android.permission.READ_PHONE_NUMBERS',
'android.permission.MANAGE_OWN_CALLS',
],
blockedPermissions: ['android.permission.READ_MEDIA_IMAGES', 'android.permission.READ_MEDIA_VIDEO'],
},
web: {
favicon: './assets/favicon.png',
Expand Down
8 changes: 6 additions & 2 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,13 @@ function createWindow() {
mainWindow = null;
});

// Handle external links
// Handle external links — only http(s) may be opened externally. Other
// schemes (file:, javascript:, custom protocols) are denied: a crafted link
// inside rendered content could otherwise escape to the OS handler.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
require('electron').shell.openExternal(url);
if (/^https?:\/\//i.test(url)) {
require('electron').shell.openExternal(url);

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

Unguarded external OS call — shell.openExternal(url) is invoked without a try/catch wrapper, leaving missing-browser and permission-denied failures unhandled. Wrap the call in try/catch with context and log the url and err on failure (e.g., try { await require('electron').shell.openExternal(url); } catch (err) { console.error('openExternal failed', { url, err }); }).

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

Prompt for LLM

File electron/main.js:

Line 83:

Unguarded external OS call — shell.openExternal(url) is invoked without a try/catch wrapper, leaving missing-browser and permission-denied failures unhandled. Wrap the call in try/catch with context and log the url and err on failure (e.g., `try { await require('electron').shell.openExternal(url); } catch (err) { console.error('openExternal failed', { url, err }); }`).

Talk to Kody by mentioning @kody

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

}
return { action: 'deny' };
});
}
Expand Down
90 changes: 90 additions & 0 deletions src/api/common/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const mockRequestInterceptorUse = jest.fn();
const mockResponseInterceptorUse = jest.fn();
const mockLoggerWarn = jest.fn();
const mockGetAuthState = jest.fn();
const mockAxiosInstance = Object.assign(jest.fn(), {
defaults: {
headers: {
common: {},
},
},
interceptors: {
request: {
use: mockRequestInterceptorUse,
},
response: {
use: mockResponseInterceptorUse,
},
},
});

jest.mock('axios', () => ({
__esModule: true,
default: {
create: jest.fn(() => mockAxiosInstance),
},
}));

jest.mock('@/lib/logging', () => ({
logger: {
warn: mockLoggerWarn,
},
}));

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

jest.mock('@/stores/auth/store', () => ({
__esModule: true,
default: {
getState: mockGetAuthState,
},
}));

let rejectResponse: (error: unknown) => Promise<unknown>;

describe('API client token refresh logging', () => {
beforeAll(() => {
jest.isolateModules(() => {
require('@/api/common/client');
});
rejectResponse = mockResponseInterceptorUse.mock.calls[0]?.[1] as (error: unknown) => Promise<unknown>;
});

beforeEach(() => {
jest.clearAllMocks();
});

it('logs the token refresh operation and original request trace ID when refresh fails', async () => {
const refreshError = new Error('Token refresh failed');
const refreshAccessToken = jest.fn().mockRejectedValue(refreshError);
const getHeader = jest.fn((name: string) => (name === 'x-trace-id' ? 'trace-123' : undefined));

mockGetAuthState.mockReturnValue({
refreshAccessToken,
refreshToken: 'refresh-token',
});

const requestError = {
config: {
headers: {
get: getHeader,
},
},
response: {
status: 401,
},
};

await expect(rejectResponse(requestError)).rejects.toBe(refreshError);

expect(mockLoggerWarn).toHaveBeenCalledWith({
message: 'Request failed after token refresh attempt',
operation: 'token_refresh',
trace_id: 'trace-123',
context: { error: refreshError },
});
expect(getHeader).toHaveBeenCalledWith('x-trace-id');
});
});
59 changes: 28 additions & 31 deletions src/api/common/client.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';

import { refreshTokenRequest } from '@/lib/auth/api';
import { isRefreshCredentialRejection } from '@/lib/auth/token-refresh';
import { logger } from '@/lib/logging';
import { getBaseApiUrl } from '@/lib/storage/app';
import useAuthStore from '@/stores/auth/store';
Expand Down Expand Up @@ -61,6 +59,10 @@ axiosInstance.interceptors.response.use(
}
// Handle 401 errors
if (error.response?.status === 401 && !(originalRequest as InternalAxiosRequestConfig & { _retry?: boolean })._retry) {
// Mark as retried immediately — also covers requests queued while a
// refresh is in flight, so a second 401 never triggers another refresh.
(originalRequest as InternalAxiosRequestConfig & { _retry: boolean })._retry = true;

if (isRefreshing) {
// If refreshing, queue the request
return new Promise((resolve, reject) => {
Expand All @@ -74,26 +76,28 @@ axiosInstance.interceptors.response.use(
});
}

// Add _retry property to request config type
(originalRequest as InternalAxiosRequestConfig & { _retry: boolean })._retry = true;
isRefreshing = true;

try {
const refreshToken = useAuthStore.getState().refreshToken;
if (!refreshToken) {
if (!useAuthStore.getState().refreshToken) {
throw new Error('No refresh token available');
}

const response = await refreshTokenRequest(refreshToken);
const { access_token, refresh_token: newRefreshToken } = response;
// Delegate to the auth store's single-flight refresh. Concurrent 401s,
// the proactive refresh timer and SignalR reconnects all share one
// request, so server-side refresh-token rotation never invalidates a
// parallel caller. The store also reschedules the proactive timer and
// performs the full logout + data wipe when the server rejects the
// refresh token.
const refreshed = await useAuthStore.getState().refreshAccessToken();
if (!refreshed) {
throw new Error('Token refresh failed');
}

// Update tokens in store
useAuthStore.setState({
accessToken: access_token,
refreshToken: newRefreshToken,
status: 'signedIn',
error: null,
});
const access_token = useAuthStore.getState().accessToken;
if (!access_token) {
throw new Error('No access token available after refresh');
}

// Update Authorization header
axiosInstance.defaults.headers.common.Authorization = `Bearer ${access_token}`;
Expand All @@ -104,22 +108,15 @@ axiosInstance.interceptors.response.use(
} catch (refreshError) {
processQueue(refreshError as Error);

// Only log the user out when the token endpoint explicitly rejected the
// refresh token (400 invalid_grant / 401). Transient failures — no response
// (offline/timeout), 5xx server errors, or 429 — must preserve the session
// and retry, otherwise a backend incident logs out every active responder.
if (isRefreshCredentialRejection(refreshError)) {
logger.warn({
message: 'Token refresh rejected by server (invalid/expired credentials), logging out user',
context: { error: refreshError },
});
useAuthStore.getState().logout();
} else {
logger.warn({
message: 'Token refresh failed transiently, preserving session for retry',
context: { error: refreshError },
});
}
// The store has already classified the failure: a credential rejection
// (400/401 from the token endpoint) triggered logout there; anything
// else is transient and the session is preserved for a later retry.
logger.warn({
message: 'Request failed after token refresh attempt',
operation: 'token_refresh',
trace_id: originalRequest.headers.get('x-trace-id')?.toString(),
context: { error: refreshError },
});
Comment on lines +114 to +119

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

Token-refresh failure log lacks the operation name and correlation identifiers required by Rule [3] for cross-log/SIEM correlation. Add structured fields for the operation and a correlation identifier, e.g., logger.warn({ message: 'Request failed after token refresh attempt', operation: 'token_refresh', trace_id: originalRequest.headers?.['x-trace-id'], context: { error: refreshError } });.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File src/api/common/client.tsx:

Line 114 to 117:

Token-refresh failure log lacks the operation name and correlation identifiers required by Rule [3] for cross-log/SIEM correlation. Add structured fields for the operation and a correlation identifier, e.g., `logger.warn({ message: 'Request failed after token refresh attempt', operation: 'token_refresh', trace_id: originalRequest.headers?.['x-trace-id'], context: { error: refreshError } });`.

Talk to Kody by mentioning @kody

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


return Promise.reject(refreshError);
} finally {
Expand Down
4 changes: 4 additions & 0 deletions src/api/notes/notes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cacheManager } from '@/lib/cache/cache-manager';
import { type NoteCategoryResult } from '@/models/v4/notes/noteCategoryResult';
import { type NoteResult } from '@/models/v4/notes/noteResult';
import { type NotesResult } from '@/models/v4/notes/notesResult';
Expand Down Expand Up @@ -62,5 +63,8 @@ export const saveNote = async (data: SaveNoteInput) => {
const response = await saveNoteApi.post<SaveNoteResult>({
...data,
});
// The notes list is cached for 2 days — invalidate so the new/updated note
// is visible immediately instead of after cache expiry.
cacheManager.remove('/Notes/GetAllNotes');

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 low

Inline cache key literal — '/Notes/GetAllNotes' is duplicated knowledge shared with the cache write site, so a typo in either location silently breaks cache invalidation. Define a shared constant (e.g., const NOTES_CACHE_KEY = '/Notes/GetAllNotes' in a routes/keys constants file) and reference it both here and wherever the notes list is cached.

Kody rule violation: Centralize string constants

Prompt for LLM

File src/api/notes/notes.ts:

Line 68:

Inline cache key literal — '/Notes/GetAllNotes' is duplicated knowledge shared with the cache write site, so a typo in either location silently breaks cache invalidation. Define a shared constant (e.g., `const NOTES_CACHE_KEY = '/Notes/GetAllNotes'` in a routes/keys constants file) and reference it both here and wherever the notes list is cached.

Talk to Kody by mentioning @kody

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

return response.data;
};
2 changes: 1 addition & 1 deletion src/app/(app)/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe('Map Component - App Lifecycle', () => {
});

mockLocationService.startLocationUpdates = jest.fn().mockResolvedValue(undefined);
mockLocationService.stopLocationUpdates = jest.fn();
mockLocationService.stopLocationUpdates = jest.fn().mockResolvedValue(undefined);
});

afterEach(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/(app)/__tests__/protocols.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,4 +431,4 @@ describe('Protocols Page', () => {
expect(screen.getByTestId('protocol-card-3')).toBeTruthy();
});
});
});
});
31 changes: 21 additions & 10 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useAnalytics } from '@/hooks/use-analytics';
import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle';
import { useAuthStore } from '@/lib/auth';
import { cacheManager } from '@/lib/cache/cache-manager';
import { logger } from '@/lib/logging';
import { useIsFirstTime } from '@/lib/storage';
import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData';
Expand Down Expand Up @@ -157,22 +158,32 @@ export default function TabLayout() {
});

try {
// Initialize core app data (init() calls fetchConfig() internally)
// Initialize core app data first (init() calls fetchConfig() internally) —
// config is required for SignalR hub URLs.
await useCoreStore.getState().init();
await useRolesStore.getState().init();
await useCallsStore.getState().init();
await useWeatherAlertsStore.getState().init();
await securityStore.getState().getRights();

await useSignalRStore.getState().connectUpdateHub();
await useSignalRStore.getState().connectGeolocationHub();
// These fetches are independent of each other — run in parallel to cut
// time-to-interactive (previously 8+ serial network hops).
await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights()]);

// SignalR needs config (EventingUrl) and rights (DepartmentId) — both
// available now. The two hub connects are independent.
await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]);

hasInitialized.current = true;

// Initialize Bluetooth and Audio services (native-only)
// Evict expired/capped API cache entries once per cold start.
cacheManager.prune();

// Initialize Bluetooth and Audio services (native-only) — deferred so they
// don't block first paint behind the loading overlay.
if (Platform.OS !== 'web') {
await bluetoothAudioService.initialize();
await audioService.initialize();
void Promise.all([bluetoothAudioService.initialize(), audioService.initialize()]).catch((error) => {
logger.warn({
message: 'Failed to initialize bluetooth/audio services',
context: { error },
});
});
}

logger.info({
Expand Down
48 changes: 39 additions & 9 deletions src/app/(app)/calls.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useFocusEffect } from '@react-navigation/native';
import { router } from 'expo-router';
import { PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, RefreshControl, View } from 'react-native';

Expand All @@ -14,12 +14,33 @@ import { FlatList } from '@/components/ui/flat-list';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input';
import { useAnalytics } from '@/hooks/use-analytics';
import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData';
import { useCallsStore } from '@/stores/calls/store';
import { securityStore } from '@/stores/security/store';

interface CallListItemProps {
call: CallResultData;
dispatches?: DispatchedEventResultData[];
priority?: CallPriorityResultData;
}

const CallListItem: React.FC<CallListItemProps> = React.memo(({ call, dispatches, priority }) => {
const handlePress = useCallback(() => {
router.push(`/call/${call.CallId}`);
}, [call.CallId]);

return (
<Pressable onPress={handlePress}>
<CallCard call={call} priority={priority} dispatches={dispatches} />
</Pressable>
);
});

export default function Calls() {
const calls = useCallsStore((state) => state.calls);
const callPriorities = useCallsStore((state) => state.callPriorities);
const isLoading = useCallsStore((state) => state.isLoading);
const error = useCallsStore((state) => state.error);
const fetchCalls = useCallsStore((state) => state.fetchCalls);
Expand Down Expand Up @@ -69,8 +90,21 @@ export default function Calls() {
router.push('/call/new/');
};

// Filter calls based on search query
const filteredCalls = calls.filter((call) => call.CallId.toLowerCase().includes(searchQuery.toLowerCase()) || (call.Nature?.toLowerCase() || '').includes(searchQuery.toLowerCase()));
// Priority lookup map — O(1) per row instead of an O(n) find per row per render
const prioritiesById = useMemo(() => new Map(callPriorities.map((p) => [p.Id, p])), [callPriorities]);

// Filter calls based on search query (memoized — previously recomputed every render)
const filteredCalls = useMemo(() => {
const query = searchQuery.toLowerCase();
return calls.filter((call) => call.CallId.toLowerCase().includes(query) || (call.Nature?.toLowerCase() || '').includes(query));
}, [calls, searchQuery]);

const renderItem = useCallback(
({ item }: { item: CallResultData }) => <CallListItem call={item} priority={prioritiesById.get(item.Priority)} dispatches={callDispatches[item.CallId]} />,
[prioritiesById, callDispatches]
);

const keyExtractor = useCallback((item: CallResultData) => item.CallId, []);

// Render content based on loading, error, and data states
const renderContent = () => {
Expand All @@ -86,12 +120,8 @@ export default function Calls() {
<FlatList<CallResultData>
testID="calls-list"
data={filteredCalls}
renderItem={({ item }: { item: CallResultData }) => (
<Pressable onPress={() => router.push(`/call/${item.CallId}`)}>
<CallCard call={item} priority={useCallsStore.getState().callPriorities.find((p: { Id: number }) => p.Id === item.Priority)} dispatches={callDispatches[item.CallId]} />
</Pressable>
)}
keyExtractor={(item: CallResultData) => item.CallId}
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshControl={<RefreshControl refreshing={false} onRefresh={handleRefresh} />}
ListEmptyComponent={<ZeroState heading={t('calls.no_calls')} description={t('calls.no_calls_description')} icon={RefreshCcwDotIcon} />}
contentContainerStyle={{ paddingBottom: 20 }}
Expand Down
7 changes: 5 additions & 2 deletions src/app/(app)/contacts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ export default function Contacts() {
);
}, [contacts, searchQuery]);

const renderContact = React.useCallback(({ item }: { item: (typeof filteredContacts)[number] }) => <ContactCard contact={item} onPress={selectContact} />, [selectContact]);
const contactKeyExtractor = React.useCallback((item: (typeof filteredContacts)[number]) => item.ContactId, []);

// Show loading page during initial fetch (when no contacts are loaded yet)
if (isLoading && contacts.length === 0) {
return (
Expand Down Expand Up @@ -90,8 +93,8 @@ export default function Contacts() {
<FlatList
testID="contacts-list"
data={filteredContacts}
keyExtractor={(item) => item.ContactId}
renderItem={({ item }) => <ContactCard contact={item} onPress={selectContact} />}
keyExtractor={contactKeyExtractor}
renderItem={renderContact}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 100 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />}
Expand Down
Loading
Loading