Compare commits

...

12 commits

19 changed files with 373 additions and 101 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,27 @@
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) ## [1.12.0](https://github.com/alerte-secours/as-app/compare/v1.11.17...v1.12.0) (2025-08-02)

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 208 versionCode 211
versionName "1.12.0" 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

@ -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.12.0</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>208</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.12.0", "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": 208, "versionCode": 211,
"buildNumber": 208 "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

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

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

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

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