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

Sprite Mobile — Technical Architecture

Deep dive into the Sprite Mobile architecture: navigation, state management, Skia rendering, BLE integration, native modules, and build configuration

Baixar raw

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.

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:

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:

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

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

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

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:

// 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:

// 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:

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

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

Events flow: BleManagerApplicationService (emit) → distance calculation → Redux dispatch → UI update.

Kalman Filter (iOS)

The iOS native module includes a full Kalman filter implementation in 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:

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

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

ScriptPurpose
yarn androidDebug build
yarn android:releaseRelease build (--mode ReleaseLocal)
yarn iosDebug build (iPhone 15 Pro Max simulator)
yarn ios:releaseRelease build
yarn podsInstall iOS pods with New Architecture enabled
yarn prunecppClean 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

AspectSprite MobileCAMDOM
BLE PatternPeripheral scanning (RSSI monitoring)Custom mesh networking (JSI C++ state machine)
ArchitectureRedux Toolkit + TurboModulesCustom hooks + native event emitters
State Persistenceredux-persist (AsyncStorage)AsyncStorage (manual)
Distance FilteringNative Kalman filter (Swift/Kotlin)BLE mesh proximity detection
BackgroundHeadlessJsTaskService / BGProcessingTaskForeground service + QUIC/TCP multiplexing
RenderingSkia (Android) + ImageBackground (iOS)Standard React Native
NavigationReact Navigation native-stackCustom navigation
Platform FocusiOS + 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