---
name: sprite-mobile-architecture
type: reference
title: "Sprite Mobile — Technical Architecture"
description: "Deep dive into the Sprite Mobile architecture: navigation, state management, Skia rendering, BLE integration, native modules, and build configuration"
tags: [react-native, architecture, redux, skia, ble, turbo-module, navigation, state-management]
timestamp: 2026-07-21
---

# Sprite Mobile — Technical Architecture

## Project Structure

The project follows a standard React Native layout with a critical addition: the `modules/` directory contains two locally-linked native modules (`app-module` and `react-native-ble-plx`) that are modified from their upstream sources.

```
sprite-mobile/
├── src/                          ← TypeScript source
│   ├── index.tsx                 ← Entry: imports App + bootstraps i18n
│   ├── App.tsx                   ← Root: Provider → PersistGate → NavigationContainer → RootStack
│   ├── i18n.ts                   ← i18next init with native locale detection
│   ├── navigation/RootStack.tsx  ← React Navigation native-stack
│   ├── screens/                  ← 4 screens + barrel export
│   ├── components/               ← 13 components + barrel export
│   └── store/                    ← Redux Toolkit store + 3 slices
├── modules/
│   ├── app-module/               ← TurboModule: native bridge
│   │   ├── src/                  ← TS: ApplicationService, events, remote config
│   │   ├── ios/                  ← Swift: Kalman filter, permissions, BG tasks
│   │   └── android/              ← Kotlin/Java: overlay, foreground service
│   └── react-native-ble-plx/    ← Forked BLE library (full source)
├── assets/                       ← Fonts, images (PNG), backgrounds
├── ios/                          ← Xcode project, Podfile
└── android/                      ← Gradle project, Kotlin/Java native code
```

## Entry Point & Boot Sequence

The app entry is `src/index.tsx`, which does two things: imports the i18n configuration (side-effect) and exports the `App` component.

```mermaid
sequenceDiagram
    participant Entry as index.tsx
    participant I18N as i18n.ts
    participant App as App.tsx
    participant Store as Redux Store
    participant Nav as RootStack
    participant Native as ApplicationService

    Entry->>I18N: import (side-effect: init i18next)
    Entry->>App: export default
    App->>Store: configureStore (combineReducers)
    App->>Store: persistStore (AsyncStorage)
    App->>Nav: NavigationContainer → RootStack
    Nav->>Native: ApplicationService.getInstance()
    Native->>Native: getRemoteConfig() from API
    Native->>Native: initApplicationListeners()
    Native->>Native: BleManager state check
    Nav->>Nav: Determine initial route
```

### Initial Route Logic

The `RootStack` component determines the initial screen based on a cascade of checks:

1. **Android overlay permission** — If missing, show `OverlayModal` (polls every 1s until granted)
2. **Bluetooth state** — If disabled, show `BluetoothModal`
3. **Onboarding state** — If `isOnboardingComplete === false`, show `Onboarding`
4. **Default** — Show `Home`

This is implemented via `useEffect` hooks that react to `isBluetoothEnabled` changes from the Redux BLE slice.

## Navigation

### Stack Navigator Configuration

React Navigation v6 with `createNativeStackNavigator`. Six screens, all with `headerShown: false`:

```typescript
type ScreenNames = [
  'Onboarding',   // First-time BLE pairing
  'Home',         // Main screen (2 animated buttons)
  'Config',       // Settings (slider, URL, switch)
  'Radar',        // Real-time distance visualization
  'BluetoothModal',  // BT disabled overlay
  'OverlayModal',    // Android overlay permission
];
```

Screens are organized into two groups:
- **Modal group**: `BluetoothModal`, `OverlayModal` — displayed as overlays
- **Main group**: `Onboarding`, `Home`, `Config`, `Radar` — primary navigation flow

### Navigation Pattern

The app uses a non-standard navigation pattern: screens navigate imperatively based on Redux state and native module callbacks, not user gestures alone. The `useEffect` in `RootStack` watches `isBluetoothEnabled` and navigates programmatically. The `OverlaysStateModal` polls overlay permission status and navigates away once granted.

## State Management

### Redux Toolkit Store

Three slices combined via `combineReducers`, all persisted to `AsyncStorage`:

```mermaid
graph LR
    STORE["configureStore"] --> APP["application"]
    STORE --> BLE["bluetoothAdapter"]
    STORE --> PERM["permissions"]

    APP --> APP_STATE["isOnboardingComplete, isAlertActive,<br/>emergencyRange, emergencyUrl,<br/>remoteConfig, isApplicationReady"]
    BLE --> BLE_STATE["isBluetoothEnabled,<br/>connectionState,<br/>pairedDevice"]
    PERM --> PERM_STATE["location, bluetooth,<br/>notification"]
```

### Application Slice

```typescript
interface ApplicationState {
  isOnboardingComplete: boolean;    // Persisted
  isAlertActive: boolean;           // NOT persisted (blacklisted)
  isAlertAlreadyShown: boolean;     // NOT persisted
  emergencyRange: number;           // Persisted (default: 3)
  emergencyUrl: string;             // Persisted
  defaultEmergencyUrl: string;      // Persisted
  notificationAlreadyShownForRange: number[];  // NOT persisted
  isApplicationReady: boolean;      // NOT persisted
  remoteConfig?: RemoteConfig;      // NOT persisted
}
```

**Persistence blacklist**: `isAlertAlreadyShown`, `isAlertActive`, `notificationAlreadyShownForRange`, `isApplicationReady` — these are transient runtime states that should reset on app restart.

### BluetoothAdapter Slice

```typescript
interface BluetoothAdapterState {
  isBluetoothEnabled: boolean;       // Persisted (whitelist)
  connectionState: 'scanning' | 'connecting' | 'connected' | 'disconnected' | 'terminated';
  pairedDevice?: DeviceReference;    // Persisted (whitelist)
}

interface DeviceReference {
  id: string;                        // BLE device ID (MAC on Android, UUID on iOS)
  name: string | null;               // BLE advertised name
  localName: string | null;          // BLE local name
  rssi: number | null;               // Current RSSI reading
  rssiAt1Meter: number | null;       // Calibration: RSSI at 1m distance
  rssiPow?: number | null;           // Path loss exponent for distance formula
  customName: string | null;         // User-friendly name from remote config
  jewelModel: number | null;         // 0=circle, 1=square, 2=oval
  approximateDistance: number | null; // Calculated distance in meters
}
```

**Persistence whitelist**: Only `pairedDevice` and `isBluetoothEnabled` survive restarts. The `connectionState` resets to `'scanning'` on each launch.

### Permissions Slice

```typescript
interface PermissionsState {
  location: { granted: boolean; canAskAgain: boolean };
  bluetooth: { granted: boolean; canAskAgain: boolean };
  notification: { granted: boolean; canAskAgain: boolean };
}
```

All three permission states are persisted. The `canAskAgain` flag determines whether the app can re-request denied permissions or must guide the user to system settings.

### Redux-Persist Configuration

Each slice has its own `persistReducer` with distinct configuration:

- **Application**: Blacklist approach — persists everything except 4 transient keys
- **BluetoothAdapter**: Whitelist approach — only persists `pairedDevice` and `isBluetoothEnabled`
- **Permissions**: Full persistence (no blacklist/whitelist)

The store uses `getDefaultMiddleware` with serializable check disabled for redux-persist actions (`FLUSH`, `REHYDRATE`, `PAUSE`, `PERSIST`, `PURGE`, `REGISTER`).

## Skia Integration

### Android: BackgroundVideo with Skia Canvas

On Android, the `BackgroundVideo` component uses `@shopify/react-native-skia` to render animated GIF/video backgrounds:

```typescript
// BackgroundVideo.android.tsx
const bird = useAnimatedImageValue(source);
return (
  <Canvas style={{ position: 'absolute', width, height, backgroundColor: 'black' }}>
    <Image image={bird} x={0} y={10} width={width} height={height} fit="contain" />
  </Canvas>
);
```

This uses Skia's `useAnimatedImageValue` hook to decode animated images and render them at native performance. The `Canvas` component is a full-screen overlay positioned at `zIndex: -1`.

### iOS: Fallback to ImageBackground

On iOS, the same component uses React Native's built-in `ImageBackground`:

```typescript
// BackgroundVideo.tsx (iOS default)
<ImageBackground source={videoSource} style={styles.bg_video} resizeMode="contain" />
```

This is a deliberate trade-off: Skia provides better animation performance on Android, but iOS handles animated images natively through `UIImageView`.

## Native Module: app-module

### TurboModule Specification

The `app-module` is a React Native TurboModule (New Architecture) with the following interface:

```typescript
interface Spec extends TurboModule {
  getLocale(): string;
  configureNativeBinds(): boolean;
  filter(rssi: number, variation: number): number;
  resetPositionsArray(): void;
  requestPermissions(): boolean;
  startBackgroundDeviceScan(): Promise<boolean>;
  stopBackgroundDeviceScan(): Promise<boolean>;
  addListener: (eventType: string) => void;
  removeListeners: (count: number) => void;
}
```

### ApplicationService (Singleton)

The `ApplicationService` class in `modules/app-module/src/index.ts` is the central orchestrator. It is a singleton that extends `EventEmitter` and manages:

1. **BLE Lifecycle** — Initializes `BleManager`, handles state changes, starts/stops scanning
2. **Device Discovery** — Filters scanned devices against `knownTags` from remote config
3. **Distance Calculation** — Converts RSSI to meters using path loss model + native Kalman filter
4. **Remote Config** — Fetches device configs and alert ranges from API
5. **Notification Management** — Shows proximity alerts and emergency notifications via `@notifee/react-native`
6. **Redux Integration** — Subscribes to store changes and dispatches actions

### Event System

```typescript
enum ApplicationEvent {
  OnDeviceCandidate = 'onDeviceCandidate',
  OnBleStateChange = 'onBleStateChange',
  OnDevicePaired = 'onDevicePaired',
  OnDeviceConnected = 'onDeviceConnected',
  OnDeviceDisconnected = 'onDeviceDisconnected',
  OnStopForegroundDeviceScan = 'onStopForegroundDeviceScan',
  OnRssiUpdate = 'onRssiUpdate',
  OnDistanceUpdate = 'onDistanceUpdate',
  OnPermissionsGranted = 'onPermissionsGranted',
  OnConfigureRadarVariables = 'configureRadarVariables',
}
```

Events flow: `BleManager` → `ApplicationService` (emit) → distance calculation → Redux dispatch → UI update.

### Kalman Filter (iOS)

The iOS native module includes a full Kalman filter implementation in Swift:

```swift
public struct KalmanFilter<Type: KalmanInput>: KalmanFilterType {
    public func predict(stateTransitionModel: Type, ...) -> KalmanFilter
    public func update(measurement: Type, ...) -> KalmanFilter
}
```

The `filterReading` method in `AppModule.swift` maintains a rolling window of the last 20 distance readings, deduplicates, takes the last N readings (configurable via `variation`), and returns the mean.

### Background Tasks

**iOS**: `BGProcessingTask` registered with identifier `com.knocklace.BgTask.ScanningForDevices`. Currently a placeholder (`while(true) { print("BGTask") }`).

**Android**: `RNBackgroundActionsTask` extends `HeadlessJsTaskService`. Creates a foreground service with notification ("Alerta Sprite — Knocklace Collection is running in background."). Uses `FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE` on API 33+.

## BLE Integration (Forked Library)

The project vendors `react-native-ble-plx` as a local module at `modules/react-native-ble-plx/`. This is a complete fork of the library with full native source (Java for Android, Swift/ObjC for iOS).

Key modifications likely include:
- Custom scan parameters (`allowDuplicates: true`, `legacyScan: true`, `ScanMode.LowLatency`)
- Background state restoration identifier (`com.knocklace.ble`)
- Event forwarding to `ApplicationService`

## Remote Configuration

### API Fetch

On startup, `ApplicationService.getRemoteConfig()` fetches from a remote API:

```typescript
const response = await fetch(API_URL, {
  headers: {
    Authorization: API_KEY,
    Platform: Platform.OS,
  },
});
const jsonResp: RemoteConfig = await response?.json();
```

The `API_URL` and `API_KEY` are loaded from environment variables via `react-native-dotenv`.

### Fallback Config

If the API is unreachable, the app falls back to bundled JSON files:
- `remoteConfig.json` (iOS) — 8 device configs, 1 range config
- `remoteConfigAndroid.json` (Android) — 6 device configs, 1 range config

### RemoteConfig Schema

```typescript
interface RemoteConfig {
  defaultVideo: string;           // Emergency URL
  enableDot: boolean;             // Radar dot visibility
  radarSteps: number;             // Kalman filter variation (3 iOS, 7 Android)
  timeoutSecs: number;            // RSSI timeout (10000ms iOS, 3000ms Android)
  knownTags: string[];            // Accepted BLE device names
  rangesConfig: RangeConfig[];    // Distance-based notification tiers
  customConfigPerDevice: DeviceCustomConfig[];  // Per-device calibration
}

interface DeviceCustomConfig {
  customConfigEnabled: boolean;
  customName: string;             // Display name
  jewelModel: number;             // 0=circle, 1=square, 2=oval
  knownAs: string;                // BLE name (iOS) or MAC (Android)
  rssiAt1Meter: number;           // Calibration reference
  rssiPow?: number;               // Path loss exponent
}
```

## Build Configuration

### Scripts

| Script | Purpose |
|---|---|
| `yarn android` | Debug build |
| `yarn android:release` | Release build (`--mode ReleaseLocal`) |
| `yarn ios` | Debug build (iPhone 15 Pro Max simulator) |
| `yarn ios:release` | Release build |
| `yarn pods` | Install iOS pods with New Architecture enabled |
| `yarn prunecpp` | Clean C++ build artifacts |

### Babel Configuration

Module aliases for clean imports:
- `@screens` → `./src/screens/index`
- `@theme` → `./src/theme/index`
- `@api` → `./src/api/index`
- `@components` → `./src/components/index`
- `@assets` → `./assets/index`
- `@store` → `./src/store/index`
- `@hooks` → `./src/hooks/index`

Plugins: `react-native-dotenv`, `module-resolver`, `react-native-reanimated/plugin`.

### react-native.config.js

Registers `Video` as a legacy component name for both platforms, and maps font assets from `./assets/fonts`.

## Comparison with CAMDOM

| Aspect | Sprite Mobile | CAMDOM |
|---|---|---|
| BLE Pattern | Peripheral scanning (RSSI monitoring) | Custom mesh networking (JSI C++ state machine) |
| Architecture | Redux Toolkit + TurboModules | Custom hooks + native event emitters |
| State Persistence | redux-persist (AsyncStorage) | AsyncStorage (manual) |
| Distance Filtering | Native Kalman filter (Swift/Kotlin) | BLE mesh proximity detection |
| Background | HeadlessJsTaskService / BGProcessingTask | Foreground service + QUIC/TCP multiplexing |
| Rendering | Skia (Android) + ImageBackground (iOS) | Standard React Native |
| Navigation | React Navigation native-stack | Custom navigation |
| Platform Focus | iOS + Android (commercial app) | Android primary (cross-platform BLE mesh) |

## Comparison with CAMDOM Architecture

The [[camdom-architecture]] used a JSI C++ state machine for BLE mesh networking — a fundamentally different approach from Sprite's peripheral scanning model. CAMDOM needed to coordinate multiple devices in a mesh topology, requiring low-latency native state management. Sprite's use case is simpler: monitor one device's RSSI and trigger alerts. The Redux + TurboModule architecture is appropriate for this simpler data flow.

## Key Technical Decisions

1. **Forked BLE library** over npm dependency — allows direct control over scan parameters and native behavior
2. **TurboModule (New Architecture)** — enables synchronous native calls for Kalman filter, critical for real-time distance updates
3. **Per-platform rendering** — Skia on Android for animated backgrounds, native UIImageView on iOS for simplicity
4. **Remote config with local fallback** — ensures the app works offline while allowing dynamic device configuration
5. **Kalman filter in native code** — avoids JS bridge overhead for the most performance-critical calculation
6. **Dark theme only** — simplifies styling and matches the product's visual identity
