Compare commits

..

3 commits

Author SHA1 Message Date
b21a91bb9e
chore(release): 1.16.6 2026-01-22 12:42:35 +01:00
c7d0b36f1b
fix(track-location): try 4 2026-01-22 12:42:05 +01:00
6c8153bdb1
fix: permissions screen 2026-01-21 19:02:36 +01:00
7 changed files with 153 additions and 30 deletions

View file

@ -2,6 +2,15 @@
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.16.6](https://github.com/alerte-secours/as-app/compare/v1.16.5...v1.16.6) (2026-01-22)
### Bug Fixes
* permissions screen ([6c8153b](https://github.com/alerte-secours/as-app/commit/6c8153bdb114582f9a597a50fd2604f7b90dd181))
* **track-location:** try 3 ([41bb6fc](https://github.com/alerte-secours/as-app/commit/41bb6fcd2dc0999d212ceab0c9a6b6b64fe07cce))
* **track-location:** try 4 ([c7d0b36](https://github.com/alerte-secours/as-app/commit/c7d0b36f1bd3e700ad15bbc3ecff987e61aadbd9))
## [1.16.5](https://github.com/alerte-secours/as-app/compare/v1.16.4...v1.16.5) (2026-01-20) ## [1.16.5](https://github.com/alerte-secours/as-app/compare/v1.16.4...v1.16.5) (2026-01-20)

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 236 versionCode 237
versionName "1.16.5" versionName "1.16.6"
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

@ -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.16.5</string> <string>1.16.6</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>236</string> <string>237</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.16.5", "version": "1.16.6",
"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",
@ -53,8 +53,8 @@
"screenshot:android": "scripts/screenshot-android.sh" "screenshot:android": "scripts/screenshot-android.sh"
}, },
"customExpoVersioning": { "customExpoVersioning": {
"versionCode": 236, "versionCode": 237,
"buildNumber": 236 "buildNumber": 237
}, },
"commit-and-tag-version": { "commit-and-tag-version": {
"scripts": { "scripts": {

View file

@ -74,7 +74,10 @@ export const BASE_GEOLOCATION_CONFIG = {
method: "POST", method: "POST",
httpRootProperty: "location", httpRootProperty: "location",
batchSync: false, batchSync: false,
autoSync: true, // We intentionally disable autoSync and perform controlled uploads from explicit triggers
// (startup, identity-change, moving-edge, active-alert). This prevents stationary "ghost"
// uploads from low-quality locations produced by some Android devices.
autoSync: false,
// Persistence: keep enough records for offline catch-up. // Persistence: keep enough records for offline catch-up.
// (The SDK already constrains with maxDaysToPersist; records are deleted after successful upload.) // (The SDK already constrains with maxDaysToPersist; records are deleted after successful upload.)
@ -101,6 +104,7 @@ export const BASE_GEOLOCATION_INVARIANTS = {
speedJumpFilter: 100, speedJumpFilter: 100,
method: "POST", method: "POST",
httpRootProperty: "location", httpRootProperty: "location",
autoSync: false,
maxRecordsToPersist: 1000, maxRecordsToPersist: 1000,
maxDaysToPersist: 7, maxDaysToPersist: 7,
}; };
@ -113,10 +117,9 @@ export const TRACKING_PROFILES = {
// only periodic fixes (several times/hour). Note many config options like // only periodic fixes (several times/hour). Note many config options like
// `distanceFilter` / `stationaryRadius` are documented as having little/no // `distanceFilter` / `stationaryRadius` are documented as having little/no
// effect in this mode. // effect in this mode.
// Some iOS devices / user settings can result in unreliable significant-change wakeups. // Some devices / OEMs can be unreliable with significant-change only.
// We keep SLC for Android (battery), but fall back to standard motion tracking on iOS // Use standard motion tracking for reliability, with conservative distanceFilter.
// with a conservative distanceFilter. useSignificantChangesOnly: false,
useSignificantChangesOnly: Platform.OS !== "ios",
// Defensive: if some devices/platform conditions fall back to standard tracking, // Defensive: if some devices/platform conditions fall back to standard tracking,
// keep the distanceFilter conservative to avoid battery drain. // keep the distanceFilter conservative to avoid battery drain.

View file

@ -50,12 +50,46 @@ export default function trackLocation() {
let didDisableUploadsForAnonymous = false; let didDisableUploadsForAnonymous = false;
let didSyncAfterAuth = false; let didSyncAfterAuth = false;
let didSyncAfterStartupFix = false; let didSyncAfterStartupFix = false;
let lastMovingEdgeAt = 0;
const MOVING_EDGE_COOLDOWN_MS = 5 * 60 * 1000;
const BAD_ACCURACY_THRESHOLD_M = 200;
// Track identity so we can force a first geopoint when the effective user changes. // Track identity so we can force a first geopoint when the effective user changes.
let lastSessionUserId = null; let lastSessionUserId = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const pruneBadLocations = async () => {
try {
const locations = await BackgroundGeolocation.getLocations();
if (!Array.isArray(locations) || !locations.length) return 0;
let deleted = 0;
// Defensive: only scan a bounded amount to avoid heavy work.
const toScan = locations.slice(-200);
for (const loc of toScan) {
const acc = loc?.coords?.accuracy;
const uuid = loc?.uuid;
if (
typeof acc === "number" &&
acc > BAD_ACCURACY_THRESHOLD_M &&
uuid
) {
try {
await BackgroundGeolocation.destroyLocation(uuid);
deleted++;
} catch (e) {
// ignore
}
}
}
return deleted;
} catch (e) {
return 0;
}
};
const safeSync = async (reason) => { const safeSync = async (reason) => {
// Sync can fail transiently (SDK busy, network warming up, etc). Retry a few times. // Sync can fail transiently (SDK busy, network warming up, etc). Retry a few times.
for (let attempt = 1; attempt <= 3; attempt++) { for (let attempt = 1; attempt <= 3; attempt++) {
@ -65,6 +99,8 @@ export default function trackLocation() {
BackgroundGeolocation.getCount(), BackgroundGeolocation.getCount(),
]); ]);
const pruned = await pruneBadLocations();
locationLogger.info("Attempting BGGeo sync", { locationLogger.info("Attempting BGGeo sync", {
reason, reason,
attempt, attempt,
@ -72,6 +108,7 @@ export default function trackLocation() {
isMoving: state?.isMoving, isMoving: state?.isMoving,
trackingMode: state?.trackingMode, trackingMode: state?.trackingMode,
pendingBefore, pendingBefore,
pruned,
}); });
const records = await BackgroundGeolocation.sync(); const records = await BackgroundGeolocation.sync();
@ -421,7 +458,7 @@ export default function trackLocation() {
} catch (e) { } catch (e) {
currentSessionUserId = null; currentSessionUserId = null;
} }
if (!userToken) { if (!userToken && !currentSessionUserId) {
// Pre-login mode: keep tracking enabled but disable uploads. // Pre-login mode: keep tracking enabled but disable uploads.
// Also applies to logout: keep tracking on (per product requirement: track all the time), // Also applies to logout: keep tracking on (per product requirement: track all the time),
// but stop sending anything to server without auth. // but stop sending anything to server without auth.
@ -491,7 +528,7 @@ export default function trackLocation() {
locationLogger.debug("Updating background geolocation config"); locationLogger.debug("Updating background geolocation config");
await BackgroundGeolocation.setConfig({ await BackgroundGeolocation.setConfig({
url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging url: env.GEOLOC_SYNC_URL, // Update the sync URL for when it's changed for staging
autoSync: true, autoSync: false,
headers: { headers: {
Authorization: `Bearer ${userToken}`, Authorization: `Bearer ${userToken}`,
}, },
@ -618,6 +655,30 @@ export default function trackLocation() {
// The final persisted location will arrive with sample=false. // The final persisted location will arrive with sample=false.
if (location.sample) return; if (location.sample) return;
// Quality gate: delete very poor-accuracy locations to prevent them from being synced
// and ignore them for UI/state.
const acc = location?.coords?.accuracy;
if (typeof acc === "number" && acc > BAD_ACCURACY_THRESHOLD_M) {
locationLogger.info("Ignoring poor-accuracy location", {
accuracy: acc,
uuid: location?.uuid,
});
if (location?.uuid) {
try {
await BackgroundGeolocation.destroyLocation(location.uuid);
locationLogger.debug("Destroyed poor-accuracy location", {
uuid: location.uuid,
});
} catch (e) {
locationLogger.warn("Failed to destroy poor-accuracy location", {
uuid: location?.uuid,
error: e?.message,
});
}
}
return;
}
if ( if (
location.coords && location.coords &&
location.coords.latitude && location.coords.latitude &&
@ -681,6 +742,46 @@ export default function trackLocation() {
isMoving: event?.isMoving, isMoving: event?.isMoving,
location: event?.location?.coords, location: event?.location?.coords,
}); });
// Moving-edge strategy: when we enter moving state, force one persisted high-quality
// point + sync so the server gets a quick update.
//
// IMPORTANT: Restrict this to ACTIVE tracking only. On Android, motion detection can
// produce false-positive moving transitions while the device is stationary (screen-off),
// which would otherwise trigger unwanted background uploads.
// Cooldown to avoid repeated work due to motion jitter.
if (event?.isMoving && authReady && currentProfile === "active") {
const now = Date.now();
if (now - lastMovingEdgeAt >= MOVING_EDGE_COOLDOWN_MS) {
lastMovingEdgeAt = now;
(async () => {
try {
const fix = await BackgroundGeolocation.getCurrentPosition({
samples: 1,
persist: true,
timeout: 30,
maximumAge: 0,
desiredAccuracy: 50,
extras: {
moving_edge: true,
},
});
locationLogger.info("Moving-edge fix acquired", {
accuracy: fix?.coords?.accuracy,
latitude: fix?.coords?.latitude,
longitude: fix?.coords?.longitude,
timestamp: fix?.timestamp,
});
} catch (e) {
locationLogger.warn("Moving-edge fix failed", {
error: e?.message,
stack: e?.stack,
});
}
await safeSync("moving-edge");
})();
}
}
}, },
onActivityChange: (event) => { onActivityChange: (event) => {
locationLogger.info("Activity change", { locationLogger.info("Activity change", {

View file

@ -1,4 +1,4 @@
import React, { useEffect, useCallback, useRef } from "react"; import React, { useEffect, useCallback, useMemo, useRef } from "react";
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@ -309,8 +309,10 @@ const PermissionItem = ({
}; };
export default function Permissions() { export default function Permissions() {
// Create permissions list based on platform // IMPORTANT: keep a stable permissions list across renders.
const getPermissionsList = () => { // If this array changes identity each render, it re-creates callbacks and can
// trigger a re-render loop via permission checks + store updates.
const permissionsList = useMemo(() => {
const basePermissions = [ const basePermissions = [
"fcm", "fcm",
"phoneCall", "phoneCall",
@ -319,29 +321,31 @@ export default function Permissions() {
"motion", "motion",
"readContacts", "readContacts",
]; ];
return Platform.OS === "android"
// Add battery optimization only on Android ? [...basePermissions, "batteryOptimizationDisabled"]
if (Platform.OS === "android") { : basePermissions;
return [...basePermissions, "batteryOptimizationDisabled"]; }, []);
}
return basePermissions;
};
const permissionsList = getPermissionsList();
const permissionsState = usePermissionsState(permissionsList); const permissionsState = usePermissionsState(permissionsList);
const titleRef = useRef(null); const titleRef = useRef(null);
const lastAnnouncementRef = useRef({}); const lastAnnouncementRef = useRef({});
const lastSetPermissionRef = useRef({});
const lastBlockedMapRef = useRef(null);
// We keep a minimal, best-effort blocked map for a11y/UX. // We keep a minimal, best-effort blocked map for a11y/UX.
const [blockedMap, setBlockedMap] = React.useState({}); const [blockedMap, setBlockedMap] = React.useState({});
// Memoize the check permissions function // Memoize the check permissions function.
// NOTE: Do NOT depend on permissionsState here (it changes on each store update),
// or we can re-run effects and create a log spam loop.
const checkAllPermissions = useCallback(async () => { const checkAllPermissions = useCallback(async () => {
// Update store only when values actually change, to avoid churn.
for (const permission of permissionsList) { for (const permission of permissionsList) {
const status = await checkPermissionStatus(permission); const status = await checkPermissionStatus(permission);
setPermissions[permission](status); if (lastSetPermissionRef.current[permission] !== status) {
lastSetPermissionRef.current[permission] = status;
setPermissions[permission](status);
}
} }
// Also refresh "blocked" state used for a11y guidance. // Also refresh "blocked" state used for a11y guidance.
@ -350,7 +354,13 @@ export default function Permissions() {
const meta = await getPermissionA11yMeta(permission); const meta = await getPermissionA11yMeta(permission);
nextBlocked[permission] = !!meta.blocked; nextBlocked[permission] = !!meta.blocked;
} }
setBlockedMap(nextBlocked);
// Avoid re-setting state if unchanged (prevents extra re-renders).
const nextBlockedKey = JSON.stringify(nextBlocked);
if (lastBlockedMapRef.current !== nextBlockedKey) {
lastBlockedMapRef.current = nextBlockedKey;
setBlockedMap(nextBlocked);
}
}, [permissionsList]); }, [permissionsList]);
// Check all permissions when component mounts // Check all permissions when component mounts