From 29b1274585e2774ab72d6091363ae551369aa6ab Mon Sep 17 00:00:00 2001 From: changzuozhen Date: Wed, 10 Jun 2026 14:26:23 +0800 Subject: [PATCH 1/2] fix: keep longest manufacturer data when scan record has multiple 0xFF AD structures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../main/java/com/bleplx/adapter/AdvertisementData.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/src/main/java/com/bleplx/adapter/AdvertisementData.java b/android/src/main/java/com/bleplx/adapter/AdvertisementData.java index 21a6d51f2..6bf4bdd0b 100644 --- a/android/src/main/java/com/bleplx/adapter/AdvertisementData.java +++ b/android/src/main/java/com/bleplx/adapter/AdvertisementData.java @@ -199,6 +199,15 @@ private static void parseTxPowerLevel(AdvertisementData advData, int adLength, B private static void parseManufacturerData(AdvertisementData advData, int adLength, ByteBuffer data) { if (adLength < 2) return; + // A merged scan record can carry more than one manufacturer-specific AD + // structure (0xFF), e.g. one from ADV_IND and another from SCAN_RSP. The + // previous behaviour kept whichever structure appeared last, so a short + // placeholder could replace the real payload. Keep the longest structure + // instead, matching what iOS exposes via CoreBluetooth's merged + // advertisement data. + if (advData.manufacturerData != null && advData.manufacturerData.length >= adLength) { + return; + } advData.manufacturerData = new byte[adLength]; data.get(advData.manufacturerData, 0, adLength); } From 160e3bc57035d8d7fdf17a45c06e229ff1cae8e9 Mon Sep 17 00:00:00 2001 From: changzuozhen Date: Thu, 11 Jun 2026 17:17:05 +0800 Subject: [PATCH 2/2] test: add unit tests for manufacturer data parsing 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 --- android/build.gradle | 2 + .../bleplx/adapter/AdvertisementDataTest.java | 130 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 android/src/test/java/com/bleplx/adapter/AdvertisementDataTest.java diff --git a/android/build.gradle b/android/build.gradle index 4f1fd5d1e..428bf65b6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -91,6 +91,8 @@ dependencies { implementation "com.facebook.react:react-native:+" implementation 'io.reactivex.rxjava2:rxjava:2.2.17' implementation "com.polidea.rxandroidble2:rxandroidble:1.17.2" + + testImplementation "junit:junit:4.13.2" } if (isNewArchitectureEnabled()) { diff --git a/android/src/test/java/com/bleplx/adapter/AdvertisementDataTest.java b/android/src/test/java/com/bleplx/adapter/AdvertisementDataTest.java new file mode 100644 index 000000000..5eca3f265 --- /dev/null +++ b/android/src/test/java/com/bleplx/adapter/AdvertisementDataTest.java @@ -0,0 +1,130 @@ +package com.bleplx.adapter; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class AdvertisementDataTest { + + // --- helpers ------------------------------------------------------------- + + private static byte[] mfgStructure(byte... payload) { + byte[] structure = new byte[payload.length + 2]; + structure[0] = (byte) (payload.length + 1); // AD length includes the type byte + structure[1] = (byte) 0xFF; // manufacturer specific data + System.arraycopy(payload, 0, structure, 2, payload.length); + return structure; + } + + private static byte[] concat(byte[]... parts) { + int length = 0; + for (byte[] part : parts) length += part.length; + byte[] record = new byte[length]; + int offset = 0; + for (byte[] part : parts) { + System.arraycopy(part, 0, record, offset, part.length); + offset += part.length; + } + return record; + } + + private static final byte[] LOCAL_NAME_TEST = {0x05, 0x09, 'T', 'e', 's', 't'}; + + private static final byte[] LONG_PAYLOAD = {0x64, 0x4E, 0x01, 0x02}; + private static final byte[] SHORT_PAYLOAD = {0x46, 0x46}; + + // --- single structure: behaviour unchanged -------------------------------- + + @Test + public void parsesSingleManufacturerDataStructure() { + AdvertisementData advData = + AdvertisementData.parseScanResponseData(mfgStructure(LONG_PAYLOAD)); + + assertArrayEquals(LONG_PAYLOAD, advData.getManufacturerData()); + } + + @Test + public void ignoresManufacturerDataShorterThanCompanyId() { + AdvertisementData advData = + AdvertisementData.parseScanResponseData(mfgStructure((byte) 0x64)); + + assertNull(advData.getManufacturerData()); + } + + // --- multiple structures: the longest one wins ---------------------------- + + @Test + public void keepsLongerManufacturerDataWhenShorterFollows() { + // e.g. real payload advertised in ADV_IND, placeholder in SCAN_RSP + byte[] record = concat(mfgStructure(LONG_PAYLOAD), mfgStructure(SHORT_PAYLOAD)); + + AdvertisementData advData = AdvertisementData.parseScanResponseData(record); + + assertArrayEquals(LONG_PAYLOAD, advData.getManufacturerData()); + } + + @Test + public void keepsLongerManufacturerDataWhenLongerFollows() { + // e.g. placeholder advertised in ADV_IND, real payload in SCAN_RSP + byte[] record = concat(mfgStructure(SHORT_PAYLOAD), mfgStructure(LONG_PAYLOAD)); + + AdvertisementData advData = AdvertisementData.parseScanResponseData(record); + + assertArrayEquals(LONG_PAYLOAD, advData.getManufacturerData()); + } + + @Test + public void keepsFirstManufacturerDataOnEqualLength() { + byte[] first = {0x01, 0x02, 0x03}; + byte[] second = {0x04, 0x05, 0x06}; + byte[] record = concat(mfgStructure(first), mfgStructure(second)); + + AdvertisementData advData = AdvertisementData.parseScanResponseData(record); + + assertArrayEquals(first, advData.getManufacturerData()); + } + + @Test + public void continuesParsingSubsequentStructuresAfterKeepingExisting() { + // a kept-as-is (ignored) shorter structure must not desynchronize the + // cursor: the local name that follows it still has to parse correctly + byte[] record = + concat(mfgStructure(LONG_PAYLOAD), mfgStructure(SHORT_PAYLOAD), LOCAL_NAME_TEST); + + AdvertisementData advData = AdvertisementData.parseScanResponseData(record); + + assertArrayEquals(LONG_PAYLOAD, advData.getManufacturerData()); + assertEquals("Test", advData.getLocalName()); + } + + // --- real-world capture ---------------------------------------------------- + + @Test + public void parsesRealWorldMergedScanRecord() { + // Merged scan record captured from a peripheral that advertises its real + // 25-byte identity in one packet and a 12-byte "FFFFFFFFFFFF" placeholder + // in the other (see PR description). Previously the placeholder won. + byte[] record = { + 0x02, 0x01, 0x06, 0x1A, (byte) 0xFF, 0x64, 0x4E, 0x43, + 0x50, 0x64, 0x53, 0x67, 0x6D, 0x52, 0x50, 0x35, + 0x30, 0x31, 0x30, 0x31, 0x30, 0x35, 0x39, 0x30, + 0x30, 0x31, 0x31, 0x38, 0x39, 0x01, 0x0D, (byte) 0xFF, + 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, 0x46, + 0x46, 0x46, 0x46, 0x46, 0x0D, 0x09, 0x41, 0x49, + 0x20, 0x4E, 0x6F, 0x74, 0x65, 0x2D, 0x34, 0x45, + 0x36, 0x33, 0x00, 0x00, 0x00, 0x00 + }; + byte[] realPayload = { + 0x64, 0x4E, 0x43, 0x50, 0x64, 0x53, 0x67, 0x6D, 0x52, 0x50, 0x35, 0x30, + 0x31, 0x30, 0x31, 0x30, 0x35, 0x39, 0x30, 0x30, 0x31, 0x31, 0x38, 0x39, + 0x01 + }; + + AdvertisementData advData = AdvertisementData.parseScanResponseData(record); + + assertArrayEquals(realPayload, advData.getManufacturerData()); + assertEquals("AI Note-4E63", advData.getLocalName()); + } +}