Compare commits

...

29 commits

Author SHA1 Message Date
236121a73c
chore(release): 1.12.3 2025-09-05 11:46:26 +02:00
cbd1803dc0
fix(android): battery optimisation 2025-09-05 11:46:21 +02:00
de560bd1e5
chore(release): 1.12.2 2025-08-24 12:57:31 +02:00
8487573c0f
fix: 112 2025-08-24 10:22:27 +02:00
958eee1f72
chore(ios): improve debugging 2025-08-10 13:20:58 +02:00
a461f445c4
chore(release): 1.12.1 2025-08-10 11:49:26 +02:00
28b8b3d826
fix(voice-message): wip 2025-08-10 11:48:56 +02:00
8d8da91696
fix: menu index typo 2025-08-09 11:01:30 +02:00
d6a3e94ea7
fix(theming): alertes archivées buttons 2025-08-09 10:57:31 +02:00
d5ad23d1da
fix: placeholder in dark theme for chat input 2025-08-09 10:52:03 +02:00
ef9b5037fb
fix(push-notif): label "undefined à " 2025-08-09 10:38:06 +02:00
0b5e936714
fix: typo 2025-08-09 10:25:23 +02:00
bce32dbd55
chore(release): 1.12.0 2025-08-02 15:38:55 +02:00
69d9fc9a6a
feat(heartbeat): remove 2025-08-02 15:38:37 +02:00
cd17372335
chore(release): 1.11.17 2025-07-30 09:35:01 +02:00
ba61baf27f
chore(io): headless debugging 2025-07-30 09:34:43 +02:00
144ed88229
chore(release): 1.11.16 2025-07-27 23:15:34 +02:00
6ea01c0c6d
fix(io): headless 2025-07-27 23:15:28 +02:00
8183c7e4af
chore(release): 1.11.15 2025-07-26 15:14:55 +02:00
364e535a02
chore: debug 2025-07-26 15:14:44 +02:00
d8583b9ad7
chore(release): 1.11.14 2025-07-25 10:32:11 +02:00
a795e82bbe
fix(io): headless + debug wip 2025-07-25 10:31:54 +02:00
7220ee5667
chore(release): 1.11.13 2025-07-24 07:14:11 +02:00
6f628979c6
fix(ios-headless): wip 2025-07-24 07:11:54 +02:00
71c31eefc7 chore(release): 1.11.12 2025-07-23 14:53:11 +02:00
38e2f821dd
fix(ios): executeHeartbeatSync with silent push notification 2025-07-23 14:28:00 +02:00
5461852ada
fix(ios-headless): big wip leap 2025-07-23 13:47:30 +02:00
a88f3bf6c7 chore(release): 1.11.2 2025-07-19 11:54:40 +02:00
e6f23b83be
fix(ios): BGTaskSchedulerPermittedIdentifiers + prebuild wip 2025-07-19 11:51:22 +02:00
39 changed files with 1262 additions and 731 deletions

3
.gitignore vendored
View file

@ -84,6 +84,9 @@ DerivedData
# aidigest # aidigest
codebase.md codebase.md
# Build logs
logs/
# Sensitive configuration files # Sensitive configuration files
ios/GoogleService-Info.plist ios/GoogleService-Info.plist
ios/AlerteSecours/GoogleService-Info.plist ios/AlerteSecours/GoogleService-Info.plist

View file

@ -2,6 +2,92 @@
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.12.3](https://github.com/alerte-secours/as-app/compare/v1.12.2...v1.12.3) (2025-09-05)
## [1.12.2](https://github.com/alerte-secours/as-app/compare/v1.12.1...v1.12.2) (2025-08-24)
### Bug Fixes
* 112 ([8487573](https://github.com/alerte-secours/as-app/commit/8487573c0f8eb6656cd5825ff217efe046d65407))
## [1.12.1](https://github.com/alerte-secours/as-app/compare/v1.12.0...v1.12.1) (2025-08-10)
### Bug Fixes
* menu index typo ([8d8da91](https://github.com/alerte-secours/as-app/commit/8d8da916965c1dd7feaa8b011ea854591c859e03))
* placeholder in dark theme for chat input ([d5ad23d](https://github.com/alerte-secours/as-app/commit/d5ad23d1dae521e4a99f757505adec3f23a7914c))
* **push-notif:** label "undefined à " ([ef9b503](https://github.com/alerte-secours/as-app/commit/ef9b5037fbb97fa597194760a9d3a22a04eeeeda))
* **theming:** alertes archivées buttons ([d6a3e94](https://github.com/alerte-secours/as-app/commit/d6a3e94ea710a494623a8636ccad71205094d9c6))
* typo ([0b5e936](https://github.com/alerte-secours/as-app/commit/0b5e936714054fe8647b8520d6432ab29fe2ecb7))
* **voice-message:** wip ([28b8b3d](https://github.com/alerte-secours/as-app/commit/28b8b3d826685de053ae5ff7f7931b24a64920b4))
## [1.12.0](https://github.com/alerte-secours/as-app/compare/v1.11.17...v1.12.0) (2025-08-02)
### Features
* **heartbeat:** remove ([69d9fc9](https://github.com/alerte-secours/as-app/commit/69d9fc9a6a1383bbbf5f1b5f5641d01b2378168b))
## [1.11.17](https://github.com/alerte-secours/as-app/compare/v1.11.16...v1.11.17) (2025-07-30)
## [1.11.16](https://github.com/alerte-secours/as-app/compare/v1.11.15...v1.11.16) (2025-07-27)
### Bug Fixes
* **io:** headless ([6ea01c0](https://github.com/alerte-secours/as-app/commit/6ea01c0c6d7f3cb8dbff51138b20f3fbba5b9766))
## [1.11.15](https://github.com/alerte-secours/as-app/compare/v1.11.14...v1.11.15) (2025-07-26)
## [1.11.14](https://github.com/alerte-secours/as-app/compare/v1.11.13...v1.11.14) (2025-07-25)
### Bug Fixes
* **io:** headless + debug wip ([a795e82](https://github.com/alerte-secours/as-app/commit/a795e82bbe30a425698173156862311d0c964207))
## [1.11.13](https://github.com/alerte-secours/as-app/compare/v1.11.12...v1.11.13) (2025-07-24)
### Bug Fixes
* **ios-headless:** wip ([6f62897](https://github.com/alerte-secours/as-app/commit/6f628979c632be7be12bddd52a8b56b105e6cbee))
## [1.11.12](https://github.com/alerte-secours/as-app/compare/v1.11.2...v1.11.12) (2025-07-23)
### Bug Fixes
* **ios-headless:** big wip leap ([5461852](https://github.com/alerte-secours/as-app/commit/5461852adaac33511889d7a2466f435d953b4a7b))
* **ios:** executeHeartbeatSync with silent push notification ([38e2f82](https://github.com/alerte-secours/as-app/commit/38e2f821ddeb5b2b4ad310bf2f32b23f5ca94ea7))
## [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)
### Bug Fixes
* **ios:** BGTaskSchedulerPermittedIdentifiers + prebuild wip ([e6f23b8](https://github.com/alerte-secours/as-app/commit/e6f23b83be7f30adc6385dc8f3e074521c7caf22))
## [1.11.1](https://github.com/alerte-secours/as-app/compare/v1.11.0...v1.11.1) (2025-07-18) ## [1.11.1](https://github.com/alerte-secours/as-app/compare/v1.11.0...v1.11.1) (2025-07-18)
## [1.11.0](https://github.com/alerte-secours/as-app/compare/v1.10.9...v1.11.0) (2025-07-12) ## [1.11.0](https://github.com/alerte-secours/as-app/compare/v1.10.9...v1.11.0) (2025-07-12)

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 191 versionCode 211
versionName "1.11.1" versionName "1.12.3"
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

@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
<uses-permission android:name="android.permission.CALL_PHONE"/> <uses-permission android:name="android.permission.CALL_PHONE"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
@ -10,6 +11,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>

View file

@ -4,8 +4,6 @@
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</string> <string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
<string name="expo_runtime_version">1.0.0</string> <string name="expo_runtime_version">1.0.0</string>
<!-- Override permission message with French text -->
<string name="message_permission_required">Alerte Secours nécessite la localisation en arrière-plan pour les alertes de proximité.</string> <string name="message_permission_required">Alerte Secours nécessite la localisation en arrière-plan pour les alertes de proximité.</string>
<string name="title_permission_required">Autorisation requise</string> <string name="title_permission_required">Autorisation requise</string>
</resources> </resources>

View file

@ -131,12 +131,13 @@ let config = {
"tel", "tel",
"telprompt", "telprompt",
], ],
BGTaskSchedulerPermittedIdentifiers: [
"com.transistorsoft",
"com.transistorsoft.fetch",
"com.transistorsoft.customtask",
],
}, },
UIBackgroundModes: ["location", "fetch", "processing"], UIBackgroundModes: ["location", "fetch", "processing"],
BGTaskSchedulerPermittedIdentifiers: [
"com.transistorsoft.fetch",
"com.transistorsoft.customtask",
],
}, },
plugins: [ plugins: [
[ [

255
index.js
View file

@ -5,6 +5,8 @@ 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 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 +20,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 +31,28 @@ 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 // try {
const taskTimeout = setTimeout(() => { // switch (event?.name) {
geolocBgLogger.error("HeadlessTask timeout", { event }); // case "heartbeat":
// await executeHeartbeatSync();
// break;
// default:
// break;
// }
// } catch (error) {
// geolocBgLogger.error("HeadlessTask error", {
// error,
// event,
// });
// }
// };
Sentry.captureException(new Error("HeadlessTask timeout"), { // if (Platform.OS === "android") {
tags: { // BackgroundGeolocation.registerHeadlessTask(HeadlessTask);
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) {
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 });
}
}
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);

View file

@ -158,9 +158,17 @@
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
6C173A6450034E8CAB58FB0C /* Fix Xcode 15 Bug */, 6C173A6450034E8CAB58FB0C /* Fix Xcode 15 Bug */,
CBB0E03A64A84F6FB794EDB2 /* Upload Debug Symbols to Sentry */, CBB0E03A64A84F6FB794EDB2 /* Upload Debug Symbols to Sentry */,
6EA5AE3725914306AC3A5BE5 /* Remove signature files (Xcode workaround) */,
8A20F54D80BCC2E27CF783AE /* [CP] Embed Pods Frameworks */, 8A20F54D80BCC2E27CF783AE /* [CP] Embed Pods Frameworks */,
36EBB336DD5343908AA35FFC /* [CP-User] [RNFB] Core Configuration */, 36EBB336DD5343908AA35FFC /* [CP-User] [RNFB] Core Configuration */,
8EC12A68941D40E98E0D60BE /* Fix Xcode 15 Bug */,
49AEAB1D332B45ED9A37B009 /* Fix Xcode 15 Bug */,
D75A41050AB3445786799848 /* Fix Xcode 15 Bug */,
FB7FA195D27D412AA897F419 /* Fix Xcode 15 Bug */,
0C44FF6DBD8F4BDD8D2B9784 /* Fix Xcode 15 Bug */,
AC008438EEF4422BA1C35CDF /* Fix Xcode 15 Bug */,
1A6C945D28C14747A29A3560 /* Fix Xcode 15 Bug */,
1C287A64431A4C0A859F067B /* Fix Xcode 15 Bug */,
EBD8BAB94522461484E3792D /* Remove signature files (Xcode workaround) */,
); );
buildRules = ( buildRules = (
); );
@ -473,6 +481,278 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`"; shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
}; };
8EC12A68941D40E98E0D60BE /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
700E335CAFA54AABB46BAB62 /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
49AEAB1D332B45ED9A37B009 /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
E611D841DFAE4F71B8077AD3 /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
D75A41050AB3445786799848 /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
ABC6C5A0D48A4B7980D60E1B /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
FB7FA195D27D412AA897F419 /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
B1FDDB484A8E497F9FF7F32C /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
0C44FF6DBD8F4BDD8D2B9784 /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
658BC0C976C44270ACBDF3C6 /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
AC008438EEF4422BA1C35CDF /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
6743177E81F94D198E926A21 /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
1A6C945D28C14747A29A3560 /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
96170835D29D4D569C60B051 /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
1C287A64431A4C0A859F067B /* Fix Xcode 15 Bug */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Fix Xcode 15 Bug";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "if [ \"$XCODE_VERSION_MAJOR\" = \"1500\" ]; then
echo \"Remove signature files (Xcode 15 workaround)\"
find \"$BUILD_DIR/${CONFIGURATION}-iphoneos\" -name \"*.signature\" -type f | xargs -r rm
fi";
};
EBD8BAB94522461484E3792D /* Remove signature files (Xcode workaround) */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
name = "Remove signature files (Xcode workaround)";
inputPaths = (
);
outputPaths = (
);
shellPath = /bin/sh;
shellScript = "
echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\";
";
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -516,7 +796,7 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.alertesecours.alertesecours; PRODUCT_BUNDLE_IDENTIFIER = com.alertesecours.alertesecours;
PRODUCT_NAME = AlerteSecours; PRODUCT_NAME = "AlerteSecours";
SWIFT_OBJC_BRIDGING_HEADER = "AlerteSecours/AlerteSecours-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "AlerteSecours/AlerteSecours-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -535,7 +815,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 2PZ49Y23LX; DEVELOPMENT_TEAM = 2PZ49Y23LX;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "arm64";
INFOPLIST_FILE = AlerteSecours/Info.plist; INFOPLIST_FILE = AlerteSecours/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@ -547,7 +827,7 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.alertesecours.alertesecours; PRODUCT_BUNDLE_IDENTIFIER = com.alertesecours.alertesecours;
PRODUCT_NAME = AlerteSecours; PRODUCT_NAME = "AlerteSecours";
SWIFT_OBJC_BRIDGING_HEADER = "AlerteSecours/AlerteSecours-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "AlerteSecours/AlerteSecours-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";

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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -2,6 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.transistorsoft</string>
<string>com.transistorsoft.fetch</string>
<string>com.transistorsoft.customtask</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
@ -19,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.1</string> <string>1.12.3</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -42,7 +48,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>191</string> <string>211</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
@ -89,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>
@ -114,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>
@ -145,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.1", "version": "1.12.3",
"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": 191, "versionCode": 211,
"buildNumber": 191 "buildNumber": 211
}, },
"commit-and-tag-version": { "commit-and-tag-version": {
"scripts": { "scripts": {

View file

@ -86,6 +86,9 @@ mv ios/main.jsbundle.hbc ios/main.jsbundle
cd ios cd ios
# Create logs directory if it doesn't exist
mkdir -p ../logs
# Create archive using xcodebuild # Create archive using xcodebuild
echo "Creating archive..." echo "Creating archive..."
xcodebuild \ xcodebuild \
@ -93,6 +96,6 @@ xcodebuild \
-scheme AlerteSecours \ -scheme AlerteSecours \
-configuration Release \ -configuration Release \
-archivePath AlerteSecours.xcarchive \ -archivePath AlerteSecours.xcarchive \
archive archive 2>&1 | tee "../logs/ios-archive-$(date +%Y%m%d-%H%M%S).log"
echo "Archive completed successfully at AlerteSecours.xcarchive" echo "Archive completed successfully at AlerteSecours.xcarchive"

View file

@ -25,6 +25,8 @@ import { useUpdates } from "~/updates";
import Error from "~/components/Error"; import Error from "~/components/Error";
import useTrackLocation from "~/hooks/useTrackLocation"; import useTrackLocation from "~/hooks/useTrackLocation";
// import { initializeBackgroundFetch } from "~/services/backgroundFetch";
import useMount from "~/hooks/useMount";
const appLogger = createLogger({ const appLogger = createLogger({
module: SYSTEM_SCOPES.APP, module: SYSTEM_SCOPES.APP,
@ -219,6 +221,23 @@ function AppContent() {
useNetworkListener(); useNetworkListener();
useTrackLocation(); useTrackLocation();
// useMount(() => {
// const setupBackgroundFetch = async () => {
// try {
// appLogger.info("Setting up BackgroundFetch");
// await initializeBackgroundFetch();
// appLogger.debug("BackgroundFetch setup completed");
// } catch (error) {
// lifecycleLogger.error("BackgroundFetch setup failed", {
// error: error?.message,
// });
// errorHandler(error);
// }
// };
// setupBackgroundFetch();
// });
// Handle deep links after app is initialized with error handling // Handle deep links after app is initialized with error handling
useEffect(() => { useEffect(() => {
let subscription; let subscription;

View file

@ -57,7 +57,7 @@ export default React.memo(function TextArea({
autoFocus={autoFocus} autoFocus={autoFocus}
showSoftInputOnFocus={keyboardEnabled} // controlled by state showSoftInputOnFocus={keyboardEnabled} // controlled by state
placeholder="Message" placeholder="Message"
placeholderTextColor={colors.onBackgroundDisabled} placeholderTextColor={colors.placeholder}
onTouchStart={() => { onTouchStart={() => {
if (!keyboardEnabled) { if (!keyboardEnabled) {
setKeyboardEnabled(true); setKeyboardEnabled(true);

View file

@ -38,16 +38,16 @@ const recordingSettings = {
outputFormat: AndroidOutputFormat.MPEG_4, outputFormat: AndroidOutputFormat.MPEG_4,
audioEncoder: AndroidAudioEncoder.AAC, audioEncoder: AndroidAudioEncoder.AAC,
sampleRate: 44100, sampleRate: 44100,
numberOfChannels: 2, numberOfChannels: 1,
bitRate: 128000, bitRate: 64000,
}, },
ios: { ios: {
extension: ".m4a", extension: ".m4a",
outputFormat: IOSOutputFormat.MPEG4AAC, outputFormat: IOSOutputFormat.MPEG4AAC,
audioQuality: IOSAudioQuality.MAX, audioQuality: IOSAudioQuality.MAX,
sampleRate: 44100, sampleRate: 44100,
numberOfChannels: 2, numberOfChannels: 1,
bitRate: 128000, bitRate: 64000,
linearPCMBitDepth: 16, linearPCMBitDepth: 16,
linearPCMIsBigEndian: false, linearPCMIsBigEndian: false,
linearPCMIsFloat: false, linearPCMIsFloat: false,
@ -194,7 +194,7 @@ export default React.memo(function ChatInput({
staysActiveInBackground: true, staysActiveInBackground: true,
}); });
const { sound: _sound } = await recording.createNewLoadedSoundAsync({ const { sound: _sound } = await recording.createNewLoadedSoundAsync({
isLooping: true, isLooping: false,
isMuted: false, isMuted: false,
volume: 1.0, volume: 1.0,
rate: 1.0, rate: 1.0,
@ -205,13 +205,12 @@ export default React.memo(function ChatInput({
const uploadAudio = useCallback(async () => { const uploadAudio = useCallback(async () => {
const uri = recording.getURI(); const uri = recording.getURI();
const filetype = uri.split(".").pop();
const fd = new FormData(); const fd = new FormData();
fd.append("data[alertId]", alertId); fd.append("data[alertId]", alertId);
fd.append("data[file]", { fd.append("data[file]", {
uri, uri,
type: `audio/${filetype}`, type: "audio/mp4",
name: "audioRecord", name: "audioRecord.m4a",
}); });
await network.oaFilesKy.post("audio/upload", { await network.oaFilesKy.post("audio/upload", {
body: fd, body: fd,

View file

@ -24,7 +24,13 @@ export default function MessageRow({
const { contentType, text, audioFileUuid, userId, createdAt, username } = row; const { contentType, text, audioFileUuid, userId, createdAt, username } = row;
const audioFileUri = const audioFileUri =
contentType === "audio" ? `${env.MINIO_URL}/audio/${audioFileUuid}` : null; contentType === "audio"
? `${env.MINIO_URL}/audio/${audioFileUuid}.m4a`
: null;
// if (contentType === "audio" && __DEV__) {
// console.log(`[MessageRow] Audio URL: ${audioFileUri}`);
// }
const isMine = userId === sessionUserId; const isMine = userId === sessionUserId;

View file

@ -17,11 +17,13 @@ import {
import { createStyles, useTheme } from "~/theme"; import { createStyles, useTheme } from "~/theme";
import openSettings from "~/lib/native/openSettings"; import openSettings from "~/lib/native/openSettings";
import { import {
RequestDisableOptimization, requestBatteryOptimizationExemption,
BatteryOptEnabled, isBatteryOptimizationEnabled,
} from "react-native-battery-optimization-check"; openBatteryOptimizationFallbacks,
} from "~/lib/native/batteryOptimization";
import requestPermissionLocationBackground from "~/permissions/requestPermissionLocationBackground"; import requestPermissionLocationBackground from "~/permissions/requestPermissionLocationBackground";
import requestPermissionLocationForeground from "~/permissions/requestPermissionLocationForeground";
import requestPermissionMotion from "~/permissions/requestPermissionMotion"; import requestPermissionMotion from "~/permissions/requestPermissionMotion";
import CustomButton from "~/components/CustomButton"; import CustomButton from "~/components/CustomButton";
import Text from "~/components/Text"; import Text from "~/components/Text";
@ -45,6 +47,8 @@ const HeroMode = () => {
useState(null); useState(null);
const [batteryOptAttempted, setBatteryOptAttempted] = useState(false); const [batteryOptAttempted, setBatteryOptAttempted] = useState(false);
const [batteryOptInProgress, setBatteryOptInProgress] = useState(false); const [batteryOptInProgress, setBatteryOptInProgress] = useState(false);
const [batteryOptFallbackOpened, setBatteryOptFallbackOpened] =
useState(false);
const permissions = usePermissionsState([ const permissions = usePermissionsState([
"locationBackground", "locationBackground",
"motion", "motion",
@ -75,16 +79,14 @@ const HeroMode = () => {
setBatteryOptInProgress(true); setBatteryOptInProgress(true);
// Check if battery optimization is enabled // Check if battery optimization is enabled
const isEnabled = await BatteryOptEnabled(); const isEnabled = await isBatteryOptimizationEnabled();
setBatteryOptimizationEnabled(isEnabled); setBatteryOptimizationEnabled(isEnabled);
if (isEnabled) { if (isEnabled) {
console.log( console.log("Battery optimization is enabled, requesting exemption...");
"Battery optimization is enabled, requesting to disable...",
);
// Request to disable battery optimization (opens Android Settings) // Request to disable battery optimization (opens Android Settings)
RequestDisableOptimization(); await requestBatteryOptimizationExemption();
setBatteryOptAttempted(true); setBatteryOptAttempted(true);
// Return false to indicate user needs to complete action in Settings // Return false to indicate user needs to complete action in Settings
@ -106,31 +108,45 @@ const HeroMode = () => {
const handleRequestPermissions = useCallback(async () => { const handleRequestPermissions = useCallback(async () => {
setRequesting(true); setRequesting(true);
try { try {
// Don't change step immediately to avoid race conditions
console.log("Starting permission requests..."); console.log("Starting permission requests...");
// Request battery optimization FIRST (opens Android Settings) // 1) Battery optimization (opens Settings)
// This prevents the bubbling issue by handling Settings-based permissions before in-app dialogs
const batteryOptDisabled = await handleBatteryOptimization(); const batteryOptDisabled = await handleBatteryOptimization();
console.log("Battery optimization disabled:", batteryOptDisabled); console.log("Battery optimization disabled:", batteryOptDisabled);
if (!batteryOptDisabled) {
// Settings flow opened; wait for user to return before requesting in-app permissions
setRequesting(false);
setHasAttempted(true);
return;
}
// Request motion permission second // 2) Foreground location
let fgGranted = await requestPermissionLocationForeground();
permissionsActions.setLocationForeground(fgGranted);
console.log("Location foreground permission:", fgGranted);
// 3) Background location (only after FG granted)
let bgGranted = false;
if (fgGranted) {
bgGranted = await requestPermissionLocationBackground();
permissionsActions.setLocationBackground(bgGranted);
} else {
console.log(
"Skipping background location since foreground not granted",
);
}
console.log("Location background permission:", bgGranted);
// 4) Motion
const motionGranted = await requestPermissionMotion.requestPermission(); const motionGranted = await requestPermissionMotion.requestPermission();
permissionsActions.setMotion(motionGranted); permissionsActions.setMotion(motionGranted);
console.log("Motion permission:", motionGranted); console.log("Motion permission:", motionGranted);
// Request background location last (after user returns from Settings if needed) // Step after all requests
const locationGranted = await requestPermissionLocationBackground();
permissionsActions.setLocationBackground(locationGranted);
console.log("Location background permission:", locationGranted);
// Only set step to tracking after all permission requests are complete
permissionWizardActions.setCurrentStep("tracking"); permissionWizardActions.setCurrentStep("tracking");
// Check if we should proceed to success immediately if (fgGranted && bgGranted && motionGranted && batteryOptDisabled) {
if (locationGranted && motionGranted && batteryOptDisabled) {
permissionWizardActions.setHeroPermissionsGranted(true); permissionWizardActions.setHeroPermissionsGranted(true);
// Don't navigate immediately, let the useEffect handle it
} }
} catch (error) { } catch (error) {
console.error("Error requesting permissions:", error); console.error("Error requesting permissions:", error);
@ -143,7 +159,7 @@ const HeroMode = () => {
// Re-check battery optimization status before retrying // Re-check battery optimization status before retrying
if (Platform.OS === "android") { if (Platform.OS === "android") {
try { try {
const isEnabled = await BatteryOptEnabled(); const isEnabled = await isBatteryOptimizationEnabled();
setBatteryOptimizationEnabled(isEnabled); setBatteryOptimizationEnabled(isEnabled);
// If battery optimization is now disabled, update the store // If battery optimization is now disabled, update the store
@ -179,7 +195,7 @@ const HeroMode = () => {
const checkInitialBatteryOptimization = async () => { const checkInitialBatteryOptimization = async () => {
if (Platform.OS === "android") { if (Platform.OS === "android") {
try { try {
const isEnabled = await BatteryOptEnabled(); const isEnabled = await isBatteryOptimizationEnabled();
setBatteryOptimizationEnabled(isEnabled); setBatteryOptimizationEnabled(isEnabled);
// If already disabled, update the store // If already disabled, update the store
@ -208,7 +224,7 @@ const HeroMode = () => {
) { ) {
console.log("App became active, re-checking battery optimization..."); console.log("App became active, re-checking battery optimization...");
try { try {
const isEnabled = await BatteryOptEnabled(); const isEnabled = await isBatteryOptimizationEnabled();
setBatteryOptimizationEnabled(isEnabled); setBatteryOptimizationEnabled(isEnabled);
if (!isEnabled) { if (!isEnabled) {
@ -216,6 +232,19 @@ const HeroMode = () => {
"Battery optimization disabled after returning from settings", "Battery optimization disabled after returning from settings",
); );
permissionsActions.setBatteryOptimizationDisabled(true); permissionsActions.setBatteryOptimizationDisabled(true);
} else if (!batteryOptFallbackOpened) {
try {
console.log(
"Battery optimization still enabled; opening fallback settings...",
);
await openBatteryOptimizationFallbacks();
setBatteryOptFallbackOpened(true);
} catch (e) {
console.error(
"Error opening battery optimization fallback settings:",
e,
);
}
} }
} catch (error) { } catch (error) {
console.error( console.error(
@ -234,7 +263,7 @@ const HeroMode = () => {
return () => { return () => {
subscription?.remove(); subscription?.remove();
}; };
}, [batteryOptAttempted]); }, [batteryOptAttempted, batteryOptFallbackOpened]);
useEffect(() => { useEffect(() => {
if (hasAttempted && allGranted) { if (hasAttempted && allGranted) {

View file

@ -1,6 +1,6 @@
import * as Location from "expo-location"; import * as Location from "expo-location";
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { storeLocation, getStoredLocation } from "~/utils/location/storage"; import { storeLocation, getStoredLocation } from "~/location/storage";
import { createLogger } from "~/lib/logger"; import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES, UI_SCOPES } from "~/lib/logger/scopes"; import { BACKGROUND_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";

View file

@ -110,6 +110,7 @@ class AudioSlider extends PureComponent {
} }
mapAudioToCurrentTime = async () => { mapAudioToCurrentTime = async () => {
if (!this.soundObject) return;
await this.soundObject.setPositionAsync(this.state.currentTime); await this.soundObject.setPositionAsync(this.state.currentTime);
}; };
@ -122,6 +123,7 @@ class AudioSlider extends PureComponent {
}; };
play = async () => { play = async () => {
if (!this.soundObject) return;
if (this.registry && this.pauseAllBeforePlay) { if (this.registry && this.pauseAllBeforePlay) {
const players = this.registry.getAll(); const players = this.registry.getAll();
await Promise.all( await Promise.all(
@ -134,12 +136,14 @@ class AudioSlider extends PureComponent {
}; };
pause = async () => { pause = async () => {
if (!this.soundObject) return;
await this.soundObject.pauseAsync(); await this.soundObject.pauseAsync();
this.setState({ playing: false }); // This is for the play-button to go to pause this.setState({ playing: false }); // This is for the play-button to go to pause
Animated.timing(this.state.dotOffset, { useNativeDriver: false }).stop(); // Will also call animationPausedOrStopped() Animated.timing(this.state.dotOffset, { useNativeDriver: false }).stop(); // Will also call animationPausedOrStopped()
}; };
startMovingDot = async () => { startMovingDot = async () => {
if (!this.soundObject) return;
const status = await this.soundObject.getStatusAsync(); const status = await this.soundObject.getStatusAsync();
const durationLeft = status["durationMillis"] - status["positionMillis"]; const durationLeft = status["durationMillis"] - status["positionMillis"];
@ -156,6 +160,7 @@ class AudioSlider extends PureComponent {
// Audio has been paused // Audio has been paused
return; return;
} }
if (!this.soundObject) return;
// Animation-duration is over (reset Animation and Audio): // Animation-duration is over (reset Animation and Audio):
await sleep(200); // In case animation has finished, but audio has not await sleep(200); // In case animation has finished, but audio has not
this.setState({ playing: false }); this.setState({ playing: false });
@ -164,6 +169,16 @@ class AudioSlider extends PureComponent {
await this.soundObject.setPositionAsync(0); await this.soundObject.setPositionAsync(0);
}; };
handlePlaybackFinished = async () => {
// console.log(`[AudioSlider] Playback finished, resetting for replay`);
// Reset for replay instead of unloading
this.setState({ playing: false });
await this.state.dotOffset.setValue({ x: 0, y: 0 });
if (this.soundObject) {
await this.soundObject.stopAsync();
}
};
measureTrack = (event) => { measureTrack = (event) => {
this.setState({ trackLayout: event.nativeEvent.layout }); // {x, y, width, height} this.setState({ trackLayout: event.nativeEvent.layout }); // {x, y, width, height}
}; };
@ -171,27 +186,92 @@ class AudioSlider extends PureComponent {
async componentDidMount() { async componentDidMount() {
// https://github.com/olapiv/expo-audio-player/issues/13 // https://github.com/olapiv/expo-audio-player/issues/13
const loadAudio = async () => { const audioUrl = this.props.audio;
try {
const { sound: newSound } = await Audio.Sound.createAsync({
uri: this.props.audio,
});
this.soundObject = newSound;
// // https://github.com/expo/expo/issues/1873 const loadAudio = async () => {
const tryLoad = async (ext) => {
// console.log(`[AudioSlider] Attempting to load with extension: ${ext}`);
const { sound } = await Audio.Sound.createAsync({
uri: audioUrl,
overrideFileExtensionAndroid: ext,
});
return sound;
};
let lastError = null;
try {
// First try with m4a (preferred)
const sound = await tryLoad("m4a");
// console.log(`[AudioSlider] Successfully loaded with m4a extension`);
this.soundObject = sound;
await this.soundObject.setIsLoopingAsync(false);
this.soundObject.setOnPlaybackStatusUpdate((status) => { this.soundObject.setOnPlaybackStatusUpdate((status) => {
if (!status.didJustFinish) return; if (!status.didJustFinish) return;
this.soundObject.unloadAsync().catch(() => {}); this.handlePlaybackFinished();
}); });
} catch (error) { return;
console.log("Error loading audio:", error); } catch (err1) {
// console.log(`[AudioSlider] Failed to load with m4a:`, err1.message);
lastError = err1;
try {
// Fallback to mp4
const sound = await tryLoad("mp4");
// console.log(`[AudioSlider] Successfully loaded with mp4 extension`);
this.soundObject = sound;
await this.soundObject.setIsLoopingAsync(false);
this.soundObject.setOnPlaybackStatusUpdate((status) => {
if (!status.didJustFinish) return;
this.handlePlaybackFinished();
});
return;
} catch (err2) {
// console.log(`[AudioSlider] Failed to load with mp4:`, err2.message);
lastError = err2;
try {
// Last fallback to aac
const sound = await tryLoad("aac");
// console.log(`[AudioSlider] Successfully loaded with aac extension`);
this.soundObject = sound;
await this.soundObject.setIsLoopingAsync(false);
this.soundObject.setOnPlaybackStatusUpdate((status) => {
if (!status.didJustFinish) return;
this.handlePlaybackFinished();
});
return;
} catch (err3) {
// console.log(`[AudioSlider] Failed to load with aac:`, err3.message);
lastError = err3;
}
}
} }
// All attempts failed
console.error(
`[AudioSlider] All load attempts failed for ${audioUrl}. Last error:`,
lastError,
);
}; };
await loadAudio(); await loadAudio();
const status = await this.soundObject.getStatusAsync(); if (!this.soundObject) {
this.setState({ duration: status.durationMillis }); // Loading failed; avoid further calls and leave UI inert or show error
console.log(
`[AudioSlider] No sound object created, setting duration to 0`,
);
this.setState({ duration: 0 });
return;
}
try {
const status = await this.soundObject.getStatusAsync();
this.setState({ duration: status.durationMillis });
} catch (error) {
console.log("Error getting audio status:", error);
this.setState({ duration: 0 });
return;
}
// This requires measureTrack to have been called. // This requires measureTrack to have been called.
this.state.dotOffset.addListener(() => { this.state.dotOffset.addListener(() => {
@ -207,7 +287,9 @@ class AudioSlider extends PureComponent {
} }
async componentWillUnmount() { async componentWillUnmount() {
await this.soundObject.unloadAsync(); if (this.soundObject) {
await this.soundObject.unloadAsync();
}
this.state.dotOffset.removeAllListeners(); this.state.dotOffset.removeAllListeners();
if (this.registry) { if (this.registry) {
this.registry.unregister(this); this.registry.unregister(this);

View file

@ -0,0 +1,97 @@
import { Platform } from "react-native";
import SendIntentAndroid from "react-native-send-intent";
import {
RequestDisableOptimization,
BatteryOptEnabled,
} from "react-native-battery-optimization-check";
import { createLogger } from "~/lib/logger";
import { FEATURE_SCOPES } from "~/lib/logger/scopes";
const log = createLogger({
module: FEATURE_SCOPES.PERMISSIONS,
feature: "battery-optimization",
});
/**
* Returns true if battery optimization is currently ENABLED for this app on Android.
* On iOS, returns false (no battery optimization concept).
*/
export async function isBatteryOptimizationEnabled() {
if (Platform.OS !== "android") return false;
try {
const enabled = await BatteryOptEnabled();
log.info("Battery optimization status", { enabled });
return enabled;
} catch (e) {
log.error("Failed to read battery optimization status", {
error: e?.message,
stack: e?.stack,
});
// Conservative: assume enabled if unknown
return true;
}
}
/**
* Launches the primary system flow to request ignoring battery optimizations.
* This opens a Settings screen; it does not yield a synchronous result.
*
* Returns:
* - false on Android to indicate the user must complete an action in Settings
* - true on iOS (no-op)
*/
export async function requestBatteryOptimizationExemption() {
if (Platform.OS !== "android") return true;
try {
log.info("Requesting to disable battery optimization (primary intent)");
// This opens the OS dialog/settings. No result is provided, handle via AppState return.
RequestDisableOptimization();
return false;
} catch (e) {
log.error("Primary request to disable battery optimization failed", {
error: e?.message,
stack: e?.stack,
});
// Even if it throws, we'll guide users via fallbacks.
return false;
}
}
/**
* Opens best-effort fallback screens to let users disable battery optimization.
* Call this AFTER the user returns and status is still enabled.
*
* Strategy:
* - Try the list of battery optimization exceptions
* - Fallback to app settings
*/
export async function openBatteryOptimizationFallbacks() {
if (Platform.OS !== "android") return true;
// Try the generic battery optimization settings list
try {
log.info("Opening fallback: IGNORE_BATTERY_OPTIMIZATION_SETTINGS");
await SendIntentAndroid.openSettings(
"android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS",
);
return true;
} catch (e) {
log.warn("Failed to open IGNORE_BATTERY_OPTIMIZATION_SETTINGS", {
error: e?.message,
});
}
// Final fallback: app details settings
try {
log.info("Opening fallback: APPLICATION_DETAILS_SETTINGS (app details)");
await SendIntentAndroid.openAppSettings();
return true;
} catch (e) {
log.error("Failed to open APPLICATION_DETAILS_SETTINGS", {
error: e?.message,
stack: e?.stack,
});
return false;
}
}

View file

@ -2,7 +2,7 @@ import { Platform, Linking } from "react-native";
import RNImmediatePhoneCall from "react-native-immediate-phone-call"; import RNImmediatePhoneCall from "react-native-immediate-phone-call";
export function phoneCallEmergency() { export function phoneCallEmergency() {
const emergencyNumber = "+112"; const emergencyNumber = "112";
if (Platform.OS === "ios") { if (Platform.OS === "ios") {
// Use telprompt URL scheme on iOS // Use telprompt URL scheme on iOS

View file

@ -0,0 +1,296 @@
import { Platform } from "react-native";
import BackgroundGeolocation from "react-native-background-geolocation";
import { memoryAsyncStorage } from "~/storage/memoryAsyncStorage";
import { STORAGE_KEYS } from "~/storage/storageKeys";
import { createLogger } from "~/lib/logger";
import { getStoredLocation } from "./storage";
import { getAuthState } from "~/stores";
import env from "~/env";
// Constants for persistence
const FORCE_SYNC_INTERVAL = 12 * 60 * 60 * 1000;
// const FORCE_SYNC_INTERVAL = 1 * 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
}
};
const executeSyncAndroid = async () => {
await BackgroundGeolocation.changePace(true);
await BackgroundGeolocation.sync();
};
const executeSyncIOS = async () => {
const debugWebhook =
"https://webhook.site/433b6aca-b169-4073-924a-4f089ca30406";
// Helper function to send debug info
const sendDebug = async (step, data = {}) => {
try {
// Build query string manually since URLSearchParams is not available in React Native
const queryData = {
step,
timestamp: new Date().toISOString(),
...Object.entries(data).reduce((acc, [key, value]) => {
acc[key] =
typeof value === "object" ? JSON.stringify(value) : String(value);
return acc;
}, {}),
};
const queryString = Object.entries(queryData)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join("&");
await fetch(`${debugWebhook}?${queryString}`, { method: "GET" });
} catch (e) {
// Ignore debug errors
}
};
try {
// Debug point 1: Function start
await sendDebug("1_function_start", { platform: "iOS" });
// Debug point 2: Before getStoredLocation
await sendDebug("2_before_get_stored_location");
const locationData = await getStoredLocation();
// Debug point 3: After getStoredLocation
await sendDebug("3_after_get_stored_location", {
hasData: !!locationData,
timestamp: locationData?.timestamp || "null",
hasCoords: !!locationData?.coords,
latitude: locationData?.coords?.latitude || "null",
longitude: locationData?.coords?.longitude || "null",
});
if (!locationData) {
geolocBgLogger.debug("No stored location data found, skipping sync");
await sendDebug("3a_no_location_data");
return;
}
const { timestamp, coords } = locationData;
// Check if timestamp is too old (> 2 weeks)
const now = new Date();
const locationTime = new Date(timestamp);
const twoWeeksInMs = 14 * 24 * 60 * 60 * 1000; // 2 weeks in milliseconds
const locationAge = now - locationTime;
// Debug point 4: Timestamp validation
await sendDebug("4_timestamp_validation", {
locationAge: locationAge,
maxAge: twoWeeksInMs,
isTooOld: locationAge > twoWeeksInMs,
timestamp: timestamp,
});
if (locationAge > twoWeeksInMs) {
geolocBgLogger.debug("Stored location is too old, skipping sync", {
locationAge: locationAge,
maxAge: twoWeeksInMs,
timestamp: timestamp,
});
await sendDebug("4a_location_too_old");
return;
}
// Get auth token
const { userToken } = getAuthState();
// Debug point 5: Auth token check
await sendDebug("5_auth_token_check", {
hasToken: !!userToken,
tokenLength: userToken ? userToken.length : 0,
});
if (!userToken) {
geolocBgLogger.debug("No auth token available, skipping sync");
await sendDebug("5a_no_auth_token");
return;
}
// Validate coordinates
if (
!coords ||
typeof coords.latitude !== "number" ||
typeof coords.longitude !== "number"
) {
geolocBgLogger.error("Invalid coordinates in stored location", {
coords,
});
await sendDebug("5b_invalid_coordinates", {
hasCoords: !!coords,
latType: typeof coords?.latitude,
lonType: typeof coords?.longitude,
});
return;
}
// Prepare payload according to API spec
const payload = {
location: {
event: "heartbeat",
coords: {
latitude: coords.latitude,
longitude: coords.longitude,
},
},
};
geolocBgLogger.debug("Syncing location to server", {
url: env.GEOLOC_SYNC_URL,
coords: payload.location.coords,
});
// Debug point 6: Before sync request
await sendDebug("6_before_sync_request", {
url: env.GEOLOC_SYNC_URL,
latitude: payload.location.coords.latitude,
longitude: payload.location.coords.longitude,
event: payload.location.event,
});
// Make HTTP request
const response = await fetch(env.GEOLOC_SYNC_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${userToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
await sendDebug("7a_sync_http_error", {
status: response.status,
statusText: response.statusText,
});
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = await response.json();
if (responseData.ok !== true) {
await sendDebug("7b_sync_api_error", {
apiOk: responseData.ok,
responseData: JSON.stringify(responseData),
});
throw new Error(`API returned ok: ${responseData.ok}`);
}
// Debug point 7: Sync success
await sendDebug("7_sync_success", {
status: response.status,
latitude: payload.location.coords.latitude,
longitude: payload.location.coords.longitude,
});
geolocBgLogger.info("iOS location sync completed successfully", {
status: response.status,
coords: payload.location.coords,
});
} catch (error) {
// Debug point 8: Error catch
await sendDebug("8_error_caught", {
errorMessage: error.message,
errorName: error.name,
errorStack: error.stack ? error.stack.substring(0, 500) : "no_stack",
});
geolocBgLogger.error("iOS location sync failed", {
error: error.message,
stack: error.stack,
});
}
};
// Shared heartbeat logic - mutualized between Android and iOS
const executeSync = async () => {
if (Platform.OS === "ios") {
await executeSyncIOS();
} else if (Platform.OS === "android") {
await executeSyncAndroid();
}
};
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", {
timeSinceLastSync,
forceInterval: FORCE_SYNC_INTERVAL,
});
try {
const syncResult = await Promise.race([
executeSync(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("changePace timeout")), 20000),
),
]);
await setLastSyncTime(now);
geolocBgLogger.info("Force sync completed successfully", {
syncResult,
});
return syncResult;
} catch (syncError) {
geolocBgLogger.error("Force sync failed", {
error: syncError.message,
timeSinceLastSync,
});
return {
syncPerformed: true,
syncSuccessful: false,
error: syncError.message,
};
}
} else {
geolocBgLogger.debug("Sync not needed yet", {
timeSinceLastSync,
forceInterval: FORCE_SYNC_INTERVAL,
timeUntilNextSync: FORCE_SYNC_INTERVAL - timeSinceLastSync,
});
return {
syncPerformed: false,
syncSuccessful: true,
};
}
};

View file

@ -8,7 +8,7 @@ import { initEmulatorMode } from "./emulatorService";
import { getAuthState, subscribeAuthState, permissionsActions } from "~/stores"; import { getAuthState, subscribeAuthState, permissionsActions } from "~/stores";
import setLocationState from "~/location/setLocationState"; import setLocationState from "~/location/setLocationState";
import { storeLocation } from "~/utils/location/storage"; import { storeLocation } from "~/location/storage";
import env from "~/env"; import env from "~/env";
@ -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,5 +1,5 @@
export default { export default {
red: "Urgence vitale.\nAppeler immétiatement les secours.", red: "Urgence vitale.\nAppeler immédiatement les secours.",
yellow: "Danger sans risque vital.\nConcerne les services d'intervention.", yellow: "Danger sans risque vital.\nConcerne les services d'intervention.",
green: green:
"Entre-aide citoyenne localisée.\nJ'ai besoin d'une aide rapide à proximité.", "Entre-aide citoyenne localisée.\nJ'ai besoin d'une aide rapide à proximité.",

View file

@ -1,6 +1,9 @@
import SectionSeparator from "./SectionSeparator"; import SectionSeparator from "./SectionSeparator";
import menuItem from "./menuItem"; import menuItem from "./menuItem";
const index1 = 5;
const index2 = 9;
export default function DrawerItemList(props) { export default function DrawerItemList(props) {
const { state } = props; const { state } = props;
const { index } = state; const { index } = state;
@ -17,18 +20,18 @@ export default function DrawerItemList(props) {
const { routes } = state; const { routes } = state;
const section1 = routes.slice(0, 5); const section1 = routes.slice(0, index1);
const section2 = routes.slice(5, 9); const section2 = routes.slice(index1, index2);
const section3 = routes.slice(9, routes.length); const section3 = routes.slice(index2, routes.length);
return ( return (
<> <>
<SectionSeparator label="Mon compte" /> <SectionSeparator label="Mon compte" />
{section2.map((props, i) => routeMenuItem(props, i + 5))} {section2.map((props, i) => routeMenuItem(props, i + index1))}
<SectionSeparator label="Alerter" /> <SectionSeparator label="Alerter" />
{section1.map((props, i) => routeMenuItem(props, i + 0))} {section1.map((props, i) => routeMenuItem(props, i + 0))}
<SectionSeparator label="Infos pratiques" /> <SectionSeparator label="Infos pratiques" />
{section3.map((props, i) => routeMenuItem(props, i + 8))} {section3.map((props, i) => routeMenuItem(props, i + index2))}
</> </>
); );
} }

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

@ -89,7 +89,9 @@ export default async function notifAlert(data) {
// Generate notification content // Generate notification content
const { title, body, bigText } = generateAlertContent({ const { title, body, bigText } = generateAlertContent({
oneAlert: { alertTag, code, level }, alertTag,
code,
level,
initialDistance, initialDistance,
reason, reason,
}); });

View file

@ -0,0 +1,38 @@
import { createLogger } from "~/lib/logger";
import { BACKGROUND_SCOPES } from "~/lib/logger/scopes";
import { executeHeartbeatSync } from "~/location/backgroundTask";
const heartbeatLogger = createLogger({
module: BACKGROUND_SCOPES.NOTIFICATIONS,
feature: "geolocation-heartbeat-sync",
});
export default async function notifGeolocationHeartbeatSync(data) {
try {
heartbeatLogger.info(
"Received iOS geolocation heartbeat sync notification",
{
data,
},
);
// This is a silent notification - no visible notification is displayed
// Instead, we trigger the geolocation heartbeat sync directly
heartbeatLogger.info("Triggering geolocation heartbeat sync");
// Execute the heartbeat sync to force location update
await executeHeartbeatSync();
heartbeatLogger.info("Geolocation heartbeat sync completed successfully");
} catch (error) {
heartbeatLogger.error("Failed to execute geolocation heartbeat sync", {
error: error.message,
stack: error.stack,
data,
});
// Don't throw the error - this is a background operation
// and we don't want to crash the notification handler
}
}

View file

@ -88,9 +88,9 @@ export const generateSuggestKeepOpenContent = (data) => {
export const generateBackgroundGeolocationLostContent = (data) => { export const generateBackgroundGeolocationLostContent = (data) => {
return { return {
title: `Alerte-Secours ne peut plus accéder à votre position`, title: `Alerte-Secours ne reçoit plus de mises à jour de votre position`,
body: `Vous ne pouvez plus recevoir d'alertes de proximité. Vérifiez les paramètres.`, body: `Vous ne pourrez plus recevoir d'alertes de proximité. Vérifiez les paramètres.`,
bigText: `Alerte-Secours ne peut plus accéder à votre position en arrière-plan. Vous ne pouvez plus recevoir d'alertes de proximité. Causes possibles : permissions révoquées, optimisation de batterie active, ou actualisation désactivée. Accédez aux paramètres de l'application pour réactiver.`, bigText: `Alerte-Secours ne reçoit plus de mises à jour de votre position en arrière-plan. Vous ne pourrez plus recevoir d'alertes de proximité. Causes possibles : permissions révoquées, optimisation de batterie active, ou actualisation désactivée. Accédez aux paramètres de l'application pour réactiver.`,
}; };
}; };

View file

@ -8,6 +8,7 @@ import notifSuggestKeepOpen from "./channels/notifSuggestKeepOpen";
import notifRelativeAllowAsk from "./channels/notifRelativeAllowAsk"; import notifRelativeAllowAsk from "./channels/notifRelativeAllowAsk";
import notifRelativeInvitation from "./channels/notifRelativeInvitation"; import notifRelativeInvitation from "./channels/notifRelativeInvitation";
import notifBackgroundGeolocationLost from "./channels/notifBackgroundGeolocationLost"; import notifBackgroundGeolocationLost from "./channels/notifBackgroundGeolocationLost";
// import notifGeolocationHeartbeatSync from "./channels/notifGeolocationHeartbeatSync.js.bak";
const displayLogger = createLogger({ const displayLogger = createLogger({
module: BACKGROUND_SCOPES.NOTIFICATIONS, module: BACKGROUND_SCOPES.NOTIFICATIONS,
@ -22,6 +23,7 @@ const SUPPORTED_ACTIONS = {
"relative-allow-ask": notifRelativeAllowAsk, "relative-allow-ask": notifRelativeAllowAsk,
"relative-invitation": notifRelativeInvitation, "relative-invitation": notifRelativeInvitation,
"background-geolocation-lost": notifBackgroundGeolocationLost, "background-geolocation-lost": notifBackgroundGeolocationLost,
// "geolocation-heartbeat-sync": notifGeolocationHeartbeatSync,
}; };
export default async function displayNotificationHandler(data) { export default async function displayNotificationHandler(data) {

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();
} }

View file

@ -69,7 +69,10 @@ export default withConnectivity(function AlertAggListArchived() {
value={sortBy} value={sortBy}
> >
<ToggleButton <ToggleButton
style={styles.sortByButton} style={[
styles.sortByButton,
sortBy === "createdAt" && styles.sortByButtonSelected,
]}
icon={() => ( icon={() => (
// <MaterialIcons // <MaterialIcons
// name="access-time" // name="access-time"
@ -79,18 +82,29 @@ export default withConnectivity(function AlertAggListArchived() {
<MaterialCommunityIcons <MaterialCommunityIcons
name="clock-time-four-outline" name="clock-time-four-outline"
size={20} size={20}
color={colors.surfaceSecondary} color={
sortBy === "createdAt"
? colors.onPrimary
: colors.onSurfaceVariant
}
/> />
)} )}
value="createdAt" value="createdAt"
/> />
<ToggleButton <ToggleButton
style={styles.sortByButton} style={[
styles.sortByButton,
sortBy === "alphabetical" && styles.sortByButtonSelected,
]}
icon={() => ( icon={() => (
<MaterialCommunityIcons <MaterialCommunityIcons
name="alphabetical" name="alphabetical"
size={20} size={20}
color={colors.surfaceSecondary} color={
sortBy === "alphabetical"
? colors.onPrimary
: colors.onSurfaceVariant
}
/> />
)} )}
value="alphabetical" value="alphabetical"
@ -156,6 +170,12 @@ const useStyles = createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
sortByButton: { sortByButton: {
height: 32, height: 32,
width: 32, width: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 6,
},
sortByButtonSelected: {
backgroundColor: colors.primary,
}, },
title: { title: {
fontSize: 13, fontSize: 13,

View file

@ -16,7 +16,7 @@ import {
import { deepEqual } from "fast-equals"; import { deepEqual } from "fast-equals";
import { useAlertState } from "~/stores"; import { useAlertState } from "~/stores";
import { storeLocation } from "~/utils/location/storage"; import { storeLocation } from "~/location/storage";
import useLocation from "~/hooks/useLocation"; import useLocation from "~/hooks/useLocation";
import withConnectivity from "~/hoc/withConnectivity"; import withConnectivity from "~/hoc/withConnectivity";

View file

@ -13,7 +13,7 @@ import { getDistance } from "geolib";
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions"; import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
import getRouteState from "~/lib/geo/getRouteState"; import getRouteState from "~/lib/geo/getRouteState";
import shallowCompare from "~/utils/array/shallowCompare"; import shallowCompare from "~/utils/array/shallowCompare";
import { storeLocation } from "~/utils/location/storage"; import { storeLocation } from "~/location/storage";
import useLocation from "~/hooks/useLocation"; import useLocation from "~/hooks/useLocation";
import withConnectivity from "~/hoc/withConnectivity"; import withConnectivity from "~/hoc/withConnectivity";

View file

@ -10,9 +10,9 @@ import { Button, Title } from "react-native-paper";
import { usePermissionsState, permissionsActions } from "~/stores"; import { usePermissionsState, permissionsActions } from "~/stores";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
RequestDisableOptimization, requestBatteryOptimizationExemption,
BatteryOptEnabled, isBatteryOptimizationEnabled,
} from "react-native-battery-optimization-check"; } from "~/lib/native/batteryOptimization";
import openSettings from "~/lib/native/openSettings"; import openSettings from "~/lib/native/openSettings";
import requestPermissionFcm from "~/permissions/requestPermissionFcm"; import requestPermissionFcm from "~/permissions/requestPermissionFcm";
@ -26,23 +26,19 @@ import * as Location from "expo-location";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import * as Contacts from "expo-contacts"; import * as Contacts from "expo-contacts";
// Battery optimization request handler
const requestBatteryOptimizationDisable = async () => { const requestBatteryOptimizationDisable = async () => {
if (Platform.OS !== "android") { if (Platform.OS !== "android") return true;
return true; // iOS doesn't have battery optimization
}
try { try {
const isEnabled = await BatteryOptEnabled(); const enabled = await isBatteryOptimizationEnabled();
if (isEnabled) { if (enabled) {
console.log("Battery optimization enabled, requesting to disable..."); console.log("Battery optimization enabled, requesting exemption...");
RequestDisableOptimization(); await requestBatteryOptimizationExemption();
// Return false as the user needs to interact with the system dialog // User must interact in Settings; will re-check on AppState 'active'
return false; return false;
} else {
console.log("Battery optimization already disabled");
return true;
} }
console.log("Battery optimization already disabled");
return true;
} catch (error) { } catch (error) {
console.error("Error handling battery optimization:", error); console.error("Error handling battery optimization:", error);
return false; return false;
@ -110,8 +106,8 @@ const checkPermissionStatus = async (permission) => {
return true; // iOS doesn't have battery optimization return true; // iOS doesn't have battery optimization
} }
try { try {
const isEnabled = await BatteryOptEnabled(); const enabled = await isBatteryOptimizationEnabled();
return !isEnabled; // Return true if optimization is disabled return !enabled; // true if optimization is disabled
} catch (error) { } catch (error) {
console.error("Error checking battery optimization:", error); console.error("Error checking battery optimization:", error);
return false; return false;
@ -200,24 +196,33 @@ export default function Permissions() {
const handleRequestPermission = async (permission) => { const handleRequestPermission = async (permission) => {
try { try {
const granted = await requestPermissions[permission](); let granted = false;
setPermissions[permission](granted);
// For battery optimization, we need to handle the async nature differently if (permission === "locationBackground") {
if ( // Ensure foreground location is granted first
permission === "batteryOptimizationDisabled" && const fgGranted = await checkPermissionStatus("locationForeground");
Platform.OS === "android" if (!fgGranted) {
) { const fgReq = await requestPermissionLocationForeground();
// Give a short delay for the system dialog to potentially complete setPermissions.locationForeground(fgReq);
setTimeout(async () => { if (!fgReq) {
const actualStatus = await checkPermissionStatus(permission); granted = false;
setPermissions[permission](actualStatus); } else {
}, 1000); granted = await requestPermissionLocationBackground();
}
} else {
granted = await requestPermissionLocationBackground();
}
setPermissions.locationBackground(granted);
} else { } else {
// Double-check the status to ensure UI is in sync granted = await requestPermissions[permission]();
const actualStatus = await checkPermissionStatus(permission); setPermissions[permission](granted);
setPermissions[permission](actualStatus);
} }
// Double-check the status to ensure UI is in sync.
// For battery optimization, this immediate check may still be 'enabled';
// we'll re-check again on AppState 'active' after returning from Settings.
const actualStatus = await checkPermissionStatus(permission);
setPermissions[permission](actualStatus);
} catch (error) { } catch (error) {
console.error(`Error requesting ${permission} permission:`, error); console.error(`Error requesting ${permission} permission:`, error);
} }

View file

@ -0,0 +1,109 @@
import { Platform } from "react-native";
import BackgroundFetch from "react-native-background-fetch";
import { createLogger } from "~/lib/logger";
import { executeHeartbeatSync } from "~/location/backgroundTask";
const backgroundFetchLogger = createLogger({
service: "background-fetch",
task: "service",
});
/**
* Initialize BackgroundFetch according to the documentation best practices.
* This should be called once when the root component mounts.
*/
export const initializeBackgroundFetch = async () => {
try {
backgroundFetchLogger.info("Initializing BackgroundFetch service");
// Configure BackgroundFetch for both platforms
const status = await BackgroundFetch.configure(
{
minimumFetchInterval: 15, // Only valid option - gives best chance of execution
},
// Event callback - handles both default fetch events and custom scheduled tasks
async (taskId) => {
backgroundFetchLogger.info("BackgroundFetch event received", {
taskId,
});
let syncResult = null;
try {
// Execute the shared heartbeat logic and get result
syncResult = await executeHeartbeatSync();
backgroundFetchLogger.debug("Heartbeat sync completed", {
syncResult,
});
} catch (error) {
backgroundFetchLogger.error("Heartbeat sync failed", {
error: error.message,
taskId,
});
} 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);
backgroundFetchLogger.debug("BackgroundFetch task finished", {
taskId,
fetchResult,
});
}
} catch (finishError) {
backgroundFetchLogger.error(
"Failed to finish BackgroundFetch task",
{
error: finishError.message,
taskId,
},
);
}
}
},
// Timeout callback (REQUIRED by BackgroundFetch API)
async (taskId) => {
backgroundFetchLogger.warn("BackgroundFetch task timeout", { taskId });
// CRITICAL: Must call finish on timeout with FAILED result
try {
BackgroundFetch.finish(taskId, BackgroundFetch.FETCH_RESULT_FAILED);
} catch (error) {
backgroundFetchLogger.error("Failed to finish timed out task", {
error: error.message,
taskId,
});
}
},
);
backgroundFetchLogger.info("BackgroundFetch configured successfully", {
status,
platform: Platform.OS,
});
return status;
} catch (error) {
backgroundFetchLogger.error("Failed to initialize BackgroundFetch", {
error: error.message,
stack: error.stack,
platform: Platform.OS,
});
throw error;
}
};