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"