fitbit_air 0.1.1
fitbit_air: ^0.1.1 copied to clipboard
Experimental Android Bluetooth access to Fitbit Air, including haptics, live heart rate, device information, and diagnostics.
fitbit_air #
Experimental Flutter access to a Fitbit Air over its direct Android Bluetooth transport: haptic controls, live heart rate, device information, and connection diagnostics. Haptics are one use case, using this communication path:
Flutter → Android BLE → Gattlink → DTLS bootstrap session → CoAP /settings → Air
This is not a Fitbit Web API wrapper. It does not require OAuth, root, unpairing, or access to Google Health's private data. The Air must already be paired to the same Android phone in the installed Google Health app.
Warning
This is independent reverse-engineering research, not an official Google or Fitbit SDK. It relies on private, version-sensitive device protocols and on a compatible, authentic Google Health installation. Android arm64 is the only configuration tested so far. Reliable wrist-tap detection has not been decoded.
Install #
Install the published package:
flutter pub add fitbit_air
Or add it to pubspec.yaml:
dependencies:
fitbit_air: ^0.1.0
For unreleased development changes only, use the Git repository instead:
dependencies:
fitbit_air:
git:
url: https://github.com/ThoreKoritzius/fitbit-air.git
The plugin contributes the Android Bluetooth permissions. Android 12+ asks the user for Nearby devices access on first use.
Use #
import 'package:fitbit_air/fitbit_air.dart';
if (await FitbitAir.isAirPaired()) {
// Exactly one pulse. This alternates and retains low/high strength.
final result = await FitbitAir.pulse();
print('${result.previousIntensity} → ${result.currentIntensity}');
}
The public haptic operations have deliberately different semantics:
pulse()produces one pulse by alternating low/high strength and leaves the new strength active from the plugin's perspective. Google Health may later reconcile its own stored setting after the plugin disconnects.setHapticIntensity()persistently selects low or high and produces one pulse only when the value changes.getHapticIntensity()is read-only.vibrate()is the restore-safe preview: it changes the strength, waits, then restores it. That necessarily produces two pulses.
Every write is read back and verified. The restore-safe operation also makes a best-effort restoration if its main sequence fails.
Run the example #
Connect an Android phone with a Fitbit Air already paired in Google Health, then:
cd example
flutter run
Current scope #
| Capability | Status |
|---|---|
| Detect an Android-bonded Fitbit Air | Implemented |
| Trigger a short Air haptic | Physically verified from this package on Pixel 7 Pro (2026-10-14) |
| Exactly one pulse | Implemented and protocol-verified; alternates the persistent strength |
| Read/set low or high vibration strength | Implemented and verified |
| Device model, firmware, manufacturer | GATT read verified on Pixel 7 Pro / Air firmware 67.20001.253.2 |
| BLE services and connection status | Read-only discovery and link status decoder implemented |
| Scan advertised heart-rate sensors | Experimental; requires Nearby devices scan permission |
| Restore/verify prior vibration intensity | Physically verified |
| Root/OAuth/private PSK required | No, for the bootstrap haptic path |
| Detect taps on the Air | Not implemented |
| Headless haptics | Release-mode background and one controlled screen-off command verified; long-term/idle reliability unresolved |
| Raw diagnostic events | Opt-in research stream; no confirmed tap decoder |
| Battery resource | Bootstrap read access verified; selected raw fields available in diagnostics, units/scale unverified |
| Heart rate | Standard BLE readings and continuous updates verified; requires an active Google Health sharing session |
| Google Health coexistence | Plugin releases GATT; Google Health remains connected. Concurrent Goldengate sessions produced packet-sequence warnings, so uninterrupted sync is not yet proven |
| iOS | Not implemented |
| Non-arm64 Android | Untested; requires a compatible library in the installed Google Health APK |
The Google Health implementation contains a GET liveactivity resource with
steps, activity, and heart-rate fields, but the Air rejects it with CoAP 4.01
in the bootstrap session available to this plugin. It is therefore documented
as research rather than exposed as a misleading public API. See
doc/research.md.
readDeviceInfo() returns available standard fields, the actual GATT service
inventory, and the read-only connection configuration/status. Battery is
nullable: the tested Air does not expose the standard Battery Service.
Heart rate uses the separate standard Bluetooth Heart Rate Service. Start the
sharing flow in Google Health (Connections → Air → Share heart rate). Keep that
page open during initial discovery. scanHeartRateDevices() finds advertised
sensors, and readHeartRate(deviceId: ...) reads one measurement. With sharing
already active for this phone, readHeartRate() uses the paired Air directly.
watchHeartRate() keeps one Bluetooth client open and streams measurements;
cancel its subscription to release that client before sending a haptic:
final subscription = FitbitAir.watchHeartRate().listen((sample) {
print('${sample.beatsPerMinute} bpm');
});
// Later:
await subscription.cancel();
An accepted subscription alone does not guarantee data: the Air accepted it
but sent nothing before sharing was active. cancelOperation() cancels reads
and scans; haptic writes finish their verification before returning.
Experimental device events #
Import package:fitbit_air/fitbit_air_diagnostics.dart to use
FitbitAirDiagnostics.watchDeviceEvents(). This monitors a limited set of
link-status and unclassified BLE characteristics, not the encrypted tracker
channel. It enables notification subscriptions but sends no haptic/control
commands. The demo keeps a bounded event log in memory only. Payloads may
contain sensitive device data; do not upload them without user consent.
These events are not wrist-tap detection. The first physical tap test was inconclusive: subscriptions succeeded, but the connection ended before any tap-related payload could be established. Cancel the stream before using other SDK operations. Health Connect and historical stats are deferred.
FitbitAirDiagnostics.probeBatteryAccess() opens a separate Golden Gate
session and performs a read-only GET of the device-info resource. Access was
verified on the tested Air. It returns CoAP status and selected raw battery
fields, not a verified percentage or voltage unit. This is an explicit research
operation with the same Google Health coexistence caveat as other secure
sessions; it is never run automatically by standard GATT reads.
Background and notification integration #
For notification-triggered commands and the headless Android test, see
doc/background.md. A release-mode headless haptic was
physically verified with the demo backgrounded. A controlled screen-off command
also completed with duplicate suppression. An earlier trial timed out during
session establishment; long-term and deep-idle reliability remain unproven.
The host app must provide message reception and lifecycle management;
a phone notification alone does not trigger the Air.
Native compatibility and provenance #
The package and its APKs do not redistribute Google's libxp.so. At runtime,
the Android plugin verifies the installed com.fitbit.FitbitMobile signing
certificate, reads the matching library from that app's publicly installed APK
split, and copies it to this app's private code cache for loading. The cached
copy is keyed by Google Health's version and update timestamp.
The JNI-facing Kotlin bindings are adapted from the Apache-2.0 Golden Gate
Android bindings at commit b8a6f2552e12aaf92c1549dc88a41c9b7e73f1aa.
See THIRD_PARTY_NOTICES.md for provenance.
This arrangement avoids distributing a proprietary native binary or reading Google Health's private files, but it creates a strict compatibility dependency: a Google Health update can change the JNI ABI and require corresponding binding updates in this package.
Safety and compatibility #
- The operation is serialized; concurrent calls return
BUSY. - Starting with 0.1.1, that transport guard also covers separate Flutter engines in the same Android process. It cannot lock Google Health's separate process.
- A 45-second deadline closes the GATT connection.
- Google Health remains the pairing owner. This plugin opens a short-lived connection and does not unregister, factory-reset, or alter pairing keys.
- The plugin accepts the native implementation only from the expected signed Google Health package; a look-alike package is rejected.
- Firmware or Google Health updates may change the private protocol.
- Do not use this package for medical or safety-critical behavior.
Name and trademarks #
The Dart package name fitbit_air was unclaimed on pub.dev when checked on
2026-10-14. Availability is not a reservation until publication. Fitbit and
Google are trademarks of their respective owners; this project is unaffiliated.
License #
Plugin code is MIT licensed. Adapted upstream source retains its Apache-2.0 license. The runtime-loaded Google Health library remains subject to its owner's terms and is not part of this distribution.