fix(ios-headless): big wip leap

This commit is contained in:
Jo 2025-07-23 13:45:41 +02:00
parent a88f3bf6c7
commit 5461852ada
Signed by: devthejo
GPG key ID: 00CCA7A92B1D5351
12 changed files with 219 additions and 606 deletions

View file

@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [1.11.11](https://github.com/alerte-secours/as-app/compare/v1.11.10...v1.11.11) (2025-07-23)
## [1.11.10](https://github.com/alerte-secours/as-app/compare/v1.11.9...v1.11.10) (2025-07-22)
## [1.11.9](https://github.com/alerte-secours/as-app/compare/v1.11.8...v1.11.9) (2025-07-22)
## [1.11.8](https://github.com/alerte-secours/as-app/compare/v1.11.7...v1.11.8) (2025-07-22)
## [1.11.7](https://github.com/alerte-secours/as-app/compare/v1.11.6...v1.11.7) (2025-07-22)
## [1.11.6](https://github.com/alerte-secours/as-app/compare/v1.11.5...v1.11.6) (2025-07-21)
## [1.11.5](https://github.com/alerte-secours/as-app/compare/v1.11.4...v1.11.5) (2025-07-21)
## [1.11.4](https://github.com/alerte-secours/as-app/compare/v1.11.3...v1.11.4) (2025-07-20)
## [1.11.3](https://github.com/alerte-secours/as-app/compare/v1.11.2...v1.11.3) (2025-07-20)
## [1.11.2](https://github.com/alerte-secours/as-app/compare/v1.11.1...v1.11.2) (2025-07-19) ## [1.11.2](https://github.com/alerte-secours/as-app/compare/v1.11.1...v1.11.2) (2025-07-19)

View file

@ -83,8 +83,8 @@ android {
applicationId 'com.alertesecours' applicationId 'com.alertesecours'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 192 versionCode 201
versionName "1.11.2" versionName "1.11.11"
multiDexEnabled true multiDexEnabled true
testBuildType System.getProperty('testBuildType', 'debug') testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View file

@ -132,6 +132,7 @@ let config = {
"telprompt", "telprompt",
], ],
BGTaskSchedulerPermittedIdentifiers: [ BGTaskSchedulerPermittedIdentifiers: [
"com.transistorsoft",
"com.transistorsoft.fetch", "com.transistorsoft.fetch",
"com.transistorsoft.customtask", "com.transistorsoft.customtask",
], ],

282
index.js
View file

@ -5,6 +5,9 @@ import "./warnFilter";
import "expo-splash-screen"; import "expo-splash-screen";
import BackgroundGeolocation from "react-native-background-geolocation"; import BackgroundGeolocation from "react-native-background-geolocation";
import { Platform } from "react-native";
import BackgroundFetch from "react-native-background-fetch";
import notifee from "@notifee/react-native"; import notifee from "@notifee/react-native";
import messaging from "@react-native-firebase/messaging"; import messaging from "@react-native-firebase/messaging";
@ -18,9 +21,7 @@ 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 { executeHeartbeatSync } from "~/location/backgroundTask";
import { memoryAsyncStorage } from "~/storage/memoryAsyncStorage";
import { STORAGE_KEYS } from "~/storage/storageKeys";
// setup notification, this have to stay in index.js // setup notification, this have to stay in index.js
notifee.onBackgroundEvent(notificationBackgroundEvent); notifee.onBackgroundEvent(notificationBackgroundEvent);
@ -31,239 +32,82 @@ messaging().setBackgroundMessageHandler(onMessageReceived);
// the environment is set up appropriately // the environment is set up appropriately
registerRootComponent(App); registerRootComponent(App);
// Constants for persistence
const FORCE_SYNC_INTERVAL = 12 * 60 * 60 * 1000;
// const FORCE_SYNC_INTERVAL = 5 * 60 * 1000; // DEBUGGING
// Helper functions for persisting sync time
const getLastSyncTime = async () => {
try {
const value = await memoryAsyncStorage.getItem(
STORAGE_KEYS.GEOLOCATION_LAST_SYNC_TIME,
);
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 memoryAsyncStorage.setItem(
STORAGE_KEYS.GEOLOCATION_LAST_SYNC_TIME,
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 geolocBgLogger = createLogger({ const geolocBgLogger = createLogger({
service: "background-geolocation", service: "background-geolocation",
task: "headless", task: "headless",
}); });
const HeadlessTask = async (event) => { const HeadlessTask = async (event) => {
// 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
// Simple performance tracking without deprecated APIs
const taskStartTime = Date.now();
try { try {
// Validate event structure switch (event?.name) {
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");
}
geolocBgLogger.info("HeadlessTask event received", { name, params });
switch (name) {
case "heartbeat": case "heartbeat":
// Get persisted last sync time await executeHeartbeatSync();
const lastSyncTime = await getLastSyncTime();
const now = Date.now();
const timeSinceLastSync = now - lastSyncTime;
// Get current position with performance tracking
const location = await getCurrentPosition();
geolocBgLogger.debug("getCurrentPosition result", { location });
if (timeSinceLastSync >= FORCE_SYNC_INTERVAL) {
geolocBgLogger.info("Forcing location sync");
try {
// Change pace to ensure location updates with timeout
await Promise.race([
BackgroundGeolocation.changePace(true),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("changePace timeout")),
10000,
),
),
]);
// Perform sync with timeout
const syncResult = await Promise.race([
BackgroundGeolocation.sync(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("sync timeout")), 20000),
),
]);
// Update last sync time after successful sync
await setLastSyncTime(now);
} 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 });
}
}
break; break;
case "location":
// Validate location parameters
if (!params || typeof params !== "object") {
geolocBgLogger.warn("Invalid location params", { params });
break;
}
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;
geolocBgLogger.debug("HTTP response received", {
response: params.response,
});
// Update last sync time on successful HTTP response
if (isHttpSuccess) {
try {
const now = Date.now();
await setLastSyncTime(now);
} 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: default:
break; break;
} }
// Task completed successfully
const taskDuration = Date.now() - taskStartTime;
} catch (error) { } catch (error) {
const taskDuration = Date.now() - taskStartTime;
// Capture any unexpected errors
Sentry.captureException(error, {
tags: {
module: "headless-task",
eventName: event?.name || "unknown",
},
extra: {
duration: taskDuration,
},
});
geolocBgLogger.error("HeadlessTask error", { geolocBgLogger.error("HeadlessTask error", {
error, error,
event, event,
duration: taskDuration,
});
} finally {
// Clear the timeout
clearTimeout(taskTimeout);
const finalDuration = Date.now() - taskStartTime;
geolocBgLogger.debug("HeadlessTask completed", {
event: event?.name,
duration: finalDuration,
}); });
} }
}; };
BackgroundGeolocation.registerHeadlessTask(HeadlessTask); if (Platform.OS === "android") {
BackgroundGeolocation.registerHeadlessTask(HeadlessTask);
} else if (Platform.OS === "ios") {
BackgroundGeolocation.onLocation(async (_location) => {
await executeHeartbeatSync();
});
// Configure BackgroundFetch for iOS (iOS-specific configuration)
BackgroundFetch.configure(
{
minimumFetchInterval: 15, // Only valid option for iOS - gives best chance of execution
},
// Event callback
async (taskId) => {
let syncResult = null;
try {
// Execute the shared heartbeat logic and get result
syncResult = await executeHeartbeatSync();
} catch (error) {
// silent error
} finally {
// CRITICAL: Always call finish with appropriate result
try {
if (taskId) {
let fetchResult;
if (syncResult?.error || !syncResult?.syncSuccessful) {
// Task failed
fetchResult = BackgroundFetch.FETCH_RESULT_FAILED;
} else if (
syncResult?.syncPerformed &&
syncResult?.syncSuccessful
) {
// Force sync was performed successfully - new data
fetchResult = BackgroundFetch.FETCH_RESULT_NEW_DATA;
} else {
// No sync was needed - no new data
fetchResult = BackgroundFetch.FETCH_RESULT_NO_DATA;
}
BackgroundFetch.finish(taskId, fetchResult);
}
} catch (finishError) {
// silent error
}
}
},
// Timeout callback (REQUIRED by BackgroundFetch API)
async (taskId) => {
// CRITICAL: Must call finish on timeout with FAILED result
BackgroundFetch.finish(taskId, BackgroundFetch.FETCH_RESULT_FAILED);
},
).catch(() => {
// silent error
});
}

View file

@ -44,7 +44,7 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "1"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"

View file

@ -4,6 +4,7 @@
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<array> <array>
<string>com.transistorsoft</string>
<string>com.transistorsoft.fetch</string> <string>com.transistorsoft.fetch</string>
<string>com.transistorsoft.customtask</string> <string>com.transistorsoft.customtask</string>
</array> </array>
@ -24,7 +25,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.11.2</string> <string>1.11.11</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -47,7 +48,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>192</string> <string>201</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
@ -94,23 +95,14 @@
<dict> <dict>
<key>alertesecours.fr</key> <key>alertesecours.fr</key>
<dict> <dict>
<key>NSIncludesSubdomains</key>
<true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/> <false/>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSExceptionMinimumTLSVersion</key> <key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string> <string>TLSv1.0</string>
</dict> <key>NSExceptionRequiresForwardSecrecy</key>
<key>sentry.io</key> <false/>
<dict>
<key>NSIncludesSubdomains</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
</dict> </dict>
<key>localhost</key> <key>localhost</key>
<dict> <dict>
@ -119,6 +111,15 @@
<key>NSIncludesSubdomains</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
</dict> </dict>
<key>sentry.io</key>
<dict>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSExceptionRequiresForwardSecrecy</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict> </dict>
</dict> </dict>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
@ -150,6 +151,7 @@
<string>fetch</string> <string>fetch</string>
<string>location</string> <string>location</string>
<string>remote-notification</string> <string>remote-notification</string>
<string>processing</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>SplashScreen</string> <string>SplashScreen</string>

View file

@ -0,0 +1,22 @@
//
// RNBackgroundGeolocation+AppDelegate.m
// RNBackgroundGeolocationSample
//
// Created by Christopher Scott on 2016-08-01.
// Copyright © 2016 Facebook. All rights reserved.
//
#import <Foundation/Foundation.h>
#import "AppDelegate.h"
#import <TSBackgroundFetch/TSBackgroundFetch.h>
@implementation AppDelegate(AppDelegate)
-(void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
NSLog(@"RNBackgroundFetch AppDelegate received fetch event");
TSBackgroundFetch *fetchManager = [TSBackgroundFetch sharedInstance];
[fetchManager performFetchWithCompletionHandler:completionHandler applicationState:application.applicationState];
}
@end

View file

@ -1,6 +1,6 @@
{ {
"name": "alerte-secours", "name": "alerte-secours",
"version": "1.11.2", "version": "1.11.11",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "expo start --dev-client --private-key-path ./keys/private-key.pem", "start": "expo start --dev-client --private-key-path ./keys/private-key.pem",
@ -50,8 +50,8 @@
"screenshot:android": "scripts/screenshot-android.sh" "screenshot:android": "scripts/screenshot-android.sh"
}, },
"customExpoVersioning": { "customExpoVersioning": {
"versionCode": 192, "versionCode": 201,
"buildNumber": 192 "buildNumber": 201
}, },
"commit-and-tag-version": { "commit-and-tag-version": {
"scripts": { "scripts": {

View file

@ -0,0 +1,93 @@
import BackgroundGeolocation from "react-native-background-geolocation";
import { memoryAsyncStorage } from "~/storage/memoryAsyncStorage";
import { STORAGE_KEYS } from "~/storage/storageKeys";
import { createLogger } from "~/lib/logger";
// Constants for persistence
const FORCE_SYNC_INTERVAL = 12 * 60 * 60 * 1000;
// const FORCE_SYNC_INTERVAL = 5 * 60 * 1000; // DEBUGGING
const geolocBgLogger = createLogger({
service: "background-task",
task: "headless",
});
// Helper functions for persisting sync time
const getLastSyncTime = async () => {
try {
const value = await memoryAsyncStorage.getItem(
STORAGE_KEYS.GEOLOCATION_LAST_SYNC_TIME,
);
return value ? parseInt(value, 10) : Date.now();
} catch (error) {
return 0;
}
};
const setLastSyncTime = async (time) => {
try {
await memoryAsyncStorage.setItem(
STORAGE_KEYS.GEOLOCATION_LAST_SYNC_TIME,
time.toString(),
);
} catch (error) {
// silent error
}
};
// Shared heartbeat logic - mutualized between Android and iOS
const executeSync = async () => {
let syncPerformed = false;
let syncSuccessful = false;
try {
syncPerformed = true;
try {
// Change pace to ensure location updates
await BackgroundGeolocation.changePace(true);
// Perform sync
await BackgroundGeolocation.sync();
syncSuccessful = true;
} catch (syncError) {
syncSuccessful = false;
}
// Return result information for BackgroundFetch
return {
syncPerformed,
syncSuccessful,
};
} catch (error) {
// Return error result for BackgroundFetch
return {
syncPerformed,
syncSuccessful: false,
error: error.message,
};
}
};
export const executeHeartbeatSync = async () => {
const lastSyncTime = await getLastSyncTime();
const now = Date.now();
const timeSinceLastSync = now - lastSyncTime;
if (timeSinceLastSync >= FORCE_SYNC_INTERVAL) {
geolocBgLogger.info("Forcing location sync");
try {
await Promise.race([
async () => {
await executeSync();
},
new Promise((_, reject) =>
setTimeout(() => reject(new Error("changePace timeout")), 10000),
),
]);
await setLastSyncTime(now);
} catch (syncError) {
geolocBgLogger.error("Force sync failed", { error: syncError });
}
}
};

View file

@ -132,7 +132,7 @@ export default async function trackLocation() {
} }
} }
BackgroundGeolocation.onLocation((location) => { BackgroundGeolocation.onLocation(async (location) => {
locationLogger.debug("Location update received", { locationLogger.debug("Location update received", {
coords: location.coords, coords: location.coords,
timestamp: location.timestamp, timestamp: location.timestamp,

View file

@ -1,364 +0,0 @@
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 () => {
await Sentry.startSpan(
{
name: "auto-cancel-expired-notifications",
op: "background-task",
},
async (span) => {
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
let notifications;
await Sentry.startSpan(
{
op: "get-displayed-notifications",
description: "Getting displayed notifications",
},
async (getNotificationsSpan) => {
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;
}
},
);
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,
},
});
span.setStatus("ok");
} catch (error) {
logger.error("Auto-cancel task failed", { error });
Sentry.captureException(error, {
tags: {
module: "auto-cancel-expired",
operation: "background-task",
},
});
span.setStatus("internal_error");
throw error; // Re-throw to be handled by caller
}
},
);
};
export const useAutoCancelExpired = () => {
useMount(() => {
// Initialize background fetch
BackgroundFetch.configure(
{
minimumFetchInterval: 180, // Fetch interval in minutes
stopOnTerminate: false,
startOnBoot: true,
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_NONE,
enableHeadless: true,
},
async (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) => {
logger.error("BackgroundFetch failed to start", { error });
Sentry.captureException(error, {
tags: {
module: "auto-cancel-expired",
operation: "background-fetch-configure",
},
});
},
);
return () => {
BackgroundFetch.stop();
};
});
};
// Register headless task
BackgroundFetch.registerHeadlessTask(async (event) => {
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) },
},
});
}
}
});

View file

@ -15,7 +15,6 @@ import {
import useMount from "~/hooks/useMount"; import useMount from "~/hooks/useMount";
import setActionCategories from "./setActionCategories"; import setActionCategories from "./setActionCategories";
import onMessageReceived from "./onMessageReceived"; import onMessageReceived from "./onMessageReceived";
import { useAutoCancelExpired } from "./autoCancelExpired";
import { requestFcmPermission, setupFcm } from "./firebase"; import { requestFcmPermission, setupFcm } from "./firebase";
import { import {
requestNotifeePermission, requestNotifeePermission,
@ -204,6 +203,4 @@ export function useFcm() {
notifLogger.debug("Badge count reset"); notifLogger.debug("Badge count reset");
}); });
}); });
useAutoCancelExpired();
} }