Merge "Implement per-field matching of ScanRecord." into sc-dev

This commit is contained in:
Jeff Sharkey
2021-03-18 23:16:45 +00:00
committed by Android (Google) Code Review
5 changed files with 429 additions and 188 deletions

View File

@@ -29,6 +29,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
/**
* Represents a scan record from Bluetooth LE scan.
@@ -168,6 +169,27 @@ public final class ScanRecord {
return mBytes;
}
/**
* Test if any fields contained inside this scan record are matched by the
* given matcher.
*
* @hide
*/
public boolean matchesAnyField(@NonNull Predicate<byte[]> matcher) {
int pos = 0;
while (pos < mBytes.length) {
final int length = mBytes[pos] & 0xFF;
if (length == 0) {
break;
}
if (matcher.test(Arrays.copyOfRange(mBytes, pos, pos + length + 1))) {
return true;
}
pos += length + 1;
}
return false;
}
private ScanRecord(List<ParcelUuid> serviceUuids,
List<ParcelUuid> serviceSolicitationUuids,
SparseArray<byte[]> manufacturerData,

View File

@@ -20,6 +20,7 @@ import android.annotation.NonNull;
import android.annotation.Nullable;
import android.bluetooth.BluetoothUuid;
import android.net.MacAddress;
import android.text.TextUtils;
import android.util.Log;
import com.android.internal.util.HexDump;
@@ -42,207 +43,273 @@ import java.util.function.Predicate;
* @hide
*/
public class BytesMatcher implements Predicate<byte[]> {
private static final String TAG = "BytesMatcher";
private static final String TAG = "BytesMatcher";
private static final char TYPE_ACCEPT = '+';
private static final char TYPE_REJECT = '-';
private static final char TYPE_EXACT_ACCEPT = '+';
private static final char TYPE_EXACT_REJECT = '-';
private static final char TYPE_PREFIX_ACCEPT = '⊆';
private static final char TYPE_PREFIX_REJECT = '⊈';
private final ArrayList<Rule> mRules = new ArrayList<>();
private final ArrayList<Rule> mRules = new ArrayList<>();
private static class Rule {
public final char type;
public final @NonNull byte[] value;
public final @Nullable byte[] mask;
private static class Rule {
public final char type;
public final @NonNull byte[] value;
public final @Nullable byte[] mask;
public Rule(char type, @NonNull byte[] value, @Nullable byte[] mask) {
if (mask != null && value.length != mask.length) {
throw new IllegalArgumentException(
"Expected length " + value.length + " but found " + mask.length);
}
this.type = type;
this.value = value;
this.mask = mask;
}
public Rule(char type, @NonNull byte[] value, @Nullable byte[] mask) {
if (mask != null && value.length != mask.length) {
throw new IllegalArgumentException(
"Expected length " + value.length + " but found " + mask.length);
}
this.type = type;
this.value = value;
this.mask = mask;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
encode(builder);
return builder.toString();
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
encode(builder);
return builder.toString();
}
public void encode(@NonNull StringBuilder builder) {
builder.append(type);
builder.append(HexDump.toHexString(value));
if (mask != null) {
builder.append('/');
builder.append(HexDump.toHexString(mask));
}
}
public void encode(@NonNull StringBuilder builder) {
builder.append(type);
builder.append(HexDump.toHexString(value));
if (mask != null) {
builder.append('/');
builder.append(HexDump.toHexString(mask));
}
}
public boolean test(@NonNull byte[] value) {
if (value.length != this.value.length) {
return false;
}
for (int i = 0; i < this.value.length; i++) {
byte local = this.value[i];
byte remote = value[i];
if (this.mask != null) {
local &= this.mask[i];
remote &= this.mask[i];
}
if (local != remote) {
return false;
}
}
return true;
}
}
public boolean test(@NonNull byte[] value) {
switch (type) {
case TYPE_EXACT_ACCEPT:
case TYPE_EXACT_REJECT:
if (value.length != this.value.length) {
return false;
}
break;
case TYPE_PREFIX_ACCEPT:
case TYPE_PREFIX_REJECT:
if (value.length < this.value.length) {
return false;
}
break;
}
for (int i = 0; i < this.value.length; i++) {
byte local = this.value[i];
byte remote = value[i];
if (this.mask != null) {
local &= this.mask[i];
remote &= this.mask[i];
}
if (local != remote) {
return false;
}
}
return true;
}
}
/**
* Add a rule that will result in {@link #test(byte[])} returning
* {@code true} when a value being tested matches it.
* <p>
* Rules are tested in the order in which they were originally added, which
* means a narrow rule can reject a specific value before a later broader
* rule might accept that same value, or vice versa.
*
* @param value to be matched
* @param mask to be applied to both values before testing for equality; if
* {@code null} then both values must match exactly
*/
public void addAcceptRule(@NonNull byte[] value, @Nullable byte[] mask) {
mRules.add(new Rule(TYPE_ACCEPT, value, mask));
}
/**
* Add a rule that will result in {@link #test(byte[])} returning
* {@code true} when a value being tested matches it. This rule will only
* match values of the exact same length.
* <p>
* Rules are tested in the order in which they were originally added, which
* means a narrow rule can reject a specific value before a later broader
* rule might accept that same value, or vice versa.
*
* @param value to be matched
* @param mask to be applied to both values before testing for equality; if
* {@code null} then both values must match exactly
*/
public void addExactAcceptRule(@NonNull byte[] value, @Nullable byte[] mask) {
mRules.add(new Rule(TYPE_EXACT_ACCEPT, value, mask));
}
/**
* Add a rule that will result in {@link #test(byte[])} returning
* {@code false} when a value being tested matches it.
* <p>
* Rules are tested in the order in which they were originally added, which
* means a narrow rule can reject a specific value before a later broader
* rule might accept that same value, or vice versa.
*
* @param value to be matched
* @param mask to be applied to both values before testing for equality; if
* {@code null} then both values must match exactly
*/
public void addRejectRule(@NonNull byte[] value, @Nullable byte[] mask) {
mRules.add(new Rule(TYPE_REJECT, value, mask));
}
/**
* Add a rule that will result in {@link #test(byte[])} returning
* {@code false} when a value being tested matches it. This rule will only
* match values of the exact same length.
* <p>
* Rules are tested in the order in which they were originally added, which
* means a narrow rule can reject a specific value before a later broader
* rule might accept that same value, or vice versa.
*
* @param value to be matched
* @param mask to be applied to both values before testing for equality; if
* {@code null} then both values must match exactly
*/
public void addExactRejectRule(@NonNull byte[] value, @Nullable byte[] mask) {
mRules.add(new Rule(TYPE_EXACT_REJECT, value, mask));
}
/**
* Test if the given {@code ParcelUuid} value matches the set of rules
* configured in this matcher.
*/
public boolean testBluetoothUuid(@NonNull ParcelUuid value) {
return test(BluetoothUuid.uuidToBytes(value));
}
/**
* Add a rule that will result in {@link #test(byte[])} returning
* {@code true} when a value being tested matches it. This rule will match
* values of the exact same length or longer.
* <p>
* Rules are tested in the order in which they were originally added, which
* means a narrow rule can reject a specific value before a later broader
* rule might accept that same value, or vice versa.
*
* @param value to be matched
* @param mask to be applied to both values before testing for equality; if
* {@code null} then both values must match exactly
*/
public void addPrefixAcceptRule(@NonNull byte[] value, @Nullable byte[] mask) {
mRules.add(new Rule(TYPE_PREFIX_ACCEPT, value, mask));
}
/**
* Test if the given {@code MacAddress} value matches the set of rules
* configured in this matcher.
*/
public boolean testMacAddress(@NonNull MacAddress value) {
return test(value.toByteArray());
}
/**
* Add a rule that will result in {@link #test(byte[])} returning
* {@code false} when a value being tested matches it. This rule will match
* values of the exact same length or longer.
* <p>
* Rules are tested in the order in which they were originally added, which
* means a narrow rule can reject a specific value before a later broader
* rule might accept that same value, or vice versa.
*
* @param value to be matched
* @param mask to be applied to both values before testing for equality; if
* {@code null} then both values must match exactly
*/
public void addPrefixRejectRule(@NonNull byte[] value, @Nullable byte[] mask) {
mRules.add(new Rule(TYPE_PREFIX_REJECT, value, mask));
}
/**
* Test if the given {@code byte[]} value matches the set of rules
* configured in this matcher.
*/
@Override
public boolean test(@NonNull byte[] value) {
return test(value, false);
}
/**
* Test if the given {@code ParcelUuid} value matches the set of rules
* configured in this matcher.
*/
public boolean testBluetoothUuid(@NonNull ParcelUuid value) {
return test(BluetoothUuid.uuidToBytes(value));
}
/**
* Test if the given {@code byte[]} value matches the set of rules
* configured in this matcher.
*/
public boolean test(@NonNull byte[] value, boolean defaultValue) {
final int size = mRules.size();
for (int i = 0; i < size; i++) {
final Rule rule = mRules.get(i);
if (rule.test(value)) {
return (rule.type == TYPE_ACCEPT);
}
}
return defaultValue;
}
/**
* Test if the given {@code MacAddress} value matches the set of rules
* configured in this matcher.
*/
public boolean testMacAddress(@NonNull MacAddress value) {
return test(value.toByteArray());
}
/**
* Encode the given matcher into a human-readable {@link String} which can
* be used to transport matchers across device boundaries.
* <p>
* The human-readable format is an ordered list separated by commas, where
* each rule is a {@code +} or {@code -} symbol indicating if the match
* should be accepted or rejected, then followed by a hex value and an
* optional hex mask. For example, {@code -caff,+cafe/ff00} is a valid
* encoded matcher.
*
* @see #decode(String)
*/
public static @NonNull String encode(@NonNull BytesMatcher matcher) {
final StringBuilder builder = new StringBuilder();
final int size = matcher.mRules.size();
for (int i = 0; i < size; i++) {
final Rule rule = matcher.mRules.get(i);
rule.encode(builder);
builder.append(',');
}
builder.deleteCharAt(builder.length() - 1);
return builder.toString();
}
/**
* Test if the given {@code byte[]} value matches the set of rules
* configured in this matcher.
*/
@Override
public boolean test(@NonNull byte[] value) {
return test(value, false);
}
/**
* Decode the given human-readable {@link String} used to transport matchers
* across device boundaries.
* <p>
* The human-readable format is an ordered list separated by commas, where
* each rule is a {@code +} or {@code -} symbol indicating if the match
* should be accepted or rejected, then followed by a hex value and an
* optional hex mask. For example, {@code -caff,+cafe/ff00} is a valid
* encoded matcher.
*
* @see #encode(BytesMatcher)
*/
public static @NonNull BytesMatcher decode(@NonNull String value) {
final BytesMatcher matcher = new BytesMatcher();
final int length = value.length();
for (int i = 0; i < length;) {
final char type = value.charAt(i);
/**
* Test if the given {@code byte[]} value matches the set of rules
* configured in this matcher.
*/
public boolean test(@NonNull byte[] value, boolean defaultValue) {
final int size = mRules.size();
for (int i = 0; i < size; i++) {
final Rule rule = mRules.get(i);
if (rule.test(value)) {
switch (rule.type) {
case TYPE_EXACT_ACCEPT:
case TYPE_PREFIX_ACCEPT:
return true;
case TYPE_EXACT_REJECT:
case TYPE_PREFIX_REJECT:
return false;
}
}
}
return defaultValue;
}
int nextRule = value.indexOf(',', i);
int nextMask = value.indexOf('/', i);
/**
* Encode the given matcher into a human-readable {@link String} which can
* be used to transport matchers across device boundaries.
* <p>
* The human-readable format is an ordered list separated by commas, where
* each rule is a {@code +} or {@code -} symbol indicating if the match
* should be accepted or rejected, then followed by a hex value and an
* optional hex mask. For example, {@code -caff,+cafe/ff00} is a valid
* encoded matcher.
*
* @see #decode(String)
*/
public static @NonNull String encode(@NonNull BytesMatcher matcher) {
final StringBuilder builder = new StringBuilder();
final int size = matcher.mRules.size();
for (int i = 0; i < size; i++) {
final Rule rule = matcher.mRules.get(i);
rule.encode(builder);
builder.append(',');
}
if (builder.length() > 0) {
builder.deleteCharAt(builder.length() - 1);
}
return builder.toString();
}
if (nextRule == -1) nextRule = length;
if (nextMask > nextRule) nextMask = -1;
/**
* Decode the given human-readable {@link String} used to transport matchers
* across device boundaries.
* <p>
* The human-readable format is an ordered list separated by commas, where
* each rule is a {@code +} or {@code -} symbol indicating if the match
* should be accepted or rejected, then followed by a hex value and an
* optional hex mask. For example, {@code -caff,+cafe/ff00} is a valid
* encoded matcher.
*
* @see #encode(BytesMatcher)
*/
public static @NonNull BytesMatcher decode(@Nullable String value) {
final BytesMatcher matcher = new BytesMatcher();
if (TextUtils.isEmpty(value)) return matcher;
final byte[] ruleValue;
final byte[] ruleMask;
if (nextMask >= 0) {
ruleValue = HexDump.hexStringToByteArray(value.substring(i + 1, nextMask));
ruleMask = HexDump.hexStringToByteArray(value.substring(nextMask + 1, nextRule));
} else {
ruleValue = HexDump.hexStringToByteArray(value.substring(i + 1, nextRule));
ruleMask = null;
}
final int length = value.length();
for (int i = 0; i < length;) {
final char type = value.charAt(i);
switch (type) {
case TYPE_ACCEPT:
matcher.addAcceptRule(ruleValue, ruleMask);
break;
case TYPE_REJECT:
matcher.addRejectRule(ruleValue, ruleMask);
break;
default:
Log.w(TAG, "Ignoring unknown type " + type);
break;
}
int nextRule = value.indexOf(',', i);
int nextMask = value.indexOf('/', i);
i = nextRule + 1;
}
return matcher;
}
if (nextRule == -1) nextRule = length;
if (nextMask > nextRule) nextMask = -1;
final byte[] ruleValue;
final byte[] ruleMask;
if (nextMask >= 0) {
ruleValue = HexDump.hexStringToByteArray(value.substring(i + 1, nextMask));
ruleMask = HexDump.hexStringToByteArray(value.substring(nextMask + 1, nextRule));
} else {
ruleValue = HexDump.hexStringToByteArray(value.substring(i + 1, nextRule));
ruleMask = null;
}
switch (type) {
case TYPE_EXACT_ACCEPT:
matcher.addExactAcceptRule(ruleValue, ruleMask);
break;
case TYPE_EXACT_REJECT:
matcher.addExactRejectRule(ruleValue, ruleMask);
break;
case TYPE_PREFIX_ACCEPT:
matcher.addPrefixAcceptRule(ruleValue, ruleMask);
break;
case TYPE_PREFIX_REJECT:
matcher.addPrefixRejectRule(ruleValue, ruleMask);
break;
default:
Log.w(TAG, "Ignoring unknown type " + type);
break;
}
i = nextRule + 1;
}
return matcher;
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<configuration description="Config for Bluetooth test cases">
<option name="test-suite-tag" value="apct"/>
<option name="test-suite-tag" value="apct-instrumentation"/>
<target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
<option name="cleanup-apks" value="true" />
<option name="test-file-name" value="BluetoothTests.apk" />
</target_preparer>
<option name="test-suite-tag" value="apct"/>
<option name="test-tag" value="BluetoothTests"/>
<test class="com.android.tradefed.testtype.AndroidJUnitTest" >
<option name="package" value="com.android.bluetooth.tests" />
<option name="hidden-api-checks" value="false"/>
<option name="runner" value="android.bluetooth.BluetoothTestRunner"/>
</test>
</configuration>

View File

@@ -16,13 +16,18 @@
package android.bluetooth.le;
import android.bluetooth.le.ScanRecord;
import android.os.BytesMatcher;
import android.os.ParcelUuid;
import android.test.suitebuilder.annotation.SmallTest;
import com.android.internal.util.HexDump;
import junit.framework.TestCase;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
/**
* Unit test cases for {@link ScanRecord}.
@@ -31,6 +36,66 @@ import java.util.Arrays;
* 'com.android.bluetooth.tests/android.bluetooth.BluetoothTestRunner'
*/
public class ScanRecordTest extends TestCase {
/**
* Example raw beacons captured from a Blue Charm BC011
*/
private static final String RECORD_URL = "0201060303AAFE1716AAFE10EE01626C7565636861726D626561636F6E730009168020691E0EFE13551109426C7565436861726D5F313639363835000000";
private static final String RECORD_UUID = "0201060303AAFE1716AAFE00EE626C7565636861726D31000000000001000009168020691E0EFE13551109426C7565436861726D5F313639363835000000";
private static final String RECORD_TLM = "0201060303AAFE1116AAFE20000BF017000008874803FB93540916802069080EFE13551109426C7565436861726D5F313639363835000000000000000000";
private static final String RECORD_IBEACON = "0201061AFF4C000215426C7565436861726D426561636F6E730EFE1355C509168020691E0EFE13551109426C7565436861726D5F31363936383500000000";
@SmallTest
public void testMatchesAnyField_Eddystone_Parser() {
final List<String> found = new ArrayList<>();
final Predicate<byte[]> matcher = (v) -> {
found.add(HexDump.toHexString(v));
return false;
};
ScanRecord.parseFromBytes(HexDump.hexStringToByteArray(RECORD_URL))
.matchesAnyField(matcher);
assertEquals(Arrays.asList(
"020106",
"0303AAFE",
"1716AAFE10EE01626C7565636861726D626561636F6E7300",
"09168020691E0EFE1355",
"1109426C7565436861726D5F313639363835"), found);
}
@SmallTest
public void testMatchesAnyField_Eddystone() {
final BytesMatcher matcher = BytesMatcher.decode("⊆0016AAFE/00FFFFFF");
assertMatchesAnyField(RECORD_URL, matcher);
assertMatchesAnyField(RECORD_UUID, matcher);
assertMatchesAnyField(RECORD_TLM, matcher);
assertNotMatchesAnyField(RECORD_IBEACON, matcher);
}
@SmallTest
public void testMatchesAnyField_iBeacon_Parser() {
final List<String> found = new ArrayList<>();
final Predicate<byte[]> matcher = (v) -> {
found.add(HexDump.toHexString(v));
return false;
};
ScanRecord.parseFromBytes(HexDump.hexStringToByteArray(RECORD_IBEACON))
.matchesAnyField(matcher);
assertEquals(Arrays.asList(
"020106",
"1AFF4C000215426C7565436861726D426561636F6E730EFE1355C5",
"09168020691E0EFE1355",
"1109426C7565436861726D5F313639363835"), found);
}
@SmallTest
public void testMatchesAnyField_iBeacon() {
final BytesMatcher matcher = BytesMatcher.decode("⊆00FF4C0002/00FFFFFFFF");
assertNotMatchesAnyField(RECORD_URL, matcher);
assertNotMatchesAnyField(RECORD_UUID, matcher);
assertNotMatchesAnyField(RECORD_TLM, matcher);
assertMatchesAnyField(RECORD_IBEACON, matcher);
}
@SmallTest
public void testParser() {
@@ -70,4 +135,14 @@ public class ScanRecordTest extends TestCase {
}
}
private static void assertMatchesAnyField(String record, BytesMatcher matcher) {
assertTrue(ScanRecord.parseFromBytes(HexDump.hexStringToByteArray(record))
.matchesAnyField(matcher));
}
private static void assertNotMatchesAnyField(String record, BytesMatcher matcher) {
assertFalse(ScanRecord.parseFromBytes(HexDump.hexStringToByteArray(record))
.matchesAnyField(matcher));
}
}

View File

@@ -58,6 +58,19 @@ public class BytesMatcherTest extends TestCase {
assertFalse(matcher.test(hexStringToByteArray("cafe00")));
}
@Test
public void testPrefix() throws Exception {
BytesMatcher matcher = BytesMatcher.decode("⊆cafe,⊆beef/ff00");
assertTrue(matcher.test(hexStringToByteArray("cafe")));
assertFalse(matcher.test(hexStringToByteArray("caff")));
assertTrue(matcher.test(hexStringToByteArray("cafecafe")));
assertFalse(matcher.test(hexStringToByteArray("ca")));
assertTrue(matcher.test(hexStringToByteArray("beef")));
assertTrue(matcher.test(hexStringToByteArray("beff")));
assertTrue(matcher.test(hexStringToByteArray("beffbeff")));
assertFalse(matcher.test(hexStringToByteArray("be")));
}
@Test
public void testMacAddress() throws Exception {
BytesMatcher matcher = BytesMatcher.decode("+cafe00112233/ffffff000000");
@@ -94,13 +107,23 @@ public class BytesMatcherTest extends TestCase {
}
@Test
public void testSerialize() throws Exception {
public void testSerialize_Empty() throws Exception {
BytesMatcher matcher = new BytesMatcher();
matcher.addRejectRule(hexStringToByteArray("cafe00112233"),
matcher = BytesMatcher.decode(BytesMatcher.encode(matcher));
// Also very empty and null values
BytesMatcher.decode("");
BytesMatcher.decode(null);
}
@Test
public void testSerialize_Exact() throws Exception {
BytesMatcher matcher = new BytesMatcher();
matcher.addExactRejectRule(hexStringToByteArray("cafe00112233"),
hexStringToByteArray("ffffff000000"));
matcher.addRejectRule(hexStringToByteArray("beef00112233"),
matcher.addExactRejectRule(hexStringToByteArray("beef00112233"),
null);
matcher.addAcceptRule(hexStringToByteArray("000000000000"),
matcher.addExactAcceptRule(hexStringToByteArray("000000000000"),
hexStringToByteArray("000000000000"));
assertFalse(matcher.test(hexStringToByteArray("cafe00ffffff")));
@@ -115,6 +138,28 @@ public class BytesMatcherTest extends TestCase {
assertTrue(matcher.test(hexStringToByteArray("beef00ffffff")));
}
@Test
public void testSerialize_Prefix() throws Exception {
BytesMatcher matcher = new BytesMatcher();
matcher.addExactRejectRule(hexStringToByteArray("aa"), null);
matcher.addExactAcceptRule(hexStringToByteArray("bb"), null);
matcher.addPrefixAcceptRule(hexStringToByteArray("aa"), null);
matcher.addPrefixRejectRule(hexStringToByteArray("bb"), null);
assertFalse(matcher.test(hexStringToByteArray("aa")));
assertTrue(matcher.test(hexStringToByteArray("bb")));
assertTrue(matcher.test(hexStringToByteArray("aaaa")));
assertFalse(matcher.test(hexStringToByteArray("bbbb")));
// Bounce through serialization pass and confirm it still works
matcher = BytesMatcher.decode(BytesMatcher.encode(matcher));
assertFalse(matcher.test(hexStringToByteArray("aa")));
assertTrue(matcher.test(hexStringToByteArray("bb")));
assertTrue(matcher.test(hexStringToByteArray("aaaa")));
assertFalse(matcher.test(hexStringToByteArray("bbbb")));
}
@Test
public void testOrdering_RejectFirst() throws Exception {
BytesMatcher matcher = BytesMatcher.decode("-ff/0f,+ff/f0");