diff --git a/index.js b/index.js index cc358ae..ac7b766 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,7 @@ import onMessageReceived from "~/notifications/onMessageReceived"; import { createLogger } from "~/lib/logger"; import * as Sentry from "@sentry/react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { handleHttpResponse } from "~/lib/geolocation/httpResponseHandler"; // setup notification, this have to stay in index.js notifee.onBackgroundEvent(notificationBackgroundEvent); @@ -33,7 +34,8 @@ registerRootComponent(App); // Constants for persistence const LAST_SYNC_TIME_KEY = "@geolocation_last_sync_time"; // const FORCE_SYNC_INTERVAL = 24 * 60 * 60 * 1000; -const FORCE_SYNC_INTERVAL = 60 * 60 * 1000; // DEBUGGING +// const FORCE_SYNC_INTERVAL = 60 * 60 * 1000; // DEBUGGING +const FORCE_SYNC_INTERVAL = 5 * 60 * 1000; // DEBUGGING // Helper functions for persisting sync time const getLastSyncTime = async () => { @@ -329,54 +331,47 @@ const HeadlessTask = async (event) => { break; case "http": - // Validate HTTP parameters - if (!params || typeof params !== "object" || !params.response) { - geolocBgLogger.warn("Invalid HTTP params", { params }); - break; - } + try { + // Use shared HTTP response handler for headless context + await handleHttpResponse(params, "headless"); - const httpStatus = params.response?.status; - const isHttpSuccess = httpStatus === 200; + // Update last sync time on successful HTTP response (keep existing logic) + const httpStatus = params.status; + if (httpStatus === 200) { + try { + const now = Date.now(); + await setLastSyncTime(now); - Sentry.addBreadcrumb({ - message: "HTTP response received", - category: "headless-task", - level: isHttpSuccess ? "info" : "warning", - data: { - status: httpStatus, - success: params.response?.success, - hasResponse: !!params.response, - }, - }); + Sentry.addBreadcrumb({ + message: "Last sync time updated (HTTP success)", + category: "headless-task", + level: "info", + data: { newSyncTime: new Date(now).toISOString() }, + }); + } catch (syncTimeError) { + geolocBgLogger.error("Failed to update sync time", { + error: syncTimeError, + }); - geolocBgLogger.debug("HTTP response received", { - response: params.response, - }); - - // Update last sync time on successful HTTP response - if (isHttpSuccess) { - try { - const now = Date.now(); - await setLastSyncTime(now); - - Sentry.addBreadcrumb({ - message: "Last sync time updated (HTTP success)", - category: "headless-task", - level: "info", - data: { newSyncTime: new Date(now).toISOString() }, - }); - } catch (syncTimeError) { - geolocBgLogger.error("Failed to update sync time", { - error: syncTimeError, - }); - - Sentry.captureException(syncTimeError, { - tags: { - module: "headless-task", - operation: "update-sync-time-http", - }, - }); + Sentry.captureException(syncTimeError, { + tags: { + module: "headless-task", + operation: "update-sync-time-http", + }, + }); + } } + } catch (httpHandlerError) { + geolocBgLogger.error("HTTP response handler failed", { + error: httpHandlerError, + }); + + Sentry.captureException(httpHandlerError, { + tags: { + module: "headless-task", + operation: "http-response-handler", + }, + }); } break; diff --git a/src/lib/geolocation/httpResponseHandler.js b/src/lib/geolocation/httpResponseHandler.js new file mode 100644 index 0000000..30b03c3 --- /dev/null +++ b/src/lib/geolocation/httpResponseHandler.js @@ -0,0 +1,225 @@ +import { createLogger } from "~/lib/logger"; +import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; +import * as Sentry from "@sentry/react-native"; +import BackgroundGeolocation from "react-native-background-geolocation"; +import throttle from "lodash.throttle"; + +import { authActions, getAuthState } from "~/stores"; +import { setLastSyncTime } from "~/lib/geolocation/syncTimeManager"; + +// Create logger for HTTP response handling +const httpLogger = createLogger({ + module: BACKGROUND_SCOPES.GEOLOCATION, + feature: "http-handler", +}); + +// Throttling configuration for auth reload +const AUTH_RELOAD_THROTTLE = 5000; // 5 seconds throttle + +// The core auth reload function that will be throttled +async function _reloadAuth() { + httpLogger.info("Refreshing authentication token via sync endpoint"); + + try { + // Get current auth state to check if we have an auth token + const { authToken, userToken } = getAuthState(); + + if (!authToken) { + httpLogger.warn("No auth token available for refresh"); + return; + } + + httpLogger.debug( + "Auth token available, updating BackgroundGeolocation config", + ); + + // Update BackgroundGeolocation config to include X-Auth-Token header + await BackgroundGeolocation.setConfig({ + headers: { + Authorization: `Bearer ${userToken}`, // Keep existing user token (may be expired) + "X-Auth-Token": authToken, // Add auth token for refresh + }, + }); + + // Trigger sync to refresh token + await BackgroundGeolocation.changePace(true); + await BackgroundGeolocation.sync(); + + httpLogger.info("Token refresh sync triggered successfully"); + } catch (error) { + httpLogger.error("Failed to refresh authentication token", { + error: error.message, + stack: error.stack, + }); + } +} + +// Create throttled version of auth reload with lodash +const reloadAuth = throttle(_reloadAuth, AUTH_RELOAD_THROTTLE, { + leading: true, + trailing: false, // Prevent trailing calls to avoid duplicate refreshes +}); + +/** + * Shared HTTP response handler for both foreground and headless contexts + * @param {Object} response - The HTTP response object from BackgroundGeolocation + * @param {string} context - Either 'foreground' or 'headless' + */ +export const handleHttpResponse = async (response, context = "foreground") => { + // Log the full response including headers if available + httpLogger.debug("HTTP response received", { + status: response?.status, + success: response?.success, + responseText: response?.responseText, + url: response?.url, + method: response?.method, + isSync: response?.isSync, + context, + requestHeaders: + response?.request?.headers || "Headers not available in response", + }); + + // Add Sentry breadcrumb for HTTP responses + Sentry.addBreadcrumb({ + message: `Background geolocation HTTP response (${context})`, + category: "geolocation-http", + level: response?.status === 200 ? "info" : "warning", + data: { + status: response?.status, + success: response?.success, + url: response?.url, + isSync: response?.isSync, + recordCount: response?.count, + context, + }, + }); + + const statusCode = response?.status; + + // Log status code and response + httpLogger.debug("Processing HTTP response", { + status: statusCode, + responseText: response?.responseText, + context, + }); + + switch (statusCode) { + case 200: + await handleSuccessResponse(response, context); + break; + case 410: + await handleAuthTokenNotFound(response, context); + break; + case 401: + await handleUnauthorized(response, context); + break; + default: + httpLogger.debug("Unhandled HTTP status code", { + status: statusCode, + context, + }); + } +}; + +/** + * Handle successful HTTP response (status 200) + */ +const handleSuccessResponse = async (response, context) => { + try { + const responseBody = response?.responseText + ? JSON.parse(response.responseText) + : null; + + if (responseBody?.userBearerJwt) { + httpLogger.info("Token refresh successful, updating stored token", { + context, + }); + + // Use auth action to update both in-memory and persistent storage + await authActions.setUserToken(responseBody.userBearerJwt); + + // Update BackgroundGeolocation config with new token and remove X-Auth-Token header + await BackgroundGeolocation.setConfig({ + headers: { + Authorization: `Bearer ${responseBody.userBearerJwt}`, + }, + }); + + httpLogger.debug( + "Updated BackgroundGeolocation with refreshed token and removed X-Auth-Token header", + { context }, + ); + + Sentry.addBreadcrumb({ + message: "Token refreshed successfully", + category: "geolocation-auth", + level: "info", + data: { context }, + }); + } + } catch (e) { + httpLogger.debug("Failed to parse successful response", { + error: e.message, + responseText: response?.responseText, + context, + }); + } +}; + +/** + * Handle auth token expired (status 410) + */ +const handleAuthTokenNotFound = async (response, context) => { + httpLogger.info("Auth token not found (410), logging out", { context }); + + Sentry.addBreadcrumb({ + message: "Auth token expired - logging out", + category: "geolocation-auth", + level: "warning", + data: { context }, + }); + + authActions.logout(); +}; + +/** + * Handle unauthorized request (status 401) + */ +const handleUnauthorized = async (response, context) => { + httpLogger.info("Unauthorized (401), attempting to refresh token", { + context, + }); + + // Add more detailed logging of the error response + try { + const errorBody = response?.responseText + ? JSON.parse(response.responseText) + : null; + + httpLogger.debug("Unauthorized error details", { + errorBody, + errorType: errorBody?.error?.type, + errorMessage: errorBody?.error?.message, + errorPath: errorBody?.error?.errors?.[0]?.path, + context, + }); + + Sentry.addBreadcrumb({ + message: "Unauthorized - refreshing token", + category: "geolocation-auth", + level: "warning", + data: { + errorType: errorBody?.error?.type, + errorMessage: errorBody?.error?.message, + context, + }, + }); + } catch (e) { + httpLogger.debug("Failed to parse error response", { + error: e.message, + responseText: response?.responseText, + context, + }); + } + await reloadAuth(); +}; diff --git a/src/lib/geolocation/syncTimeManager.js b/src/lib/geolocation/syncTimeManager.js new file mode 100644 index 0000000..2fb449f --- /dev/null +++ b/src/lib/geolocation/syncTimeManager.js @@ -0,0 +1,67 @@ +import { memoryAsyncStorage } from "~/lib/memoryAsyncStorage"; +import { createLogger } from "~/lib/logger"; +import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; +import * as Sentry from "@sentry/react-native"; + +// Constants for persistence +const LAST_SYNC_TIME_KEY = "@geolocation_last_sync_time"; + +// Create logger for sync time management +const syncTimeLogger = createLogger({ + module: BACKGROUND_SCOPES.GEOLOCATION, + feature: "sync-time", +}); + +/** + * Get the last sync time from storage + * @returns {Promise} The last sync time in milliseconds, or current time if not found + */ +export const getLastSyncTime = async () => { + try { + const value = await memoryAsyncStorage.getItem(LAST_SYNC_TIME_KEY); + const lastSyncTime = value ? parseInt(value, 10) : Date.now(); + + syncTimeLogger.debug("Retrieved last sync time", { + value, + lastSyncTime, + isDefault: !value, + }); + + return lastSyncTime; + } catch (error) { + syncTimeLogger.error("Failed to get last sync time", { + error: error.message, + }); + + Sentry.captureException(error, { + tags: { module: "sync-time-manager", operation: "get-last-sync-time" }, + }); + + // Return current time as fallback + return Date.now(); + } +}; + +/** + * Set the last sync time in storage + * @param {number} time - The sync time in milliseconds + */ +export const setLastSyncTime = async (time) => { + try { + await memoryAsyncStorage.setItem(LAST_SYNC_TIME_KEY, time.toString()); + + syncTimeLogger.debug("Set last sync time", { + time, + timeISO: new Date(time).toISOString(), + }); + } catch (error) { + syncTimeLogger.error("Failed to set last sync time", { + error: error.message, + time, + }); + + Sentry.captureException(error, { + tags: { module: "sync-time-manager", operation: "set-last-sync-time" }, + }); + } +}; diff --git a/src/lib/memoryAsyncStorage.js b/src/lib/memoryAsyncStorage.js index 90caee6..038faee 100644 --- a/src/lib/memoryAsyncStorage.js +++ b/src/lib/memoryAsyncStorage.js @@ -152,19 +152,20 @@ export const memoryAsyncStorage = { storageLogger.debug("Set in memory cache", { key }); // Try to persist to AsyncStorage - try { - await AsyncStorage.setItem(key, value); - storageLogger.debug("Persisted to AsyncStorage", { key }); - } catch (error) { - storageLogger.warn( - "Failed to persist to AsyncStorage, kept in memory only", - { - key, - error: error.message, - }, - ); - // Continue - value is at least in memory - } + (async () => { + try { + await AsyncStorage.setItem(key, value); + storageLogger.debug("Persisted to AsyncStorage", { key }); + } catch (error) { + storageLogger.warn( + "Failed to persist to AsyncStorage, kept in memory only", + { + key, + error: error.message, + }, + ); + } + })(); }, /** @@ -178,16 +179,18 @@ export const memoryAsyncStorage = { storageLogger.debug("Deleted from memory cache", { key }); // Try to delete from AsyncStorage - try { - await AsyncStorage.removeItem(key); - storageLogger.debug("Deleted from AsyncStorage", { key }); - } catch (error) { - storageLogger.warn("Failed to delete from AsyncStorage", { - key, - error: error.message, - }); - // Continue - at least removed from memory - } + (async () => { + try { + await AsyncStorage.removeItem(key); + storageLogger.debug("Deleted from AsyncStorage", { key }); + } catch (error) { + storageLogger.warn("Failed to delete from AsyncStorage", { + key, + error: error.message, + }); + // Continue - at least removed from memory + } + })(); }, /** @@ -242,14 +245,16 @@ export const memoryAsyncStorage = { storageLogger.info("Cleared memory cache"); // Try to clear AsyncStorage - try { - await AsyncStorage.clear(); - storageLogger.info("Cleared AsyncStorage"); - } catch (error) { - storageLogger.warn("Failed to clear AsyncStorage", { - error: error.message, - }); - } + (async () => { + try { + await AsyncStorage.clear(); + storageLogger.info("Cleared AsyncStorage"); + } catch (error) { + storageLogger.warn("Failed to clear AsyncStorage", { + error: error.message, + }); + } + })(); }, /** diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index 5bab556..e224dd2 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -18,6 +18,7 @@ import { import setLocationState from "~/location/setLocationState"; import { storeLocation } from "~/utils/location/storage"; +import { handleHttpResponse } from "~/lib/geolocation/httpResponseHandler"; import env from "~/env"; @@ -76,53 +77,6 @@ export default async function trackLocation() { isStaging: env.IS_STAGING, }); - // Throttling configuration for auth reload only - const AUTH_RELOAD_THROTTLE = 5000; // 5 seconds throttle - - // The core auth reload function that will be throttled - async function _reloadAuth() { - locationLogger.info("Refreshing authentication token via sync endpoint"); - - try { - // Get current auth state to check if we have an auth token - const { authToken, userToken } = getAuthState(); - - if (!authToken) { - locationLogger.warn("No auth token available for refresh"); - return; - } - - locationLogger.debug( - "Auth token available, updating BackgroundGeolocation config", - ); - - // Update BackgroundGeolocation config to include X-Auth-Token header - await BackgroundGeolocation.setConfig({ - headers: { - Authorization: `Bearer ${userToken}`, // Keep existing user token (may be expired) - "X-Auth-Token": authToken, // Add auth token for refresh - }, - }); - - // Trigger sync to refresh token - await BackgroundGeolocation.changePace(true); - await BackgroundGeolocation.sync(); - - locationLogger.info("Token refresh sync triggered successfully"); - } catch (error) { - locationLogger.error("Failed to refresh authentication token", { - error: error.message, - stack: error.stack, - }); - } - } - - // Create throttled version of auth reload with lodash - const reloadAuth = throttle(_reloadAuth, AUTH_RELOAD_THROTTLE, { - leading: true, - trailing: false, // Prevent trailing calls to avoid duplicate refreshes - }); - // Handle auth function - no throttling or cooldown async function handleAuth(userToken) { locationLogger.info("Handling auth token update", { @@ -152,25 +106,6 @@ export default async function trackLocation() { }, ); - // Verify the current configuration - try { - const currentConfig = await BackgroundGeolocation.getConfig(); - locationLogger.debug("Current background geolocation config", { - hasHeaders: !!currentConfig.headers, - headerKeys: currentConfig.headers - ? Object.keys(currentConfig.headers) - : [], - authHeader: currentConfig.headers?.Authorization - ? currentConfig.headers.Authorization.substring(0, 15) + "..." - : "Not set", - url: currentConfig.url, - }); - } catch (error) { - locationLogger.error("Failed to get background geolocation config", { - error: error.message, - }); - } - const state = await BackgroundGeolocation.getState(); try { const decodedToken = jwtDecode(userToken); @@ -240,132 +175,8 @@ export default async function trackLocation() { }); BackgroundGeolocation.onHttp(async (response) => { - // Log the full response including headers if available - locationLogger.debug("HTTP response received", { - status: response?.status, - success: response?.success, - responseText: response?.responseText, - url: response?.url, - method: response?.method, - isSync: response?.isSync, - requestHeaders: - response?.request?.headers || "Headers not available in response", - }); - - // Add Sentry breadcrumb for HTTP responses - Sentry.addBreadcrumb({ - message: "Background geolocation HTTP response", - category: "geolocation-http", - level: response?.status === 200 ? "info" : "warning", - data: { - status: response?.status, - success: response?.success, - url: response?.url, - isSync: response?.isSync, - recordCount: response?.count, - }, - }); - - // Log the current auth token for comparison - const { userToken } = getAuthState(); - locationLogger.debug("Current auth state token", { - tokenAvailable: !!userToken, - tokenPrefix: userToken ? userToken.substring(0, 10) + "..." : null, - }); - - const statusCode = response?.status; - - // log status code and response - locationLogger.debug("HTTP response received", { - status: statusCode, - responseText: response?.responseText, - }); - - switch (statusCode) { - case 200: - // Successful response, check for token refresh - try { - const responseBody = response?.responseText - ? JSON.parse(response.responseText) - : null; - - if (responseBody?.userBearerJwt) { - locationLogger.info( - "Token refresh successful, updating stored token", - ); - - // Use auth action to update both in-memory and persistent storage - await authActions.setUserToken(responseBody.userBearerJwt); - - // Update BackgroundGeolocation config with new token - await BackgroundGeolocation.setConfig({ - headers: { - Authorization: `Bearer ${responseBody.userBearerJwt}`, - }, - }); - - locationLogger.debug( - "Updated BackgroundGeolocation with refreshed token and removed X-Auth-Token header", - ); - - Sentry.addBreadcrumb({ - message: "Token refreshed successfully", - category: "geolocation-auth", - level: "info", - }); - } - } catch (e) { - locationLogger.debug("Failed to parse successful response", { - error: e.message, - responseText: response?.responseText, - }); - } - break; - case 410: - // Auth token expired, logout - locationLogger.info("Auth token expired (410), logging out"); - Sentry.addBreadcrumb({ - message: "Auth token expired - logging out", - category: "geolocation-auth", - level: "warning", - }); - authActions.logout(); - break; - case 401: - // Unauthorized: User token expired, refresh using throttled reload - locationLogger.info("Unauthorized (401), attempting to refresh token"); - - // Add more detailed logging of the error response - try { - const errorBody = response?.responseText - ? JSON.parse(response.responseText) - : null; - locationLogger.debug("Unauthorized error details", { - errorBody, - errorType: errorBody?.error?.type, - errorMessage: errorBody?.error?.message, - errorPath: errorBody?.error?.errors?.[0]?.path, - }); - - Sentry.addBreadcrumb({ - message: "Unauthorized - refreshing token", - category: "geolocation-auth", - level: "warning", - data: { - errorType: errorBody?.error?.type, - errorMessage: errorBody?.error?.message, - }, - }); - } catch (e) { - locationLogger.debug("Failed to parse error response", { - error: e.message, - responseText: response?.responseText, - }); - } - - reloadAuth(); - break; - } + // Use shared HTTP response handler for foreground context + await handleHttpResponse(response, "foreground"); }); try { diff --git a/src/stores/auth.js b/src/stores/auth.js index 4a0e602..cc5e1fe 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -71,6 +71,7 @@ export default createAtom(({ get, merge, getActions }) => { await secureStore.setItemAsync("userToken", userToken); endLoading({ userToken, + authToken, }); sessionActions.loadSessionFromJWT(userToken); return { userToken }; @@ -110,6 +111,7 @@ export default createAtom(({ get, merge, getActions }) => { } else { endLoading({ userToken, + authToken, }); sessionActions.loadSessionFromJWT(jwtData); return; @@ -122,6 +124,7 @@ export default createAtom(({ get, merge, getActions }) => { authLogger.info("Successfully registered new user"); authToken = res.authToken; await secureStore.setItemAsync("authToken", authToken); + merge({ authToken }); } if (!userToken && authToken) { @@ -260,6 +263,8 @@ export default createAtom(({ get, merge, getActions }) => { ]); merge({ userOffMode: true, + authToken: null, + userToken: null, }); return; } @@ -303,6 +308,7 @@ export default createAtom(({ get, merge, getActions }) => { return { default: { userToken: null, + authToken: null, loading: true, initialized: false, onReload: false,