Compare commits

...

24 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
33 changed files with 1034 additions and 279 deletions

5
.gitignore vendored
View file

@ -84,6 +84,9 @@ DerivedData
# aidigest
codebase.md
# Build logs
logs/
# Sensitive configuration files
ios/GoogleService-Info.plist
ios/AlerteSecours/GoogleService-Info.plist
@ -96,4 +99,4 @@ android/app/google-services.json
!ios/AlerteSecours/Supporting/Expo.example.plist
!android/app/google-services.example.json
screenshot-*.png
screenshot-*.png

View file

@ -2,6 +2,59 @@
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)

View file

@ -83,8 +83,8 @@ android {
applicationId 'com.alertesecours'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 202
versionName "1.11.12"
versionCode 211
versionName "1.12.3"
multiDexEnabled true
testBuildType System.getProperty('testBuildType', 'debug')
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_COARSE_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.INTERNET"/>
<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.RECEIVE_BOOT_COMPLETED"/>
<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.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>

View file

@ -6,7 +6,6 @@ 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";
@ -21,7 +20,7 @@ import { onBackgroundEvent as notificationBackgroundEvent } from "~/notification
import onMessageReceived from "~/notifications/onMessageReceived";
import { createLogger } from "~/lib/logger";
import { executeHeartbeatSync } from "~/location/backgroundTask";
// import { executeHeartbeatSync } from "~/location/backgroundTask";
// setup notification, this have to stay in index.js
notifee.onBackgroundEvent(notificationBackgroundEvent);
@ -37,77 +36,23 @@ const geolocBgLogger = createLogger({
task: "headless",
});
const HeadlessTask = async (event) => {
try {
switch (event?.name) {
case "heartbeat":
await executeHeartbeatSync();
break;
default:
break;
}
} catch (error) {
geolocBgLogger.error("HeadlessTask error", {
error,
event,
});
}
};
// const HeadlessTask = async (event) => {
// try {
// switch (event?.name) {
// case "heartbeat":
// await executeHeartbeatSync();
// break;
// default:
// break;
// }
// } catch (error) {
// geolocBgLogger.error("HeadlessTask error", {
// error,
// event,
// });
// }
// };
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
});
}
// if (Platform.OS === "android") {
// BackgroundGeolocation.registerHeadlessTask(HeadlessTask);
// }

View file

@ -163,7 +163,12 @@
8EC12A68941D40E98E0D60BE /* Fix Xcode 15 Bug */,
49AEAB1D332B45ED9A37B009 /* Fix Xcode 15 Bug */,
D75A41050AB3445786799848 /* Fix Xcode 15 Bug */,
ABC6C5A0D48A4B7980D60E1B /* Remove signature files (Xcode workaround) */,
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 = (
);
@ -576,6 +581,176 @@ fi";
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 */

View file

@ -25,7 +25,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.11.12</string>
<string>1.12.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -48,7 +48,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>202</string>
<string>211</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>

View file

@ -1,6 +1,6 @@
{
"name": "alerte-secours",
"version": "1.11.12",
"version": "1.12.3",
"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": 202,
"buildNumber": 202
"versionCode": 211,
"buildNumber": 211
},
"commit-and-tag-version": {
"scripts": {

View file

@ -86,6 +86,9 @@ mv ios/main.jsbundle.hbc ios/main.jsbundle
cd ios
# Create logs directory if it doesn't exist
mkdir -p ../logs
# Create archive using xcodebuild
echo "Creating archive..."
xcodebuild \
@ -93,6 +96,6 @@ xcodebuild \
-scheme AlerteSecours \
-configuration Release \
-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"

View file

@ -25,6 +25,8 @@ import { useUpdates } from "~/updates";
import Error from "~/components/Error";
import useTrackLocation from "~/hooks/useTrackLocation";
// import { initializeBackgroundFetch } from "~/services/backgroundFetch";
import useMount from "~/hooks/useMount";
const appLogger = createLogger({
module: SYSTEM_SCOPES.APP,
@ -219,6 +221,23 @@ function AppContent() {
useNetworkListener();
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
useEffect(() => {
let subscription;

View file

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

View file

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

View file

@ -24,7 +24,13 @@ export default function MessageRow({
const { contentType, text, audioFileUuid, userId, createdAt, username } = row;
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;

View file

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

View file

@ -1,6 +1,6 @@
import * as Location from "expo-location";
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 { BACKGROUND_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";

View file

@ -110,6 +110,7 @@ class AudioSlider extends PureComponent {
}
mapAudioToCurrentTime = async () => {
if (!this.soundObject) return;
await this.soundObject.setPositionAsync(this.state.currentTime);
};
@ -122,6 +123,7 @@ class AudioSlider extends PureComponent {
};
play = async () => {
if (!this.soundObject) return;
if (this.registry && this.pauseAllBeforePlay) {
const players = this.registry.getAll();
await Promise.all(
@ -134,12 +136,14 @@ class AudioSlider extends PureComponent {
};
pause = async () => {
if (!this.soundObject) return;
await this.soundObject.pauseAsync();
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()
};
startMovingDot = async () => {
if (!this.soundObject) return;
const status = await this.soundObject.getStatusAsync();
const durationLeft = status["durationMillis"] - status["positionMillis"];
@ -156,6 +160,7 @@ class AudioSlider extends PureComponent {
// Audio has been paused
return;
}
if (!this.soundObject) return;
// Animation-duration is over (reset Animation and Audio):
await sleep(200); // In case animation has finished, but audio has not
this.setState({ playing: false });
@ -164,6 +169,16 @@ class AudioSlider extends PureComponent {
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) => {
this.setState({ trackLayout: event.nativeEvent.layout }); // {x, y, width, height}
};
@ -171,27 +186,92 @@ class AudioSlider extends PureComponent {
async componentDidMount() {
// https://github.com/olapiv/expo-audio-player/issues/13
const loadAudio = async () => {
try {
const { sound: newSound } = await Audio.Sound.createAsync({
uri: this.props.audio,
});
this.soundObject = newSound;
const audioUrl = this.props.audio;
// // 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) => {
if (!status.didJustFinish) return;
this.soundObject.unloadAsync().catch(() => {});
this.handlePlaybackFinished();
});
} catch (error) {
console.log("Error loading audio:", error);
return;
} 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();
const status = await this.soundObject.getStatusAsync();
this.setState({ duration: status.durationMillis });
if (!this.soundObject) {
// 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.state.dotOffset.addListener(() => {
@ -207,7 +287,9 @@ class AudioSlider extends PureComponent {
}
async componentWillUnmount() {
await this.soundObject.unloadAsync();
if (this.soundObject) {
await this.soundObject.unloadAsync();
}
this.state.dotOffset.removeAllListeners();
if (this.registry) {
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";
export function phoneCallEmergency() {
const emergencyNumber = "+112";
const emergencyNumber = "112";
if (Platform.OS === "ios") {
// Use telprompt URL scheme on iOS

View file

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

View file

@ -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 setLocationState from "~/location/setLocationState";
import { storeLocation } from "~/utils/location/storage";
import { storeLocation } from "~/location/storage";
import env from "~/env";

View file

@ -1,5 +1,5 @@
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.",
green:
"Entre-aide citoyenne localisée.\nJ'ai besoin d'une aide rapide à proximité.",

View file

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

View file

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

View file

@ -88,9 +88,9 @@ export const generateSuggestKeepOpenContent = (data) => {
export const generateBackgroundGeolocationLostContent = (data) => {
return {
title: `Alerte-Secours ne peut plus accéder à votre position`,
body: `Vous ne pouvez 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.`,
title: `Alerte-Secours ne reçoit plus de mises à jour de votre position`,
body: `Vous ne pourrez plus recevoir d'alertes de proximité. Vérifiez les paramètres.`,
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,7 +8,7 @@ import notifSuggestKeepOpen from "./channels/notifSuggestKeepOpen";
import notifRelativeAllowAsk from "./channels/notifRelativeAllowAsk";
import notifRelativeInvitation from "./channels/notifRelativeInvitation";
import notifBackgroundGeolocationLost from "./channels/notifBackgroundGeolocationLost";
import notifGeolocationHeartbeatSync from "./channels/notifGeolocationHeartbeatSync";
// import notifGeolocationHeartbeatSync from "./channels/notifGeolocationHeartbeatSync.js.bak";
const displayLogger = createLogger({
module: BACKGROUND_SCOPES.NOTIFICATIONS,
@ -23,7 +23,7 @@ const SUPPORTED_ACTIONS = {
"relative-allow-ask": notifRelativeAllowAsk,
"relative-invitation": notifRelativeInvitation,
"background-geolocation-lost": notifBackgroundGeolocationLost,
"geolocation-heartbeat-sync": notifGeolocationHeartbeatSync,
// "geolocation-heartbeat-sync": notifGeolocationHeartbeatSync,
};
export default async function displayNotificationHandler(data) {

View file

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

View file

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

View file

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

View file

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