Post

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.

NoiseFit - Reverse Engineering and Building Custom 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

  1. Device Discovery
  2. Protocol Structure
  3. Core Building Blocks
  4. The Ping & ACK Mechanism
  5. Session Initialization
  6. Notification Injection
  7. Find Watch
  8. Telecom Reverse Engineering
  9. Fake Call — Early Failures
  10. Packet Capture Analysis
  11. Fake Call — Working Implementation
  12. Dynamic Caller Names
  13. Full Telecom Lifecycle
  14. Android Companion App
  15. What’s Still Unknown
  16. 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

BLE service and characteristics discovered in scanner

GATT handle map with characteristic properties

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 here
  • 16186f01 — 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 08 is a fixed 3-byte frame header
  • opcode_varint is a protobuf-style varint-encoded message ID
  • payload is 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) and 0xA1 (161) are above 127 and must be varint-encoded. 0xB3 encodes as B3 02, and 0xA1 as A1 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:

  1. Write PING to 16186f02 (pre-ping)
  2. Sleep 150ms
  3. Write command packet to 16186f02
  4. Sleep 80ms
  5. Write ACK_OK to 16186f01 ← the notify char, not the write char
  6. Sleep 30ms
  7. Write ACK_END to 16186f01
  8. Sleep 30ms

The ACKs go to 16186f01, not 16186f02. This is the asymmetry that caused every early failure.

Terminal log showing correct ping-ACK packet sequence

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")

Watch screen after successful session initialization

Python terminal log showing full 9-frame init sequence completing successfully


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
WhatsApp com.whatsapp
Telegram org.telegram.messenger
Instagram com.instagram.android
Snapchat com.snapchat.android
Discord com.discord
Signal org.thoughtcrime.securesms

WhatsApp notification displayed on watch screen

Telegram notification displayed on watch screen


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")
}

Watch responding to find-watch command


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.

Watch displaying broken fake call — question mark caller name

Watch showing "Connect to Bluetooth" error from missing telecom prepare packet


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.

Wireshark BLE capture showing official app telecom lifecycle — B4 through B6 sequence

Annotated BLE capture highlighting B5/B6 keepalive packets repeating at 1-second intervals


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.

First successful fake call — "Pappa" displayed with full incoming call UI on watch


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.

Watch displaying "Himynameislinux" as caller name — confirming dynamic length calculation works


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")

Watch showing stable fake call UI with caller name, answer and decline buttons


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.

NoiseFit Toolkit app — main screen overview


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.

Android notification shade showing the persistent NoiseFit Toolkit foreground service notification


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:

  • GreenReady ✓
  • OrangeConnecting… / Scanning… / Reconnecting…
  • RedDisconnected / 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.

Scan dialog showing discovered BLE devices with paired watch highlighted in green

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 ONB6 ON → keepalive loop → graceful teardown.

App fake call section — caller name entered and ready to send

Watch displaying incoming fake call triggered from the Android app

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.

App custom notification section — app dropdown and message field

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.

App showing Auto Notifications status as ON with green indicator

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.

App live log panel showing real-time BLE packet log in green monospace text


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.

This post is licensed under CC BY 4.0 by the author.