diff --git a/.gitignore b/.gitignore index b4bbade..7b74ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ expo-env.d.ts .dual-graph/ .mcp.json opencode.json +/.dual-graph-pro diff --git a/CLAUDE.md b/CLAUDE.md index 0346f30..f1d268f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,51 @@ + +# Dual-Graph Context Policy + +This project uses a local dual-graph MCP server (graperoot-pro) for efficient, +budget-aware context retrieval. Always prefer it over native file exploration. + +## MANDATORY: Always follow this order + +1. **Call `graph_continue` first** -- before any file exploration, grep, or code reading. + +2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the + current project directory (`pwd`). Do NOT ask the user. + +3. **If `graph_continue` returns `skip=true`**: project is too small for the graph to + help. Skip all graph tools and explore normally. + +4. **Read `recommended_files`** using `graph_read` -- one call per file. + - `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`). + Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` -- it reads only + that symbol's lines, not the full file. + +5. **Check `confidence` and obey the caps strictly:** + - `confidence=high` -> Stop. Do NOT grep or explore further. + - `confidence=medium` -> If recommended files are insufficient, call `fallback_rg` + at most `max_supplementary_greps` time(s) with specific terms, then `graph_read` + at most `max_supplementary_files` additional file(s). Then stop. + - `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s), + then `graph_read` at most `max_supplementary_files` file(s). Then stop. + +## Exhaustive enumeration tasks + +Some tasks require scanning **every file** -- e.g. "find all dead exports", "list every +.find() without a limit", "audit all test files". Use these tools first: + +- **`graph_dead_exports()`** -- pre-computed at scan time. Use for any dead-export task. +- **`graph_grep_all(pattern, file_glob?, max_hits?)`** -- exhaustive grep, no call cap. + +## Rules + +- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`. +- Do NOT do broad/recursive exploration at any confidence level. +- After edits, call `graph_register_edit(files: ["path/to/file"])`. The parameter is + `files` (plural, always an array). Use `file::symbol` notation when the edit targets + a specific function, class, or hook. + + +--- + # Resgrid Unit — AI Coding Guidelines > **Resgrid Unit** is a multi-platform emergency response mobile application built with TypeScript, React Native, Expo (managed + prebuild), targeting iOS, Android, Web, and Electron. diff --git a/plugins/withWebRTCFrameworkFix.js b/plugins/withWebRTCFrameworkFix.js index 81bc27e..0884929 100644 --- a/plugins/withWebRTCFrameworkFix.js +++ b/plugins/withWebRTCFrameworkFix.js @@ -3,15 +3,18 @@ const fs = require('fs'); const path = require('path'); /** - * Config plugin that patches the Podfile for Xcode 26+ compatibility with - * use_frameworks! :linkage => :static (required by @react-native-firebase). + * Config plugin that patches the Podfile and source files for Xcode 26+ + * compatibility with use_frameworks! :linkage => :static (required by + * @react-native-firebase). * - * Fixes two classes of errors: + * Fixes three classes of errors: * 1. "include of non-modular header inside framework module" — sets * CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES for all pods. * 2. "declaration of 'X' must be imported from module 'Y' before it is required" * — adds use_modular_headers! so #import statements resolve correctly across * framework module boundaries. + * 3. "RCTPromiseRejectBlock must be imported from module 'RNFBApp.RNFBAppModule'" + * — patches RNFBMessaging source files to import RNFBAppModule explicitly. */ const withWebRTCFrameworkFix = (config) => { return withDangerousMod(config, [ @@ -76,6 +79,44 @@ const withWebRTCFrameworkFix = (config) => { } fs.writeFileSync(podfilePath, contents); + + // 3. Patch RNFBMessaging source files to import RNFBAppModule explicitly. + // With use_frameworks! :linkage => :static on Xcode 26, the module + // system requires RNFBMessaging to import RNFBAppModule to access + // RCTPromiseRejectBlock and RCTPromiseResolveBlock. + const projectRoot = config.modRequest.projectRoot; + const appDelegatePath = path.join( + projectRoot, + 'node_modules/@react-native-firebase/messaging/ios/RNFBMessaging/RNFBMessaging+AppDelegate.h' + ); + + if (fs.existsSync(appDelegatePath)) { + let appDelegate = fs.readFileSync(appDelegatePath, 'utf-8'); + const rnfbImport = '#import '; + if (!appDelegate.includes(rnfbImport)) { + // Insert after the last #import line, or at the start if none exist. + const lastImportIdx = appDelegate.lastIndexOf('#import'); + let insertAt; + if (lastImportIdx === -1) { + insertAt = 0; + } else { + const endOfLine = appDelegate.indexOf('\n', lastImportIdx); + insertAt = endOfLine === -1 ? appDelegate.length : endOfLine + 1; + } + // Guard against merging into the previous line when the insertion point + // is not preceded by a newline (e.g. the file's last line is an #import + // with no trailing newline, so endOfLine === -1 and insertAt === length). + const needsLeadingNewline = insertAt > 0 && appDelegate[insertAt - 1] !== '\n'; + appDelegate = + appDelegate.slice(0, insertAt) + + (needsLeadingNewline ? '\n' : '') + + rnfbImport + + '\n' + + appDelegate.slice(insertAt); + fs.writeFileSync(appDelegatePath, appDelegate); + } + } + return config; }, ]); diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index 92d2005..32183eb 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -1,6 +1,7 @@ -import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, isAxiosError } from 'axios'; +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'; @@ -103,19 +104,19 @@ axiosInstance.interceptors.response.use( } catch (refreshError) { processQueue(refreshError as Error); - // Check if it's a network error vs an invalid refresh token - const isNetworkError = isAxiosError(refreshError) && !refreshError.response; - - if (!isNetworkError) { - // Only logout for non-network errors (e.g., invalid refresh token, 400/401 from token endpoint) + // 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 failed with non-recoverable error, logging out user', + 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 due to network error', + message: 'Token refresh failed transiently, preserving session for retry', context: { error: refreshError }, }); } diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 587ddd1..45c8671 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -35,6 +35,7 @@ import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; +import { isNetworkError } from '@/utils/network'; export default function TabLayout() { const { t } = useTranslation(); @@ -178,10 +179,20 @@ export default function TabLayout() { message: 'App initialization completed successfully', }); } catch (error) { - logger.error({ - message: 'Failed to initialize app', - context: { error }, - }); + // Transient connectivity failures are expected (e.g. brief network loss) and + // already logged deeper in the stack, so keep them at warn to avoid reporting + // the same recoverable error to Sentry. Genuine failures still report as errors. + if (isNetworkError(error)) { + logger.warn({ + message: 'Failed to initialize app due to network connectivity', + context: { error }, + }); + } else { + logger.error({ + message: 'Failed to initialize app', + context: { error }, + }); + } // Reset initialization state on error so it can be retried hasInitialized.current = false; setInitRetryCount((c) => c + 1); @@ -234,7 +245,14 @@ export default function TabLayout() { } }, [status, initRetryCount]); useEffect(() => { - const shouldInitialize = status === 'signedIn' && !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES; + // Defer initialization while the app is in the background. iOS cold-launches the + // app in the background (push wake, background fetch) where network access is + // restricted, so running init here just fails the config fetch with a transient + // "Network Error" and burns the retry budget. Wait until the app is foregrounded — + // this effect re-runs when appState changes to 'active'. (appState is always + // 'active' on web, so web behavior is unchanged.) + const isAppInBackground = Platform.OS !== 'web' && appState === 'background'; + const shouldInitialize = status === 'signedIn' && !isAppInBackground && !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES; if (shouldInitialize) { logger.info({ @@ -246,7 +264,7 @@ export default function TabLayout() { }); initializeApp(); } - }, [status, initializeApp, initRetryCount]); + }, [status, initializeApp, initRetryCount, appState]); // Handle app resuming from background - separate from initialization useEffect(() => { @@ -363,6 +381,12 @@ export default function TabLayout() { headerLeft: headerLeftMap, tabBarButtonTestID: 'map-tab' as const, headerRight: headerRightNotification, + // Freeze (suspend) the map screen when switching tabs instead of letting + // react-native-screens detach/destroy its native views. Tearing the map + // down while a camera event is in flight crashes natively in @rnmapbox/maps' + // Fabric event emitter (EXC_BAD_ACCESS in RNMBXMapViewEventEmitter::onCameraChanged, + // a use-after-free). Freezing preserves the native MapView, shrinking that race. + freezeOnBlur: true, }), [t, mapIcon, headerLeftMap, headerRightNotification] ); diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index e587fe9..0739eb1 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -50,6 +50,11 @@ function MapContent() { const mapRef = useRef>(null); const cameraRef = useRef(null); // Using any due to imperative handle const [isMapReady, setIsMapReady] = useState(false); + // Track screen focus so camera follow/animations can be stopped on blur. A camera + // event delivered while the native map view is tearing down crashes in @rnmapbox/maps + // (onCameraChanged use-after-free), so we quiet the camera when the user navigates + // away to shrink that window. + const [isScreenFocused, setIsScreenFocused] = useState(true); const [hasUserMovedMap, setHasUserMovedMap] = useState(false); const [mapPins, setMapPins] = useState([]); const [selectedPin, setSelectedPin] = useState(null); @@ -198,6 +203,9 @@ function MapContent() { // Handle navigation focus - reset map state when user navigates back to map page useFocusEffect( useCallback(() => { + // Mark the screen focused again so camera follow/animations are allowed + setIsScreenFocused(true); + // Reset hasUserMovedMap when navigating back to map setHasUserMovedMap(false); @@ -228,6 +236,12 @@ function MapContent() { }, }); } + + // On blur (cleanup), stop the camera following/animating before the native + // map view is detached, so a camera event can't fire into a torn-down view. + return () => { + setIsScreenFocused(false); + }; }, [isMapReady, locationLatitude, locationLongitude, isMapLocked, locationHeading]) ); @@ -258,7 +272,10 @@ function MapContent() { }, []); useEffect(() => { - if (isMapReady && locationLatitude && locationLongitude) { + // Skip camera animations while the screen is unfocused so a location update + // arriving during/after navigation away doesn't drive the (possibly tearing + // down) native map view. + if (isScreenFocused && isMapReady && locationLatitude && locationLongitude) { // When map is locked, always follow the location // When map is unlocked, only follow if user hasn't moved the map if (isMapLocked || !hasUserMovedMap) { @@ -278,7 +295,7 @@ function MapContent() { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isMapReady, locationLatitude, locationLongitude, locationHeading, isMapLocked]); + }, [isScreenFocused, isMapReady, locationLatitude, locationLongitude, locationHeading, isMapLocked]); // NOTE: hasUserMovedMap intentionally excluded from deps to avoid toggle loop // on web where programmatic easeTo → moveend → setHasUserMovedMap(true) → re-trigger. @@ -514,9 +531,9 @@ function MapContent() { ref={cameraRef} defaultSettings={initialCameraSettings} followZoomLevel={isMapLocked ? 16 : 12} - followUserLocation={isMapLocked} - followUserMode={isMapLocked ? Mapbox.UserTrackingMode.FollowWithHeading : undefined} - followPitch={isMapLocked ? 45 : undefined} + followUserLocation={isMapLocked && isScreenFocused} + followUserMode={isMapLocked && isScreenFocused ? Mapbox.UserTrackingMode.FollowWithHeading : undefined} + followPitch={isMapLocked && isScreenFocused ? 45 : undefined} /> {locationLatitude != null && locationLongitude != null ? ( diff --git a/src/lib/auth/__tests__/token-refresh.test.ts b/src/lib/auth/__tests__/token-refresh.test.ts new file mode 100644 index 0000000..d02ed4a --- /dev/null +++ b/src/lib/auth/__tests__/token-refresh.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from '@jest/globals'; +import { AxiosError, type AxiosResponse } from 'axios'; + +import { isRefreshCredentialRejection } from '../token-refresh'; + +const axiosErrorWithStatus = (status: number): AxiosError => { + const response = { status, statusText: '', data: {}, headers: {}, config: {} as never } as AxiosResponse; + return new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, response); +}; + +describe('isRefreshCredentialRejection', () => { + it('returns true when the token endpoint rejects the refresh token (400 invalid_grant)', () => { + expect(isRefreshCredentialRejection(axiosErrorWithStatus(400))).toBe(true); + }); + + it('returns true for a 401 from the token endpoint', () => { + expect(isRefreshCredentialRejection(axiosErrorWithStatus(401))).toBe(true); + }); + + it.each([500, 502, 503, 504, 429])('returns false for transient server status %i (preserve session)', (status) => { + expect(isRefreshCredentialRejection(axiosErrorWithStatus(status))).toBe(false); + }); + + it('returns false for 403 (ambiguous, not a definitive credential rejection)', () => { + expect(isRefreshCredentialRejection(axiosErrorWithStatus(403))).toBe(false); + }); + + it('returns false for a network error (no response — offline/DNS/TLS)', () => { + expect(isRefreshCredentialRejection(new AxiosError('Network Error', 'ERR_NETWORK'))).toBe(false); + }); + + it('returns false for a timeout (no response)', () => { + expect(isRefreshCredentialRejection(new AxiosError('timeout exceeded', 'ECONNABORTED'))).toBe(false); + }); + + it('returns false for a non-Axios error (e.g. "no refresh token available")', () => { + expect(isRefreshCredentialRejection(new Error('No refresh token available'))).toBe(false); + }); + + it('returns false for non-error values', () => { + expect(isRefreshCredentialRejection(null)).toBe(false); + expect(isRefreshCredentialRejection(undefined)).toBe(false); + expect(isRefreshCredentialRejection({ response: { status: 401 } })).toBe(false); + }); +}); diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index bc4b930..a0076ee 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -80,8 +80,11 @@ export const refreshTokenRequest = async (refreshToken: string): Promise { + if (!isAxiosError(error)) { + return false; + } + const status = error.response?.status; + return status === 400 || status === 401; +}; diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index 69c5147..e1c9c99 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -24,6 +24,7 @@ jest.mock('@/lib/storage/app', () => ({ jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), + warn: jest.fn(), error: jest.fn(), }, })); @@ -62,6 +63,7 @@ jest.mock('@/lib/storage', () => ({ import { useCoreStore } from '../core-store'; import { getActiveUnitId, getActiveCallId } from '@/lib/storage/app'; import { getConfig } from '@/api/config'; +import { logger } from '@/lib/logging'; import { GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; const mockGetActiveUnitId = getActiveUnitId as jest.MockedFunction; @@ -269,6 +271,25 @@ describe('Core Store', () => { expect(result.current.isLoading).toBe(false); }); + it('should log transient network errors at warn level, not error', async () => { + // Axios "Network Error" — no response received (offline / background launch) + const networkError = Object.assign(new Error('Network Error'), { + isAxiosError: true, + code: 'ERR_NETWORK', + }); + mockGetConfig.mockRejectedValue(networkError); + + const { result } = renderHook(() => useCoreStore()); + + await act(async () => { + await expect(result.current.fetchConfig()).rejects.toBe(networkError); + }); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('network connectivity') })); + expect(logger.error).not.toHaveBeenCalled(); + expect(result.current.error).toBe('Failed to fetch config'); + }); + it('should provide EventingUrl for SignalR connections', async () => { const eventingUrl = 'https://eventing.resgrid.com/'; mockGetConfig.mockResolvedValue({ diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index 059ec84..d7d92b4 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -16,6 +16,7 @@ import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData import { type UnitTypeStatusResultData } from '@/models/v4/statuses/unitTypeStatusResultData'; import { type UnitResultData } from '@/models/v4/units/unitResultData'; import { type UnitStatusResultData } from '@/models/v4/unitStatus/unitStatusResultData'; +import { isNetworkError } from '@/utils/network'; import { useCallsStore } from '../calls/store'; //import { useRolesStore } from '../roles/store'; @@ -118,10 +119,20 @@ export const useCoreStore = create()( isLoading: false, isInitializing: false, }); - logger.error({ - message: `Failed to init core app data: ${JSON.stringify(error)}`, - context: { error }, - }); + // A network failure here has already been surfaced by fetchConfig; keep it + // at warn so the same transient, recoverable error is not reported to Sentry + // multiple times as it bubbles up the call stack. + if (isNetworkError(error)) { + logger.warn({ + message: 'Failed to init core app data due to network connectivity', + context: { error }, + }); + } else { + logger.error({ + message: `Failed to init core app data: ${JSON.stringify(error)}`, + context: { error }, + }); + } throw error; } }, @@ -255,10 +266,21 @@ export const useCoreStore = create()( } } catch (error) { set({ error: 'Failed to fetch config', isLoading: false }); - logger.error({ - message: `Failed to fetch config: ${JSON.stringify(error)}`, - context: { error }, - }); + // Transient connectivity failures (offline, or the app cold-launched in the + // background with restricted network access) are expected and recoverable, + // so log them at warn level to avoid flooding Sentry with non-actionable + // errors. Genuine server responses (4xx/5xx) still report as errors. + if (isNetworkError(error)) { + logger.warn({ + message: 'Failed to fetch config due to network connectivity', + context: { error }, + }); + } else { + logger.error({ + message: `Failed to fetch config: ${JSON.stringify(error)}`, + context: { error }, + }); + } throw error; // Re-throw to allow calling code to handle } }, diff --git a/src/utils/__tests__/network.test.ts b/src/utils/__tests__/network.test.ts new file mode 100644 index 0000000..20018a9 --- /dev/null +++ b/src/utils/__tests__/network.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from '@jest/globals'; +import { AxiosError } from 'axios'; + +import { isNetworkError } from '../network'; + +describe('isNetworkError', () => { + it('returns true for an Axios network error (no response received)', () => { + expect(isNetworkError(new AxiosError('Network Error', 'ERR_NETWORK'))).toBe(true); + }); + + it('returns true for an Axios timeout error', () => { + expect(isNetworkError(new AxiosError('timeout of 0ms exceeded', 'ECONNABORTED'))).toBe(true); + }); + + it('returns true when the request was sent but no response came back (no specific code)', () => { + // request object present, no response, no ERR_NETWORK/ECONNABORTED code (e.g. socket hang up) + const error = new AxiosError('socket hang up', undefined, undefined, {} as never); + expect(isNetworkError(error)).toBe(true); + }); + + it('returns false for an Axios error with no response AND no request (setup/config error)', () => { + // Failed before the request was ever sent — a client/setup bug, not transient + // connectivity; it should surface as an error, not be classified as a network error. + expect(isNetworkError(new AxiosError('Something failed'))).toBe(false); + }); + + it('returns false for an Axios error that received a server response (4xx/5xx)', () => { + const response = { + status: 500, + statusText: 'Internal Server Error', + data: {}, + headers: {}, + config: {} as never, + }; + const error = new AxiosError('Request failed with status code 500', 'ERR_BAD_RESPONSE', undefined, undefined, response as never); + expect(isNetworkError(error)).toBe(false); + }); + + it('returns false for a plain (non-Axios) Error', () => { + expect(isNetworkError(new Error('boom'))).toBe(false); + }); + + it('returns false for non-error values', () => { + expect(isNetworkError(null)).toBe(false); + expect(isNetworkError(undefined)).toBe(false); + expect(isNetworkError('Network Error')).toBe(false); + expect(isNetworkError({ message: 'Network Error' })).toBe(false); + }); +}); diff --git a/src/utils/network.ts b/src/utils/network.ts new file mode 100644 index 0000000..d8e239c --- /dev/null +++ b/src/utils/network.ts @@ -0,0 +1,17 @@ +import axios from 'axios'; + +/** + * Returns true for transient connectivity failures where the request never + * received a response from the server — e.g. the device is offline, DNS/TLS + * failed, the request timed out, or the app was cold-launched in the background + * with restricted network access. + * + * A failure that occurred before a request was ever sent (a setup/config error with + * no `request`) is NOT treated as a network error, so genuine client bugs still + * surface as errors rather than being swept under "transient connectivity". + * + * These conditions are expected and recoverable, so callers should log them at + * `warn` level (which does NOT report to Sentry) instead of `error`, while still + * treating genuine server responses (4xx/5xx) as real errors. + */ +export const isNetworkError = (error: unknown): boolean => axios.isAxiosError(error) && (error.code === 'ERR_NETWORK' || error.code === 'ECONNABORTED' || (error.request != null && !error.response));