WikifitaGitHub live67e8de5
outro · sprite-mobile/sprite-mobile-redux

Sprite Mobile Redux Store — Slices, Actions, Persistence & State Shape

Complete deep-dive into the Knocklace Collection Redux Toolkit store: 3 slices (application, bluetoothAdapter, permissions), all actions/reducers, AsyncStorage persistence, state shape, and component connections.

Baixar raw

Sprite Mobile Redux Store — Slices, Actions, Persistence & State Shape

Source: Reverse-engineering of /Users/alefita/workdir/sprite-mobile/ Version: 4.24.0 (JS)


1. Store Configuration

1.1 Root Store Setup

The store uses Redux Toolkit (@reduxjs/toolkit) with redux-persist for AsyncStorage persistence. The root configuration:

import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';

const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
});

const PersistentStore = persistStore(store);

The serializable check middleware ignores all redux-persist lifecycle actions, which is standard practice since redux-persist dispatches non-serializable actions internally.

1.2 Provider Architecture

The app wraps the component tree in App.tsx:

<Provider store={store}>
  <PersistGate persistor={PersistentStore}>
    <NavigationContainer theme={DarkTheme}>
      <RootStack />
    </NavigationContainer>
  </PersistGate>
</Provider>

PersistGate blocks rendering until redux-persist rehydrates the store from AsyncStorage, ensuring the app never renders with empty state on subsequent launches.


2. Slice 1: Application Slice

2.1 State Shape

interface ApplicationState {
  notificationAlreadyShownForRange: number[];  // which range thresholds have fired
  isOnboardingComplete: boolean;               // first-run pairing completed
  isAlertActive: boolean;                      // master alert toggle
  isAlertAlreadyShown: boolean;                // prevents re-firing same alert
  defaultEmergencyUrl: string;                 // from remote config
  emergencyRange: number;                      // distance threshold (3-7m)
  emergencyUrl: string;                        // user-configurable URL
  remoteConfig?: RemoteConfig;                 // full remote config object
  isApplicationReady: boolean;                 // initialization complete
}

2.2 RemoteConfig Type

interface RemoteConfig {
  defaultVideo: string;                    // emergency YouTube URL
  enableDot: boolean;                      // UI feature flag
  radarSteps: number;                      // Kalman filter variation (3-7)
  timeoutSecs: number;                     // RSSI debounce timeout (ms)
  knownTags: string[];                     // device name whitelist
  rangesConfig: RangeConfig[];             // notification thresholds
  customConfigPerDevice: DeviceCustomConfig[];  // per-device BLE calibration
}

interface DeviceCustomConfig {
  customConfigEnabled: boolean;
  customName: string;                      // display name (e.g., "OVAL 2")
  jewelModel: number;                      // 0=circle, 1=square, 2=oval
  knownAs: string;                         // BLE identifier
  rssiAt1Meter: number;                    // calibration value
  rssiPow: number;                         // path loss exponent
}

interface RangeConfig {
  range: number;                           // distance in meters
  notificationTitle: string;
  notificationBody: string;
}

2.3 Actions & Reducers

ActionPayloadReducer LogicPersisted
setApplicationReady()noneSets isApplicationReady = trueNo (blacklisted)
updateRemoteConfig(RemoteConfig)RemoteConfig objectStores config, extracts defaultVideo to defaultEmergencyUrlYes
resetNotificationAlreadyShownForRange()noneClears notificationAlreadyShownForRange to []Yes
setNotificationAlreadyShownForRange(number)range valuePushes value to notificationAlreadyShownForRange arrayYes
setAlertState(boolean)booleanSets isAlertActive, resets isAlertAlreadyShown = false when activatingYes
setDefaultEmergencyUrl(string)URL stringSets defaultEmergencyUrlYes
setEmergencyRange(number)meters (3-7)Sets emergencyRangeYes
setEmergencyUrl(string)URL stringSets emergencyUrlYes
setAlertAlreadyShown()noneSets isAlertAlreadyShown = trueNo (blacklisted)
resetAlertShownState()noneSets isAlertAlreadyShown = falseNo (blacklisted)
setOnboardingState(boolean)booleanSets isOnboardingCompleteYes

2.4 Persistence Strategy

const applicationPersistConfig = {
  key: 'application',
  storage: AsyncStorage,
  blacklist: [
    'isAlertAlreadyShown',           // transient runtime flag
    'isAlertActive',                 // re-evaluated on app start
    'notificationAlreadyShownForRange',  // reset per session
    'isApplicationReady',            // always false on fresh start
  ],
};

Whitelisted (persisted): isOnboardingComplete, emergencyRange, emergencyUrl, defaultEmergencyUrl, remoteConfig Blacklisted (transient): isAlertAlreadyShown, isAlertActive, notificationAlreadyShownForRange, isApplicationReady

This is a thoughtful persistence strategy. The blacklist prevents stale runtime flags from surviving app restarts, while preserving user configuration and onboarding state.


3. Slice 2: Bluetooth Adapter Slice

3.1 State Shape

interface BluetoothAdapterState {
  isBluetoothEnabled: boolean;
  connectionState: 'scanning' | 'connecting' | 'connected' | 'disconnected' | 'terminated';
  pairedDevice?: DeviceReference;
}

interface DeviceReference {
  id: string;                          // BLE device ID (MAC on Android, UUID on iOS)
  name: string | null;                 // device advertisement name
  localName: string | null;            // local name from advertisement
  rssi: number | null;                 // last raw RSSI reading
  rssiAt1Meter: number | null;         // calibration value for distance
  rssiPow: number | null;              // path loss exponent
  customName: string | null;           // display name from remote config
  jewelModel: number | null;           // 0=circle, 1=square, 2=oval
  approximateDistance: number | null | undefined;  // live distance in meters
}

3.2 Actions & Reducers

ActionPayloadReducer Logic
setBluetoothEnabled(boolean)booleanSets isBluetoothEnabled
setConnectionState(state)ConnectionState unionSets connectionState
setPairedDevice(DeviceReference)DeviceReference or undefinedSets pairedDevice
updatePairedDeviceApproximatedDistance(number)metersUpdates pairedDevice.approximateDistance

3.3 Persistence Strategy

const bluetoothPersistConfig = {
  key: 'bluetoothAdapter',
  storage: AsyncStorage,
  whitelist: ['pairedDevice', 'isBluetoothEnabled'],
};

Only pairedDevice and isBluetoothEnabled survive restarts. connectionState is transient — the app reconnects on launch. approximateDistance is always recalculated from live RSSI.

3.4 Connection State Machine

The connectionState field forms an implicit state machine:

stateDiagram-v2
    [*] --> scanning: startDeviceScan()
    scanning --> connecting: known device found
    connecting --> connected: GATT connected (not used in current app)
    connected --> disconnected: signal lost / timeout
    disconnected --> scanning: resume scan
    scanning --> terminated: app background / BLE off
    terminated --> scanning: BLE on / app foreground

Note: The current Knocklace app never actually enters connecting or connected states because it only reads RSSI from advertisements (no GATT connection). The state machine exists in the code but the connected state is unused.


4. Slice 3: Permissions Slice

4.1 State Shape

interface PermissionsState {
  location: Permission;
  bluetooth: Permission;
  notification: Permission;
}

interface Permission {
  granted: boolean;
  canAskAgain: boolean;
}

4.2 Actions (Thunks)

The permissions slice uses async thunks rather than synchronous reducers:

ThunkPlatform Behavior
requestLocationPermissionAndroid: ACCESS_FINE_LOCATION; iOS: auto-granted
requestBlePermissionAndroid: BLUETOOTH_SCAN + BLUETOOTH_CONNECT + ACCESS_BACKGROUND_LOCATION; iOS: auto-granted
requestNotificationsPermissionBoth: @notifee/react-native permission request
requestPermissionsOrchestrator: runs all three with 1-second delays, then emits OnPermissionsGranted

4.3 Known Bug

The setNotificationPermission reducer has a confirmed bug:

// BUG: writes to state.bluetooth instead of state.notification
setNotificationPermission: (state, action) => {
  state.bluetooth.granted = action.payload.granted;
  state.bluetooth.canAskAgain = action.payload.canAskAgain;
}

See sprite-mobile-code-review Bug 1 for full analysis.

4.4 Persistence Strategy

const permissionsPersistConfig = {
  key: 'permissions',
  storage: AsyncStorage,
  // No blacklist or whitelist = full persist
};

All permission state persists. This means the app remembers that permissions were granted and does not re-request them on subsequent launches.


5. Store Subscription Pattern

5.1 The ApplicationService Mirror

The ApplicationService singleton subscribes to the Redux store and mirrors key values into its own instance variables:

class ApplicationService {
  private isAlertActive: boolean = false;
  private isAlertAlreadyShown: boolean = false;
  private emergencyRange: number = 3;
  private emergencyUrl: string = '';

  constructor() {
    store.subscribe(() => {
      const state = store.getState();
      this.isAlertActive = state.application.isAlertActive;
      this.isAlertAlreadyShown = state.application.isAlertAlreadyShown;
      this.emergencyRange = state.application.emergencyRange;
      this.emergencyUrl = state.application.emergencyUrl;
    });
  }
}

5.2 Why This Exists

The ApplicationService processes RSSI updates at BLE scan frequency (potentially dozens of times per second). Reading from Redux via store.getState() on every RSSI callback would:

  1. Trigger the subscription notification chain (performance overhead)
  2. Create garbage collection pressure from selector re-evaluation
  3. Block the BLE callback thread on store access

By mirroring values into instance variables, the service reads from plain JavaScript properties — O(1) property access with no Redux overhead.

5.3 Trade-offs

AdvantageDisadvantage
Zero-overhead reads in hot pathTwo sources of truth
No React re-renders for BLE updatesStale reads possible (subscription is async)
Decoupled from React lifecycleDebugging difficulty (Redux DevTools won't show reads)

5.4 Alternative Approaches

A more idiomatic approach would use store.getState() directly in the hot path (which is synchronous and does not trigger subscriptions):

// This is actually what the subscribe callback does internally
const state = store.getState();
const range = state.application.emergencyRange;

The subscription pattern adds no benefit over direct getState() calls since both are synchronous reads.


6. State Flow Diagram

flowchart TD
    subgraph Redux Store
        APP[application slice]
        BLE[bluetoothAdapter slice]
        PERM[permissions slice]
    end

    subgraph ApplicationService
        MIRROR[Mirrored State]
        CALC[Distance Calculator]
        ALERT[Alert Manager]
        CONFIG[Remote Config]
    end

    subgraph UI Components
        HOME[HomeScreen]
        RADAR[RadarScreen]
        CONFIG_UI[ConfigScreen]
        ONBOARD[OnboardingScreen]
    end

    subgraph Native Modules
        BLE_SCAN[BLE Scan Callback]
        FILTER[AppModule.filter]
    end

    BLE_SCAN -->|RSSI| CALC
    CALC -->|raw distance| FILTER
    FILTER -->|filtered distance| MIRROR
    MIRROR -->|distance < range| ALERT
    ALERT -->|notification| NATIVE[Notifee / Linking]

    APP -->|subscribe| MIRROR
    BLE -->|subscribe| MIRROR

    HOME -->|dispatch: setAlertState| APP
    RADAR -->|read: approximateDistance| BLE
    CONFIG_UI -->|dispatch: setEmergencyRange, setEmergencyUrl| APP
    ONBOARD -->|dispatch: setPairedDevice, setOnboardingState| BLE, APP

    CONFIG -->|fetch| API[Google Cloud Run]
    CONFIG -->|dispatch: updateRemoteConfig| APP

    PERM -->|dispatch: requestPermissions| NATIVE_PERM[Native Permission APIs]

7. Component-Store Connections

7.1 ActivateApplicationSwitch

// Reads
const isAlertActive = useSelector((state) => state.application.isAlertActive);

// Dispatches
dispatch(setAlertState(!isAlertActive));
// Also calls: AppModule.resetNotificationAlreadyShownForRange()

7.2 SliderCustom

// Reads
const emergencyRange = useSelector((state) => state.application.emergencyRange);

// Dispatches (on slide complete)
dispatch(setEmergencyRange(value));

7.3 UrlButton

// Reads
const emergencyUrl = useSelector((state) => state.application.emergencyUrl);
const defaultEmergencyUrl = useSelector((state) => state.application.defaultEmergencyUrl);

// Dispatches
dispatch(setEmergencyUrl(text));

7.4 RadarComponent

// Reads
const approximateDistance = useSelector(
  (state) => state.bluetoothAdapter.pairedDevice?.approximateDistance
);

7.5 ConnectionStateManagerView

// Reads
const pairedDevice = useSelector((state) => state.bluetoothAdapter.pairedDevice);

// Dispatches (on unpair)
dispatch(setPairedDevice(undefined));
dispatch(setOnboardingState(false));

7.6 SearchingDeviceIndicator

// Reads
const pairedDevice = useSelector((state) => state.bluetoothAdapter.pairedDevice);
// Shows green pill if pairedDevice exists, spinner otherwise

7.7 RootStack (Navigation)

// Reads
const isOnboardingComplete = useSelector((state) => state.application.isOnboardingComplete);
const isBluetoothEnabled = useSelector((state) => state.bluetoothAdapter.isBluetoothEnabled);
// Determines navigation state (which screen to show)

8. AsyncStorage Key Layout

After persistence, the following keys exist in AsyncStorage:

KeyContent
persist:application{ isOnboardingComplete, emergencyRange, emergencyUrl, defaultEmergencyUrl, remoteConfig }
persist:bluetoothAdapter{ pairedDevice, isBluetoothEnabled }
persist:permissions{ location: { granted, canAskAgain }, bluetooth: {...}, notification: {...} }

Each key is a JSON-serialized string. redux-persist handles serialization/deserialization automatically.

8.1 Rehydration Behavior

On app launch:

  1. redux-persist reads all three keys from AsyncStorage
  2. Parses JSON and merges into initial Redux state
  3. PersistGate waits for rehydration to complete
  4. React tree renders with persisted state
  5. ApplicationService subscribes and mirrors values
  6. Remote config fetch may update remoteConfig and defaultEmergencyUrl

If AsyncStorage is empty (first launch), all slices start with their initial state:

  • isOnboardingComplete: false
  • emergencyRange: 3
  • isBluetoothEnabled: false
  • All permissions: { granted: false, canAskAgain: true }

9. Improvement Opportunities

9.1 Type Safety

The store uses TypeScript but does not export typed hooks. Adding typed selectors and dispatches would prevent the Bug 1 class of errors:

// Recommended: typed hooks
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

9.2 RTK Query for Remote Config

The remote config fetch is handled imperatively in ApplicationService. Migrating to RTK Query would provide:

  • Automatic caching and refetching
  • Loading/error states in Redux
  • Normalized cache invalidation
  • Built-in polling support

9.3 State Machine for Alert Lifecycle

The alert system uses multiple boolean flags (isAlertActive, isAlertAlreadyShown, notificationAlreadyShownForRange) to manage what is fundamentally a state machine. XState or a manual state machine would make the logic explicit:

type AlertState = 'idle' | 'monitoring' | 'in_range' | 'alert_fired' | 'cooldown';

9.4 Persist Migration

The current persistence configuration has no version or migration strategy. If the RemoteConfig type changes between app versions, rehydration will silently drop unknown fields or corrupt the shape. Adding redux-persist-migrate or a manual migration function would prevent this.


Cross-References