fix(headless-task): wip
This commit is contained in:
parent
ac84ae707b
commit
6c290f21b4
7 changed files with 890 additions and 32 deletions
|
@ -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
380
index.js
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Add table
Reference in a new issue