Sprite Mobile — Technical Architecture
Deep dive into the Sprite Mobile architecture: navigation, state management, Skia rendering, BLE integration, native modules, and build configuration
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:
- Android overlay permission — If missing, show
OverlayModal(polls every 1s until granted) - Bluetooth state — If disabled, show
BluetoothModal - Onboarding state — If
isOnboardingComplete === false, showOnboarding - 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
pairedDeviceandisBluetoothEnabled - 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:
- BLE Lifecycle — Initializes
BleManager, handles state changes, starts/stops scanning - Device Discovery — Filters scanned devices against
knownTagsfrom remote config - Distance Calculation — Converts RSSI to meters using path loss model + native Kalman filter
- Remote Config — Fetches device configs and alert ranges from API
- Notification Management — Shows proximity alerts and emergency notifications via
@notifee/react-native - 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: BleManager → ApplicationService (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 configremoteConfigAndroid.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
| 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
- Forked BLE library over npm dependency — allows direct control over scan parameters and native behavior
- TurboModule (New Architecture) — enables synchronous native calls for Kalman filter, critical for real-time distance updates
- Per-platform rendering — Skia on Android for animated backgrounds, native UIImageView on iOS for simplicity
- Remote config with local fallback — ensures the app works offline while allowing dynamic device configuration
- Kalman filter in native code — avoids JS bridge overhead for the most performance-critical calculation
- Dark theme only — simplifies styling and matches the product's visual identity