fix(headless-task): wip

This commit is contained in:
devthejo 2025-06-22 11:18:59 +02:00
parent ac84ae707b
commit 6c290f21b4
7 changed files with 890 additions and 32 deletions

View file

@ -10,7 +10,8 @@ let config = {
version, version,
updates: { updates: {
url: "https://expo-updates.alertesecours.fr/api/manifest?project=alerte-secours&channel=release", url: "https://expo-updates.alertesecours.fr/api/manifest?project=alerte-secours&channel=release",
enabled: true, // enabled: true,
enabled: false, // DEBUGGING
checkAutomatically: "ON_ERROR_RECOVERY", checkAutomatically: "ON_ERROR_RECOVERY",
fallbackToCacheTimeout: 0, fallbackToCacheTimeout: 0,
codeSigningCertificate: "./keys/certificate.pem", codeSigningCertificate: "./keys/certificate.pem",

380
index.js
View file

@ -18,6 +18,8 @@ import { onBackgroundEvent as notificationBackgroundEvent } from "~/notification
import onMessageReceived from "~/notifications/onMessageReceived"; import onMessageReceived from "~/notifications/onMessageReceived";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import * as Sentry from "@sentry/react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
// setup notification, this have to stay in index.js // setup notification, this have to stay in index.js
notifee.onBackgroundEvent(notificationBackgroundEvent); notifee.onBackgroundEvent(notificationBackgroundEvent);
@ -28,73 +30,423 @@ messaging().setBackgroundMessageHandler(onMessageReceived);
// the environment is set up appropriately // the environment is set up appropriately
registerRootComponent(App); registerRootComponent(App);
// Constants for persistence
const LAST_SYNC_TIME_KEY = "@geolocation_last_sync_time";
// const FORCE_SYNC_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const FORCE_SYNC_INTERVAL = 60 * 1000; // DEBUGGING
// Helper functions for persisting sync time
const getLastSyncTime = async () => {
try {
const value = await AsyncStorage.getItem(LAST_SYNC_TIME_KEY);
return value ? parseInt(value, 10) : Date.now();
} catch (error) {
Sentry.captureException(error, {
tags: { module: "headless-task", operation: "get-last-sync-time" },
});
return Date.now();
}
};
const setLastSyncTime = async (time) => {
try {
await AsyncStorage.setItem(LAST_SYNC_TIME_KEY, time.toString());
} catch (error) {
Sentry.captureException(error, {
tags: { module: "headless-task", operation: "set-last-sync-time" },
});
}
};
// this have to stay in index.js, see also https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode // this have to stay in index.js, see also https://github.com/transistorsoft/react-native-background-geolocation/wiki/Android-Headless-Mode
const getCurrentPosition = () => { const getCurrentPosition = () => {
return new Promise((resolve) => { return new Promise((resolve) => {
// Add timeout protection
const timeout = setTimeout(() => {
resolve({ code: -1, message: "getCurrentPosition timeout" });
}, 15000); // 15 second timeout
BackgroundGeolocation.getCurrentPosition( BackgroundGeolocation.getCurrentPosition(
{ {
samples: 1, samples: 1,
persist: true, persist: true,
extras: { background: true }, extras: { background: true },
timeout: 10, // 10 second timeout in the plugin itself
}, },
(location) => { (location) => {
clearTimeout(timeout);
resolve(location); resolve(location);
}, },
(error) => { (error) => {
clearTimeout(timeout);
resolve(error); resolve(error);
}, },
); );
}); });
}; };
const FORCE_SYNC_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
let lastSyncTime = Date.now();
const geolocBgLogger = createLogger({ const geolocBgLogger = createLogger({
service: "background-geolocation", service: "background-geolocation",
task: "headless", task: "headless",
}); });
const HeadlessTask = async (event) => { const HeadlessTask = async (event) => {
const { name, params } = event; // Add timeout protection for the entire headless task
geolocBgLogger.info("HeadlessTask event received", { name, params }); const taskTimeout = setTimeout(() => {
geolocBgLogger.error("HeadlessTask timeout", { event });
Sentry.captureException(new Error("HeadlessTask timeout"), {
tags: {
module: "background-geolocation",
operation: "headless-task-timeout",
eventName: event?.name,
},
});
}, 60000); // 60 second timeout
let transaction;
try { try {
// Validate event structure
if (!event || typeof event !== "object") {
throw new Error("Invalid event object received");
}
const { name, params } = event;
if (!name || typeof name !== "string") {
throw new Error("Invalid event name received");
}
// Start Sentry transaction for the entire HeadlessTask
transaction = Sentry.startTransaction({
name: "headless-task",
op: "background-task",
data: { eventName: name },
});
Sentry.getCurrentScope().setSpan(transaction);
// Add initial breadcrumb
Sentry.addBreadcrumb({
message: "HeadlessTask started",
category: "headless-task",
level: "info",
data: {
eventName: name,
params: params ? JSON.stringify(params) : null,
timestamp: Date.now(),
},
});
geolocBgLogger.info("HeadlessTask event received", { name, params });
switch (name) { switch (name) {
case "heartbeat": case "heartbeat":
// Check if we need to force a sync // Add breadcrumb for heartbeat event
Sentry.addBreadcrumb({
message: "Heartbeat event received",
category: "headless-task",
level: "info",
timestamp: Date.now() / 1000,
});
// Get persisted last sync time
const lastSyncTime = await getLastSyncTime();
const now = Date.now(); const now = Date.now();
const timeSinceLastSync = now - lastSyncTime; const timeSinceLastSync = now - lastSyncTime;
// Add context about sync timing
Sentry.setContext("sync-timing", {
lastSyncTime: new Date(lastSyncTime).toISOString(),
currentTime: new Date(now).toISOString(),
timeSinceLastSync: timeSinceLastSync,
timeSinceLastSyncHours: (
timeSinceLastSync /
(1000 * 60 * 60)
).toFixed(2),
needsForceSync: timeSinceLastSync >= FORCE_SYNC_INTERVAL,
});
Sentry.addBreadcrumb({
message: "Sync timing calculated",
category: "headless-task",
level: "info",
data: {
timeSinceLastSyncHours: (
timeSinceLastSync /
(1000 * 60 * 60)
).toFixed(2),
needsForceSync: timeSinceLastSync >= FORCE_SYNC_INTERVAL,
},
});
// Get current position
const locationSpan = transaction.startChild({
op: "get-current-position",
description: "Getting current position",
});
const location = await getCurrentPosition(); const location = await getCurrentPosition();
locationSpan.finish();
const isLocationError = location && location.code !== undefined;
Sentry.addBreadcrumb({
message: "getCurrentPosition completed",
category: "headless-task",
level: isLocationError ? "warning" : "info",
data: {
success: !isLocationError,
error: isLocationError ? location : undefined,
coords: !isLocationError ? location?.coords : undefined,
},
});
geolocBgLogger.debug("getCurrentPosition result", { location }); geolocBgLogger.debug("getCurrentPosition result", { location });
if (timeSinceLastSync >= FORCE_SYNC_INTERVAL) { if (timeSinceLastSync >= FORCE_SYNC_INTERVAL) {
geolocBgLogger.info("Forcing location sync after 24h"); geolocBgLogger.info("Forcing location sync after 24h");
// Update last sync time after successful sync
await BackgroundGeolocation.changePace(true); Sentry.addBreadcrumb({
await BackgroundGeolocation.sync(); message: "Force sync triggered",
lastSyncTime = now; category: "headless-task",
level: "info",
data: {
timeSinceLastSyncHours: (
timeSinceLastSync /
(1000 * 60 * 60)
).toFixed(2),
},
});
try {
// Get pending records count before sync with timeout
const pendingCount = await Promise.race([
BackgroundGeolocation.getCount(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("getCount timeout")), 10000),
),
]);
Sentry.addBreadcrumb({
message: "Pending records count",
category: "headless-task",
level: "info",
data: { pendingCount },
});
// Change pace to ensure location updates with timeout
const paceSpan = transaction.startChild({
op: "change-pace",
description: "Changing pace to true",
});
await Promise.race([
BackgroundGeolocation.changePace(true),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("changePace timeout")),
10000,
),
),
]);
paceSpan.finish();
Sentry.addBreadcrumb({
message: "changePace completed",
category: "headless-task",
level: "info",
});
// Perform sync with timeout
const syncSpan = transaction.startChild({
op: "sync-locations",
description: "Syncing locations",
});
const syncResult = await Promise.race([
BackgroundGeolocation.sync(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("sync timeout")), 20000),
),
]);
syncSpan.finish();
Sentry.addBreadcrumb({
message: "Sync completed successfully",
category: "headless-task",
level: "info",
data: {
syncResult: Array.isArray(syncResult)
? `${syncResult.length} records`
: "completed",
},
});
// Update last sync time after successful sync
await setLastSyncTime(now);
Sentry.addBreadcrumb({
message: "Last sync time updated",
category: "headless-task",
level: "info",
data: { newSyncTime: new Date(now).toISOString() },
});
} catch (syncError) {
Sentry.captureException(syncError, {
tags: {
module: "headless-task",
operation: "force-sync",
eventName: name,
},
contexts: {
syncAttempt: {
timeSinceLastSync: timeSinceLastSync,
lastSyncTime: new Date(lastSyncTime).toISOString(),
},
},
});
geolocBgLogger.error("Force sync failed", { error: syncError });
}
} else {
Sentry.addBreadcrumb({
message: "Force sync not needed",
category: "headless-task",
level: "info",
data: {
timeSinceLastSyncHours: (
timeSinceLastSync /
(1000 * 60 * 60)
).toFixed(2),
nextSyncInHours: (
(FORCE_SYNC_INTERVAL - timeSinceLastSync) /
(1000 * 60 * 60)
).toFixed(2),
},
});
} }
break; break;
case "location": case "location":
// Validate location parameters
if (!params || typeof params !== "object") {
geolocBgLogger.warn("Invalid location params", { params });
break;
}
Sentry.addBreadcrumb({
message: "Location update received",
category: "headless-task",
level: "info",
data: {
coords: params.location?.coords,
activity: params.location?.activity,
hasLocation: !!params.location,
},
});
geolocBgLogger.debug("Location update received", { geolocBgLogger.debug("Location update received", {
location: params.location, location: params.location,
}); });
break; break;
case "http": case "http":
// Validate HTTP parameters
if (!params || typeof params !== "object" || !params.response) {
geolocBgLogger.warn("Invalid HTTP params", { params });
break;
}
const httpStatus = params.response?.status;
const isHttpSuccess = httpStatus === 200;
Sentry.addBreadcrumb({
message: "HTTP response received",
category: "headless-task",
level: isHttpSuccess ? "info" : "warning",
data: {
status: httpStatus,
success: params.response?.success,
hasResponse: !!params.response,
},
});
geolocBgLogger.debug("HTTP response received", { geolocBgLogger.debug("HTTP response received", {
response: params.response, response: params.response,
}); });
// Update last sync time on successful HTTP response // Update last sync time on successful HTTP response
if (params.response?.status === 200) { if (isHttpSuccess) {
lastSyncTime = Date.now(); 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",
},
});
}
} }
break; break;
default:
Sentry.addBreadcrumb({
message: "Unknown event type",
category: "headless-task",
level: "warning",
data: { eventName: name },
});
}
// Finish transaction successfully
if (transaction) {
transaction.setStatus("ok");
} }
} catch (error) { } catch (error) {
geolocBgLogger.error("HeadlessTask error", { error }); // Capture any unexpected errors
Sentry.captureException(error, {
tags: {
module: "headless-task",
eventName: event?.name || "unknown",
},
});
geolocBgLogger.error("HeadlessTask error", { error, event });
// Mark transaction as failed
if (transaction) {
transaction.setStatus("internal_error");
}
} finally {
// Clear the timeout
clearTimeout(taskTimeout);
// Always finish the transaction
if (transaction) {
transaction.finish();
}
geolocBgLogger.debug("HeadlessTask completed", { event: event?.name });
} }
}; };
BackgroundGeolocation.registerHeadlessTask(HeadlessTask); let headlessTaskRegistered = false;
if (!headlessTaskRegistered) {
BackgroundGeolocation.registerHeadlessTask(HeadlessTask);
headlessTaskRegistered = true;
}

View file

@ -174,6 +174,7 @@
"react-native-app-link": "^1.0.1", "react-native-app-link": "^1.0.1",
"react-native-background-fetch": "^4.2.7", "react-native-background-fetch": "^4.2.7",
"react-native-background-geolocation": "^4.18.6", "react-native-background-geolocation": "^4.18.6",
"react-native-battery-optimization-check": "^1.0.8",
"react-native-contact-pick": "^0.1.2", "react-native-contact-pick": "^0.1.2",
"react-native-country-picker-modal": "^2.0.0", "react-native-country-picker-modal": "^2.0.0",
"react-native-device-country": "^1.0.5", "react-native-device-country": "^1.0.5",
@ -276,4 +277,4 @@
} }
}, },
"packageManager": "yarn@4.5.3" "packageManager": "yarn@4.5.3"
} }

View file

@ -2,6 +2,10 @@ import React, { useState, useCallback, useEffect } from "react";
import { View, StyleSheet, Image, ScrollView, Platform } from "react-native"; import { View, StyleSheet, Image, ScrollView, Platform } from "react-native";
import { Title } from "react-native-paper"; import { Title } from "react-native-paper";
import { Ionicons, Entypo } from "@expo/vector-icons"; import { Ionicons, Entypo } from "@expo/vector-icons";
import {
RequestDisableOptimization,
BatteryOptEnabled,
} from "react-native-battery-optimization-check";
import { import {
permissionsActions, permissionsActions,
usePermissionsState, usePermissionsState,
@ -30,6 +34,9 @@ const HeroMode = () => {
const [requesting, setRequesting] = useState(false); const [requesting, setRequesting] = useState(false);
const [hasAttempted, setHasAttempted] = useState(false); const [hasAttempted, setHasAttempted] = useState(false);
const [hasRetried, setHasRetried] = useState(false); const [hasRetried, setHasRetried] = useState(false);
const [batteryOptimizationEnabled, setBatteryOptimizationEnabled] =
useState(null);
const [batteryOptAttempted, setBatteryOptAttempted] = useState(false);
const permissions = usePermissionsState(["locationBackground", "motion"]); const permissions = usePermissionsState(["locationBackground", "motion"]);
const theme = useTheme(); const theme = useTheme();
@ -60,8 +67,24 @@ const HeroMode = () => {
const locationGranted = await requestPermissionLocationBackground(); const locationGranted = await requestPermissionLocationBackground();
permissionsActions.setLocationBackground(locationGranted); permissionsActions.setLocationBackground(locationGranted);
// If both granted, move to success // Check and request battery optimization disable (Android only)
if (locationGranted && motionGranted) { let batteryOptDisabled = true;
if (Platform.OS === "android") {
try {
const isEnabled = await BatteryOptEnabled();
setBatteryOptimizationEnabled(isEnabled);
if (isEnabled) {
RequestDisableOptimization();
batteryOptDisabled = false;
}
setBatteryOptAttempted(true);
} catch (error) {
console.error("Error checking battery optimization:", error);
}
}
// If all permissions granted and battery optimization handled, move to success
if (locationGranted && motionGranted && batteryOptDisabled) {
permissionWizardActions.setHeroPermissionsGranted(true); permissionWizardActions.setHeroPermissionsGranted(true);
permissionWizardActions.setCurrentStep("success"); permissionWizardActions.setCurrentStep("success");
} }
@ -73,11 +96,23 @@ const HeroMode = () => {
}, []); }, []);
const handleRetry = useCallback(async () => { const handleRetry = useCallback(async () => {
// Re-check battery optimization status before retrying
if (Platform.OS === "android") {
try {
const isEnabled = await BatteryOptEnabled();
setBatteryOptimizationEnabled(isEnabled);
} catch (error) {
console.error("Error re-checking battery optimization:", error);
}
}
await handleRequestPermissions(); await handleRequestPermissions();
setHasRetried(true); setHasRetried(true);
}, [handleRequestPermissions]); }, [handleRequestPermissions]);
const allGranted = permissions.locationBackground && permissions.motion; const allGranted =
permissions.locationBackground &&
permissions.motion &&
(Platform.OS === "ios" || !batteryOptimizationEnabled);
useEffect(() => { useEffect(() => {
if (hasAttempted && allGranted) { if (hasAttempted && allGranted) {
@ -99,6 +134,15 @@ const HeroMode = () => {
"Sans la localisation en arrière-plan, vous ne pourrez pas être alerté des situations d'urgence à proximité lorsque l'application est fermée.", "Sans la localisation en arrière-plan, vous ne pourrez pas être alerté des situations d'urgence à proximité lorsque l'application est fermée.",
); );
} }
if (
Platform.OS === "android" &&
batteryOptimizationEnabled &&
batteryOptAttempted
) {
warnings.push(
"L'optimisation de la batterie est encore activée. L'application pourrait ne pas fonctionner correctement en arrière-plan.",
);
}
return warnings.length > 0 ? ( return warnings.length > 0 ? (
<View style={styles.warningsContainer}> <View style={styles.warningsContainer}>
{warnings.map((warning, index) => ( {warnings.map((warning, index) => (
@ -140,6 +184,22 @@ const HeroMode = () => {
version d'Android) version d'Android)
</Text> </Text>
</View> </View>
{batteryOptimizationEnabled && batteryOptAttempted && (
<View style={styles.androidWarningSteps}>
<Text style={styles.androidWarningText}>
Pour désactiver l'optimisation de la batterie :
</Text>
<Text style={styles.androidWarningStep}>
4. Recherchez "Batterie" ou "Optimisation de la batterie"
</Text>
<Text style={styles.androidWarningStep}>
5. Trouvez cette application dans la liste
</Text>
<Text style={styles.androidWarningStep}>
6. Sélectionnez "Ne pas optimiser" ou "Désactiver l'optimisation"
</Text>
</View>
)}
<CustomButton <CustomButton
mode="outlined" mode="outlined"
onPress={openSettings} onPress={openSettings}
@ -295,6 +355,20 @@ const HeroMode = () => {
donnée de mouvement n'est stockée ni transmise. donnée de mouvement n'est stockée ni transmise.
</Text> </Text>
</View> </View>
{Platform.OS === "android" && (
<View style={styles.permissionItem}>
<Ionicons
name="battery-charging"
size={24}
style={styles.icon}
/>
<Text style={styles.permissionText}>
Optimisation de la batterie : désactiver l'optimisation de
la batterie pour cette application afin qu'elle puisse
fonctionner correctement en arrière-plan.
</Text>
</View>
)}
</View> </View>
</View> </View>

View file

@ -4,6 +4,7 @@ import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; 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 * as Sentry from "@sentry/react-native";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
@ -32,6 +33,7 @@ const config = {
locationAuthorizationRequest: "Always", locationAuthorizationRequest: "Always",
stopOnTerminate: false, stopOnTerminate: false,
startOnBoot: true, startOnBoot: true,
heartbeatInterval: 60, // DEBUGGING
// Force the plugin to start aggressively // Force the plugin to start aggressively
foregroundService: true, foregroundService: true,
notification: { notification: {
@ -167,6 +169,20 @@ export default async function trackLocation() {
activity: location.activity, activity: location.activity,
battery: location.battery, battery: location.battery,
}); });
// Add Sentry breadcrumb for location updates
Sentry.addBreadcrumb({
message: "Location update in trackLocation",
category: "geolocation",
level: "info",
data: {
coords: location.coords,
activity: location.activity?.type,
battery: location.battery?.level,
isMoving: location.isMoving,
},
});
if ( if (
location.coords && location.coords &&
location.coords.latitude && location.coords.latitude &&
@ -203,6 +219,20 @@ export default async function trackLocation() {
response?.request?.headers || "Headers not available in response", 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 // Log the current auth token for comparison
const { userToken } = getAuthState(); const { userToken } = getAuthState();
locationLogger.debug("Current auth state token", { locationLogger.debug("Current auth state token", {
@ -216,6 +246,11 @@ export default async function trackLocation() {
case 410: case 410:
// Token expired, logout // Token expired, logout
locationLogger.info("Auth token expired (410), logging out"); locationLogger.info("Auth token expired (410), logging out");
Sentry.addBreadcrumb({
message: "Auth token expired - logging out",
category: "geolocation-auth",
level: "warning",
});
authActions.logout(); authActions.logout();
break; break;
case 401: case 401:
@ -233,6 +268,16 @@ export default async function trackLocation() {
errorMessage: errorBody?.error?.message, errorMessage: errorBody?.error?.message,
errorPath: errorBody?.error?.errors?.[0]?.path, 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) { } catch (e) {
locationLogger.debug("Failed to parse error response", { locationLogger.debug("Failed to parse error response", {
error: e.message, error: e.message,
@ -291,8 +336,21 @@ export default async function trackLocation() {
const count = await BackgroundGeolocation.getCount(); const count = await BackgroundGeolocation.getCount();
locationLogger.debug("Pending location records", { count }); locationLogger.debug("Pending location records", { count });
Sentry.addBreadcrumb({
message: "Checking pending location records",
category: "geolocation",
level: "info",
data: { pendingCount: count },
});
if (count > 0) { if (count > 0) {
locationLogger.info(`Found ${count} pending records, forcing sync`); locationLogger.info(`Found ${count} pending records, forcing sync`);
const transaction = Sentry.startTransaction({
name: "force-sync-pending-records",
op: "geolocation-sync",
});
try { try {
const { userToken } = getAuthState(); const { userToken } = getAuthState();
const state = await BackgroundGeolocation.getState(); const state = await BackgroundGeolocation.getState();
@ -301,18 +359,64 @@ export default async function trackLocation() {
locationLogger.debug("Forced sync result", { locationLogger.debug("Forced sync result", {
recordsCount: records?.length || 0, recordsCount: records?.length || 0,
}); });
Sentry.addBreadcrumb({
message: "Forced sync completed",
category: "geolocation",
level: "info",
data: {
recordsCount: records?.length || 0,
hadToken: true,
wasEnabled: true,
},
});
transaction.setStatus("ok");
} else {
Sentry.addBreadcrumb({
message: "Forced sync skipped",
category: "geolocation",
level: "warning",
data: {
hasToken: !!userToken,
isEnabled: state.enabled,
},
});
transaction.setStatus("cancelled");
} }
} catch (error) { } catch (error) {
locationLogger.error("Forced sync failed", { locationLogger.error("Forced sync failed", {
error: error, error: error,
stack: error.stack, stack: error.stack,
}); });
Sentry.captureException(error, {
tags: {
module: "track-location",
operation: "force-sync-pending",
},
contexts: {
pendingRecords: { count },
},
});
transaction.setStatus("internal_error");
} finally {
transaction.finish();
} }
} }
} catch (error) { } catch (error) {
locationLogger.error("Failed to get pending records count", { locationLogger.error("Failed to get pending records count", {
error: error.message, error: error.message,
}); });
Sentry.captureException(error, {
tags: {
module: "track-location",
operation: "check-pending-records",
},
});
} }
} }

View file

@ -1,17 +1,186 @@
import notifee from "@notifee/react-native"; import notifee from "@notifee/react-native";
import BackgroundFetch from "react-native-background-fetch"; import BackgroundFetch from "react-native-background-fetch";
import * as Sentry from "@sentry/react-native";
import useMount from "~/hooks/useMount"; import useMount from "~/hooks/useMount";
import { createLogger } from "~/lib/logger";
const logger = createLogger({
service: "notifications",
task: "auto-cancel-expired",
});
// Background task to cancel expired notifications // Background task to cancel expired notifications
const backgroundTask = async () => { const backgroundTask = async () => {
const notifications = await notifee.getDisplayedNotifications(); const transaction = Sentry.startTransaction({
const currentTime = Math.round(new Date() / 1000); name: "auto-cancel-expired-notifications",
for (const notification of notifications) { op: "background-task",
const expires = notification.data?.expires; });
if (expires && expires < currentTime) {
await notifee.cancelNotification(notification.id); Sentry.getCurrentScope().setSpan(transaction);
try {
logger.info("Starting auto-cancel expired notifications task");
Sentry.addBreadcrumb({
message: "Auto-cancel task started",
category: "notifications",
level: "info",
});
// Get displayed notifications with timeout protection
const getNotificationsSpan = transaction.startChild({
op: "get-displayed-notifications",
description: "Getting displayed notifications",
});
let notifications;
try {
// Add timeout protection for the API call
notifications = await Promise.race([
notifee.getDisplayedNotifications(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Timeout getting notifications")),
10000,
),
),
]);
getNotificationsSpan.setStatus("ok");
} catch (error) {
getNotificationsSpan.setStatus("internal_error");
throw error;
} finally {
getNotificationsSpan.finish();
} }
if (!Array.isArray(notifications)) {
logger.warn("No notifications array received", { notifications });
Sentry.addBreadcrumb({
message: "No notifications array received",
category: "notifications",
level: "warning",
});
return;
}
const currentTime = Math.round(new Date() / 1000);
let cancelledCount = 0;
let errorCount = 0;
logger.info("Processing notifications", {
totalNotifications: notifications.length,
currentTime,
});
Sentry.addBreadcrumb({
message: "Processing notifications",
category: "notifications",
level: "info",
data: {
totalNotifications: notifications.length,
currentTime,
},
});
// Process notifications with individual error handling
for (const notification of notifications) {
try {
if (!notification || !notification.id) {
logger.warn("Invalid notification object", { notification });
continue;
}
const expires = notification.data?.expires;
if (!expires) {
continue; // Skip notifications without expiry
}
if (typeof expires !== "number" || expires < currentTime) {
logger.debug("Cancelling expired notification", {
notificationId: notification.id,
expires,
currentTime,
expired: expires < currentTime,
});
// Cancel notification with timeout protection
await Promise.race([
notifee.cancelNotification(notification.id),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Timeout cancelling notification")),
5000,
),
),
]);
cancelledCount++;
Sentry.addBreadcrumb({
message: "Notification cancelled",
category: "notifications",
level: "info",
data: {
notificationId: notification.id,
expires,
},
});
}
} catch (notificationError) {
errorCount++;
logger.error("Failed to process notification", {
error: notificationError,
notificationId: notification?.id,
});
Sentry.captureException(notificationError, {
tags: {
module: "auto-cancel-expired",
operation: "cancel-notification",
},
contexts: {
notification: {
id: notification?.id,
expires: notification?.data?.expires,
},
},
});
}
}
logger.info("Auto-cancel task completed", {
totalNotifications: notifications.length,
cancelledCount,
errorCount,
});
Sentry.addBreadcrumb({
message: "Auto-cancel task completed",
category: "notifications",
level: "info",
data: {
totalNotifications: notifications.length,
cancelledCount,
errorCount,
},
});
transaction.setStatus("ok");
} catch (error) {
logger.error("Auto-cancel task failed", { error });
Sentry.captureException(error, {
tags: {
module: "auto-cancel-expired",
operation: "background-task",
},
});
transaction.setStatus("internal_error");
throw error; // Re-throw to be handled by caller
} finally {
transaction.finish();
} }
}; };
@ -27,12 +196,61 @@ export const useAutoCancelExpired = () => {
enableHeadless: true, enableHeadless: true,
}, },
async (taskId) => { async (taskId) => {
console.log("[BackgroundFetch] taskId:", taskId); logger.info("BackgroundFetch task started", { taskId });
await backgroundTask();
BackgroundFetch.finish(taskId); try {
await backgroundTask();
logger.info("BackgroundFetch task completed successfully", {
taskId,
});
} catch (error) {
logger.error("BackgroundFetch task failed", { taskId, error });
Sentry.captureException(error, {
tags: {
module: "auto-cancel-expired",
operation: "background-fetch-task",
taskId,
},
});
} finally {
// CRITICAL: Always call finish, even on error
try {
if (taskId) {
BackgroundFetch.finish(taskId);
logger.debug("BackgroundFetch task finished", { taskId });
} else {
logger.error("Cannot finish BackgroundFetch task - no taskId");
}
} catch (finishError) {
// This is a critical error - the native side might be in a bad state
logger.error("CRITICAL: BackgroundFetch.finish() failed", {
taskId,
error: finishError,
});
Sentry.captureException(finishError, {
tags: {
module: "auto-cancel-expired",
operation: "background-fetch-finish",
critical: true,
},
contexts: {
task: { taskId },
},
});
}
}
}, },
(error) => { (error) => {
console.log("[BackgroundFetch] failed to start", error); logger.error("BackgroundFetch failed to start", { error });
Sentry.captureException(error, {
tags: {
module: "auto-cancel-expired",
operation: "background-fetch-configure",
},
});
}, },
); );
return () => { return () => {
@ -43,7 +261,104 @@ export const useAutoCancelExpired = () => {
// Register headless task // Register headless task
BackgroundFetch.registerHeadlessTask(async (event) => { BackgroundFetch.registerHeadlessTask(async (event) => {
const taskId = event.taskId; const taskId = event?.taskId;
await backgroundTask();
BackgroundFetch.finish(taskId); logger.info("Headless task started", { taskId, event });
// Add timeout protection for the entire headless task
const taskTimeout = setTimeout(() => {
logger.error("Headless task timeout", { taskId });
Sentry.captureException(new Error("Headless task timeout"), {
tags: {
module: "auto-cancel-expired",
operation: "headless-task-timeout",
taskId,
},
});
// Force finish the task to prevent native side hanging
try {
if (taskId) {
BackgroundFetch.finish(taskId);
logger.debug("Headless task force-finished due to timeout", { taskId });
}
} catch (finishError) {
logger.error("CRITICAL: Failed to force-finish timed out headless task", {
taskId,
error: finishError,
});
Sentry.captureException(finishError, {
tags: {
module: "auto-cancel-expired",
operation: "headless-task-timeout-finish",
critical: true,
},
contexts: {
task: { taskId },
},
});
}
}, 30000); // 30 second timeout
try {
if (!taskId) {
throw new Error("No taskId provided in headless task event");
}
await backgroundTask();
logger.info("Headless task completed successfully", { taskId });
} catch (error) {
logger.error("Headless task failed", { taskId, error });
Sentry.captureException(error, {
tags: {
module: "auto-cancel-expired",
operation: "headless-task",
taskId,
},
contexts: {
event: {
taskId,
eventData: JSON.stringify(event),
},
},
});
} finally {
// Clear the timeout
clearTimeout(taskTimeout);
// CRITICAL: Always call finish, even on error
try {
if (taskId) {
BackgroundFetch.finish(taskId);
logger.debug("Headless task finished", { taskId });
} else {
logger.error("Cannot finish headless task - no taskId", { event });
}
} catch (finishError) {
// This is a critical error - the native side might be in a bad state
logger.error(
"CRITICAL: BackgroundFetch.finish() failed in headless task",
{
taskId,
error: finishError,
event,
},
);
Sentry.captureException(finishError, {
tags: {
module: "auto-cancel-expired",
operation: "headless-task-finish",
critical: true,
},
contexts: {
task: { taskId },
event: { eventData: JSON.stringify(event) },
},
});
}
}
}); });

View file

@ -6565,6 +6565,7 @@ __metadata:
react-native-app-link: "npm:^1.0.1" react-native-app-link: "npm:^1.0.1"
react-native-background-fetch: "npm:^4.2.7" react-native-background-fetch: "npm:^4.2.7"
react-native-background-geolocation: "npm:^4.18.6" react-native-background-geolocation: "npm:^4.18.6"
react-native-battery-optimization-check: "npm:^1.0.8"
react-native-clean-project: "npm:^4.0.3" react-native-clean-project: "npm:^4.0.3"
react-native-contact-pick: "npm:^0.1.2" react-native-contact-pick: "npm:^0.1.2"
react-native-country-picker-modal: "npm:^2.0.0" react-native-country-picker-modal: "npm:^2.0.0"
@ -15978,6 +15979,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-native-battery-optimization-check@npm:^1.0.8":
version: 1.0.8
resolution: "react-native-battery-optimization-check@npm:1.0.8"
peerDependencies:
react: "*"
react-native: ">=0.63.3"
checksum: 10/e79219cc9e9a7521b5dda2cf2f0b806f5f510aca7c62d8d654c84b07a829b19063fe4839bcedd90a5574b6c28b20a3388f0a62e1d9912310e77e5919521a563f
languageName: node
linkType: hard
"react-native-clean-project@npm:^4.0.3": "react-native-clean-project@npm:^4.0.3":
version: 4.0.3 version: 4.0.3
resolution: "react-native-clean-project@npm:4.0.3" resolution: "react-native-clean-project@npm:4.0.3"