diff --git a/scripts/dae/geodae-to-csv.js b/scripts/dae/geodae-to-csv.js index 3e838d1..b2e8178 100644 --- a/scripts/dae/geodae-to-csv.js +++ b/scripts/dae/geodae-to-csv.js @@ -191,7 +191,14 @@ function formatAddress(p) { // Strip parenthesized cp from city name, e.g. "GANAC (09000)" → "GANAC" let city = (p.c_com_nom || "").trim(); if (cp && city) { - city = city.replace(new RegExp("\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)"), "").trim(); + city = city + .replace( + new RegExp( + "\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)", + ), + "", + ) + .trim(); } // Strip cp+city already embedded in street field @@ -199,7 +206,9 @@ function formatAddress(p) { if (cp && street.includes(cp)) { const cpEscaped = cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Trailing: "street 38200 Vienne" → "street" - street = street.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "").trim(); + street = street + .replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "") + .trim(); // Leading: "62117 rue de Lambres" → "rue de Lambres" street = street.replace(new RegExp("^" + cpEscaped + "\\s+"), "").trim(); } @@ -359,7 +368,8 @@ function fixCoordinates(lat, lon, geometry) { // 3. Try power-of-10 normalization for misplaced decimals const fixedLat = tryNormalizeCoord(lat, 90); const fixedLon = tryNormalizeCoord(lon, 180); - if (isPlausibleFrance(fixedLat, fixedLon)) return { lat: fixedLat, lon: fixedLon }; + if (isPlausibleFrance(fixedLat, fixedLon)) + return { lat: fixedLat, lon: fixedLon }; return null; } diff --git a/src/db/openDb.js b/src/db/openDb.js index 8468147..e40192d 100644 --- a/src/db/openDb.js +++ b/src/db/openDb.js @@ -50,6 +50,28 @@ export default function getDb() { return _dbPromise; } +/** + * Close the current DB connection and clear all cached state. + * After calling this, the next `getDb()` / `getDbSafe()` call will re-open + * the DB from disk — picking up any file that was swapped in the meantime. + */ +export function resetDb() { + // Close the op-sqlite backend if it was loaded. + try { + // eslint-disable-next-line global-require + const { resetDbOpSqlite } = require("./openDbOpSqlite"); + if (typeof resetDbOpSqlite === "function") { + resetDbOpSqlite(); + } + } catch { + // op-sqlite not available — nothing to close. + } + + _dbPromise = null; + _backendPromise = null; + _selectedBackendName = null; +} + /** * Non-throwing DB opener. * diff --git a/src/db/openDbOpSqlite.js b/src/db/openDbOpSqlite.js index 4a7cbf7..1fc9734 100644 --- a/src/db/openDbOpSqlite.js +++ b/src/db/openDbOpSqlite.js @@ -212,6 +212,24 @@ async function openDbOpSqlite() { return _dbPromise; } +/** + * Close the current DB connection and clear cached promises. + * After calling this, the next `openDbOpSqlite()` call will re-open the DB. + */ +function resetDbOpSqlite() { + if (_rawDb) { + try { + if (typeof _rawDb.close === "function") { + _rawDb.close(); + } + } catch { + // Non-fatal: DB may already be closed or in an invalid state. + } + _rawDb = null; + } + _dbPromise = null; +} + // Exports (CJS + ESM-ish): // Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes // cannot hand back a nullish / unexpected callable export shape. @@ -220,6 +238,7 @@ module.exports = { openDbOpSqlite, openDb: openDbOpSqlite, default: openDbOpSqlite, + resetDbOpSqlite, // Named export for unit tests. adaptDbToRepoInterface, }; diff --git a/src/db/updateDaeDb.js b/src/db/updateDaeDb.js new file mode 100644 index 0000000..f56311d --- /dev/null +++ b/src/db/updateDaeDb.js @@ -0,0 +1,213 @@ +// Over-the-air DAE database update. +// +// Downloads a fresh geodae.db from the Minio/S3 bucket, validates it, +// swaps the on-device copy, and resets the DB connection so subsequent +// queries use the new data. +// +// IMPORTANT: +// - All native requires must stay inside functions so this file can be loaded +// in Jest/node without crashing. + +import env from "~/env"; +import { STORAGE_KEYS } from "~/storage/storageKeys"; + +const DB_NAME = "geodae.db"; +const GEODAE_BUCKET = "geodae"; +const METADATA_FILE = "metadata.json"; + +/** + * Build the public Minio URL for a given bucket/object. + * @param {string} object - object key within the geodae bucket + * @returns {string} + */ +function geodaeUrl(object) { + const base = env.MINIO_URL.replace(/\/+$/, ""); + return `${base}/${GEODAE_BUCKET}/${object}`; +} + +/** + * @typedef {Object} UpdateProgress + * @property {number} totalBytesWritten + * @property {number} totalBytesExpectedToWrite + */ + +/** + * @typedef {Object} UpdateResult + * @property {boolean} success + * @property {boolean} [alreadyUpToDate] + * @property {string} [updatedAt] + * @property {Error} [error] + */ + +/** + * Download and install the latest geodae.db from the server. + * + * @param {Object} options + * @param {function(UpdateProgress): void} [options.onProgress] - download progress callback + * @param {function(string): void} [options.onPhase] - phase change callback ("checking"|"downloading"|"installing") + * @returns {Promise} + */ +export async function updateDaeDb({ onProgress, onPhase } = {}) { + // Lazy requires to keep Jest/node stable. + // eslint-disable-next-line global-require + const FileSystemModule = require("expo-file-system"); + const FileSystem = FileSystemModule?.default ?? FileSystemModule; + + const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`; + const dbUri = `${sqliteDirUri}/${DB_NAME}`; + const tmpUri = `${FileSystem.cacheDirectory}geodae-update-${Date.now()}.db`; + + try { + // ── Phase 1: Check metadata ────────────────────────────────────────── + onPhase?.("checking"); + + const metadataUrl = geodaeUrl(METADATA_FILE); + const metaResponse = await fetch(metadataUrl); + if (!metaResponse.ok) { + throw new Error( + `[DAE_UPDATE] Failed to fetch metadata: HTTP ${metaResponse.status}`, + ); + } + const metadata = await metaResponse.json(); + const remoteUpdatedAt = metadata.updatedAt; + + if (!remoteUpdatedAt) { + throw new Error("[DAE_UPDATE] Metadata missing updatedAt field"); + } + + // Compare with stored last update timestamp + // eslint-disable-next-line global-require + const memoryAsyncStorageModule = require("~/storage/memoryAsyncStorage"); + const memoryAsyncStorage = + memoryAsyncStorageModule?.default ?? memoryAsyncStorageModule; + const storedUpdatedAt = await memoryAsyncStorage.getItem( + STORAGE_KEYS.DAE_DB_UPDATED_AT, + ); + + if ( + storedUpdatedAt && + new Date(remoteUpdatedAt).getTime() <= new Date(storedUpdatedAt).getTime() + ) { + return { success: true, alreadyUpToDate: true }; + } + + // ── Phase 2: Download ──────────────────────────────────────────────── + onPhase?.("downloading"); + + const dbUrl = geodaeUrl(DB_NAME); + const downloadResumable = FileSystem.createDownloadResumable( + dbUrl, + tmpUri, + {}, + onProgress, + ); + const downloadResult = await downloadResumable.downloadAsync(); + + if (!downloadResult?.uri) { + throw new Error("[DAE_UPDATE] Download failed: no URI returned"); + } + + // Verify the downloaded file is non-empty + const tmpInfo = await FileSystem.getInfoAsync(tmpUri); + if (!tmpInfo.exists || tmpInfo.size === 0) { + throw new Error("[DAE_UPDATE] Downloaded file is empty or missing"); + } + + // ── Phase 3: Validate ──────────────────────────────────────────────── + onPhase?.("installing"); + + // Quick validation: open the downloaded DB and check schema + // We use the same validation as the main DB opener. + // eslint-disable-next-line global-require + const { assertDbHasTable } = require("./validateDbSchema"); + + // Try to open the temp DB with op-sqlite for validation + let validationDb = null; + try { + // eslint-disable-next-line global-require + const opSqliteMod = require("@op-engineering/op-sqlite"); + const open = opSqliteMod?.open ?? opSqliteMod?.default?.open; + if (typeof open === "function") { + // op-sqlite needs the directory and filename separately + const tmpDir = tmpUri.substring(0, tmpUri.lastIndexOf("/")); + const tmpName = tmpUri.substring(tmpUri.lastIndexOf("/") + 1); + validationDb = open({ name: tmpName, location: tmpDir }); + + // Wrap for assertDbHasTable compatibility + const getAllAsync = async (sql, params = []) => { + const exec = + typeof validationDb.executeAsync === "function" + ? validationDb.executeAsync.bind(validationDb) + : validationDb.execute?.bind(validationDb); + if (!exec) throw new Error("No execute method on validation DB"); + const res = params.length ? await exec(sql, params) : await exec(sql); + return res?.rows ?? []; + }; + + await assertDbHasTable({ getAllAsync }, "defibs"); + } + } catch (validationError) { + // Clean up temp file + try { + await FileSystem.deleteAsync(tmpUri, { idempotent: true }); + } catch { + // ignore cleanup errors + } + const err = new Error("[DAE_UPDATE] Downloaded DB failed validation"); + err.cause = validationError; + throw err; + } finally { + // Close validation DB + if (validationDb && typeof validationDb.close === "function") { + try { + validationDb.close(); + } catch { + // ignore + } + } + } + + // ── Phase 4: Swap ──────────────────────────────────────────────────── + // IMPORTANT: resetDb() closes the DB and clears cached promises. + // No concurrent DB queries should be in flight at this point. + // The caller (store action) is the only code path that triggers this, + // and it awaits completion before allowing new queries. + // eslint-disable-next-line global-require + const { resetDb } = require("./openDb"); + resetDb(); + + // Ensure SQLite directory exists + const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(sqliteDirUri, { + intermediates: true, + }); + } + + // Replace the DB file + await FileSystem.moveAsync({ from: tmpUri, to: dbUri }); + + // Persist the update timestamp + await memoryAsyncStorage.setItem( + STORAGE_KEYS.DAE_DB_UPDATED_AT, + remoteUpdatedAt, + ); + + console.warn( + "[DAE_UPDATE] Successfully updated geodae.db to version:", + remoteUpdatedAt, + ); + + return { success: true, updatedAt: remoteUpdatedAt }; + } catch (error) { + // Clean up temp file on any error (FileSystem is in scope from the outer try) + try { + await FileSystem.deleteAsync(tmpUri, { idempotent: true }); + } catch { + // ignore cleanup errors + } + + console.warn("[DAE_UPDATE] Update failed:", error?.message, error); + return { success: false, error }; + } +} diff --git a/src/network/NetworkProviders.js b/src/network/NetworkProviders.js index 77f8b27..1e9f721 100644 --- a/src/network/NetworkProviders.js +++ b/src/network/NetworkProviders.js @@ -57,7 +57,9 @@ network.oaFilesKy = oaFilesKy; export default function NetworkProviders({ children }) { const [key, setKey] = useState(0); - const [transportClient, setTransportClient] = useState(() => network.apolloClient); + const [transportClient, setTransportClient] = useState( + () => network.apolloClient, + ); const networkState = useNetworkState([ "initialized", diff --git a/src/scenes/DAEItem/Carte.js b/src/scenes/DAEItem/Carte.js index 28788f3..6cc1f9c 100644 --- a/src/scenes/DAEItem/Carte.js +++ b/src/scenes/DAEItem/Carte.js @@ -23,7 +23,10 @@ import { useTheme } from "~/theme"; import { useDefibsState, useNetworkState } from "~/stores"; import useLocation from "~/hooks/useLocation"; import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; -import { osmProfileUrl, profileDefaultModes } from "~/scenes/AlertCurMap/routing"; +import { + osmProfileUrl, + profileDefaultModes, +} from "~/scenes/AlertCurMap/routing"; import { routeToInstructions } from "~/lib/geo/osrmTextInstructions"; import { announceForA11yIfScreenReaderEnabled, @@ -172,15 +175,12 @@ export default React.memo(function DAEItemCarte() { const mapHeadSeeAllRef = useRef(null); const lastStepsTriggerRef = useRef(null); - const openStepper = useCallback( - (triggerRef) => { - if (triggerRef) { - lastStepsTriggerRef.current = triggerRef; - } - setStepperIsOpened(true); - }, - [], - ); + const openStepper = useCallback((triggerRef) => { + if (triggerRef) { + lastStepsTriggerRef.current = triggerRef; + } + setStepperIsOpened(true); + }, []); const closeStepper = useCallback(() => { setStepperIsOpened(false); diff --git a/src/scenes/DAEItem/Infos.js b/src/scenes/DAEItem/Infos.js index 6d6ae23..411ead7 100644 --- a/src/scenes/DAEItem/Infos.js +++ b/src/scenes/DAEItem/Infos.js @@ -392,6 +392,7 @@ export default React.memo(function DAEItemInfos() { {/* In-app navigation option */} openExternalApp(app)} style={modalStyles.option} activeOpacity={0.6} diff --git a/src/scenes/DAEList/Carte.js b/src/scenes/DAEList/Carte.js index 8ababdf..5b8dfaf 100644 --- a/src/scenes/DAEList/Carte.js +++ b/src/scenes/DAEList/Carte.js @@ -146,9 +146,7 @@ export default React.memo(function DAEListCarte() { // Waiting for location if (!hasLocation && defibs.length === 0 && !hasCoords) { - return ( - - ); + return ; } // Loading defibs from database diff --git a/src/scenes/DAEList/DaeUpdateBanner.js b/src/scenes/DAEList/DaeUpdateBanner.js new file mode 100644 index 0000000..e995398 --- /dev/null +++ b/src/scenes/DAEList/DaeUpdateBanner.js @@ -0,0 +1,309 @@ +import React, { useEffect, useCallback } from "react"; +import { View, StyleSheet, TouchableOpacity } from "react-native"; +import { ProgressBar, ActivityIndicator } from "react-native-paper"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; + +import Text from "~/components/Text"; +import { useTheme } from "~/theme"; +import { defibsActions, useDefibsState } from "~/stores"; + +function formatDate(isoString) { + if (!isoString) return null; + try { + const d = new Date(isoString); + return d.toLocaleDateString("fr-FR", { + day: "numeric", + month: "long", + year: "numeric", + }); + } catch { + return null; + } +} + +export default React.memo(function DaeUpdateBanner() { + const { colors } = useTheme(); + const { + daeUpdateState, + daeUpdateProgress, + daeUpdateError, + daeLastUpdatedAt, + } = useDefibsState([ + "daeUpdateState", + "daeUpdateProgress", + "daeUpdateError", + "daeLastUpdatedAt", + ]); + + // Load persisted last-update date on mount + useEffect(() => { + defibsActions.loadLastDaeUpdate(); + }, []); + + const handleUpdate = useCallback(() => { + defibsActions.triggerDaeUpdate(); + }, []); + + const handleDismissError = useCallback(() => { + defibsActions.dismissDaeUpdateError(); + }, []); + + const isActive = + daeUpdateState === "checking" || + daeUpdateState === "downloading" || + daeUpdateState === "installing"; + + // Done state + if (daeUpdateState === "done") { + return ( + + + + {"Base de donn\u00e9es mise \u00e0 jour !"} + + + ); + } + + // Already up-to-date + if (daeUpdateState === "up-to-date") { + return ( + + + + {"Donn\u00e9es d\u00e9j\u00e0 \u00e0 jour"} + + + ); + } + + // Error state + if (daeUpdateState === "error") { + return ( + + + + {daeUpdateError || "Erreur lors de la mise \u00e0 jour"} + + + + + + + + + ); + } + + // Downloading state + if (daeUpdateState === "downloading") { + const pct = Math.round(daeUpdateProgress * 100); + return ( + + + + + {`T\u00e9l\u00e9chargement\u2026 ${pct}%`} + + + + + ); + } + + // Checking / Installing state + if (isActive) { + const label = + daeUpdateState === "checking" + ? "V\u00e9rification\u2026" + : "Installation\u2026"; + return ( + + + + {label} + + + ); + } + + // Idle state + const formattedDate = formatDate(daeLastUpdatedAt); + + return ( + + + + + {formattedDate + ? `Derni\u00e8re mise \u00e0 jour : ${formattedDate}` + : "Donn\u00e9es int\u00e9gr\u00e9es \u00e0 l'application"} + + + + + {"Mettre \u00e0 jour"} + + + ); +}); + +const styles = StyleSheet.create({ + banner: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 8, + gap: 8, + }, + progressBanner: { + flexDirection: "column", + alignItems: "stretch", + gap: 6, + }, + progressHeader: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + progressBar: { + height: 4, + borderRadius: 2, + }, + statusText: { + fontSize: 13, + fontWeight: "500", + flex: 1, + }, + errorText: { + fontSize: 12, + flex: 1, + }, + retryTouch: { + padding: 4, + }, + dismissTouch: { + padding: 4, + }, + idleTextContainer: { + flex: 1, + }, + dateText: { + fontSize: 12, + }, + updateButton: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 16, + }, + updateButtonText: { + color: "#fff", + fontSize: 12, + fontWeight: "600", + }, +}); diff --git a/src/scenes/DAEList/Liste.js b/src/scenes/DAEList/Liste.js index 2a3f054..18138c6 100644 --- a/src/scenes/DAEList/Liste.js +++ b/src/scenes/DAEList/Liste.js @@ -192,9 +192,7 @@ export default React.memo(function DAEListListe() { // Waiting for location if (!hasLocation && allDefibs.length === 0) { - return ( - - ); + return ; } // Loading defibs from database diff --git a/src/scenes/DAEList/index.js b/src/scenes/DAEList/index.js index 39a935e..7c50138 100644 --- a/src/scenes/DAEList/index.js +++ b/src/scenes/DAEList/index.js @@ -1,4 +1,5 @@ import React from "react"; +import { View, StyleSheet } from "react-native"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { MaterialCommunityIcons } from "@expo/vector-icons"; @@ -6,6 +7,7 @@ import { fontFamily, useTheme } from "~/theme"; import DAEListListe from "./Liste"; import DAEListCarte from "./Carte"; +import DaeUpdateBanner from "./DaeUpdateBanner"; const Tab = createBottomTabNavigator(); @@ -13,49 +15,63 @@ export default React.memo(function DAEList() { const { colors } = useTheme(); return ( - - ( - - ), - }} - /> - ( - - ), - }} - /> - + + + + + ( + + ), + }} + /> + ( + + ), + }} + /> + + + ); }); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + tabContainer: { + flex: 1, + }, +}); diff --git a/src/scenes/DAEList/useNearbyDefibs.js b/src/scenes/DAEList/useNearbyDefibs.js index ff46779..86c2bad 100644 --- a/src/scenes/DAEList/useNearbyDefibs.js +++ b/src/scenes/DAEList/useNearbyDefibs.js @@ -12,13 +12,19 @@ const RADIUS_METERS = 10_000; */ export default function useNearbyDefibs() { const { coords, isLastKnown, lastKnownTimestamp } = useLocation(); - const { nearUserDefibs, loadingNearUser, errorNearUser, showUnavailable } = - useDefibsState([ - "nearUserDefibs", - "loadingNearUser", - "errorNearUser", - "showUnavailable", - ]); + const { + nearUserDefibs, + loadingNearUser, + errorNearUser, + showUnavailable, + daeUpdateState, + } = useDefibsState([ + "nearUserDefibs", + "loadingNearUser", + "errorNearUser", + "showUnavailable", + "daeUpdateState", + ]); const hasLocation = coords && coords.latitude !== null && coords.longitude !== null; @@ -42,6 +48,16 @@ export default function useNearbyDefibs() { }); }, [hasLocation, coords]); + // After a successful DB update, reset the position cache so the next + // render re-queries the fresh database. + const prevUpdateState = useRef(daeUpdateState); + useEffect(() => { + if (prevUpdateState.current !== "done" && daeUpdateState === "done") { + lastLoadedRef.current = null; + } + prevUpdateState.current = daeUpdateState; + }, [daeUpdateState]); + useEffect(() => { if (hasLocation) { setNoLocation(false); diff --git a/src/storage/storageKeys.js b/src/storage/storageKeys.js index 1256575..ab7fa63 100644 --- a/src/storage/storageKeys.js +++ b/src/storage/storageKeys.js @@ -81,4 +81,5 @@ export const STORAGE_KEYS = { EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"), EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"), SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"), + DAE_DB_UPDATED_AT: registerAsyncStorageKey("@dae_db_updated_at"), }; diff --git a/src/stores/defibs.js b/src/stores/defibs.js index 6417823..bb97c0d 100644 --- a/src/stores/defibs.js +++ b/src/stores/defibs.js @@ -5,11 +5,16 @@ import { computeCorridorQueryRadiusMeters, filterDefibsInCorridor, } from "~/utils/geo/corridor"; +import { updateDaeDb } from "~/db/updateDaeDb"; +import memoryAsyncStorage from "~/storage/memoryAsyncStorage"; +import { STORAGE_KEYS } from "~/storage/storageKeys"; const DEFAULT_NEAR_USER_RADIUS_M = 10_000; const DEFAULT_CORRIDOR_M = 10_000; const DEFAULT_LIMIT = 200; +const AUTO_DISMISS_DELAY = 4_000; + export default createAtom(({ merge, reset }) => { const actions = { reset, @@ -98,6 +103,78 @@ export default createAtom(({ merge, reset }) => { return { defibs: [], error }; } }, + + // ── DAE DB Over-the-Air Update ───────────────────────────────────── + + loadLastDaeUpdate: async () => { + try { + const stored = await memoryAsyncStorage.getItem( + STORAGE_KEYS.DAE_DB_UPDATED_AT, + ); + if (stored) { + merge({ daeLastUpdatedAt: stored }); + } + } catch { + // Non-fatal + } + }, + + triggerDaeUpdate: async () => { + merge({ + daeUpdateState: "checking", + daeUpdateProgress: 0, + daeUpdateError: null, + }); + + const result = await updateDaeDb({ + onPhase: (phase) => { + merge({ daeUpdateState: phase }); + }, + onProgress: ({ totalBytesWritten, totalBytesExpectedToWrite }) => { + const progress = + totalBytesExpectedToWrite > 0 + ? totalBytesWritten / totalBytesExpectedToWrite + : 0; + merge({ + daeUpdateState: "downloading", + daeUpdateProgress: progress, + }); + }, + }); + + if (result.alreadyUpToDate) { + merge({ daeUpdateState: "up-to-date" }); + setTimeout(() => { + merge({ daeUpdateState: "idle" }); + }, AUTO_DISMISS_DELAY); + return; + } + + if (!result.success) { + merge({ + daeUpdateState: "error", + daeUpdateError: result.error?.message || "Erreur inconnue", + }); + return; + } + + // Success: update stored timestamp and clear loaded defibs + // so the next query fetches from the fresh DB. + merge({ + daeUpdateState: "done", + daeLastUpdatedAt: result.updatedAt, + nearUserDefibs: [], + corridorDefibs: [], + }); + + setTimeout(() => { + merge({ daeUpdateState: "idle" }); + }, AUTO_DISMISS_DELAY); + }, + + dismissDaeUpdateError: () => { + merge({ daeUpdateState: "idle", daeUpdateError: null }); + }, }; return { @@ -113,6 +190,12 @@ export default createAtom(({ merge, reset }) => { loadingCorridor: false, errorNearUser: null, errorCorridor: null, + + // DAE DB update state + daeUpdateState: "idle", // "idle"|"checking"|"downloading"|"installing"|"done"|"error"|"up-to-date" + daeUpdateProgress: 0, // 0..1 + daeUpdateError: null, + daeLastUpdatedAt: null, }, actions, };