Skip to main content

Command Palette

Search for a command to run...

How We Built a Production-Ready React Native Bluetooth App

The engineering behind SKEEDAD: BLE packet reconstruction, five-sensor streaming, offline-first SQLite, Android background monitoring, Firebase sync, and reliable APK delivery.

Updated
15 min readView as Markdown
How We Built a Production-Ready React Native Bluetooth App
S
Sr Software Engineer - All things web and mobile

Most Bluetooth demos stop at three steps:

  1. scan for a device,

  2. connect to a characteristic,

  3. print incoming values.

That is enough to prove the radio works. It is nowhere near enough to ship a companion app.

For SKEEDAD, we built a React Native companion app for a wearable posture-monitoring device. Five physical sensors stream orientation data over Bluetooth Low Energy (BLE). The app reconstructs that stream, converts it into live posture feedback, stores raw readings locally, generates daily and weekly summaries, keeps monitoring alive on Android, and distributes standalone APKs through CI/CD.

The interesting part was not any single screen. It was making firmware, BLE, React Native's New Architecture, Android lifecycle rules, SQLite, Firebase, and release engineering behave as one system.

This post walks through how we built that system end to end, including the hardware assumptions that turned out to be wrong.

SKEEDAD is a posture-feedback product, not a medical diagnosis system. The score described here measures deviation from a user-calibrated reference posture.

The problem we were solving

The wearable sends measurements from five sensors. The mobile app needed to provide:

  • live connection state and posture feedback,

  • a score and per-region body visualization,

  • calibration and alert preferences,

  • daily and weekly insights,

  • local raw-data retention,

  • cloud synchronization of aggregates,

  • haptic and audible phone alerts,

  • exportable reports,

  • reliable operation from a standalone APK,

  • internal delivery through Firebase App Distribution.

There were also some important constraints:

  • React Native CLI, not Expo;

  • New Architecture enabled;

  • no login flow — Firebase Anonymous Auth provides device-scoped identity;

  • raw high-frequency telemetry stays on the phone;

  • Firestore stores summaries, not the entire sensor stream;

  • Bluetooth must be tested on physical hardware;

  • Android-first internal distribution.

Architecture at a glance

SKEEDAD wearable
    │
    │ BLE notifications (~2 Hz, fragmented JSON)
    ▼
BLE connection + packet assembler
    │
    ├──► Zustand live state ──► Live posture UI
    │                              │
    │                              └──► Phone alert state machine
    │
    └──► 1 Hz durable sampling ──► SQLite raw frames/readings
                                      │
                                      ├──► Daily aggregation
                                      └──► Firestore summary sync

Android foreground service ──► keeps monitoring process alive
GitHub Actions ──► signed APK ──► Firebase App Distribution
Wearable to BLE to packet assembler, then a live UI lane and a durable SQLite lane that syncs to Firestore separately

The key boundary is deliberate: the UI can update frequently, but persistence and cloud writes do not run at radio frequency.

Why React Native New Architecture?

BLE, SQLite, haptics, notifications, PDF generation, and background Android behavior all cross the JavaScript/native boundary. That makes a hardware companion app a meaningful test of React Native's architecture — not just a CRUD application with native-looking screens.

We used a React Native CLI app with Hermes and the New Architecture enabled. The main libraries included:

Concern Choice
BLE react-native-ble-plx
Local telemetry @op-engineering/op-sqlite
State Zustand
Navigation React Navigation
Cloud React Native Firebase
Haptics react-native-haptic-feedback
Notifications react-native-notifications
Animation Reanimated

The New Architecture did not eliminate native complexity. It made library compatibility and native build correctness more important. We validated release builds early instead of treating a successful Metro-powered debug build as proof.

That distinction matters: a debug app can work while depending on Metro, then fail when a tester installs an APK with an embedded Hermes bundle.

Discovering the real BLE contract

The original technical plan assumed the firmware team would provide a complete GATT contract: sensor characteristics, battery, calibration commands, sensitivity commands, and alert events.

The hardware told a smaller story.

The device advertised as SKEEDAD5 and exposed:

Service FFE0
├── FFE1  notify + write
├── FFE2  notify + write
└── FFE3  notify + write

FFE1 produced JSON shaped like this:

{
  "S0": { "P": -21.6, "R": 40.1 },
  "S1": { "P": -65.9, "R": -4.5 },
  "S2": { "P": 33.9, "R": 42.8 },
  "S3": { "P": 12.4, "R": 8.5 },
  "S4": { "P": 0.8, "R": 0.6 }
}

P and R are pitch and roll. Frames arrived roughly twice per second.

The sensor-to-body mapping we used was:

Sensor Region
S0 Neck
S1 Left shoulder
S2 Right shoulder
S3 Upper back
S4 Lower back
Five sensors mapped onto body regions during physical testing, not from a firmware spec

This mapping was inferred during physical testing and must still be confirmed against the firmware source before making clinical claims.

That is an important engineering lesson: discovering a packet shape is not the same as owning a protocol contract.

BLE notifications are chunks, not messages

One of the first implementation traps was assuming one notification equals one JSON object.

BLE notifications are byte chunks. A JSON frame may be split across several notifications, or one chunk may contain the end of one frame and the beginning of another.

So the parser maintains a string buffer and scans for balanced JSON objects while respecting quoted strings and escaped characters:

private consumeTelemetryChunk(base64Value: string) {
  this.telemetryBuffer += decodeTelemetryChunk(base64Value);

  const frames: TelemetryFrame[] = [];
  let start = -1;
  let depth = 0;
  let inString = false;
  let escaped = false;

  for (let index = 0; index < this.telemetryBuffer.length; index += 1) {
    const character = this.telemetryBuffer[index];

    if (start < 0 && character === "{") {
      start = index;
      depth = 1;
      continue;
    }

    if (start < 0) continue;
    if (escaped) {
      escaped = false;
      continue;
    }
    if (character === "\\" && inString) {
      escaped = true;
      continue;
    }
    if (character === '"') inString = !inString;
    if (inString) continue;

    if (character === "{") depth += 1;
    if (character === "}") depth -= 1;

    if (depth === 0) {
      frames.push(
        parseTelemetryFrame(this.telemetryBuffer.slice(start, index + 1)),
      );
      this.telemetryBuffer = this.telemetryBuffer.slice(index + 1);
      break;
    }
  }

  return frames;
}
Raw BLE chunks don't align to JSON boundaries — the assembler tracks brace depth and carries incomplete prefixes forward

Production code additionally handles multiple completed objects, incomplete prefixes, malformed data, and an upper bound on buffer size.

Every completed frame is validated before it reaches application state:

type SensorId = "S0" | "S1" | "S2" | "S3" | "S4";
type SensorVector = { P: number; R: number };
type TelemetryFrame = Record<SensorId, SensorVector>;

We reject missing sensors, non-numeric values, NaN, and incomplete objects. Hardware input is an external boundary and deserves the same validation discipline as an HTTP request.

Turning angles into live posture feedback

The app stores a neutral calibration frame for each BLE device. Incoming measurements are compared with that baseline rather than with a universal definition of "correct posture."

For each region:

  1. calculate wrapped pitch and roll deltas,

  2. combine them as angular deviation,

  3. apply the selected sensitivity penalty,

  4. clamp the result to 0...100.

const pitchDelta = angleDelta(frame[id].P, neutral[id].P);
const rollDelta = angleDelta(frame[id].R, neutral[id].R);
const deviation = Math.hypot(pitchDelta, rollDelta);
const regionScore = clamp(Math.round(100 - deviation * penalty), 0, 100);

The overall score is the mean of the five region scores.

We throttle UI writes separately from BLE receipt. Radio callbacks can arrive whenever the device sends data; React rendering should happen at a controlled rate. This keeps animations smooth without discarding data needed by the persistence layer.

Offline-first telemetry without burning storage

Sending every sensor frame to Firestore would be expensive, noisy, and unnecessary. Raw data is mainly useful for local debugging and aggregation.

We split storage into two SQLite tables:

raw_sensor_frames
  recorded_at
  payload             # original validated S0-S4 P/R JSON

raw_readings
  recorded_at
  score
  battery
  neck
  left_shoulder
  right_shoulder
  upper_back
  lower_back
BLE receipt fans into three lanes with three different frequencies and flush triggers

The live stream is approximately 2 Hz, while durable sampling runs at 1 Hz. Samples are batched and committed transactionally:

  • flush at 10 samples,

  • or flush after 3 seconds,

  • flush immediately on disconnect,

  • flush when the app backgrounds,

  • keep a bounded in-memory retry buffer,

  • prune raw history after seven days.

This gives us enough temporal resolution for summaries and debugging without turning SQLite into an unbounded event dump.

The aggregation logic also avoids a subtle error: "bad posture episodes" are not the number of samples below a threshold. An episode is a transition into the bad state. Otherwise, ten seconds at 2 Hz would incorrectly become twenty incidents.

Wear time has a similar edge case. We calculate it from consecutive sample timestamps while capping large gaps. We do not assume that elapsed wall-clock time between the first and last row represents active device usage.

Local first, cloud second

Firestore receives daily summaries and settings, not raw telemetry.

The sync pipeline runs:

  • periodically while monitoring,

  • while the app backgrounds,

  • when the wearable disconnects.

Firebase Anonymous Auth silently creates an identity, allowing security rules to scope each document without building registration screens for an internal trial.

This separation gives us four useful properties:

  1. live posture works without internet,

  2. radio ingestion never waits for a cloud write,

  3. Firestore usage remains predictable,

  4. summaries can sync later after temporary failure.

In other words, BLE is the hot path, SQLite is the durable path, and Firestore is the synchronization path.

Android background monitoring required native code

Keeping a JavaScript process alive while a BLE wearable streams in the background is not something Android guarantees automatically.

We added a native Kotlin foreground service. When the wearable connects:

  1. React Native starts PostureMonitorService,

  2. Android displays a low-importance persistent notification,

  3. the service keeps the app process important enough for continued monitoring,

  4. JavaScript periodically updates the score shown in the notification,

  5. disconnecting stops the service.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    ServiceCompat.startForeground(
        this,
        NOTIFICATION_ID,
        notification,
        ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
    )
} else {
    startForeground(NOTIFICATION_ID, notification)
}

The service is deliberately small. BLE parsing and scoring remain in the application layer; the native component owns Android lifecycle behavior.

That boundary kept the solution maintainable while satisfying Android's connected-device foreground-service rules.

Alerts: what worked and what the firmware did not expose

The phone can produce warning haptics and audible notifications. Initially, we triggered those from a local state machine:

  • score below 72,

  • sustained for three seconds,

  • rearm after recovery to 80.

This prevents a vibration on every frame and creates one alert per poor-posture episode.

But physical testing revealed an important product mismatch: the wearable itself beeps based on firmware logic, and the requirement was for the phone to mirror that exact event.

We monitored all three proprietary characteristics. FFE1 continued sending sensor frames, but FFE2 and FFE3 emitted no alert event. The live JSON also contained no beep flag or sequence number.

Therefore the app cannot know exactly when the wearable beeped. Reimplementing firmware thresholds in JavaScript can only approximate it.

The correct long-term protocol is an explicit firmware event, for example:

{
  "event": "posture_alert",
  "active": true,
  "sequence": 42,
  "reason": "upper_back"
}

The sequence number lets the phone deduplicate reconnects and repeated notifications. An explicit recovery event — or a documented cooldown — lets it rearm correctly.

This was one of the most useful findings in the entire build: sometimes the right mobile implementation is to stop and improve the firmware contract.

Why battery showed as unavailable

The planned app supported the standard BLE Battery Service:

Service:        0x180F
Characteristic: 0x2A19

The physical SKEEDAD5 firmware exposed neither one.

We checked three possible sources:

  • the complete discovered GATT table,

  • the FFE1 telemetry payload,

  • BLE advertisement/manufacturer data.

The advertisement bytes decoded to the device MAC address, not a charge percentage. FFE2 and FFE3 produced no battery events. Even connecting the USB-C charging port to macOS exposed no serial, debug, storage, or DFU interface.

So the correct UI is "battery unavailable," not an invented percentage.

The firmware needs either the standard Battery Service or a documented proprietary battery command. A polished placeholder must never become fake telemetry.

Designing the UI for real Android devices

The design was implemented as a floating six-item bottom tab bar. It looked correct in the iOS simulator but initially overlapped Samsung's three-button system navigation bar.

The fix was to treat safe areas as runtime data, not fixed padding:

const insets = useSafeAreaInsets();
const bottom = Math.max(insets.bottom + 10, 16);

We also replaced the default Android tab-button ripple with a controlled press state. The stock ripple created a large gray "hover" effect across the floating navigation surface.

This is why physical-device QA matters even for apparently simple layout work. Simulator screenshots do not represent every navigation mode, cutout, font scale, or OEM behavior.

Testing the complete system

Our validation stack covered different failure classes.

Protocol tests

Unit tests covered:

  • valid five-sensor frames,

  • fragmented payload reconstruction,

  • malformed and incomplete JSON,

  • score conversion and angle wrapping.

Physical BLE testing

Using ADB and scrcpy, we verified:

  • device discovery and connection,

  • actual GATT services and characteristic properties,

  • live frames at roughly 2 Hz,

  • reconnect behavior, calibration persistence,

  • SQLite writes and lifecycle flushing,

  • foreground-service survival,

  • phone vibration through Android vibrator logs.

Standalone APK testing

We stopped Metro, installed a release APK, launched it directly, and connected the wearable again.

This is a critical test. "It works with npm start running" does not prove that the artifact sent to a tester contains the correct JavaScript bundle and native libraries.

CI/CD and internal distribution

The GitHub Actions pipeline performs:

  1. dependency installation,

  2. TypeScript validation,

  3. linting,

  4. unit tests,

  5. debug APK builds for pull requests and non-main branches,

  6. signed release builds on main,

  7. artifact upload,

  8. Firebase App Distribution upload.

Release credentials are injected through encrypted GitHub Actions secrets:

  • base64-encoded Android keystore,

  • keystore and key passwords,

  • key alias,

  • Firebase service-account JSON.

The Gradle build fails closed when release signing is missing. A disposable debug-signed release is allowed only through an explicit local smoke-test flag; CI cannot accidentally ship it as production.

Version code comes from the GitHub run number, giving each distributed APK an installable upgrade path.

Firebase App Distribution provides the internal tester group and installation flow without publishing to the Play Store.

Key engineering decisions

Decision Why
Buffer BLE chunks before parsing Notifications do not preserve message boundaries
Validate every frame Hardware input is an external, failure-prone boundary
Calibrate per device Scores should measure deviation from a personal baseline
Throttle UI separately Rendering frequency and ingestion frequency are different concerns
Persist at 1 Hz Enough resolution without unnecessary storage pressure
Batch SQLite writes Reduce transaction overhead on the hot path
Store raw data locally Privacy, offline operation, and predictable cloud cost
Sync only aggregates Firestore is not a high-frequency telemetry database
Native foreground service Android background survival is an OS responsibility
Explicit firmware events The phone should not guess when the wearable beeped
Show unknown battery honestly Missing telemetry is not zero and not a demo percentage
Test release without Metro The APK — not the development environment — is the product

What I would improve next

The next iteration depends heavily on a signed firmware protocol document.

My priority list would be:

  1. Add standard 180F/2A19 battery telemetry.

  2. Add an explicit, sequenced hardware posture-alert event.

  3. Document calibration, sensitivity, beep, and vibration write commands with acknowledgements.

  4. Confirm the physical S0–S4 body mapping.

  5. Add firmware version and protocol version characteristics.

  6. Move Android BLE ownership into a dedicated native connected-device service if the process must survive longer background periods independently of JavaScript.

  7. Complete physical iOS background-restoration testing.

  8. Expand long-duration tests for disconnects, packet corruption, storage growth, and day-boundary aggregation.

What I learned

The biggest lesson is that a Bluetooth companion app is a distributed system in miniature.

The firmware owns sensing and hardware events. BLE owns an unreliable, chunked transport. The mobile app owns interpretation, UX, and local durability. Android and iOS own process lifetime. Firebase owns eventual cloud synchronization. CI/CD owns whether testers receive a reproducible artifact.

If any boundary is vague, the UI eventually exposes that ambiguity.

For SKEEDAD, the strongest engineering decisions were not flashy:

  • preserve raw validated frames,

  • separate hot-path state from durable writes,

  • test the actual GATT profile,

  • avoid inventing missing battery data,

  • acknowledge when phone alerts cannot exactly mirror firmware,

  • verify a standalone APK against physical hardware.

That is the difference between a BLE demo and a companion app you can confidently hand to a tester.


Stack: React Native CLI · TypeScript · New Architecture · Hermes · react-native-ble-plx · Zustand · OP-SQLite · React Native Firebase · Firestore · Crashlytics · React Navigation · Reanimated · React Native SVG · Kotlin foreground service · GitHub Actions · Firebase App Distribution

#reactnative #bluetooth #mobiledevelopment #firebase #android