From 6c290f21b4d57513aa90dabcc68e9a7b074a6430 Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 22 Jun 2025 11:18:59 +0200 Subject: [PATCH] fix(headless-task): wip --- app.config.js | 3 +- index.js | 380 +++++++++++++++++++- package.json | 3 +- src/containers/PermissionWizard/HeroMode.js | 80 ++++- src/location/trackLocation.js | 104 ++++++ src/notifications/autoCancelExpired.js | 341 +++++++++++++++++- yarn.lock | 11 + 7 files changed, 890 insertions(+), 32 deletions(-) diff --git a/app.config.js b/app.config.js index 58f4088..fe368c9 100644 --- a/app.config.js +++ b/app.config.js @@ -10,7 +10,8 @@ let config = { version, updates: { url: "https://expo-updates.alertesecours.fr/api/manifest?project=alerte-secours&channel=release", - enabled: true, + // enabled: true, + enabled: false, // DEBUGGING checkAutomatically: "ON_ERROR_RECOVERY", fallbackToCacheTimeout: 0, codeSigningCertificate: "./keys/certificate.pem", diff --git a/index.js b/index.js index 40bb55a..d30d4dc 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,8 @@ import { onBackgroundEvent as notificationBackgroundEvent } from "~/notification 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"; // setup notification, this have to stay in index.js notifee.onBackgroundEvent(notificationBackgroundEvent); @@ -28,73 +30,423 @@ messaging().setBackgroundMessageHandler(onMessageReceived); // the environment is set up appropriately 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 const getCurrentPosition = () => { return new Promise((resolve) => { + // Add timeout protection + const timeout = setTimeout(() => { + resolve({ code: -1, message: "getCurrentPosition timeout" }); + }, 15000); // 15 second timeout + BackgroundGeolocation.getCurrentPosition( { samples: 1, persist: true, extras: { background: true }, + timeout: 10, // 10 second timeout in the plugin itself }, (location) => { + clearTimeout(timeout); resolve(location); }, (error) => { + clearTimeout(timeout); resolve(error); }, ); }); }; -const FORCE_SYNC_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds -let lastSyncTime = Date.now(); - const geolocBgLogger = createLogger({ service: "background-geolocation", task: "headless", }); const HeadlessTask = async (event) => { - const { name, params } = event; - geolocBgLogger.info("HeadlessTask event received", { name, params }); + // Add timeout protection for the entire headless task + 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 { + // 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) { 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 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(); + 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 }); if (timeSinceLastSync >= FORCE_SYNC_INTERVAL) { geolocBgLogger.info("Forcing location sync after 24h"); - // Update last sync time after successful sync - await BackgroundGeolocation.changePace(true); - await BackgroundGeolocation.sync(); - lastSyncTime = now; + + Sentry.addBreadcrumb({ + message: "Force sync triggered", + 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; + 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", { location: params.location, }); break; + 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", { response: params.response, }); + // Update last sync time on successful HTTP response - if (params.response?.status === 200) { - lastSyncTime = Date.now(); + 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", + }, + }); + } } 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) { - 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; +} diff --git a/package.json b/package.json index 4b80e68..5b0ded9 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "react-native-app-link": "^1.0.1", "react-native-background-fetch": "^4.2.7", "react-native-background-geolocation": "^4.18.6", + "react-native-battery-optimization-check": "^1.0.8", "react-native-contact-pick": "^0.1.2", "react-native-country-picker-modal": "^2.0.0", "react-native-device-country": "^1.0.5", @@ -276,4 +277,4 @@ } }, "packageManager": "yarn@4.5.3" -} \ No newline at end of file +} diff --git a/src/containers/PermissionWizard/HeroMode.js b/src/containers/PermissionWizard/HeroMode.js index 8ff1beb..889f67f 100644 --- a/src/containers/PermissionWizard/HeroMode.js +++ b/src/containers/PermissionWizard/HeroMode.js @@ -2,6 +2,10 @@ import React, { useState, useCallback, useEffect } from "react"; import { View, StyleSheet, Image, ScrollView, Platform } from "react-native"; import { Title } from "react-native-paper"; import { Ionicons, Entypo } from "@expo/vector-icons"; +import { + RequestDisableOptimization, + BatteryOptEnabled, +} from "react-native-battery-optimization-check"; import { permissionsActions, usePermissionsState, @@ -30,6 +34,9 @@ const HeroMode = () => { const [requesting, setRequesting] = useState(false); const [hasAttempted, setHasAttempted] = useState(false); const [hasRetried, setHasRetried] = useState(false); + const [batteryOptimizationEnabled, setBatteryOptimizationEnabled] = + useState(null); + const [batteryOptAttempted, setBatteryOptAttempted] = useState(false); const permissions = usePermissionsState(["locationBackground", "motion"]); const theme = useTheme(); @@ -60,8 +67,24 @@ const HeroMode = () => { const locationGranted = await requestPermissionLocationBackground(); permissionsActions.setLocationBackground(locationGranted); - // If both granted, move to success - if (locationGranted && motionGranted) { + // Check and request battery optimization disable (Android only) + 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.setCurrentStep("success"); } @@ -73,11 +96,23 @@ const HeroMode = () => { }, []); 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(); setHasRetried(true); }, [handleRequestPermissions]); - const allGranted = permissions.locationBackground && permissions.motion; + const allGranted = + permissions.locationBackground && + permissions.motion && + (Platform.OS === "ios" || !batteryOptimizationEnabled); useEffect(() => { 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.", ); } + 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 ? ( {warnings.map((warning, index) => ( @@ -140,6 +184,22 @@ const HeroMode = () => { version d'Android) + {batteryOptimizationEnabled && batteryOptAttempted && ( + + + Pour désactiver l'optimisation de la batterie : + + + 4. Recherchez "Batterie" ou "Optimisation de la batterie" + + + 5. Trouvez cette application dans la liste + + + 6. Sélectionnez "Ne pas optimiser" ou "Désactiver l'optimisation" + + + )} { donnée de mouvement n'est stockée ni transmise. + {Platform.OS === "android" && ( + + + + Optimisation de la batterie : désactiver l'optimisation de + la batterie pour cette application afin qu'elle puisse + fonctionner correctement en arrière-plan. + + + )} diff --git a/src/location/trackLocation.js b/src/location/trackLocation.js index 7d6e94f..e6e63db 100644 --- a/src/location/trackLocation.js +++ b/src/location/trackLocation.js @@ -4,6 +4,7 @@ import { createLogger } from "~/lib/logger"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; import jwtDecode from "jwt-decode"; import { initEmulatorMode } from "./emulatorService"; +import * as Sentry from "@sentry/react-native"; import throttle from "lodash.throttle"; @@ -32,6 +33,7 @@ const config = { locationAuthorizationRequest: "Always", stopOnTerminate: false, startOnBoot: true, + heartbeatInterval: 60, // DEBUGGING // Force the plugin to start aggressively foregroundService: true, notification: { @@ -167,6 +169,20 @@ export default async function trackLocation() { activity: location.activity, 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 ( location.coords && location.coords.latitude && @@ -203,6 +219,20 @@ export default async function trackLocation() { 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", { @@ -216,6 +246,11 @@ export default async function trackLocation() { case 410: // 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: @@ -233,6 +268,16 @@ export default async function trackLocation() { 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, @@ -291,8 +336,21 @@ export default async function trackLocation() { const count = await BackgroundGeolocation.getCount(); locationLogger.debug("Pending location records", { count }); + Sentry.addBreadcrumb({ + message: "Checking pending location records", + category: "geolocation", + level: "info", + data: { pendingCount: count }, + }); + if (count > 0) { locationLogger.info(`Found ${count} pending records, forcing sync`); + + const transaction = Sentry.startTransaction({ + name: "force-sync-pending-records", + op: "geolocation-sync", + }); + try { const { userToken } = getAuthState(); const state = await BackgroundGeolocation.getState(); @@ -301,18 +359,64 @@ export default async function trackLocation() { locationLogger.debug("Forced sync result", { 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) { locationLogger.error("Forced sync failed", { error: error, 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) { locationLogger.error("Failed to get pending records count", { error: error.message, }); + + Sentry.captureException(error, { + tags: { + module: "track-location", + operation: "check-pending-records", + }, + }); } } diff --git a/src/notifications/autoCancelExpired.js b/src/notifications/autoCancelExpired.js index 4dd4553..e59f431 100644 --- a/src/notifications/autoCancelExpired.js +++ b/src/notifications/autoCancelExpired.js @@ -1,17 +1,186 @@ import notifee from "@notifee/react-native"; import BackgroundFetch from "react-native-background-fetch"; +import * as Sentry from "@sentry/react-native"; 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 const backgroundTask = async () => { - const notifications = await notifee.getDisplayedNotifications(); - const currentTime = Math.round(new Date() / 1000); - for (const notification of notifications) { - const expires = notification.data?.expires; - if (expires && expires < currentTime) { - await notifee.cancelNotification(notification.id); + const transaction = Sentry.startTransaction({ + name: "auto-cancel-expired-notifications", + op: "background-task", + }); + + 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, }, async (taskId) => { - console.log("[BackgroundFetch] taskId:", taskId); - await backgroundTask(); - BackgroundFetch.finish(taskId); + logger.info("BackgroundFetch task started", { 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) => { - 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 () => { @@ -43,7 +261,104 @@ export const useAutoCancelExpired = () => { // Register headless task BackgroundFetch.registerHeadlessTask(async (event) => { - const taskId = event.taskId; - await backgroundTask(); - BackgroundFetch.finish(taskId); + const taskId = event?.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) }, + }, + }); + } + } }); diff --git a/yarn.lock b/yarn.lock index 73e98b8..6e3f929 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6565,6 +6565,7 @@ __metadata: react-native-app-link: "npm:^1.0.1" react-native-background-fetch: "npm:^4.2.7" 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-contact-pick: "npm:^0.1.2" react-native-country-picker-modal: "npm:^2.0.0" @@ -15978,6 +15979,16 @@ __metadata: languageName: node 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": version: 4.0.3 resolution: "react-native-clean-project@npm:4.0.3"