From 9f6452d5e368c82e0f93afd06c354756dd7913db Mon Sep 17 00:00:00 2001 From: devthejo Date: Sun, 29 Jun 2025 15:40:24 +0200 Subject: [PATCH] fix(headless): async-storage in memory first --- index.js | 3 +- src/app/index.js | 4 +- src/components/EULA/index.js | 2 +- src/containers/AppLifecycleListener.js | 16 +- src/hooks/useEULA.js | 2 +- src/lib/memoryAsyncStorage.js | 284 +++++++++++++++++++++++++ src/location/emulatorService.js | 2 +- src/stores/aggregatedMessages.js | 2 +- src/stores/permissionWizard.js | 2 +- src/updates/index.js | 2 +- src/utils/location/storage.js | 2 +- 11 files changed, 308 insertions(+), 13 deletions(-) create mode 100644 src/lib/memoryAsyncStorage.js diff --git a/index.js b/index.js index 45a83d9..cc358ae 100644 --- a/index.js +++ b/index.js @@ -32,7 +32,8 @@ registerRootComponent(App); // Constants for persistence const LAST_SYNC_TIME_KEY = "@geolocation_last_sync_time"; -const FORCE_SYNC_INTERVAL = 24 * 60 * 60 * 1000; +// const FORCE_SYNC_INTERVAL = 24 * 60 * 60 * 1000; +const FORCE_SYNC_INTERVAL = 60 * 60 * 1000; // DEBUGGING // Helper functions for persisting sync time const getLastSyncTime = async () => { diff --git a/src/app/index.js b/src/app/index.js index 5384190..d3fc231 100644 --- a/src/app/index.js +++ b/src/app/index.js @@ -8,6 +8,7 @@ import { SYSTEM_SCOPES } from "~/lib/logger/scopes"; import { authActions, permissionWizardActions } from "~/stores"; import { secureStore } from "~/lib/memorySecureStore"; +import memoryAsyncStorage from "~/lib/memoryAsyncStorage"; import "~/lib/mapbox"; import "~/i18n"; @@ -54,8 +55,9 @@ const initializeStores = () => { } }; - // Initialize memory secure store first + // Initialize memory stores first initializeStore("memorySecureStore", secureStore.init); + initializeStore("memoryAsyncStorage", memoryAsyncStorage.init); // Then initialize other stores sequentially initializeStore("authActions", authActions.init); diff --git a/src/components/EULA/index.js b/src/components/EULA/index.js index 05c3199..3f26267 100644 --- a/src/components/EULA/index.js +++ b/src/components/EULA/index.js @@ -1,6 +1,6 @@ import React from "react"; import { View, ScrollView, StyleSheet, Platform } from "react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from "~/lib/memoryAsyncStorage"; import Text from "../Text"; diff --git a/src/containers/AppLifecycleListener.js b/src/containers/AppLifecycleListener.js index 53cbc67..88bd55b 100644 --- a/src/containers/AppLifecycleListener.js +++ b/src/containers/AppLifecycleListener.js @@ -12,6 +12,7 @@ import { useNetworkState, } from "~/stores"; import { secureStore } from "~/lib/memorySecureStore"; +import memoryAsyncStorage from "~/lib/memoryAsyncStorage"; import requestPermissionLocationBackground from "~/permissions/requestPermissionLocationBackground"; import requestPermissionLocationForeground from "~/permissions/requestPermissionLocationForeground"; @@ -212,16 +213,23 @@ const AppLifecycleListener = () => { ); checkPermissions(completed); - // Sync memory secure store back to persistent storage - lifecycleLogger.info( - "Syncing memory secure store to persistent storage", - ); + // Sync memory stores back to persistent storage + lifecycleLogger.info("Syncing memory stores to persistent storage"); + + // Sync secure store secureStore.syncToSecureStore().catch((error) => { lifecycleLogger.error("Failed to sync memory secure store", { error: error.message, }); }); + // Sync async storage + memoryAsyncStorage.syncToAsyncStorage().catch((error) => { + lifecycleLogger.error("Failed to sync memory async storage", { + error: error.message, + }); + }); + // Then handle WebSocket reconnection with proper error handling activeTimeout.current = setTimeout(() => { try { diff --git a/src/hooks/useEULA.js b/src/hooks/useEULA.js index a989d05..ae38107 100644 --- a/src/hooks/useEULA.js +++ b/src/hooks/useEULA.js @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from "~/lib/memoryAsyncStorage"; import { Platform } from "react-native"; const EULA_STORAGE_KEY = "@eula_accepted"; diff --git a/src/lib/memoryAsyncStorage.js b/src/lib/memoryAsyncStorage.js new file mode 100644 index 0000000..90caee6 --- /dev/null +++ b/src/lib/memoryAsyncStorage.js @@ -0,0 +1,284 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createLogger } from "~/lib/logger"; +import { SYSTEM_SCOPES } from "~/lib/logger/scopes"; + +const storageLogger = createLogger({ + module: SYSTEM_SCOPES.STORAGE, + feature: "memory-async-storage", +}); + +// In-memory cache for AsyncStorage values +const memoryCache = new Map(); + +// Track if we've loaded from AsyncStorage +let isInitialized = false; +const initPromise = new Promise((resolve) => { + global.__memoryAsyncStorageInitResolve = resolve; +}); + +/** + * Memory-first AsyncStorage wrapper that maintains an in-memory cache + * for headless/background mode access when AsyncStorage is unavailable + */ +export const memoryAsyncStorage = { + /** + * Initialize the memory cache by loading all known keys from AsyncStorage + */ + async init() { + if (isInitialized) return; + + storageLogger.info("Initializing memory async storage"); + + // List of known keys that need to be cached + const knownKeys = [ + "permission_wizard_completed", + "override_messages", + "last_known_location", + "eula_accepted", + "last_update_check", + "emulator_mode_enabled", + ]; + + // Load all known keys into memory + for (const key of knownKeys) { + try { + const value = await AsyncStorage.getItem(key); + if (value !== null) { + memoryCache.set(key, value); + storageLogger.debug("Loaded key into memory", { + key, + hasValue: true, + }); + } + } catch (error) { + storageLogger.warn("Failed to load key from AsyncStorage", { + key, + error: error.message, + }); + } + } + + // Also load any keys that might exist with getAllKeys + try { + const allKeys = await AsyncStorage.getAllKeys(); + for (const key of allKeys) { + if (!memoryCache.has(key)) { + try { + const value = await AsyncStorage.getItem(key); + if (value !== null) { + memoryCache.set(key, value); + storageLogger.debug("Loaded additional key into memory", { key }); + } + } catch (error) { + storageLogger.warn("Failed to load additional key", { + key, + error: error.message, + }); + } + } + } + } catch (error) { + storageLogger.warn("Failed to get all keys from AsyncStorage", { + error: error.message, + }); + } + + isInitialized = true; + if (global.__memoryAsyncStorageInitResolve) { + global.__memoryAsyncStorageInitResolve(); + delete global.__memoryAsyncStorageInitResolve; + } + + storageLogger.info("Memory async storage initialized", { + cachedKeys: Array.from(memoryCache.keys()), + }); + }, + + /** + * Ensure initialization is complete before operations + */ + async ensureInitialized() { + if (!isInitialized) { + await initPromise; + } + }, + + /** + * Get item from memory first, fallback to AsyncStorage + */ + async getItem(key) { + await this.ensureInitialized(); + + // Try memory first + if (memoryCache.has(key)) { + const value = memoryCache.get(key); + storageLogger.debug("Retrieved from memory cache", { + key, + hasValue: !!value, + }); + return value; + } + + // Fallback to AsyncStorage + try { + const value = await AsyncStorage.getItem(key); + if (value !== null) { + // Cache for future use + memoryCache.set(key, value); + storageLogger.debug("Retrieved from AsyncStorage and cached", { key }); + } + return value; + } catch (error) { + storageLogger.warn( + "Failed to retrieve from AsyncStorage, returning null", + { + key, + error: error.message, + }, + ); + // In headless mode, AsyncStorage might not be accessible + return null; + } + }, + + /** + * Set item in both memory and AsyncStorage + */ + async setItem(key, value) { + await this.ensureInitialized(); + + // Always set in memory first + memoryCache.set(key, value); + storageLogger.debug("Set in memory cache", { key }); + + // Try to persist to AsyncStorage + try { + await AsyncStorage.setItem(key, value); + storageLogger.debug("Persisted to AsyncStorage", { key }); + } catch (error) { + storageLogger.warn( + "Failed to persist to AsyncStorage, kept in memory only", + { + key, + error: error.message, + }, + ); + // Continue - value is at least in memory + } + }, + + /** + * Remove item from both memory and AsyncStorage + */ + async removeItem(key) { + await this.ensureInitialized(); + + // Delete from memory + memoryCache.delete(key); + storageLogger.debug("Deleted from memory cache", { key }); + + // Try to delete from AsyncStorage + try { + await AsyncStorage.removeItem(key); + storageLogger.debug("Deleted from AsyncStorage", { key }); + } catch (error) { + storageLogger.warn("Failed to delete from AsyncStorage", { + key, + error: error.message, + }); + // Continue - at least removed from memory + } + }, + + /** + * Get all keys from memory cache + */ + async getAllKeys() { + await this.ensureInitialized(); + return Array.from(memoryCache.keys()); + }, + + /** + * Get multiple items + */ + async multiGet(keys) { + await this.ensureInitialized(); + const result = []; + for (const key of keys) { + const value = await this.getItem(key); + result.push([key, value]); + } + return result; + }, + + /** + * Set multiple items + */ + async multiSet(keyValuePairs) { + await this.ensureInitialized(); + for (const [key, value] of keyValuePairs) { + await this.setItem(key, value); + } + }, + + /** + * Remove multiple items + */ + async multiRemove(keys) { + await this.ensureInitialized(); + for (const key of keys) { + await this.removeItem(key); + } + }, + + /** + * Clear all items (use with caution) + */ + async clear() { + await this.ensureInitialized(); + + // Clear memory + memoryCache.clear(); + storageLogger.info("Cleared memory cache"); + + // Try to clear AsyncStorage + try { + await AsyncStorage.clear(); + storageLogger.info("Cleared AsyncStorage"); + } catch (error) { + storageLogger.warn("Failed to clear AsyncStorage", { + error: error.message, + }); + } + }, + + /** + * Sync memory cache back to AsyncStorage (useful when returning from background) + */ + async syncToAsyncStorage() { + storageLogger.info("Syncing memory cache to AsyncStorage"); + + const syncResults = { + success: 0, + failed: 0, + }; + + for (const [key, value] of memoryCache.entries()) { + try { + await AsyncStorage.setItem(key, value); + syncResults.success++; + } catch (error) { + syncResults.failed++; + storageLogger.warn("Failed to sync key to AsyncStorage", { + key, + error: error.message, + }); + } + } + + storageLogger.info("Memory cache sync completed", syncResults); + }, +}; + +// Export as default to match the AsyncStorage interface +export default memoryAsyncStorage; diff --git a/src/location/emulatorService.js b/src/location/emulatorService.js index deea548..f24fbf7 100644 --- a/src/location/emulatorService.js +++ b/src/location/emulatorService.js @@ -1,5 +1,5 @@ import BackgroundGeolocation from "react-native-background-geolocation"; -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from "~/lib/memoryAsyncStorage"; import { createLogger } from "~/lib/logger"; import { BACKGROUND_SCOPES } from "~/lib/logger/scopes"; diff --git a/src/stores/aggregatedMessages.js b/src/stores/aggregatedMessages.js index 44fef87..1bea507 100644 --- a/src/stores/aggregatedMessages.js +++ b/src/stores/aggregatedMessages.js @@ -1,6 +1,6 @@ import { createAtom } from "~/lib/atomic-zustand"; import debounce from "lodash.debounce"; -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from "~/lib/memoryAsyncStorage"; const OVERRIDE_MESSAGES_STORAGE_KEY = "@override_messages"; diff --git a/src/stores/permissionWizard.js b/src/stores/permissionWizard.js index fa26019..5f73773 100644 --- a/src/stores/permissionWizard.js +++ b/src/stores/permissionWizard.js @@ -1,5 +1,5 @@ import { createAtom } from "~/lib/atomic-zustand"; -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from "~/lib/memoryAsyncStorage"; const WIZARD_COMPLETED_KEY = "@permission_wizard_completed"; diff --git a/src/updates/index.js b/src/updates/index.js index ff609b1..4323563 100644 --- a/src/updates/index.js +++ b/src/updates/index.js @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { Alert } from "react-native"; import * as Updates from "expo-updates"; -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from "~/lib/memoryAsyncStorage"; import useNow from "~/hooks/useNow"; import * as Sentry from "@sentry/react-native"; diff --git a/src/utils/location/storage.js b/src/utils/location/storage.js index 2221e6e..5659658 100644 --- a/src/utils/location/storage.js +++ b/src/utils/location/storage.js @@ -1,4 +1,4 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from "~/lib/memoryAsyncStorage"; import { createLogger } from "~/lib/logger"; import { SYSTEM_SCOPES } from "~/lib/logger/scopes";