Compare commits

...

22 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
33 changed files with 1027 additions and 283 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,52 @@
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) ## [1.11.13](https://github.com/alerte-secours/as-app/compare/v1.11.12...v1.11.13) (2025-07-24)

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 203 versionCode 211
versionName "1.11.13" 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

@ -6,7 +6,6 @@ import "expo-splash-screen";
import BackgroundGeolocation from "react-native-background-geolocation"; import BackgroundGeolocation from "react-native-background-geolocation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import BackgroundFetch from "react-native-background-fetch";
import notifee from "@notifee/react-native"; import notifee from "@notifee/react-native";
import messaging from "@react-native-firebase/messaging"; import messaging from "@react-native-firebase/messaging";
@ -21,7 +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 { executeHeartbeatSync } from "~/location/backgroundTask"; // import { executeHeartbeatSync } from "~/location/backgroundTask";
// setup notification, this have to stay in index.js // setup notification, this have to stay in index.js
notifee.onBackgroundEvent(notificationBackgroundEvent); notifee.onBackgroundEvent(notificationBackgroundEvent);
@ -37,81 +36,23 @@ const geolocBgLogger = createLogger({
task: "headless", task: "headless",
}); });
const HeadlessTask = async (event) => { // const HeadlessTask = async (event) => {
try { // try {
switch (event?.name) { // switch (event?.name) {
case "heartbeat": // case "heartbeat":
await executeHeartbeatSync(); // await executeHeartbeatSync();
break; // break;
default: // default:
break; // break;
} // }
} catch (error) { // } catch (error) {
geolocBgLogger.error("HeadlessTask error", { // geolocBgLogger.error("HeadlessTask error", {
error, // error,
event, // event,
}); // });
} // }
}; // };
if (Platform.OS === "android") { // if (Platform.OS === "android") {
BackgroundGeolocation.registerHeadlessTask(HeadlessTask); // BackgroundGeolocation.registerHeadlessTask(HeadlessTask);
} else if (Platform.OS === "ios") { // }
BackgroundGeolocation.onLocation(async () => {
await executeHeartbeatSync();
});
BackgroundGeolocation.onMotionChange(async () => {
await executeHeartbeatSync();
});
// Configure BackgroundFetch for iOS (iOS-specific configuration)
BackgroundFetch.configure(
{
minimumFetchInterval: 15, // Only valid option for iOS - gives best chance of execution
},
// Event callback
async (taskId) => {
let syncResult = null;
try {
// Execute the shared heartbeat logic and get result
syncResult = await executeHeartbeatSync();
} catch (error) {
// silent error
} finally {
// CRITICAL: Always call finish with appropriate result
try {
if (taskId) {
let fetchResult;
if (syncResult?.error || !syncResult?.syncSuccessful) {
// Task failed
fetchResult = BackgroundFetch.FETCH_RESULT_FAILED;
} else if (
syncResult?.syncPerformed &&
syncResult?.syncSuccessful
) {
// Force sync was performed successfully - new data
fetchResult = BackgroundFetch.FETCH_RESULT_NEW_DATA;
} else {
// No sync was needed - no new data
fetchResult = BackgroundFetch.FETCH_RESULT_NO_DATA;
}
BackgroundFetch.finish(taskId, fetchResult);
}
} catch (finishError) {
// silent error
}
}
},
// Timeout callback (REQUIRED by BackgroundFetch API)
async (taskId) => {
// CRITICAL: Must call finish on timeout with FAILED result
BackgroundFetch.finish(taskId, BackgroundFetch.FETCH_RESULT_FAILED);
},
).catch(() => {
// silent error
});
}

View file

@ -163,7 +163,12 @@
8EC12A68941D40E98E0D60BE /* Fix Xcode 15 Bug */, 8EC12A68941D40E98E0D60BE /* Fix Xcode 15 Bug */,
49AEAB1D332B45ED9A37B009 /* Fix Xcode 15 Bug */, 49AEAB1D332B45ED9A37B009 /* Fix Xcode 15 Bug */,
D75A41050AB3445786799848 /* 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 = ( buildRules = (
); );
@ -576,6 +581,176 @@ fi";
shellScript = " shellScript = "
echo \"Remove signature files (Xcode workaround)\"; echo \"Remove signature files (Xcode workaround)\";
rm -rf \"$CONFIGURATION_BUILD_DIR/MapLibre.xcframework-ios.signature\"; 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 */

View file

@ -25,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.13</string> <string>1.12.3</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -48,7 +48,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>203</string> <string>211</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>

View file

@ -1,6 +1,6 @@
{ {
"name": "alerte-secours", "name": "alerte-secours",
"version": "1.11.13", "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": 203, "versionCode": 211,
"buildNumber": 203 "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();
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(); const status = await this.soundObject.getStatusAsync();
this.setState({ duration: status.durationMillis }); 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() {
if (this.soundObject) {
await this.soundObject.unloadAsync(); 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

@ -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 { 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";

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

@ -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

@ -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,7 +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"; // import notifGeolocationHeartbeatSync from "./channels/notifGeolocationHeartbeatSync.js.bak";
const displayLogger = createLogger({ const displayLogger = createLogger({
module: BACKGROUND_SCOPES.NOTIFICATIONS, module: BACKGROUND_SCOPES.NOTIFICATIONS,
@ -23,7 +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, // "geolocation-heartbeat-sync": notifGeolocationHeartbeatSync,
}; };
export default async function displayNotificationHandler(data) { export default async function displayNotificationHandler(data) {

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"); console.log("Battery optimization already disabled");
return true; 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);
}, 1000);
} else { } else {
// Double-check the status to ensure UI is in sync granted = await requestPermissionLocationBackground();
}
} else {
granted = await requestPermissionLocationBackground();
}
setPermissions.locationBackground(granted);
} else {
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); const actualStatus = await checkPermissionStatus(permission);
setPermissions[permission](actualStatus); 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;
}
};