Skip to content

fix: keep longest manufacturer data when scan record has multiple 0xFF AD structures#1340

Open
zuozhen-ai wants to merge 2 commits into
dotintent:masterfrom
zuozhen-ai:fix/manufacturer-data-multiple-ad-structures
Open

fix: keep longest manufacturer data when scan record has multiple 0xFF AD structures#1340
zuozhen-ai wants to merge 2 commits into
dotintent:masterfrom
zuozhen-ai:fix/manufacturer-data-multiple-ad-structures

Conversation

@zuozhen-ai

@zuozhen-ai zuozhen-ai commented Jun 10, 2026

Copy link
Copy Markdown

Prerequisites

  • I am working on the latest version of the library (branched from master, also reproduced on v3.5.1).
  • I tested the change locally on Android hardware — 7 physical peripherals verified with the fix; a representative before/after capture from one of them is decoded below. The change is in Android-only parsing code (AdvertisementData.java); the iOS path (CoreBluetooth's merged advertisement data) is untouched.
  • I ran npm test and npm run lint: results are identical on unmodified master and on this branch (the change is Java-only). In my environment some jest suites fail at setup on both branches equally (a local NativeEventEmitter/Node-version issue; every test that runs passes on both), and the final tsc --noEmit lint step needs the example app's dependencies on both — flow, eslint and documentation lint pass unchanged. CI's pinned environments should come back green.
  • Local regression tests: added android/src/test/java/com/bleplx/adapter/AdvertisementDataTest.java (plus a testImplementation "junit:junit:4.13.2" line — test-only, nothing shipped to consumers). 7 tests cover single-structure parsing, the sub-2-byte guard, both orderings of duplicate 0xFF structures, the equal-length tie-break, cursor integrity after a kept-as-is structure, and the real-world captured record below. 4 of the 7 fail against the previous last-wins behaviour; all 7 pass with the fix. The class only depends on java.*, so they run as plain JVM unit tests.

Bug description

On Android, when a merged scan record contains more than one manufacturer-specific AD structure (0xFF) — typically one in ADV_IND and another in SCAN_RSP — Device.manufacturerData only ever exposes the last structure in the byte stream. Whatever came before it is silently lost.

Cause: parseScanResponseData walks every AD structure of the merged record and parseManufacturerData unconditionally overwrites advData.manufacturerData on each 0xFF it meets:

private static void parseManufacturerData(AdvertisementData advData, int adLength, ByteBuffer data) {
  if (adLength < 2) return;
  advData.manufacturerData = new byte[adLength];   // <- last one always wins
  data.get(advData.manufacturerData, 0, adLength);
}

iOS is unaffected: CoreBluetooth hands over the merged CBAdvertisementDataManufacturerDataKey, so the same peripheral yields the real payload on iOS and a placeholder on Android — a platform behavior divergence.

This has been reported repeatedly over the years but never fixed at the parse layer: #483 (2019, byte-level analysis, closed by stale bot), #556 (reopen attempt), #949 (filed 2022, closed in 2023 by adding the rawScanRecord escape hatch in #1134 rather than fixing the merge), #970, and most recently #1306 (closed 2026 with the maintainer confirming the library exposes no ADV/SCAN_RSP distinction).

Real-world reproduction (captured from production hardware)

Our peripheral (an audio recorder pen) advertises a 12-byte placeholder in one packet and the real 25-byte identity (which contains the device serial number) in the other. Raw scan record captured via rawScanRecord on Android (base64 AgEGGv9kTkNQZFNnbVJQNTAxMDEwNTkwMDExODkBDf9GRkZGRkZGRkZGRkYNCUFJIE5vdGUtNEU2MwAAAAA=), decoded:

# AD structure len data (ASCII) meaning
0 Flags 0x01 1 0x06
1 Manufacturer 0xFF 25 dNCPdSgmRP50101059001189· real payload (contains serial)
2 Manufacturer 0xFF 12 FFFFFFFFFFFF placeholder
3 Complete Local Name 0x09 12 AI Note-4E63
  • device.manufacturerData (before this fix): RkZGRkZGRkZGRkZG = "FFFFFFFFFFFF" — the 12-byte placeholder, structure Unable to install module via npm install on Windows and Ubuntu #1 is lost.
  • Same device on iOS: the real payload is present in manufacturerData (our serial parsing works there unmodified).
  • device.manufacturerData (after this fix): ZE5DUGRTZ21SUDUwMTAxMDU5MDAxMTg5AQ== — the real payload, restoring parity with iOS.

Verified on 7 physical devices: with the fix, every one of them exposes the real manufacturer data through the normal manufacturerData field instead of requiring the rawScanRecord workaround.

Details

The fix

Keep the longest manufacturer-data structure instead of the last one:

if (advData.manufacturerData != null && advData.manufacturerData.length >= adLength) {
  return;
}
  • Cursor safety: the early return cannot desynchronize parsing — parseScanResponseData advances rawData.position() itself (line 84) and passes each parser an independent slice(), so whether the callee consumes 0 or adLength bytes is irrelevant. parseLocalName already takes the same consume-nothing path when it ignores a shortened name (0x08) after a name has been set.
  • Scope: nothing else in the library depends on the last-wins semantics — getManufacturerData() is only consumed by ScanResultToJsObjectConverter (base64 pass-through to JS).
  • Behavioral note: for two structures of equal length the previous code kept the last, this change keeps the first. For the motivating case (placeholder vs. real payload) lengths always differ.

Alternatives considered

  1. First-wins — would fix our device but breaks peripherals that put the placeholder first; ordering of ADV_IND vs SCAN_RSP inside ScanRecord.getBytes() is not something to rely on either way.
  2. Concatenating all 0xFF structures — matches what some stacks do for same-company-ID fragments, but blindly concatenating distinct structures corrupts payloads of devices that broadcast genuinely different data per packet.
  3. Exposing all manufacturer-data structures (list, or map keyed by company ID — note the BLE spec explicitly allows multiple structures with different company IDs) — the most complete fix, but an API addition rather than a bug fix. I'd be happy to follow up with that if you're interested; this PR deliberately stays minimal and non-breaking.

Keep-longest is the smallest change that restores iOS/Android parity for the common real-world pattern (short placeholder + full payload) without any API change.

Standalone reproduction harness

AdvertisementData only depends on java.*, so the fix can be verified without an emulator:

// two 0xFF structures: 4-byte payload first, 2-byte placeholder second
byte[] record = {
  0x05, (byte) 0xFF, 0x64, 0x4E, 0x01, 0x02,   // mfg: 64 4E 01 02 (real)
  0x03, (byte) 0xFF, 0x46, 0x46,               // mfg: 46 46 (placeholder)
  0x05, 0x09, 'T', 'e', 's', 't'               // local name: "Test"
};
AdvertisementData adv = AdvertisementData.parseScanResponseData(record);
// before: manufacturerData == [0x46, 0x46]  (placeholder wins)
// after:  manufacturerData == [0x64, 0x4E, 0x01, 0x02], localName == "Test"

zuozhen-ai and others added 2 commits June 10, 2026 14:42
…F AD structures

On Android, parseScanResponseData walks the merged scan record and
parseManufacturerData unconditionally overwrites advData.manufacturerData,
so when a record carries more than one manufacturer-specific AD structure
(e.g. one from ADV_IND and another from SCAN_RSP) whichever appears last
wins. Peripherals that advertise a short placeholder alongside the real
payload lose their data, while iOS (CoreBluetooth) exposes the full merged
manufacturer data.

Keep the longest structure instead. The early return is safe: the caller
advances the buffer cursor itself and hands each parser an independent
slice — parseLocalName already takes the same consume-nothing path when
it ignores a shortened name after a name has been set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces JUnit (testImplementation only, not shipped to consumers)
and covers AdvertisementData.parseScanResponseData: single-structure
parsing, the sub-2-byte guard, multiple 0xFF structures in both orders,
the equal-length tie-break, cursor integrity after a kept-as-is
structure, and the real-world merged scan record from the PR
description. Four of the seven tests fail against the previous
last-wins behaviour and all pass with the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@zuozhen-ai

Copy link
Copy Markdown
Author

Added unit tests in 160e3bc to make the fix easy to verify: AdvertisementDataTest covers both orderings of duplicate 0xFF structures, the equal-length tie-break, cursor integrity after a kept-as-is structure, and the real-world merged scan record from the description. 4 of the 7 tests fail against the previous last-wins behaviour; all 7 pass with the fix. AdvertisementData only depends on java.*, so they run as plain JVM unit tests (JUnit added as testImplementation only).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant