fix(ios-headless): big wip leap
This commit is contained in:
parent
a88f3bf6c7
commit
5461852ada
12 changed files with 219 additions and 606 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -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.
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
|
|
@ -83,8 +83,8 @@ android {
|
|||
applicationId 'com.alertesecours'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 192
|
||||
versionName "1.11.2"
|
||||
versionCode 201
|
||||
versionName "1.11.11"
|
||||
multiDexEnabled true
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
|
|
@ -132,6 +132,7 @@ let config = {
|
|||
"telprompt",
|
||||
],
|
||||
BGTaskSchedulerPermittedIdentifiers: [
|
||||
"com.transistorsoft",
|
||||
"com.transistorsoft.fetch",
|
||||
"com.transistorsoft.customtask",
|
||||
],
|
||||
|
|
282
index.js
282
index.js
|
@ -5,6 +5,9 @@ import "./warnFilter";
|
|||
import "expo-splash-screen";
|
||||
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 messaging from "@react-native-firebase/messaging";
|
||||
|
||||
|
@ -18,9 +21,7 @@ import { onBackgroundEvent as notificationBackgroundEvent } from "~/notification
|
|||
import onMessageReceived from "~/notifications/onMessageReceived";
|
||||
|
||||
import { createLogger } from "~/lib/logger";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { memoryAsyncStorage } from "~/storage/memoryAsyncStorage";
|
||||
import { STORAGE_KEYS } from "~/storage/storageKeys";
|
||||
import { executeHeartbeatSync } from "~/location/backgroundTask";
|
||||
|
||||
// setup notification, this have to stay in index.js
|
||||
notifee.onBackgroundEvent(notificationBackgroundEvent);
|
||||
|
@ -31,239 +32,82 @@ messaging().setBackgroundMessageHandler(onMessageReceived);
|
|||
// the environment is set up appropriately
|
||||
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({
|
||||
service: "background-geolocation",
|
||||
task: "headless",
|
||||
});
|
||||
|
||||
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 {
|
||||
// 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");
|
||||
}
|
||||
|
||||
geolocBgLogger.info("HeadlessTask event received", { name, params });
|
||||
|
||||
switch (name) {
|
||||
switch (event?.name) {
|
||||
case "heartbeat":
|
||||
// Get persisted last sync time
|
||||
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 });
|
||||
}
|
||||
}
|
||||
await executeHeartbeatSync();
|
||||
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:
|
||||
break;
|
||||
}
|
||||
|
||||
// Task completed successfully
|
||||
const taskDuration = Date.now() - taskStartTime;
|
||||
} 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", {
|
||||
error,
|
||||
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
|
||||
});
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@
|
|||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
launchStyle = "1"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.transistorsoft</string>
|
||||
<string>com.transistorsoft.fetch</string>
|
||||
<string>com.transistorsoft.customtask</string>
|
||||
</array>
|
||||
|
@ -24,7 +25,7 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.11.2</string>
|
||||
<string>1.11.11</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
@ -47,7 +48,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>192</string>
|
||||
<string>201</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
|
@ -94,23 +95,14 @@
|
|||
<dict>
|
||||
<key>alertesecours.fr</key>
|
||||
<dict>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<false/>
|
||||
<key>NSExceptionRequiresForwardSecrecy</key>
|
||||
<false/>
|
||||
<key>NSExceptionMinimumTLSVersion</key>
|
||||
<string>TLSv1.0</string>
|
||||
</dict>
|
||||
<key>sentry.io</key>
|
||||
<dict>
|
||||
<key>NSExceptionRequiresForwardSecrecy</key>
|
||||
<false/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
<key>NSExceptionRequiresForwardSecrecy</key>
|
||||
<false/>
|
||||
<key>NSExceptionMinimumTLSVersion</key>
|
||||
<string>TLSv1.0</string>
|
||||
</dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
|
@ -119,6 +111,15 @@
|
|||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>sentry.io</key>
|
||||
<dict>
|
||||
<key>NSExceptionMinimumTLSVersion</key>
|
||||
<string>TLSv1.0</string>
|
||||
<key>NSExceptionRequiresForwardSecrecy</key>
|
||||
<false/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
|
@ -150,6 +151,7 @@
|
|||
<string>fetch</string>
|
||||
<string>location</string>
|
||||
<string>remote-notification</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
|
|
22
ios/RNBackgroundFetch+AppDelegate.m
Normal file
22
ios/RNBackgroundFetch+AppDelegate.m
Normal 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
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "alerte-secours",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.11",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start --dev-client --private-key-path ./keys/private-key.pem",
|
||||
|
@ -50,8 +50,8 @@
|
|||
"screenshot:android": "scripts/screenshot-android.sh"
|
||||
},
|
||||
"customExpoVersioning": {
|
||||
"versionCode": 192,
|
||||
"buildNumber": 192
|
||||
"versionCode": 201,
|
||||
"buildNumber": 201
|
||||
},
|
||||
"commit-and-tag-version": {
|
||||
"scripts": {
|
||||
|
|
93
src/location/backgroundTask.js
Normal file
93
src/location/backgroundTask.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
};
|
|
@ -132,7 +132,7 @@ export default async function trackLocation() {
|
|||
}
|
||||
}
|
||||
|
||||
BackgroundGeolocation.onLocation((location) => {
|
||||
BackgroundGeolocation.onLocation(async (location) => {
|
||||
locationLogger.debug("Location update received", {
|
||||
coords: location.coords,
|
||||
timestamp: location.timestamp,
|
||||
|
|
|
@ -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) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
|
@ -15,7 +15,6 @@ import {
|
|||
import useMount from "~/hooks/useMount";
|
||||
import setActionCategories from "./setActionCategories";
|
||||
import onMessageReceived from "./onMessageReceived";
|
||||
import { useAutoCancelExpired } from "./autoCancelExpired";
|
||||
import { requestFcmPermission, setupFcm } from "./firebase";
|
||||
import {
|
||||
requestNotifeePermission,
|
||||
|
@ -204,6 +203,4 @@ export function useFcm() {
|
|||
notifLogger.debug("Badge count reset");
|
||||
});
|
||||
});
|
||||
|
||||
useAutoCancelExpired();
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue