Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit e6e4c052 authored by Jeff Sharkey's avatar Jeff Sharkey
Browse files

Implement per-field matching of ScanRecord.

As part of building out support for robustly matching Bluetooth LE
devices in the wild, this change checks all "fields" contained in a
ScanRecord against a given BytesMatcher.

To support matching variable-length Eddystone beacons, this change
also expands BytesMatcher to support both exact length and prefix
based rules, which are then used with rules that verify that example
Eddystone and iBeacon values can be detected with these rules:

    Eddystone: ⊆0016AAFE/00FFFFFF
    iBeacon: ⊆00FF4C0002/00FFFFFFFF

Expands testing to confirm all newly added capabilities are working.

Bug: 181812624
Test: atest BluetoothTests:android.bluetooth.le
Test: atest FrameworksCoreTests:android.os.BytesMatcherTest
Change-Id: I1cff8e08604436f4bba6f55aad64c3ce5969bf56
parent 5ec748f7
Loading
Loading
Loading
Loading
+22 −0
Original line number Diff line number Diff line
@@ -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,
+270 −203
Original line number Diff line number Diff line
@@ -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;
@@ -44,8 +45,10 @@ import java.util.function.Predicate;
public class BytesMatcher implements Predicate<byte[]> {
    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<>();

@@ -81,9 +84,20 @@ public class BytesMatcher implements Predicate<byte[]> {
        }

        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];
@@ -101,7 +115,25 @@ public class BytesMatcher implements Predicate<byte[]> {

    /**
     * Add a rule that will result in {@link #test(byte[])} returning
    * {@code true} when a value being tested matches it.
     * {@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. 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
@@ -111,13 +143,14 @@ public class BytesMatcher implements Predicate<byte[]> {
     * @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));
    public void addExactRejectRule(@NonNull byte[] value, @Nullable byte[] mask) {
        mRules.add(new Rule(TYPE_EXACT_REJECT, value, mask));
    }

    /**
     * Add a rule that will result in {@link #test(byte[])} returning
    * {@code false} when a value being tested matches it.
     * {@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
@@ -127,8 +160,25 @@ public class BytesMatcher implements Predicate<byte[]> {
     * @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));
    public void addPrefixAcceptRule(@NonNull byte[] value, @Nullable byte[] mask) {
        mRules.add(new Rule(TYPE_PREFIX_ACCEPT, 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 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));
    }

    /**
@@ -165,7 +215,14 @@ public class BytesMatcher implements Predicate<byte[]> {
        for (int i = 0; i < size; i++) {
            final Rule rule = mRules.get(i);
            if (rule.test(value)) {
               return (rule.type == TYPE_ACCEPT);
                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;
@@ -191,7 +248,9 @@ public class BytesMatcher implements Predicate<byte[]> {
            rule.encode(builder);
            builder.append(',');
        }
        if (builder.length() > 0) {
            builder.deleteCharAt(builder.length() - 1);
        }
        return builder.toString();
    }

@@ -207,8 +266,10 @@ public class BytesMatcher implements Predicate<byte[]> {
     *
     * @see #encode(BytesMatcher)
     */
   public static @NonNull BytesMatcher decode(@NonNull String value) {
    public static @NonNull BytesMatcher decode(@Nullable String value) {
        final BytesMatcher matcher = new BytesMatcher();
        if (TextUtils.isEmpty(value)) return matcher;

        final int length = value.length();
        for (int i = 0; i < length;) {
            final char type = value.charAt(i);
@@ -230,11 +291,17 @@ public class BytesMatcher implements Predicate<byte[]> {
            }

            switch (type) {
               case TYPE_ACCEPT:
                   matcher.addAcceptRule(ruleValue, ruleMask);
                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_REJECT:
                   matcher.addRejectRule(ruleValue, ruleMask);
                case TYPE_PREFIX_REJECT:
                    matcher.addPrefixRejectRule(ruleValue, ruleMask);
                    break;
                default:
                    Log.w(TAG, "Ignoring unknown type " + type);
+32 −0
Original line number Diff line number Diff line
<?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>
+76 −1
Original line number Diff line number Diff line
@@ -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));
    }
}
+49 −4
Original line number Diff line number Diff line
@@ -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 = 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.addRejectRule(hexStringToByteArray("cafe00112233"),
        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");