Compare commits

..

2 commits

Author SHA1 Message Date
0cf1139f9b
fix: trackLocation 2026-01-11 15:18:00 +01:00
39d2ede295
fix: reload + improve subscriptions 2026-01-11 15:17:55 +01:00
7 changed files with 400 additions and 141 deletions

View file

@ -2,8 +2,15 @@ import { useRef, useEffect, useMemo, useState } from "react";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import * as Sentry from "@sentry/react-native"; import * as Sentry from "@sentry/react-native";
import { useNetworkState } from "~/stores"; import { useNetworkState } from "~/stores";
import { createLogger } from "~/lib/logger";
import { UI_SCOPES } from "~/lib/logger/scopes";
import useShallowMemo from "./useShallowMemo"; import useShallowMemo from "./useShallowMemo";
const hookLogger = createLogger({
module: UI_SCOPES.HOOKS,
feature: "useLatestWithSubscription",
});
// Constants for retry configuration // Constants for retry configuration
const MAX_RETRIES = 5; const MAX_RETRIES = 5;
const INITIAL_BACKOFF_MS = 1000; // 1 second const INITIAL_BACKOFF_MS = 1000; // 1 second
@ -47,13 +54,15 @@ export default function useLatestWithSubscription(
const retryCountRef = useRef(0); const retryCountRef = useRef(0);
const subscriptionErrorRef = useRef(null); const subscriptionErrorRef = useRef(null);
const timeoutIdRef = useRef(null); const timeoutIdRef = useRef(null);
const unsubscribeRef = useRef(null);
const lastWsClosedDateRef = useRef(null);
useEffect(() => { useEffect(() => {
const currentVarsHash = JSON.stringify(variables); const currentVarsHash = JSON.stringify(variables);
if (currentVarsHash !== variableHashRef.current) { if (currentVarsHash !== variableHashRef.current) {
console.log( hookLogger.debug("Variables changed; resetting subscription setup", {
`[${subscriptionKey}] Variables changed, resetting subscription setup`, subscriptionKey,
); });
highestIdRef.current = null; highestIdRef.current = null;
variableHashRef.current = currentVarsHash; variableHashRef.current = currentVarsHash;
initialSetupDoneRef.current = false; initialSetupDoneRef.current = false;
@ -98,19 +107,19 @@ export default function useLatestWithSubscription(
(highestIdRef.current === null || highestId > highestIdRef.current) (highestIdRef.current === null || highestId > highestIdRef.current)
) { ) {
highestIdRef.current = highestId; highestIdRef.current = highestId;
console.log( hookLogger.debug("Updated subscription cursor to highest ID", {
`[${subscriptionKey}] Updated subscription cursor to highest ID:`, subscriptionKey,
highestId, highestId,
); });
} }
} else { } else {
// Handle empty results case - initialize with 0 to allow subscription for first item // Handle empty results case - initialize with 0 to allow subscription for first item
if (highestIdRef.current === null) { if (highestIdRef.current === null) {
highestIdRef.current = 0; highestIdRef.current = 0;
console.log( hookLogger.debug("No initial items; setting subscription cursor", {
`[${subscriptionKey}] No initial items, setting subscription cursor to:`, subscriptionKey,
0, highestId: 0,
); });
} }
} }
}, [queryData, cursorKey, subscriptionKey]); }, [queryData, cursorKey, subscriptionKey]);
@ -134,12 +143,20 @@ export default function useLatestWithSubscription(
if (!subscribeToMore) return; if (!subscribeToMore) return;
if (highestIdRef.current === null) return; // Wait until we have the highest ID if (highestIdRef.current === null) return; // Wait until we have the highest ID
// Track WS close events so we only react when wsClosedDate actually changes
const wsClosedDateChanged =
!!wsClosedDate && wsClosedDate !== lastWsClosedDateRef.current;
if (wsClosedDateChanged) {
lastWsClosedDateRef.current = wsClosedDate;
}
// Check if max retries reached and we have an error // Check if max retries reached and we have an error
if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) { if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) {
console.error( hookLogger.error("Max retries reached; stopping subscription attempts", {
`[${subscriptionKey}] Max retries (${maxRetries}) reached. Stopping subscription attempts.`, subscriptionKey,
subscriptionErrorRef.current, maxRetries,
); error: subscriptionErrorRef.current,
});
// Report to Sentry when max retries are reached // Report to Sentry when max retries are reached
try { try {
@ -155,17 +172,24 @@ export default function useLatestWithSubscription(
}, },
}); });
} catch (sentryError) { } catch (sentryError) {
console.error("Failed to report to Sentry:", sentryError); hookLogger.error("Failed to report max-retries to Sentry", {
subscriptionKey,
error: sentryError,
});
} }
return; return;
} }
// Wait for: // Wait for:
// - either initial setup not done yet // - initial setup not done yet
// - or a new wsClosedDate (WS reconnect) // - OR a new wsClosedDate (WS reconnect)
// - or a retry trigger // - OR a retry trigger
if (initialSetupDoneRef.current && !wsClosedDate && retryTrigger === 0) { if (
initialSetupDoneRef.current &&
!wsClosedDateChanged &&
retryTrigger === 0
) {
return; return;
} }
@ -178,6 +202,16 @@ export default function useLatestWithSubscription(
timeoutIdRef.current = null; timeoutIdRef.current = null;
} }
// Always cleanup any existing subscription before creating a new one
if (unsubscribeRef.current) {
try {
unsubscribeRef.current();
} catch (_error) {
// ignore
}
unsubscribeRef.current = null;
}
// Calculate backoff delay if this is a retry // Calculate backoff delay if this is a retry
const backoffDelay = const backoffDelay =
retryCountRef.current > 0 retryCountRef.current > 0
@ -187,15 +221,13 @@ export default function useLatestWithSubscription(
) )
: 0; : 0;
const retryMessage = hookLogger.debug("Setting up subscription", {
retryCountRef.current > 0 subscriptionKey,
? ` Retry attempt ${retryCountRef.current}/${maxRetries} after ${backoffDelay}ms delay` retryCount: retryCountRef.current,
: ""; maxRetries,
backoffDelay,
console.log( highestId: highestIdRef.current,
`[${subscriptionKey}] Setting up subscription${retryMessage} with highestId:`, });
highestIdRef.current,
);
// Use timeout for backoff // Use timeout for backoff
timeoutIdRef.current = setTimeout(() => { timeoutIdRef.current = setTimeout(() => {
@ -222,10 +254,12 @@ export default function useLatestWithSubscription(
maxRetries, maxRetries,
); );
console.error( hookLogger.warn("Subscription error", {
`[${subscriptionKey}] Subscription error (attempt ${retryCountRef.current}/${maxRetries}):`, subscriptionKey,
attempt: retryCountRef.current,
maxRetries,
error, error,
); });
// If we haven't reached max retries, trigger a retry // If we haven't reached max retries, trigger a retry
if (retryCountRef.current < maxRetries) { if (retryCountRef.current < maxRetries) {
@ -270,10 +304,11 @@ export default function useLatestWithSubscription(
} }
}); });
console.log( hookLogger.debug("Received new items", {
`[${subscriptionKey}] Received ${filteredNewItems.length} new items, updated highestId:`, subscriptionKey,
highestIdRef.current, receivedCount: filteredNewItems.length,
); highestId: highestIdRef.current,
});
// For latest items pattern, we prepend new items (DESC order in UI) // For latest items pattern, we prepend new items (DESC order in UI)
return { return {
@ -283,30 +318,27 @@ export default function useLatestWithSubscription(
}, },
}); });
// Cleanup on unmount or re-run // Save unsubscribe for cleanup on reruns/unmount
return () => { unsubscribeRef.current = unsubscribe;
console.log(`[${subscriptionKey}] Cleaning up subscription`);
if (timeoutIdRef.current) { // Note: cleanup is handled by the effect cleanup below.
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
unsubscribe();
};
} catch (error) { } catch (error) {
// Handle setup errors (like malformed queries) // Handle setup errors (like malformed queries)
console.error( hookLogger.error("Error setting up subscription", {
`[${subscriptionKey}] Error setting up subscription:`, subscriptionKey,
error, error,
); });
subscriptionErrorRef.current = error; subscriptionErrorRef.current = error;
// Increment retry counter but don't exceed maxRetries // Increment retry counter but don't exceed maxRetries
retryCountRef.current = Math.min(retryCountRef.current + 1, maxRetries); retryCountRef.current = Math.min(retryCountRef.current + 1, maxRetries);
console.error( hookLogger.warn("Subscription setup error", {
`[${subscriptionKey}] Subscription setup error (attempt ${retryCountRef.current}/${maxRetries}):`, subscriptionKey,
attempt: retryCountRef.current,
maxRetries,
error, error,
); });
// If we haven't reached max retries, trigger a retry // If we haven't reached max retries, trigger a retry
if (retryCountRef.current < maxRetries) { if (retryCountRef.current < maxRetries) {
@ -328,16 +360,14 @@ export default function useLatestWithSubscription(
}, },
}); });
} catch (sentryError) { } catch (sentryError) {
console.error("Failed to report to Sentry:", sentryError); hookLogger.error("Failed to report setup error to Sentry", {
subscriptionKey,
error: sentryError,
});
} }
} }
return () => { // Cleanup is handled by the effect cleanup below.
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
};
} }
}, backoffDelay); }, backoffDelay);
@ -347,6 +377,16 @@ export default function useLatestWithSubscription(
clearTimeout(timeoutIdRef.current); clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null; timeoutIdRef.current = null;
} }
if (unsubscribeRef.current) {
try {
hookLogger.debug("Cleaning up subscription", { subscriptionKey });
unsubscribeRef.current();
} catch (_error) {
// ignore
}
unsubscribeRef.current = null;
}
}; };
}, [ }, [
skip, skip,

View file

@ -2,8 +2,15 @@ import { useRef, useEffect, useMemo, useState } from "react";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import * as Sentry from "@sentry/react-native"; import * as Sentry from "@sentry/react-native";
import { useNetworkState } from "~/stores"; import { useNetworkState } from "~/stores";
import { createLogger } from "~/lib/logger";
import { UI_SCOPES } from "~/lib/logger/scopes";
import useShallowMemo from "./useShallowMemo"; import useShallowMemo from "./useShallowMemo";
const hookLogger = createLogger({
module: UI_SCOPES.HOOKS,
feature: "useStreamQueryWithSubscription",
});
// Constants for retry configuration // Constants for retry configuration
const MAX_RETRIES = 5; const MAX_RETRIES = 5;
const INITIAL_BACKOFF_MS = 1000; // 1 second const INITIAL_BACKOFF_MS = 1000; // 1 second
@ -40,14 +47,16 @@ export default function useStreamQueryWithSubscription(
const retryCountRef = useRef(0); const retryCountRef = useRef(0);
const subscriptionErrorRef = useRef(null); const subscriptionErrorRef = useRef(null);
const timeoutIdRef = useRef(null); const timeoutIdRef = useRef(null);
const unsubscribeRef = useRef(null);
const lastWsClosedDateRef = useRef(null);
useEffect(() => { useEffect(() => {
const currentVarsHash = JSON.stringify(variables); const currentVarsHash = JSON.stringify(variables);
if (currentVarsHash !== variableHashRef.current) { if (currentVarsHash !== variableHashRef.current) {
console.log( hookLogger.debug("Variables changed; resetting cursor", {
`[${subscriptionKey}] Variables changed, resetting cursor to initial value:`, subscriptionKey,
initialCursor, initialCursor,
); });
lastCursorRef.current = initialCursor; lastCursorRef.current = initialCursor;
variableHashRef.current = currentVarsHash; variableHashRef.current = currentVarsHash;
initialSetupDoneRef.current = false; initialSetupDoneRef.current = false;
@ -99,10 +108,10 @@ export default function useStreamQueryWithSubscription(
const newCursor = lastItem[cursorKey]; const newCursor = lastItem[cursorKey];
lastCursorRef.current = newCursor; lastCursorRef.current = newCursor;
console.log( hookLogger.debug("Updated subscription cursor", {
`[${subscriptionKey}] Updated subscription cursor:`, subscriptionKey,
newCursor, cursor: newCursor,
); });
} }
}, [queryData, cursorKey, subscriptionKey]); }, [queryData, cursorKey, subscriptionKey]);
@ -124,12 +133,20 @@ export default function useStreamQueryWithSubscription(
if (skip) return; // If skipping, do nothing if (skip) return; // If skipping, do nothing
if (!subscribeToMore) return; if (!subscribeToMore) return;
// Track WS close events so we only react when wsClosedDate actually changes
const wsClosedDateChanged =
!!wsClosedDate && wsClosedDate !== lastWsClosedDateRef.current;
if (wsClosedDateChanged) {
lastWsClosedDateRef.current = wsClosedDate;
}
// Check if max retries reached and we have an error - this check must be done regardless of other conditions // Check if max retries reached and we have an error - this check must be done regardless of other conditions
if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) { if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) {
console.error( hookLogger.error("Max retries reached; stopping subscription attempts", {
`[${subscriptionKey}] Max retries (${maxRetries}) reached. Stopping subscription attempts.`, subscriptionKey,
subscriptionErrorRef.current, maxRetries,
); error: subscriptionErrorRef.current,
});
// Report to Sentry when max retries are reached // Report to Sentry when max retries are reached
try { try {
@ -145,17 +162,24 @@ export default function useStreamQueryWithSubscription(
}, },
}); });
} catch (sentryError) { } catch (sentryError) {
console.error("Failed to report to Sentry:", sentryError); hookLogger.error("Failed to report max-retries to Sentry", {
subscriptionKey,
error: sentryError,
});
} }
return; return;
} }
// Wait for: // Wait for:
// - either initial setup not done yet // - initial setup not done yet
// - or a new wsClosedDate (WS reconnect) // - OR a new wsClosedDate (WS reconnect)
// - or a retry trigger // - OR a retry trigger
if (initialSetupDoneRef.current && !wsClosedDate && retryTrigger === 0) { if (
initialSetupDoneRef.current &&
!wsClosedDateChanged &&
retryTrigger === 0
) {
return; return;
} }
@ -168,6 +192,16 @@ export default function useStreamQueryWithSubscription(
timeoutIdRef.current = null; timeoutIdRef.current = null;
} }
// Always cleanup any existing subscription before creating a new one
if (unsubscribeRef.current) {
try {
unsubscribeRef.current();
} catch (_error) {
// ignore
}
unsubscribeRef.current = null;
}
// Calculate backoff delay if this is a retry // Calculate backoff delay if this is a retry
const backoffDelay = const backoffDelay =
retryCountRef.current > 0 retryCountRef.current > 0
@ -177,15 +211,13 @@ export default function useStreamQueryWithSubscription(
) )
: 0; : 0;
const retryMessage = hookLogger.debug("Setting up subscription", {
retryCountRef.current > 0 subscriptionKey,
? ` Retry attempt ${retryCountRef.current}/${maxRetries} after ${backoffDelay}ms delay` retryCount: retryCountRef.current,
: ""; maxRetries,
backoffDelay,
console.log( cursor: lastCursorRef.current,
`[${subscriptionKey}] Setting up subscription${retryMessage} with cursor:`, });
lastCursorRef.current,
);
// Use timeout for backoff // Use timeout for backoff
timeoutIdRef.current = setTimeout(() => { timeoutIdRef.current = setTimeout(() => {
@ -212,10 +244,12 @@ export default function useStreamQueryWithSubscription(
maxRetries, maxRetries,
); );
console.error( hookLogger.warn("Subscription error", {
`[${subscriptionKey}] Subscription error (attempt ${retryCountRef.current}/${maxRetries}):`, subscriptionKey,
attempt: retryCountRef.current,
maxRetries,
error, error,
); });
// If we haven't reached max retries, trigger a retry // If we haven't reached max retries, trigger a retry
if (retryCountRef.current < maxRetries) { if (retryCountRef.current < maxRetries) {
@ -263,10 +297,10 @@ export default function useStreamQueryWithSubscription(
newItemCursor > lastCursorRef.current newItemCursor > lastCursorRef.current
) { ) {
lastCursorRef.current = newItemCursor; lastCursorRef.current = newItemCursor;
console.log( hookLogger.debug("Received item; cursor advanced", {
`[${subscriptionKey}] New message received with cursor:`, subscriptionKey,
lastCursorRef.current, cursor: lastCursorRef.current,
); });
} }
const existing = itemMap.get(item[uniqKey]); const existing = itemMap.get(item[uniqKey]);
@ -289,30 +323,27 @@ export default function useStreamQueryWithSubscription(
}, },
}); });
// Cleanup on unmount or re-run // Save unsubscribe for cleanup on reruns/unmount
return () => { unsubscribeRef.current = unsubscribe;
console.log(`[${subscriptionKey}] Cleaning up subscription`);
if (timeoutIdRef.current) { // Note: cleanup is handled by the effect cleanup below.
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
unsubscribe();
};
} catch (error) { } catch (error) {
// Handle setup errors (like malformed queries) // Handle setup errors (like malformed queries)
console.error( hookLogger.error("Error setting up subscription", {
`[${subscriptionKey}] Error setting up subscription:`, subscriptionKey,
error, error,
); });
subscriptionErrorRef.current = error; subscriptionErrorRef.current = error;
// Increment retry counter but don't exceed maxRetries // Increment retry counter but don't exceed maxRetries
retryCountRef.current = Math.min(retryCountRef.current + 1, maxRetries); retryCountRef.current = Math.min(retryCountRef.current + 1, maxRetries);
console.error( hookLogger.warn("Subscription setup error", {
`[${subscriptionKey}] Subscription setup error (attempt ${retryCountRef.current}/${maxRetries}):`, subscriptionKey,
attempt: retryCountRef.current,
maxRetries,
error, error,
); });
// If we haven't reached max retries, trigger a retry // If we haven't reached max retries, trigger a retry
if (retryCountRef.current < maxRetries) { if (retryCountRef.current < maxRetries) {
@ -334,16 +365,14 @@ export default function useStreamQueryWithSubscription(
}, },
}); });
} catch (sentryError) { } catch (sentryError) {
console.error("Failed to report to Sentry:", sentryError); hookLogger.error("Failed to report setup error to Sentry", {
subscriptionKey,
error: sentryError,
});
} }
} }
return () => { // Cleanup is handled by the effect cleanup below.
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null;
}
};
} }
}, backoffDelay); }, backoffDelay);
@ -353,6 +382,16 @@ export default function useStreamQueryWithSubscription(
clearTimeout(timeoutIdRef.current); clearTimeout(timeoutIdRef.current);
timeoutIdRef.current = null; timeoutIdRef.current = null;
} }
if (unsubscribeRef.current) {
try {
hookLogger.debug("Cleaning up subscription", { subscriptionKey });
unsubscribeRef.current();
} catch (_error) {
// ignore
}
unsubscribeRef.current = null;
}
}; };
}, [ }, [
skip, skip,

View file

@ -5,27 +5,40 @@ import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import jwtDecode from "jwt-decode"; import jwtDecode from "jwt-decode";
import { initEmulatorMode } from "./emulatorService"; import { initEmulatorMode } from "./emulatorService";
import { getAuthState, subscribeAuthState, permissionsActions } from "~/stores"; import {
getAlertState,
getAuthState,
getSessionState,
subscribeAlertState,
subscribeAuthState,
subscribeSessionState,
permissionsActions,
} from "~/stores";
import setLocationState from "~/location/setLocationState"; import setLocationState from "~/location/setLocationState";
import { storeLocation } from "~/location/storage"; import { storeLocation } from "~/location/storage";
import env from "~/env"; import env from "~/env";
const config = { // Common config: keep always-on tracking enabled, but default to an IDLE low-power profile.
// High-accuracy and "moving" mode are only enabled when an active alert is open.
const baseConfig = {
// https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode // https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode
enableHeadless: true, enableHeadless: true,
disableProviderChangeRecord: true, disableProviderChangeRecord: true,
// disableMotionActivityUpdates: true, // disableMotionActivityUpdates: true,
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH, // Default to low-power (idle) profile; will be overridden when needed.
distanceFilter: TRACK_MOVE, desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
// Larger distance filter in idle mode to prevent frequent GPS wakes.
distanceFilter: 200,
// debug: true, // Enable debug mode for more detailed logs // debug: true, // Enable debug mode for more detailed logs
logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE, logLevel: BackgroundGeolocation.LOG_LEVEL_VERBOSE,
// Disable automatic permission requests // Disable automatic permission requests
locationAuthorizationRequest: "Always", locationAuthorizationRequest: "Always",
stopOnTerminate: false, stopOnTerminate: false,
startOnBoot: true, startOnBoot: true,
heartbeatInterval: 900, // Keep heartbeat very infrequent in idle mode.
heartbeatInterval: 3600,
// Force the plugin to start aggressively // Force the plugin to start aggressively
foregroundService: true, foregroundService: true,
notification: { notification: {
@ -53,7 +66,19 @@ const config = {
autoSync: true, autoSync: true,
reset: true, reset: true,
}; };
const defaultConfig = config;
const TRACKING_PROFILES = {
idle: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_LOW,
distanceFilter: 200,
heartbeatInterval: 3600,
},
active: {
desiredAccuracy: BackgroundGeolocation.DESIRED_ACCURACY_HIGH,
distanceFilter: TRACK_MOVE,
heartbeatInterval: 900,
},
};
export default async function trackLocation() { export default async function trackLocation() {
const locationLogger = createLogger({ const locationLogger = createLogger({
@ -61,6 +86,66 @@ export default async function trackLocation() {
feature: "tracking", feature: "tracking",
}); });
let currentProfile = null;
let authReady = false;
let stopAlertSubscription = null;
let stopSessionSubscription = null;
const computeHasOwnOpenAlert = () => {
try {
const { userId } = getSessionState();
const { alertingList } = getAlertState();
if (!userId || !Array.isArray(alertingList)) return false;
return alertingList.some(
({ oneAlert }) =>
oneAlert?.state === "open" && oneAlert?.userId === userId,
);
} catch (e) {
locationLogger.warn("Failed to compute active-alert state", {
error: e?.message,
});
return false;
}
};
const applyProfile = async (profileName) => {
if (!authReady) {
// We only apply profile once auth headers are configured.
return;
}
if (currentProfile === profileName) return;
const profile = TRACKING_PROFILES[profileName];
if (!profile) {
locationLogger.warn("Unknown tracking profile", { profileName });
return;
}
locationLogger.info("Applying tracking profile", {
profileName,
desiredAccuracy: profile.desiredAccuracy,
distanceFilter: profile.distanceFilter,
heartbeatInterval: profile.heartbeatInterval,
});
try {
await BackgroundGeolocation.setConfig(profile);
// Key battery fix:
// - IDLE profile forces stationary mode
// - ACTIVE profile forces moving mode
await BackgroundGeolocation.changePace(profileName === "active");
currentProfile = profileName;
} catch (error) {
locationLogger.error("Failed to apply tracking profile", {
profileName,
error: error?.message,
stack: error?.stack,
});
}
};
// Log the geolocation sync URL for debugging // Log the geolocation sync URL for debugging
locationLogger.info("Geolocation sync URL configuration", { locationLogger.info("Geolocation sync URL configuration", {
url: env.GEOLOC_SYNC_URL, url: env.GEOLOC_SYNC_URL,
@ -76,6 +161,18 @@ export default async function trackLocation() {
locationLogger.info("No auth token, stopping location tracking"); locationLogger.info("No auth token, stopping location tracking");
await BackgroundGeolocation.stop(); await BackgroundGeolocation.stop();
locationLogger.debug("Location tracking stopped"); locationLogger.debug("Location tracking stopped");
// Cleanup subscriptions when logged out.
try {
stopAlertSubscription && stopAlertSubscription();
stopSessionSubscription && stopSessionSubscription();
} finally {
stopAlertSubscription = null;
stopSessionSubscription = null;
}
authReady = false;
currentProfile = null;
return; return;
} }
// unsub(); // unsub();
@ -87,6 +184,8 @@ export default async function trackLocation() {
}, },
}); });
authReady = true;
// Log the authorization header that was set // Log the authorization header that was set
locationLogger.debug( locationLogger.debug(
"Set Authorization header for background geolocation", "Set Authorization header for background geolocation",
@ -106,19 +205,7 @@ export default async function trackLocation() {
}); });
} }
if (state.enabled) { if (!state.enabled) {
locationLogger.info("Syncing location data");
try {
await BackgroundGeolocation.changePace(true);
await BackgroundGeolocation.sync();
locationLogger.debug("Sync initiated successfully");
} catch (error) {
locationLogger.error("Failed to sync location data", {
error: error.message,
stack: error.stack,
});
}
} else {
locationLogger.info("Starting location tracking"); locationLogger.info("Starting location tracking");
try { try {
await BackgroundGeolocation.start(); await BackgroundGeolocation.start();
@ -130,6 +217,31 @@ export default async function trackLocation() {
}); });
} }
} }
// Ensure we are NOT forcing "moving" mode by default.
// Default profile is idle unless an active alert requires higher accuracy.
const shouldBeActive = computeHasOwnOpenAlert();
await applyProfile(shouldBeActive ? "active" : "idle");
// Subscribe to changes that may require switching profiles.
if (!stopSessionSubscription) {
stopSessionSubscription = subscribeSessionState(
(s) => s?.userId,
() => {
const active = computeHasOwnOpenAlert();
applyProfile(active ? "active" : "idle");
},
);
}
if (!stopAlertSubscription) {
stopAlertSubscription = subscribeAlertState(
(s) => s?.alertingList,
() => {
const active = computeHasOwnOpenAlert();
applyProfile(active ? "active" : "idle");
},
);
}
} }
BackgroundGeolocation.onLocation(async (location) => { BackgroundGeolocation.onLocation(async (location) => {
@ -161,8 +273,8 @@ export default async function trackLocation() {
try { try {
locationLogger.info("Initializing background geolocation"); locationLogger.info("Initializing background geolocation");
await BackgroundGeolocation.ready(defaultConfig); await BackgroundGeolocation.ready(baseConfig);
await BackgroundGeolocation.setConfig(config); await BackgroundGeolocation.setConfig(baseConfig);
// Only set the permission state if we already have the permission // Only set the permission state if we already have the permission
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();
@ -203,6 +315,9 @@ export default async function trackLocation() {
subscribeAuthState(({ userToken }) => userToken, handleAuth); subscribeAuthState(({ userToken }) => userToken, handleAuth);
locationLogger.debug("Performing initial auth handling"); locationLogger.debug("Performing initial auth handling");
handleAuth(userToken); handleAuth(userToken);
// Initialize emulator mode if previously enabled
// Initialize emulator mode only in dev/staging to avoid accidental production battery drain.
if (__DEV__ || env.IS_STAGING) {
initEmulatorMode(); initEmulatorMode();
}
} }

View file

@ -18,8 +18,16 @@ import * as store from "~/stores";
import getRetryMaxAttempts from "./getRetryMaxAttemps"; import getRetryMaxAttempts from "./getRetryMaxAttemps";
import { createLogger } from "~/lib/logger";
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
const { useNetworkState, networkActions } = store; const { useNetworkState, networkActions } = store;
const networkProvidersLogger = createLogger({
module: NETWORK_SCOPES.APOLLO,
feature: "NetworkProviders",
});
const initializeNewApolloClient = (reload) => { const initializeNewApolloClient = (reload) => {
if (reload) { if (reload) {
const { apolloClient } = network; const { apolloClient } = network;
@ -47,6 +55,10 @@ export default function NetworkProviders({ children }) {
const networkState = useNetworkState(["initialized", "triggerReload"]); const networkState = useNetworkState(["initialized", "triggerReload"]);
useEffect(() => { useEffect(() => {
if (networkState.triggerReload) { if (networkState.triggerReload) {
networkProvidersLogger.debug("Network triggerReload received", {
reloadId: store.getAuthState()?.reloadId,
hasUserToken: !!store.getAuthState()?.userToken,
});
initializeNewApolloClient(true); initializeNewApolloClient(true);
setKey((prevKey) => prevKey + 1); setKey((prevKey) => prevKey + 1);
} }
@ -54,6 +66,10 @@ export default function NetworkProviders({ children }) {
useEffect(() => { useEffect(() => {
if (key > 0) { if (key > 0) {
networkProvidersLogger.debug("Network reloaded", {
reloadId: store.getAuthState()?.reloadId,
hasUserToken: !!store.getAuthState()?.userToken,
});
networkActions.onReload(); networkActions.onReload();
} }
}, [key]); }, [key]);

View file

@ -7,6 +7,8 @@ import { NETWORK_SCOPES } from "~/lib/logger/scopes";
import getStatusCode from "./getStatusCode"; import getStatusCode from "./getStatusCode";
import isAbortError from "./isAbortError"; import isAbortError from "./isAbortError";
import { getSessionState } from "~/stores";
let pendingRequests = []; let pendingRequests = [];
const resolvePendingRequests = () => { const resolvePendingRequests = () => {
@ -127,10 +129,31 @@ export default function createErrorLink({ store }) {
// Capture all other errors in Sentry // Capture all other errors in Sentry
const errorMessage = `apollo error: ${getErrorMessage(error)}`; const errorMessage = `apollo error: ${getErrorMessage(error)}`;
Sentry.captureException(new Error(errorMessage), {
extra: { const authState = getAuthState();
errorObject: error, const sessionState = getSessionState() || {};
// Keep Sentry context useful but avoid high-volume/PII payloads.
// - Don't attach the raw Apollo error object (can contain request details)
// - Don't attach identifiers (userId/deviceId)
// - Keep role info since it's relevant to the incident class
const safeExtras = {
operationName: operation.operationName,
statusCode,
reloadId: authState?.reloadId,
hasUserToken: !!authState?.userToken,
authLoading: !!authState?.loading,
session: {
initialized: !!sessionState.initialized,
defaultRole: sessionState.defaultRole,
allowedRolesCount: Array.isArray(sessionState.allowedRoles)
? sessionState.allowedRoles.length
: 0,
}, },
};
Sentry.captureException(new Error(errorMessage), {
extra: safeExtras,
}); });
} }
}); });

View file

@ -202,6 +202,7 @@ export default createAtom(({ get, merge, getActions }) => {
}; };
const confirmLoginRequest = async ({ authTokenJwt, isConnected }) => { const confirmLoginRequest = async ({ authTokenJwt, isConnected }) => {
authLogger.info("Confirming login request", { isConnected }); authLogger.info("Confirming login request", { isConnected });
const reloadId = Date.now();
if (!isConnected) { if (!isConnected) {
// backup anon tokens // backup anon tokens
const [anonAuthToken, anonUserToken] = await Promise.all([ const [anonAuthToken, anonUserToken] = await Promise.all([
@ -213,7 +214,7 @@ export default createAtom(({ get, merge, getActions }) => {
secureStore.setItemAsync(STORAGE_KEYS.ANON_USER_TOKEN, anonUserToken), secureStore.setItemAsync(STORAGE_KEYS.ANON_USER_TOKEN, anonUserToken),
]); ]);
} }
merge({ onReloadAuthToken: authTokenJwt }); merge({ onReloadAuthToken: authTokenJwt, reloadId });
triggerReload(); triggerReload();
}; };
@ -308,6 +309,7 @@ export default createAtom(({ get, merge, getActions }) => {
initialized: false, initialized: false,
onReload: false, onReload: false,
onReloadAuthToken: null, onReloadAuthToken: null,
reloadId: null,
userOffMode: false, userOffMode: false,
isReloading: false, isReloading: false,
lastReloadTime: 0, lastReloadTime: 0,

View file

@ -1,5 +1,13 @@
import { createAtom } from "~/lib/atomic-zustand"; import { createAtom } from "~/lib/atomic-zustand";
import { createLogger } from "~/lib/logger";
import { SYSTEM_SCOPES } from "~/lib/logger/scopes";
const treeLogger = createLogger({
module: SYSTEM_SCOPES.APP,
feature: "tree-reload",
});
const reloadCallbacks = []; const reloadCallbacks = [];
export default createAtom(({ merge, getActions }) => { export default createAtom(({ merge, getActions }) => {
@ -24,12 +32,14 @@ export default createAtom(({ merge, getActions }) => {
if (callback) { if (callback) {
reloadCallbacks.push(callback); reloadCallbacks.push(callback);
} }
networkActions.triggerReload(); // Clear session/store state first to stop user-level queries/subscriptions
// while we swap identity tokens.
sessionActions.clear(); sessionActions.clear();
resetStores(); resetStores();
merge({ merge({
triggerReload: true, triggerReload: true,
suspend: false, // Keep the tree suspended until we've run reload callbacks.
suspend: true,
}); });
}; };
@ -37,12 +47,26 @@ export default createAtom(({ merge, getActions }) => {
merge({ merge({
triggerReload: false, triggerReload: false,
}); });
// Run all reload callbacks sequentially and await them.
// This ensures auth identity swap completes BEFORE the network layer is recreated.
while (reloadCallbacks.length > 0) { while (reloadCallbacks.length > 0) {
let callback = reloadCallbacks.shift(); let callback = reloadCallbacks.shift();
if (callback) { if (callback) {
callback(); try {
await Promise.resolve(callback());
} catch (error) {
treeLogger.error("Reload callback threw", {
error: error?.message,
});
} }
} }
}
networkActions.triggerReload();
// Allow tree to render again; NetworkProviders will show its loader until ready.
merge({ suspend: false });
}; };
const suspendTree = () => { const suspendTree = () => {