NoiseFit - Reverse Engineering and Building Custom App.
A complete technical walkthrough of reverse engineering the NoiseFit smartwatch BLE protocol — from raw packet analysis to a fully working custom Android companion app.
Reverse Engineering the NoiseFit Smartwatch BLE Protocol
How I replaced the official companion app by decoding a proprietary Bluetooth protocol from scratch — packet by packet.
I own a NoiseFit smartwatch. The official Android app works, but I wanted to understand exactly what it was doing over Bluetooth — and eventually replace it with something I built myself. This writeup documents the full journey: from initial GATT discovery to a working Android companion app that injects notifications and emulates incoming phone calls.
No SDKs, no documentation, no help from the manufacturer. Just a BLE scanner, a packet capture tool, and a lot of trial and error.
Table of Contents
- Device Discovery
- Protocol Structure
- Core Building Blocks
- The Ping & ACK Mechanism
- Session Initialization
- Notification Injection
- Find Watch
- Telecom Reverse Engineering
- Fake Call — Early Failures
- Packet Capture Analysis
- Fake Call — Working Implementation
- Dynamic Caller Names
- Full Telecom Lifecycle
- Android Companion App
- What’s Still Unknown
- Key Takeaways
1. Device Discovery
The first step was connecting to the watch with a generic BLE scanner and inspecting its GATT profile. The watch advertised a single primary service:
1
16186f00-0000-1000-8000-00807f9b34fb
Within that service, five characteristics were present:
| UUID | Role |
|---|---|
16186f01-...-00807f9b34fb |
Notify + Write |
16186f02-...-00807f9b34fb |
Write |
16186f03-...-00807f9b34fb |
Unknown |
16186f04-...-00807f9b34fb |
Unknown |
16186f05-...-00807f9b34fb |
Unknown |
The most important early discovery: 16186f01 and 16186f02 are not interchangeable, and they serve completely different roles.
16186f02— all outgoing commands, ping packets, and data frames go here16186f01— ACK packets are written to this characteristic, and incoming watch responses arrive from it via BLE notifications
Confusing these two caused every single early failure. More on that below.
2. Protocol Structure
Once I established a connection, I needed to understand how commands were structured. After a lot of failed attempts, a clear pattern emerged.
Every packet follows this base format:
1
01 00 08 <opcode_varint> <payload>
01 00 08is a fixed 3-byte frame headeropcode_varintis a protobuf-style varint-encoded message IDpayloadis protobuf-encoded data (or empty for simple commands)
A single Python function generates every packet in the protocol:
1
2
3
4
5
6
def frame(mid, payload=b""):
return (
bytes([0x01, 0x00, 0x08])
+ varint(mid)
+ payload
)
3. Core Building Blocks
Varint Encoding
The watch uses protobuf-style varints. Values up to 127 encode as a single byte. Values above 127 use continuation bits — a subtle but critical detail that caused several silent failures before I caught it.
1
2
3
4
5
6
7
8
9
def varint(n):
out = []
while True:
b = n & 0x7F
n >>= 7
out.append(b | 0x80 if n else b)
if not n:
break
return bytes(out)
Gotcha: Opcodes like
0xB3(179) and0xA1(161) are above 127 and must be varint-encoded.0xB3encodes asB3 02, and0xA1asA1 01. Writing them as raw single bytes produces silent failures — the watch processes the malformed header and discards the rest.
Protobuf Field Helpers
1
2
3
4
5
6
7
8
9
10
11
def pb_vi(field, value):
# Varint field
return varint((field << 3) | 0) + varint(value)
def pb_b(field, data):
# Length-delimited field
return varint((field << 3) | 2) + varint(len(data)) + data
def pb_s(field, text):
# String field
return pb_b(field, text.encode())
4. The Ping & ACK Mechanism
This was the single most impactful discovery of the entire project.
The watch requires a strict ping-acknowledge handshake wrapped around every command frame. Skipping any part of it causes the command to be silently ignored, or the session to drop entirely.
Three special packets are involved:
1
2
3
PING = 000000000100
ACK_OK = 000001010000
ACK_END = 000001000000
Every command must be sent in this exact order:
- Write
PINGto16186f02(pre-ping) - Sleep 150ms
- Write command packet to
16186f02 - Sleep 80ms
- Write
ACK_OKto16186f01← the notify char, not the write char - Sleep 30ms
- Write
ACK_ENDto16186f01 - Sleep 30ms
The ACKs go to 16186f01, not 16186f02. This is the asymmetry that caused every early failure.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌────────────────────┐
│ Android / Python │
│ Reverse Engineered│
│ Client │
└─────────┬──────────┘
│
┌────────────────────┴────────────────────┐
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ 16186f02 │ │ 16186f01 │
│ COMMAND CHANNEL │ │ ACK / NOTIFY CHANNEL │
├──────────────────────┤ ├──────────────────────┤
│ 010008b201... │ │ 000001010000 │
│ 010008b401 │ │ ACK_OK │
│ 010008b501 │ │ │
│ 010008b601 │ │ 000001000000 │
│ 010008a1 │ │ ACK_END │
│ Notification frames │ │ │
│ Telecom frames │ │ Incoming watch ping │
└─-────────────────────┘ └──────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
async def send_frame(client, packet, label):
await client.write_gatt_char(WRITE, PING, response=False)
await asyncio.sleep(0.15)
await client.write_gatt_char(WRITE, packet, response=False)
await asyncio.sleep(0.08)
# ACKs go to the NOTIFY characteristic
await client.write_gatt_char(NOTIFY, ACK_OK, response=False)
await asyncio.sleep(0.03)
await client.write_gatt_char(NOTIFY, ACK_END, response=False)
await asyncio.sleep(0.03)
5. Session Initialization
A stable session requires a specific sequence of nine frames sent after connection. Omitting or reordering any frame produces an unstable or broken session.
| Step | Opcode | Purpose |
|---|---|---|
| 1 | 0x00 |
Protocol init |
| 2 | 0x10 |
Session start |
| 3 | 0x11 |
Feature flags |
| 4 | 0x12 |
Device registration |
| 5 | 0x13 |
Device ID |
| 6 | 0x21 |
Capability query |
| 7 | 0x30 |
Time sync |
| 8 | 0x20 |
Feature query A |
| 9 | 0x22 |
Feature query B |
Before sending any of these frames, an initial manual ping-ACK handshake is required:
1
2
3
4
5
# Manual startup handshake
await client.write_gatt_char(WRITE, PING, response=False)
await asyncio.sleep(0.4)
await client.write_gatt_char(NOTIFY, ACK_OK, response=False)
await asyncio.sleep(0.2)
Then the full init sequence:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
await send_frame(client,
frame(0x00, bytes.fromhex("9a06022000")), "init")
await send_frame(client, frame(0x10), "session")
await send_frame(client,
frame(0x11, bytes.fromhex("1a0412020801")), "feature_flags")
await send_frame(client,
frame(0x12,
pb_b(3, pb_b(3,
pb_vi(1, 0) +
pb_s(2, "282e89d13748") +
pb_vi(3, 0)
))
), "registration")
await send_frame(client,
frame(0x13, pb_b(3, pb_s(6, "282e89d13748"))), "device_id")
await send_frame(client, frame(0x21), "capability")
await send_frame(client, build_time_sync(), "time_sync")
await send_frame(client, frame(0x20), "feat_20")
await send_frame(client, frame(0x22), "feat_22")
6. Notification Injection
Discovery
Notifications use opcode 0xB3. Because 0xB3 = 179 > 127, the varint-encoded frame header becomes:
1
01 00 08 B3 02 <payload>
I spent a while sending 01 00 08 B3 <payload> — missing that continuation byte — and getting zero results. Once that was fixed, notifications worked immediately.
Payload Structure
The notification payload nests protobuf fields like this:
1
2
3
4
5
6
7
8
9
field 13 {
field 2 {
field 1: app_name_lowercase (string)
field 2: package_name (string)
field 3: app_name (string)
field 4: message (string)
field 5: "msg" (string, literal)
}
}
Builder
1
2
3
4
5
6
7
8
9
10
def build_notification(app_name, package_name, message):
inner = (
pb_s(1, app_name.lower())
+ pb_s(2, package_name)
+ pb_s(3, app_name)
+ pb_s(4, message)
+ pb_s(5, "msg")
)
payload = pb_b(13, pb_b(2, inner))
return frame(0xB3, payload)
Tested and Working Apps
| App | Package |
|---|---|
com.whatsapp |
|
| Telegram | org.telegram.messenger |
com.instagram.android |
|
| Snapchat | com.snapchat.android |
| Discord | com.discord |
| Signal | org.thoughtcrime.securesms |
7. Find Watch
Opcode 0xA1 triggers the find-watch function. Like 0xB3, it’s above 127 and requires varint encoding:
1
2
3
0xA1 = 161 → encodes as A1 01
Full packet: 01 00 08 A1 01
Behavior observed through testing:
- Single frame → vibration only
- Repeated frames with delays → vibration + audible beep
Kotlin implementation:
1
2
3
4
fun findWatch() {
val pkt = byteArrayOf(0x01, 0x00, 0x08) + varintBytes(0xA1)
sendFrame(pkt, "find_watch")
}
8. Telecom Reverse Engineering
Through packet analysis, I identified four opcodes that control the call UI:
| Opcode | Meaning |
|---|---|
0xB2 |
Call metadata (caller name, number) |
0xB4 |
Telecom session state (on/off) |
0xB5 |
Ring state (on/off, keepalive) |
0xB6 |
UI state (on/off, keepalive) |
The 01 suffix activates a state; 00 deactivates it.
9. Fake Call — Early Failures
Before I understood the full telecom lifecycle, every attempt at a fake call produced some combination of these failures:
| Symptom | Root Cause |
|---|---|
? displayed as caller name |
Incorrect protobuf field lengths |
| “Connect to Bluetooth” message | Missing telecom prepare packet (B4 ON) |
| No ringtone, vibration only | UI state (B6) never activated |
| Watch froze | Keepalive loop missing |
| Random disconnects | Missing graceful teardown |
The watch isn’t forgiving of partial state. If you skip a step or send them out of order, it gets stuck and needs a restart.
10. Packet Capture Analysis
I captured BLE traffic between the official NoiseFit app and the watch using a packet sniffer. This revealed the complete telecom state machine that the app was running through on every call.
Activation sequence:
1
B4 ON → B2 (call metadata) → B5 ON → B6 ON
Keepalive loop (runs every ~1 second while ringing):
1
B5 keepalive → sleep 1s → B6 keepalive → sleep 1s
Teardown sequence (reverse of activation):
1
B6 OFF → B5 OFF → B4 OFF
Sending teardown in the wrong order left the watch in a state requiring a hard restart.
11. Fake Call — Working Implementation
Metadata Packet Structure
The call metadata packet for a caller name (with empty number field) looks like this:
1
2
3
4
5
6
7
8
010008b201
6a <name_len+10>
0a <name_len+8>
0800
1200 ← empty phone number field
1a <name_len>
<name_bytes>
2200
Python Builder
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def build_call(name):
name_bytes = name.encode()
name_len = len(name_bytes)
payload = (
"010008b201"
+ "6a" + f"{name_len + 10:02x}"
+ "0a" + f"{name_len + 8:02x}"
+ "0800"
+ "1200"
+ "1a" + f"{name_len:02x}"
+ name_bytes.hex()
+ "2200"
)
return bytes.fromhex(payload)
Verified Working Example — “Pappa”
1
010008b201 6a1c 0a1a 0800 1200 1a05 5061707061 2200
This correctly displayed Pappa on the watch with the full incoming call UI, ringtone, and vibration.
12. Dynamic Caller Names
Once the packet builder was confirmed working, I tested arbitrary names:
| Name | Result |
|---|---|
| Pappa | ✓ |
| Nancy | ✓ |
| Mummy | ✓ |
| Himynameislinux | ✓ |
The key was dynamically recalculating name_len, name_len + 8, and name_len + 10 for each name. Hardcoding the lengths works only for the exact name you calculated them for — anything longer or shorter and the packet is silently malformed.
13. Full Telecom Lifecycle
With all the pieces understood, here’s the complete working implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 1. Telecom prepare
await send_frame(client, bytes.fromhex("010008b401"), "telecom_prepare")
await asyncio.sleep(0.4)
# 2. Call metadata
await send_frame(client, build_call(name), "call_metadata")
await asyncio.sleep(0.4)
# 3. Ring state on
await send_frame(client, bytes.fromhex("010008b501"), "ring_state")
await asyncio.sleep(0.4)
# 4. UI activate
await send_frame(client, bytes.fromhex("010008b601"), "ui_activate")
# 5. Keepalive loop (runs while call is active)
start = time.time()
while time.time() - start < 6:
await send_frame(client, bytes.fromhex("010008b501"), "ring_keepalive")
await asyncio.sleep(1.0)
await send_frame(client, bytes.fromhex("010008b601"), "ui_keepalive")
await asyncio.sleep(1.0)
# 6. Graceful teardown (reverse order)
await send_frame(client, bytes.fromhex("010008b600"), "ui_close")
await asyncio.sleep(0.3)
await send_frame(client, bytes.fromhex("010008b500"), "ring_stop")
await asyncio.sleep(0.3)
await send_frame(client, bytes.fromhex("010008b400"), "telecom_end")
14. Android Companion App
With the full protocol understood in Python, the final step was translating everything into a proper Android app — something that could run permanently in the background, survive reboots, and actually replace the official NoiseFit app for day-to-day use.
The stack: Kotlin + Jetpack Compose for the UI, the Nordic BLE library (no.nordicsemi.android.ble) for reliable GATT operations, and a handful of Android system components for persistence.
The app is called NoiseFit Toolkit.
App Architecture
The app is built around four components that work together:
1
2
3
4
5
MainActivity — UI, scan, connect, manual controls
WatchBleManager — singleton BLE core, all protocol logic
WatchForegroundService — background persistence, auto-reconnect
NotificationListenerService — intercepts phone notifications, forwards to watch
BootReceiver — restarts the service after reboot
Each component has a single clear responsibility, and they communicate through the singleton WatchBleManager.
WatchBleManager — The Protocol Core
WatchBleManager extends Nordic’s BleManager and is instantiated as a singleton using applicationContext, so it survives across activity recreations and service restarts without leaking.
1
2
3
4
5
6
7
8
9
companion object {
@Volatile private var instance: WatchBleManager? = null
fun getInstance(context: Context): WatchBleManager {
return instance ?: synchronized(this) {
instance ?: WatchBleManager(context.applicationContext).also { instance = it }
}
}
}
During GATT discovery, it identifies the two critical characteristics and stores them separately — writeChar for commands, notifyChar for ACKs and incoming data:
1
2
3
4
5
6
7
8
9
10
11
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
gatt.services.forEach { svc ->
svc.characteristics.forEach { chr ->
when (chr.uuid) {
WRITE_UUID -> writeChar = chr // 16186f02 — commands
NOTIFY_UUID -> notifyChar = chr // 16186f01 — ACKs
}
}
}
return writeChar != null && notifyChar != null
}
The characteristic asymmetry discovered during Python research is enforced in two separate write functions:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Commands and ping go to the WRITE characteristic
private fun txWrite(data: ByteArray, label: String) {
writeCharacteristic(writeChar!!, data,
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE).enqueue()
Thread.sleep(80)
}
// ACKs go to the NOTIFY characteristic
private fun txNotify(data: ByteArray, label: String) {
writeCharacteristic(notifyChar!!, data,
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE).enqueue()
Thread.sleep(30)
}
Every command is wrapped in the full ping-ACK sequence:
1
2
3
4
5
6
7
fun sendFrame(packet: ByteArray, label: String) {
txWrite(PING, "pre_ping")
Thread.sleep(150)
txWrite(packet, label)
txNotify(ACK_OK, "ack_ok")
txNotify(ACK_END, "ack_end")
}
All protocol functions — initWatch(), sendFakeCall(), sendNotification(), findWatch() — are implemented directly in WatchBleManager using the same packet builders from the Python research, translated to Kotlin.
WatchForegroundService — Staying Alive
The foreground service is the backbone of background operation. It runs as a START_STICKY service with foregroundServiceType="dataSync", which means Android keeps it alive and restarts it automatically if the process is killed.
On start, it registers two BroadcastReceivers inline:
Bluetooth state receiver — fires when the user toggles Bluetooth:
1
2
3
4
5
6
7
8
BluetoothAdapter.STATE_ON -> {
updateNotification("Bluetooth on — connecting to watch…")
scheduleReconnect(delayMs = 2000)
}
BluetoothAdapter.STATE_OFF -> {
reconnectJob?.cancel()
updateNotification("Bluetooth off — waiting…")
}
ACL connection receiver — fires on watch connect/disconnect events:
1
2
3
4
5
6
7
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
updateNotification("Watch disconnected — reconnecting…")
scheduleReconnect(delayMs = 3000)
}
BluetoothDevice.ACTION_ACL_CONNECTED -> {
scheduleReconnect(delayMs = 1000)
}
The reconnect logic reads the last connected MAC from SharedPreferences, finds it in the bonded device list, connects, waits 800ms for the GATT stack to settle, then runs initWatch(). If connection fails, it schedules another attempt 10 seconds later:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bleManager.connect(device)
.useAutoConnect(true)
.timeout(15_000)
.retry(5, 3000)
.done {
serviceScope.launch {
delay(800)
bleManager.initWatch()
updateNotification("$savedName connected ✓ — forwarding notifications")
}
}
.fail { _, _ ->
updateNotification("Connection failed — retrying…")
scheduleReconnect(delayMs = 10_000)
}
.enqueue()
The persistent foreground notification reflects live connection state — “Connecting…”, “Watch connected ✓ — forwarding notifications”, “Bluetooth off — waiting…” — so there’s always a clear status indicator in the notification shade without opening the app.
NotificationListenerService — Real-Time Forwarding
NotificationListenerService intercepts every notification posted on the phone and forwards qualifying ones to the watch. It monitors 16 apps out of the box:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val WATCHED_APPS = mapOf(
"com.whatsapp" to "WhatsApp",
"com.whatsapp.w4b" to "WhatsApp",
"com.instagram.android" to "Instagram",
"org.telegram.messenger" to "Telegram",
"org.telegram.messenger.web" to "Telegram",
"com.snapchat.android" to "Snapchat",
"com.discord" to "Discord",
"org.thoughtcrime.securesms" to "Signal",
"com.facebook.katana" to "Facebook",
"com.facebook.orca" to "Messenger",
"com.twitter.android" to "Twitter",
"com.google.android.gm" to "Gmail",
"com.microsoft.teams" to "Teams",
"com.slack" to "Slack",
"com.viber.voip" to "Viber",
"com.skype.raider" to "Skype",
)
Before forwarding, several filters run to avoid noise:
1
2
3
4
5
6
7
8
9
10
// Skip ongoing notifications (music, navigation, active calls)
if (sbn.isOngoing) return
// Skip group summary duplicates
if ((notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
// Debounce — skip identical notification within 3 seconds
val key = "$pkg|$message"
val now = System.currentTimeMillis()
if (key == lastNotifKey && now - lastNotifTime < 3000) return
Forwarding runs on a coroutine with a 3-attempt retry loop. If the watch isn’t connected on the first attempt, it triggers a reconnect and waits 4 seconds before retrying:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private suspend fun sendToWatchWithRetry(
appName: String, pkg: String, message: String, retries: Int
) {
val mgr = WatchBleManager.getInstance(applicationContext)
repeat(retries) { attempt ->
if (mgr.isConnected) {
mgr.sendNotification(appName, pkg, message)
return
} else {
WatchForegroundService.start(applicationContext)
delay(4000)
}
}
}
If the listener service disconnects from the system (which Android can do under memory pressure), it immediately requests a rebind:
1
2
3
override fun onListenerDisconnected() {
requestRebind(ComponentName(this, NotificationListenerService::class.java))
}
BootReceiver — Surviving Reboots
BootReceiver listens for three intents to catch every phone restart scenario:
1
2
3
4
5
if (action == Intent.ACTION_BOOT_COMPLETED ||
action == Intent.ACTION_MY_PACKAGE_REPLACED ||
action == "android.intent.action.QUICKBOOT_POWERON") {
WatchForegroundService.start(context)
}
MY_PACKAGE_REPLACED also catches app updates — the service restarts automatically after an APK install without requiring the user to reopen the app.
MainActivity — The UI
The UI is a single-screen Jetpack Compose layout on a dark background (#121212). The status indicator at the top colour-codes connection state in real time:
- Green —
Ready ✓ - Orange —
Connecting…/Scanning…/Reconnecting… - Red —
Disconnected/Failed
The screen is divided into four functional sections:
Scan & Connect — tapping the button runs a 5-second BLE scan, then presents a dialog that merges scanned results with the bonded device list. Already-paired devices are labelled “paired” in green. Selecting a device connects, runs initWatch(), saves the MAC to SharedPreferences, and starts the foreground service. On subsequent app opens, the last MAC is used to auto-reconnect without scanning.
Fake Call — a text field for the caller name and a red “📞 Send Fake Call” button. Tapping it runs the full telecom lifecycle on a background coroutine: B4 ON → call metadata → B5 ON → B6 ON → keepalive loop → graceful teardown.
Custom Notification — an app dropdown (WhatsApp, Instagram, Telegram, Snapchat, Discord, Signal) and a message field. The “🔔 Send Notification” button injects the notification to the watch with the correct protobuf structure for whichever app is selected.
Find Watch — a single “🔊 Find Watch (Buzz)” button that sends the 0xA1 frame and triggers vibration + beep on the watch.
Auto Notifications status — shows ● ON in green if notification listener access is granted. If not, a “Grant” button opens the system notification listener settings directly.
Live log panel — a scrollable monospace terminal at the bottom of the screen showing every packet sent, connection event, and error in real time. Autoscrolls to the latest entry. A “Clear” tap resets it.
Permissions
The manifest declares a complete set of Bluetooth permissions to handle both old and new Android versions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- Android 11 and below -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- Boot and notifications -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
BLUETOOTH_SCAN is declared with neverForLocation — this tells Android 12+ that the scan is not being used to infer location, which avoids the ACCESS_FINE_LOCATION runtime prompt on modern devices. The foreground service uses type dataSync, which is the correct type for background BLE data transfer and requires no additional runtime permission beyond BLUETOOTH_CONNECT.
Feature Summary
| Feature | Implementation | Status |
|---|---|---|
| BLE scan + device picker | BluetoothLeScanner + bondedDevices merged |
✅ Stable |
| Session initialization | Full 9-frame init sequence | ✅ Stable |
| Fake incoming calls | Full telecom lifecycle with keepalive | ✅ Stable |
| Custom notification injection | Protobuf builder, 6 apps | ✅ Stable |
| Find watch | 0xA1 varint frame |
✅ Stable |
| Real-time notification forwarding | NotificationListenerService, 16 apps |
✅ Stable |
| Auto-reconnect on BT toggle | BluetoothAdapter.ACTION_STATE_CHANGED receiver |
✅ Stable |
| Auto-reconnect on disconnect | ACTION_ACL_DISCONNECTED receiver |
✅ Stable |
| Auto-reconnect on app open | Last MAC from SharedPreferences |
✅ Stable |
| Persistence after app close | START_STICKY foreground service |
✅ Stable |
| Boot persistence | BOOT_COMPLETED + MY_PACKAGE_REPLACED |
✅ Stable |
| Live BLE log | Monospace terminal in UI | ✅ Stable |
15. What’s Still Unknown
Not everything was reversed — some areas weren’t worth digging into for this project:
| Area | Status |
|---|---|
| Ringtone audio subsystem | Unknown — watch selects tone internally |
| Answered call state | Not investigated |
| Media playback controls | Not investigated |
| Weather data push | Not investigated |
| Custom notification icons | Not investigated |
| Step / health data readback | Not investigated |
16. Key Takeaways
This project had a handful of discoveries that unblocked everything else. In rough order of impact:
1. Characteristic asymmetry. Commands go to 16186f02; ACKs go to 16186f01. This single mistake was the root cause of every early failure.
2. Varint encoding. Any opcode above 0x7F must be varint-encoded. Writing raw bytes for 0xB3 or 0xA1 causes silent failures with no error feedback from the watch.
3. Strict timing. The watch uses timing windows to validate frame sequences. Commands sent too quickly after connection were ignored even when structurally correct.
4. State machine completeness. The telecom lifecycle is not optional — partial sequences leave the watch stuck. The full activation → keepalive → teardown cycle is mandatory.
5. No cryptographic authentication. Session registration uses a static device identifier. Any client that sends the correct byte sequences in the correct order is accepted as a legitimate companion app. This is convenient for reverse engineering; less ideal from a security standpoint.
Research conducted through BLE packet analysis, traffic capture, and iterative protocol reconstruction.

























