records = new SparseArray<>();
120 |
121 | int index = 0;
122 | while (index < scanRecord.length) {
123 | final int length = scanRecord[index++];
124 | //Done once we run out of records
125 | if (length == 0) break;
126 |
127 | final int type = ByteUtils.getIntFromByte(scanRecord[index]);
128 |
129 | //Done if our record isn't a valid type
130 | if (type == 0) break;
131 |
132 | final byte[] data = Arrays.copyOfRange(scanRecord, index + 1, index + length);
133 |
134 | records.put(type, new AdRecord(length, type, data));
135 |
136 | //Advance
137 | index += length;
138 | }
139 |
140 | return records;
141 | }
142 |
143 | private static String toString(@Nullable byte[] array) {
144 | if (array == null) {
145 | return "";
146 | }
147 |
148 | try {
149 | //noinspection CharsetObjectCanBeUsed
150 | return new String(array, "UTF-8");
151 | } catch (UnsupportedEncodingException e) {
152 | return "";
153 | }
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/library/src/main/java/dev/alt236/bluetoothlelib/util/ByteUtils.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.util;
2 |
3 | import java.nio.ByteBuffer;
4 |
5 | import androidx.annotation.Nullable;
6 |
7 | public class ByteUtils {
8 |
9 | /**
10 | * The Constant HEXES.
11 | */
12 | private static final String HEXES = "0123456789ABCDEF";
13 |
14 | private ByteUtils(){
15 | // TO AVOID INSTANTIATION
16 | }
17 |
18 | /**
19 | * Gets a pretty representation of a Byte Array as a HEX String.
20 | *
21 | * Sample output: [01, 30, FF, AA]
22 | *
23 | * @param array the array
24 | * @return the string
25 | */
26 | public static String byteArrayToHexString(@Nullable final byte[] array) {
27 | final byte[] safeArray = array == null ? new byte[0] : array;
28 | final StringBuilder sb = new StringBuilder();
29 | boolean firstEntry = true;
30 | sb.append('[');
31 |
32 | for (final byte b : safeArray) {
33 | if (!firstEntry) {
34 | sb.append(", ");
35 | }
36 | sb.append(HEXES.charAt((b & 0xF0) >> 4));
37 | sb.append(HEXES.charAt((b & 0x0F)));
38 | firstEntry = false;
39 | }
40 |
41 | sb.append(']');
42 | return sb.toString();
43 | }
44 |
45 | /**
46 | * Checks to see if a byte array starts with another byte array.
47 | *
48 | * @param array the array
49 | * @param prefix the prefix
50 | * @return true, if successful
51 | */
52 | public static boolean doesArrayBeginWith(final byte[] array, final byte[] prefix) {
53 | if (array.length < prefix.length) {
54 | return false;
55 | }
56 |
57 | for (int i = 0; i < prefix.length; i++) {
58 | if (array[i] != prefix[i]) {
59 | return false;
60 | }
61 | }
62 |
63 | return true;
64 | }
65 |
66 | /**
67 | * Converts a byte array with a length of 2 into an int
68 | *
69 | * @param input the input
70 | * @return the int from the array
71 | */
72 | public static int getIntFrom2ByteArray(final byte[] input) {
73 | final byte[] result = new byte[4];
74 |
75 | result[0] = 0;
76 | result[1] = 0;
77 | result[2] = input[0];
78 | result[3] = input[1];
79 |
80 | return ByteUtils.getIntFromByteArray(result);
81 | }
82 |
83 | /**
84 | * Converts a byte to an int, preserving the sign.
85 | *
86 | * For example, FF will be converted to 255 and not -1.
87 | *
88 | * @param bite the byte
89 | * @return the int from byte
90 | */
91 | public static int getIntFromByte(final byte bite) {
92 | return bite & 0xFF;
93 | }
94 |
95 | /**
96 | * Converts a byte array to an int.
97 | *
98 | * @param bytes the bytes
99 | * @return the int from byte array
100 | */
101 | public static int getIntFromByteArray(final byte[] bytes) {
102 | return ByteBuffer.wrap(bytes).getInt();
103 | }
104 |
105 | /**
106 | * Converts a byte array to a long.
107 | *
108 | * @param bytes the bytes
109 | * @return the long from byte array
110 | */
111 | public static long getLongFromByteArray(final byte[] bytes) {
112 | return ByteBuffer.wrap(bytes).getLong();
113 | }
114 |
115 |
116 | /**
117 | * Inverts an byte array in place.
118 | *
119 | * @param array the array
120 | */
121 | public static void invertArray(final byte[] array) {
122 | final int size = array.length;
123 | byte temp;
124 |
125 | for (int i = 0; i < size / 2; i++) {
126 | temp = array[i];
127 | array[i] = array[size - 1 - i];
128 | array[size - 1 - i] = temp;
129 | }
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/library/src/main/java/dev/alt236/bluetoothlelib/util/LimitedLinkHashMap.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.util;
2 |
3 | import java.util.LinkedHashMap;
4 | import java.util.Map;
5 |
6 | public class LimitedLinkHashMap extends LinkedHashMap {
7 | private static final long serialVersionUID = -5375660288461724925L;
8 |
9 | private final int mMaxSize;
10 |
11 | public LimitedLinkHashMap(final int maxSize) {
12 | super(maxSize + 1, 1, false);
13 | mMaxSize = maxSize;
14 | }
15 |
16 | @Override
17 | protected boolean removeEldestEntry(final Map.Entry eldest) {
18 | return this.size() > mMaxSize;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/library/src/test/java/dev/alt236/bluetoothlelib/device/beacon/BeaconUtilsTest.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.device.beacon;
2 |
3 | import junit.framework.TestCase;
4 |
5 | /**
6 | *
7 | */
8 | public class BeaconUtilsTest extends TestCase {
9 |
10 | public void testGetBeaconTypeIBeacon() throws Exception {
11 | assertEquals(BeaconType.IBEACON, BeaconUtils.getBeaconType(new byte[]{
12 | 0x4C, 0x00, 0x02, 0x15, 0x00, // <- Magic iBeacon header
13 | 0x00, 0x00, 0x00, 0x00, 0x00,
14 | 0x00, 0x00, 0x00, 0x00, 0x00,
15 | 0x00, 0x00, 0x00, 0x00, 0x00,
16 | 0x00, 0x00, 0x00, 0x00, 0x00
17 | }));
18 | }
19 |
20 | public void testGetBeaconTypeInvalid() throws Exception {
21 | assertEquals(BeaconType.NOT_A_BEACON, BeaconUtils.getBeaconType((byte[]) null));
22 | assertEquals(BeaconType.NOT_A_BEACON, BeaconUtils.getBeaconType(new byte[0]));
23 | assertEquals(BeaconType.NOT_A_BEACON, BeaconUtils.getBeaconType(new byte[25]));
24 | }
25 | }
--------------------------------------------------------------------------------
/library/src/test/java/dev/alt236/bluetoothlelib/device/beacon/ibeacon/IBeaconManufacturerDataTest.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.device.beacon.ibeacon;
2 |
3 | import junit.framework.TestCase;
4 |
5 | import dev.alt236.bluetoothlelib.device.beacon.BeaconManufacturerData;
6 |
7 | /**
8 | *
9 | */
10 | public class IBeaconManufacturerDataTest extends TestCase {
11 | private static final byte[] NON_BEACON =
12 | {2, 1, 26, 11, -1, 76, 0, 9, 6, 3, -32, -64, -88,
13 | 1, 98, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
14 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
15 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
16 |
17 | public void testNonIBeaconData() throws Exception{
18 | try {
19 | BeaconManufacturerData data = new IBeaconManufacturerData(NON_BEACON);
20 | fail("Should have thrown an exception");
21 | } catch (final IllegalArgumentException e){
22 | // EXPECTED
23 | }
24 |
25 | try {
26 | BeaconManufacturerData data = new IBeaconManufacturerData((byte[]) null);
27 | fail("Should have thrown an exception");
28 | } catch (final IllegalArgumentException e){
29 | // EXPECTED
30 | }
31 |
32 | try {
33 | BeaconManufacturerData data = new IBeaconManufacturerData(new byte[0]);
34 | fail("Should have thrown an exception");
35 | } catch (final IllegalArgumentException e){
36 | // EXPECTED
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/library/src/test/java/dev/alt236/bluetoothlelib/device/beacon/ibeacon/IBeaconUtilsTest.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.device.beacon.ibeacon;
2 |
3 | import junit.framework.TestCase;
4 |
5 | /**
6 | *
7 | */
8 | public class IBeaconUtilsTest extends TestCase {
9 |
10 | public void testCalculateUuidString() throws Exception {
11 | assertEquals("00", IBeaconUtils.calculateUuidString(new byte[]{0}));
12 | assertEquals("0a", IBeaconUtils.calculateUuidString(new byte[]{10}));
13 | assertEquals("0f", IBeaconUtils.calculateUuidString(new byte[]{15}));
14 | assertEquals("10", IBeaconUtils.calculateUuidString(new byte[]{16}));
15 | assertEquals("7f", IBeaconUtils.calculateUuidString(new byte[]{127}));
16 | assertEquals(
17 | "00000000-0000-0000-0000-00",
18 | IBeaconUtils.calculateUuidString(new byte[]{0,0,0,0,0,0,0,0,0,0,0}));
19 | }
20 |
21 | public void testGetDistanceDescriptor() throws Exception {
22 | assertEquals(IBeaconDistanceDescriptor.UNKNOWN, IBeaconUtils.getDistanceDescriptor(-1));
23 |
24 | assertEquals(IBeaconDistanceDescriptor.IMMEDIATE, IBeaconUtils.getDistanceDescriptor(0));
25 | assertEquals(IBeaconDistanceDescriptor.IMMEDIATE, IBeaconUtils.getDistanceDescriptor(0.4));
26 |
27 | assertEquals(IBeaconDistanceDescriptor.NEAR, IBeaconUtils.getDistanceDescriptor(0.5));
28 | assertEquals(IBeaconDistanceDescriptor.NEAR, IBeaconUtils.getDistanceDescriptor(2.9));
29 |
30 | assertEquals(IBeaconDistanceDescriptor.FAR, IBeaconUtils.getDistanceDescriptor(3));
31 | }
32 | }
--------------------------------------------------------------------------------
/library/src/test/java/dev/alt236/bluetoothlelib/resolvers/GattAttributeResolverTest.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.resolvers;
2 |
3 | import junit.framework.TestCase;
4 |
5 | /**
6 | *
7 | */
8 | public class GattAttributeResolverTest extends TestCase {
9 | private static final String UKNOWN = "unknown";
10 |
11 | public void testGetAttributeName() throws Exception {
12 | assertEquals(UKNOWN, GattAttributeResolver.getAttributeName("foo", UKNOWN));
13 | assertEquals("Estimote Advertising Vector", GattAttributeResolver.getAttributeName("b9402002-f5f8-466e-aff9-25556b57fe6d", UKNOWN));
14 | assertEquals("LINK_LOSS", GattAttributeResolver.getAttributeName("00001803-0000-1000-8000-00805f9b34fb", UKNOWN));
15 | assertEquals("Base GUID", GattAttributeResolver.getAttributeName("00000000-0000-1000-8000-00805f9b34fb", UKNOWN));
16 | assertEquals("PNPID", GattAttributeResolver.getAttributeName("00002a50-0000-1000-8000-00805f9b34fb", UKNOWN));
17 | assertEquals("HTTP", GattAttributeResolver.getAttributeName("0000000c-0000-1000-8000-00805f9b34fb", UKNOWN));
18 |
19 | }
20 | }
--------------------------------------------------------------------------------
/library/src/test/java/dev/alt236/bluetoothlelib/util/AdRecordUtilsTest.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.util;
2 |
3 | import junit.framework.TestCase;
4 |
5 | import java.util.List;
6 | import java.util.Map;
7 |
8 | import dev.alt236.bluetoothlelib.device.adrecord.AdRecord;
9 |
10 | /**
11 | *
12 | */
13 | public class AdRecordUtilsTest extends TestCase {
14 | private static final byte[] NON_IBEACON =
15 | {2, 1, 26, 11, -1, 76, 0, 9, 6, 3, -32, -64, -88,
16 | 1, 98, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
17 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
18 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
19 |
20 | public void testParseScanRecordAsList() throws Exception {
21 | final List adRecords = AdRecordUtils.parseScanRecordAsList(NON_IBEACON);
22 | assertNotNull(adRecords);
23 | assertEquals(2, adRecords.size());
24 |
25 | int type = AdRecord.TYPE_FLAGS;
26 | assertEquals(type, adRecords.get(0).getType());
27 | assertEquals(2, adRecords.get(0).getLength());
28 |
29 | type = AdRecord.TYPE_MANUFACTURER_SPECIFIC_DATA;
30 | assertEquals(type, adRecords.get(1).getType());
31 | assertEquals(11, adRecords.get(1).getLength());
32 | }
33 |
34 | public void testParseScanRecordAsMap() throws Exception {
35 | final Map adRecords = AdRecordUtils.parseScanRecordAsMap(NON_IBEACON);
36 | assertNotNull(adRecords);
37 | assertEquals(2, adRecords.size());
38 |
39 | int type = AdRecord.TYPE_FLAGS;
40 | assertEquals(type, adRecords.get(type).getType());
41 | assertEquals(2, adRecords.get(type).getLength());
42 |
43 | type = AdRecord.TYPE_MANUFACTURER_SPECIFIC_DATA;
44 | assertEquals(type, adRecords.get(type).getType());
45 | assertEquals(11, adRecords.get(type).getLength());
46 | }
47 |
48 | public void testParseScanRecordAsSparseArray() throws Exception {
49 | //
50 | // Cannot be tested here as it relies on Android code...
51 | //
52 | // final SparseArray adRecords = AdRecordUtils.parseScanRecordAsSparseArray(NON_IBEACON);
53 | // assertNotNull(adRecords);
54 | // assertEquals(2, adRecords.size());
55 | // assertEquals(AdRecord.TYPE_FLAGS, adRecords.get(AdRecord.TYPE_FLAGS).getType());
56 | // assertEquals(AdRecord.TYPE_MANUFACTURER_SPECIFIC_DATA, adRecords.get(AdRecord.TYPE_MANUFACTURER_SPECIFIC_DATA).getType());
57 | }
58 | }
--------------------------------------------------------------------------------
/library/src/test/java/dev/alt236/bluetoothlelib/util/ByteUtilsTest.java:
--------------------------------------------------------------------------------
1 | package dev.alt236.bluetoothlelib.util;
2 |
3 | import junit.framework.TestCase;
4 |
5 | /**
6 | *
7 | */
8 | public class ByteUtilsTest extends TestCase {
9 |
10 | public void testByteArrayToHexString() throws Exception {
11 | assertEquals("[]", ByteUtils.byteArrayToHexString(new byte[0]));
12 |
13 | assertEquals("[]", ByteUtils.byteArrayToHexString(null));
14 |
15 | final byte[] one = {1, 10, 15, 127};
16 | assertEquals("[01, 0A, 0F, 7F]", ByteUtils.byteArrayToHexString(one));
17 | }
18 |
19 | public void testDoesArrayBeginWith() throws Exception {
20 |
21 | // If the prefix is longer than the array,
22 | // we automatically fail
23 | byte[] array = new byte[10];
24 | byte[] prefix = new byte[array.length * 2];
25 | assertFalse(ByteUtils.doesArrayBeginWith(array, prefix));
26 |
27 | array = new byte[]{1, 2, 3};
28 | prefix = new byte[]{1, 3};
29 | assertFalse(ByteUtils.doesArrayBeginWith(array, prefix));
30 |
31 | array = new byte[10];
32 | prefix = new byte[array.length];
33 | assertTrue(ByteUtils.doesArrayBeginWith(array, prefix));
34 |
35 | array = new byte[]{1, 2, 3};
36 | prefix = new byte[]{1, 2};
37 | assertTrue(ByteUtils.doesArrayBeginWith(array, prefix));
38 | }
39 |
40 | public void testGetIntFromByte() throws Exception {
41 | byte bite = 127;
42 | int integer = ByteUtils.getIntFromByte(bite);
43 | assertEquals(127, integer);
44 |
45 | bite = -1;
46 | integer = ByteUtils.getIntFromByte(bite);
47 | assertEquals(255, integer);
48 | }
49 |
50 | public void testInvertArray() throws Exception {
51 | final byte[] original = {1, 2 ,3 ,4};
52 | final byte[] out = new byte[original.length];
53 |
54 | System.arraycopy( original, 0, out, 0, original.length);
55 | ByteUtils.invertArray(out);
56 |
57 | assertEquals(original[0], out[3]);
58 | assertEquals(original[1], out[2]);
59 | assertEquals(original[2], out[1]);
60 | assertEquals(original[3], out[0]);
61 | }
62 | }
--------------------------------------------------------------------------------
/sample_app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/sample_app/apk_v1.1.1.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alt236/Bluetooth-LE-Library---Android/d33bacd807b2f6be8df8e052942fa82c51aaeb31/sample_app/apk_v1.1.1.apk
--------------------------------------------------------------------------------
/sample_app/app_v1.1.0.apk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alt236/Bluetooth-LE-Library---Android/d33bacd807b2f6be8df8e052942fa82c51aaeb31/sample_app/app_v1.1.0.apk
--------------------------------------------------------------------------------
/sample_app/build.gradle:
--------------------------------------------------------------------------------
1 | import com.github.triplet.gradle.androidpublisher.ReleaseStatus
2 |
3 | plugins {
4 | id 'com.android.application'
5 | alias(libs.plugins.kotlin.android)
6 | alias(libs.plugins.kotlin.kapt)
7 | alias(libs.plugins.kotlin.parcelize)
8 | alias(libs.plugins.hilt.android)
9 | alias(libs.plugins.triplet.play)
10 | }
11 |
12 | apply from: "${project.rootDir}/buildsystem/android-defaults.gradle"
13 |
14 | final int versionMajor = 2
15 | final int versionMinor = 0
16 | final int versionPatch = getBuildNumber()
17 | final int androidVersionCode = getBuildNumber()
18 |
19 | final String semanticVersion = "${versionMajor}.${versionMinor}.${versionPatch}"
20 |
21 | repositories {
22 | google()
23 | mavenCentral()
24 | maven { url "https://repo.commonsware.com.s3.amazonaws.com" }
25 | maven { url "https://s3.amazonaws.com/repo.commonsware.com" }
26 | }
27 |
28 | android {
29 | buildFeatures {
30 | buildConfig = true
31 | }
32 |
33 | compileOptions {
34 | sourceCompatibility JavaVersion.VERSION_11
35 | targetCompatibility JavaVersion.VERSION_11
36 | }
37 |
38 | signingConfigs {
39 | release {
40 | storeFile file(System.getenv("ANDROID_KEYSTORE") ?: "[KEY_NOT_DEFINED]")
41 | storePassword System.getenv("KEYSTORE_PASSWORD")
42 | keyAlias System.getenv("KEY_ALIAS")
43 | keyPassword System.getenv("KEY_PASSWORD")
44 | }
45 |
46 | debug {
47 | storeFile file("${project.rootDir}/buildsystem/signing_keys/debug.keystore")
48 | keyAlias 'androiddebugkey'
49 | keyPassword 'android'
50 | storePassword 'android'
51 | }
52 | }
53 | androidResources {
54 | noCompress 'zip'
55 | }
56 | lint {
57 | lintConfig file("$rootDir/buildsystem/codequality/lint.xml")
58 | }
59 | namespace 'uk.co.alt236.btlescan'
60 |
61 | defaultConfig {
62 | versionCode androidVersionCode
63 | versionName semanticVersion
64 | }
65 |
66 | buildTypes {
67 | release {
68 | minifyEnabled false
69 | resValue "string", "app_name", "Bluetooth LE Scanner"
70 | if(isRunningOnCi()) {
71 | signingConfig signingConfigs.release
72 | }
73 | }
74 |
75 | debug {
76 | minifyEnabled false
77 | applicationIdSuffix ".debug"
78 | resValue "string", "app_name", "Debug Bluetooth LE Scanner"
79 | signingConfig signingConfigs.debug
80 | }
81 | }
82 |
83 | compileOptions {
84 | sourceCompatibility JavaVersion.VERSION_17
85 | targetCompatibility JavaVersion.VERSION_17
86 | }
87 |
88 | kotlinOptions {
89 | jvmTarget = 17
90 | }
91 | }
92 |
93 | dependencies {
94 | implementation project(':library')
95 |
96 | implementation libs.androidx.appcompat
97 | implementation libs.androidx.recyclerview
98 | implementation libs.permissionx
99 | implementation libs.easycursor.android
100 |
101 | implementation libs.hilt.android
102 | kapt libs.hilt.compiler
103 |
104 | testImplementation libs.junit4
105 | testImplementation libs.mockito
106 | }
107 |
108 | play {
109 | def credentialsPath = System.getenv("GPLAY_DEPLOY_KEY") ?: "[KEY_NOT_DEFINED]"
110 | def lastCommitMessage = getLastGitCommitMessage().take(50)
111 |
112 | logger.warn("GPP Config: $credentialsPath")
113 | logger.warn("Release Name: '$lastCommitMessage'")
114 |
115 | if(isRunningOnCi()) {
116 | enabled = true
117 | track = "internal"
118 | //userFraction = 1.0
119 | releaseStatus = ReleaseStatus.COMPLETED
120 | serviceAccountCredentials = file(credentialsPath)
121 | releaseName = lastCommitMessage
122 | artifactDir = file("${project.rootDir}/sample_app/build/outputs/apk/release/")
123 | } else {
124 | enabled = false
125 | }
126 | }
--------------------------------------------------------------------------------
/sample_app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # By default, the flags in this file are appended to flags specified
3 | # in /home/alex/Dev/android-sdk-linux/tools/proguard/proguard-android.txt
4 | # You can edit the include path and order by changing the proguardFiles
5 | # directive in build.gradle.
6 | #
7 | # For more details, see
8 | # http://developer.android.com/guide/developing/tools/proguard.html
9 |
10 | # Add any project specific keep options here:
11 |
12 | # If your project uses WebView with JS, uncomment the following
13 | # and specify the fully qualified class name to the JavaScript interface
14 | # class:
15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
16 | # public *;
17 | #}
18 |
--------------------------------------------------------------------------------
/sample_app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
8 |
11 |
12 |
13 |
14 |
17 |
18 |
19 |
20 |
21 |
24 |
25 |
31 |
32 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
45 |
46 |
49 |
50 |
53 |
54 |
59 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/containers/BluetoothLeDeviceStore.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.containers
2 |
3 | import dev.alt236.bluetoothlelib.device.BluetoothLeDevice
4 | import dev.alt236.easycursor.objectcursor.EasyObjectCursor
5 | import java.util.Collections
6 |
7 | class BluetoothLeDeviceStore {
8 | private val mDeviceMap = HashMap()
9 |
10 | fun addDevice(device: BluetoothLeDevice) {
11 | if (mDeviceMap.containsKey(device.address)) {
12 | mDeviceMap[device.address]!!.updateRssiReading(device.timestamp, device.rssi)
13 | } else {
14 | mDeviceMap[device.address] = device
15 | }
16 | }
17 |
18 | fun clear() {
19 | mDeviceMap.clear()
20 | }
21 |
22 | val size: Int
23 | get() = mDeviceMap.size
24 |
25 | val deviceCursor: EasyObjectCursor
26 | get() = getDeviceCursor(DEFAULT_COMPARATOR)
27 |
28 | fun getDeviceCursor(comparator: Comparator): EasyObjectCursor =
29 | EasyObjectCursor(
30 | BluetoothLeDevice::class.java,
31 | getDeviceList(comparator),
32 | "address",
33 | )
34 |
35 | val deviceList: List
36 | get() = getDeviceList(DEFAULT_COMPARATOR)
37 |
38 | fun getDeviceList(comparator: Comparator): List {
39 | val methodResult: List = ArrayList(mDeviceMap.values)
40 | Collections.sort(methodResult, comparator)
41 | return methodResult
42 | }
43 |
44 | private class BluetoothLeDeviceComparator : Comparator {
45 | override fun compare(
46 | arg0: BluetoothLeDevice,
47 | arg1: BluetoothLeDevice,
48 | ): Int = arg0.address.compareTo(arg1.address)
49 | }
50 |
51 | companion object {
52 | private val DEFAULT_COMPARATOR = BluetoothLeDeviceComparator()
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/kt/ByteArrayExt.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.kt
2 |
3 | object ByteArrayExt {
4 | fun ByteArray.toCharString(): String {
5 | val chars = ArrayList(this.size)
6 |
7 | for (byte in this) {
8 | if (byte in 0..31) {
9 | val unicode = (0x2400 + byte).toChar()
10 | chars.add(unicode)
11 | } else {
12 | chars.add(byte.toChar())
13 | }
14 | }
15 | return String(chars.toCharArray())
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/permission/BluetoothPermissionCheck.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.permission
2 |
3 | import android.Manifest
4 | import android.os.Build
5 | import androidx.fragment.app.FragmentActivity
6 | import com.permissionx.guolindev.PermissionX
7 | import uk.co.alt236.btlescan.R
8 |
9 | class BluetoothPermissionCheck {
10 | fun checkBluetoothPermissions(
11 | activity: FragmentActivity,
12 | callback: PermissionCheckResultCallback,
13 | ) {
14 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
15 | checkNeeded(activity, callback)
16 | } else {
17 | checkNotNeeded(callback)
18 | }
19 | }
20 |
21 | private fun checkNotNeeded(callback: PermissionCheckResultCallback) {
22 | callback.onSuccess()
23 | }
24 |
25 | private fun checkNeeded(
26 | activity: FragmentActivity,
27 | callback: PermissionCheckResultCallback,
28 | ) {
29 | val permissionRequest = getPermissionRequest()
30 | val appContext = activity.applicationContext
31 |
32 | PermissionX
33 | .init(activity)
34 | .permissions(permissionRequest.permissions)
35 | .onExplainRequestReason { scope, deniedList ->
36 | scope.showRequestReasonDialog(
37 | deniedList,
38 | message = appContext.getString(permissionRequest.permissionRationaleResId),
39 | positiveText = appContext.getString(android.R.string.ok),
40 | negativeText = appContext.getString(android.R.string.cancel),
41 | )
42 | }.onForwardToSettings { scope, deniedList ->
43 | scope.showForwardToSettingsDialog(
44 | deniedList,
45 | appContext.getString(permissionRequest.permissionNeedToGoToSettings),
46 | positiveText = appContext.getString(android.R.string.ok),
47 | negativeText = appContext.getString(android.R.string.cancel),
48 | )
49 | }.request { allGranted, _, _ ->
50 | if (allGranted) {
51 | callback.onSuccess()
52 | } else {
53 | val notGrantedMessage = appContext.getString(permissionRequest.notGrantedResId)
54 | callback.onFailure(notGrantedMessage)
55 | }
56 | }
57 | }
58 |
59 | private fun getPermissionRequest(): PermissionRequest {
60 | val permissions: List
61 | val notGrantedResId: Int
62 |
63 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
64 | permissions =
65 | listOf(
66 | Manifest.permission.BLUETOOTH_CONNECT,
67 | Manifest.permission.BLUETOOTH_SCAN,
68 | Manifest.permission.ACCESS_FINE_LOCATION,
69 | )
70 | notGrantedResId = R.string.permission_not_granted_bt_scan
71 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
72 | permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)
73 | notGrantedResId = R.string.permission_not_granted_fine_location
74 | } else {
75 | permissions = listOf(Manifest.permission.ACCESS_COARSE_LOCATION)
76 | notGrantedResId = R.string.permission_not_granted_coarse_location
77 | }
78 |
79 | return PermissionRequest(
80 | permissions = permissions,
81 | notGrantedResId = notGrantedResId,
82 | permissionRationaleResId = R.string.permission_rationale,
83 | permissionNeedToGoToSettings = R.string.permission_need_to_go_to_settings,
84 | )
85 | }
86 |
87 | private data class PermissionRequest(
88 | val permissions: List,
89 | val notGrantedResId: Int,
90 | val permissionRationaleResId: Int,
91 | val permissionNeedToGoToSettings: Int,
92 | )
93 |
94 | interface PermissionCheckResultCallback {
95 | fun onSuccess()
96 |
97 | fun onFailure(message: CharSequence)
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/permission/PermissionDeniedDialogFragment.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.permission
2 |
3 | import android.app.Dialog
4 | import android.os.Bundle
5 | import androidx.appcompat.app.AlertDialog
6 | import androidx.fragment.app.DialogFragment
7 |
8 | class PermissionDeniedDialogFragment : DialogFragment() {
9 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
10 | AlertDialog
11 | .Builder(requireContext())
12 | .setMessage(requireArguments().getCharSequence(EXTRA_MESSAGE))
13 | .setPositiveButton(getString(android.R.string.ok)) { _, _ -> }
14 | .create()
15 |
16 | companion object {
17 | private val EXTRA_MESSAGE =
18 | PermissionDeniedDialogFragment::class.java.name + ".EXTRA_MESSAGE"
19 |
20 | @JvmStatic
21 | fun create(message: CharSequence): DialogFragment {
22 | val fragment = PermissionDeniedDialogFragment()
23 | val args = Bundle()
24 | args.putCharSequence(EXTRA_MESSAGE, message)
25 | fragment.arguments = args
26 | return fragment
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/services/LocalBinder.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.services
2 |
3 | import android.os.Binder
4 |
5 | class LocalBinder(
6 | val service: BluetoothLeService,
7 | ) : Binder()
8 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/services/State.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.services
2 |
3 | internal enum class State {
4 | DISCONNECTED,
5 | CONNECTING,
6 | CONNECTED,
7 | }
8 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/common/IntentReceiverCompat.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.common
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Activity
5 | import android.content.BroadcastReceiver
6 | import android.content.Context
7 | import android.content.IntentFilter
8 | import android.os.Build
9 |
10 | object IntentReceiverCompat {
11 | @SuppressLint("UnspecifiedRegisterReceiverFlag")
12 | @JvmStatic
13 | fun registerExportedReceiver(
14 | activity: Activity,
15 | receiver: BroadcastReceiver,
16 | filter: IntentFilter,
17 | ) {
18 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
19 | activity.registerReceiver(
20 | receiver,
21 | filter,
22 | Context.RECEIVER_EXPORTED,
23 | )
24 | } else {
25 | activity.registerExportUnawareReceiver(receiver, filter)
26 | }
27 | }
28 |
29 | @SuppressLint("UnspecifiedRegisterReceiverFlag")
30 | private fun Activity.registerExportUnawareReceiver(
31 | receiver: BroadcastReceiver,
32 | filter: IntentFilter,
33 | ) {
34 | this.registerReceiver(
35 | receiver,
36 | filter,
37 | )
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/common/Navigation.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.common
2 |
3 | import android.app.Activity
4 | import android.content.Intent
5 | import android.net.Uri
6 | import androidx.core.app.ActivityCompat
7 | import androidx.core.app.ShareCompat
8 | import dev.alt236.bluetoothlelib.device.BluetoothLeDevice
9 | import uk.co.alt236.btlescan.R
10 | import uk.co.alt236.btlescan.ui.control.DeviceControlActivity
11 | import uk.co.alt236.btlescan.ui.details.DeviceDetailsActivity
12 |
13 | class Navigation(
14 | private val activity: Activity,
15 | ) {
16 | fun openDetailsActivity(device: BluetoothLeDevice?) {
17 | val intent = DeviceDetailsActivity.createIntent(activity, device)
18 | startActivity(intent)
19 | }
20 |
21 | fun startControlActivity(device: BluetoothLeDevice?) {
22 | val intent = DeviceControlActivity.createIntent(activity, device)
23 | startActivity(intent)
24 | }
25 |
26 | fun shareFileViaEmail(
27 | uri: Uri,
28 | recipient: Array?,
29 | subject: String?,
30 | message: String?,
31 | ) {
32 | val intent =
33 | ShareCompat.IntentBuilder
34 | .from(activity)
35 | .setChooserTitle(R.string.exporter_email_device_list_picker_text)
36 | .setStream(uri)
37 | .setEmailTo(recipient ?: emptyArray())
38 | .setSubject(subject ?: "")
39 | .setText(message ?: "")
40 | .setType("text/text")
41 | .intent
42 | .setAction(Intent.ACTION_SEND)
43 | .setDataAndType(uri, "plain/text")
44 | .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
45 |
46 | startActivity(intent)
47 | }
48 |
49 | private fun startActivity(intent: Intent) {
50 | ActivityCompat.startActivity(activity, intent, null)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/common/recyclerview/BaseRecyclerViewAdapter.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.common.recyclerview
2 |
3 | import android.view.ViewGroup
4 | import androidx.recyclerview.widget.RecyclerView
5 |
6 | abstract class BaseRecyclerViewAdapter
7 | @JvmOverloads
8 | constructor(
9 | private val core: RecyclerViewBinderCore,
10 | items: List = ArrayList(),
11 | ) : RecyclerView.Adapter>() {
12 | private val list = ArrayList()
13 |
14 | init {
15 | list.addAll(items)
16 | }
17 |
18 | override fun onCreateViewHolder(
19 | parent: ViewGroup,
20 | viewType: Int,
21 | ): BaseViewHolder = core.create(parent, viewType)
22 |
23 | override fun onBindViewHolder(
24 | holder: BaseViewHolder,
25 | position: Int,
26 | ) {
27 | val viewType = getItemViewType(position)
28 | val binder = core.getBinder(viewType)
29 |
30 | bind(binder, holder, getItem(position))
31 | }
32 |
33 | override fun getItemCount(): Int = list.size
34 |
35 | override fun getItemViewType(position: Int): Int = core.getViewType(getItem(position))
36 |
37 | fun getItem(position: Int): RecyclerViewItem? = list[position]
38 |
39 | fun setData(data: Collection) {
40 | list.clear()
41 | list.addAll(data)
42 | notifyDataSetChanged()
43 | }
44 |
45 | companion object {
46 | private fun bind(
47 | binder: BaseViewBinder,
48 | holder: BaseViewHolder<*>,
49 | item: RecyclerViewItem?,
50 | ) {
51 | @Suppress("UNCHECKED_CAST")
52 | binder.bind((holder as BaseViewHolder), item as T)
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/common/recyclerview/BaseViewBinder.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.common.recyclerview
2 |
3 | import android.content.Context
4 | import androidx.annotation.StringRes
5 | import uk.co.alt236.btlescan.R
6 |
7 | abstract class BaseViewBinder(
8 | protected val context: Context,
9 | ) {
10 | abstract fun bind(
11 | holder: BaseViewHolder,
12 | item: T,
13 | )
14 |
15 | abstract fun canBind(item: RecyclerViewItem): Boolean
16 |
17 | protected fun getString(
18 | @StringRes id: Int,
19 | ): String = context.getString(id)
20 |
21 | protected fun getString(
22 | @StringRes resId: Int,
23 | vararg formatArgs: Any?,
24 | ): String = context.getString(resId, *formatArgs)
25 |
26 | protected fun getQuotedString(vararg formatArgs: Any?): String = getString(R.string.formatter_single_quoted_string, *formatArgs)
27 | }
28 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/common/recyclerview/BaseViewHolder.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.common.recyclerview
2 |
3 | import android.view.View
4 | import androidx.recyclerview.widget.RecyclerView.ViewHolder
5 |
6 | abstract class BaseViewHolder(
7 | val view: View,
8 | ) : ViewHolder(view)
9 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/common/recyclerview/RecyclerViewBinderCore.java:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.common.recyclerview;
2 |
3 | import android.util.Log;
4 | import android.view.LayoutInflater;
5 | import android.view.View;
6 | import android.view.ViewGroup;
7 |
8 | import java.lang.reflect.Constructor;
9 | import java.lang.reflect.InvocationTargetException;
10 | import java.util.ArrayList;
11 | import java.util.List;
12 |
13 | public class RecyclerViewBinderCore {
14 | public static final int INVALID_VIEWTYPE = -1;
15 |
16 | private static final String TAG = RecyclerViewBinderCore.class.getSimpleName();
17 | private final List>> mViewHolderClasses;
18 | private final List> mViewBinders;
19 | private final List mLayoutIds;
20 |
21 | public RecyclerViewBinderCore() {
22 | mViewBinders = new ArrayList<>();
23 | mViewHolderClasses = new ArrayList<>();
24 | mLayoutIds = new ArrayList<>();
25 | }
26 |
27 | public void clear() {
28 | mViewBinders.clear();
29 | mViewHolderClasses.clear();
30 | mLayoutIds.clear();
31 | }
32 |
33 | public void add(
34 | final BaseViewBinder binder,
35 | final Class extends BaseViewHolder> viewHolder,
36 | final int layoutId) {
37 |
38 | mViewBinders.add(binder);
39 | mViewHolderClasses.add(viewHolder);
40 | mLayoutIds.add(layoutId);
41 | }
42 |
43 | public BaseViewHolder extends RecyclerViewItem> create(ViewGroup parent, final int viewType) {
44 | if (viewType == INVALID_VIEWTYPE) {
45 | throw new IllegalArgumentException("Invalid viewType: " + viewType);
46 | }
47 |
48 | final Class> clazz = mViewHolderClasses.get(viewType);
49 | final int layoutId = mLayoutIds.get(viewType);
50 | final View itemView = LayoutInflater.from(parent.getContext()).inflate(layoutId, parent, false);
51 |
52 | return (BaseViewHolder extends RecyclerViewItem>) instantiate(clazz, itemView);
53 | }
54 |
55 | public int getViewType(final T item) {
56 | int result = INVALID_VIEWTYPE;
57 | int count = 0;
58 |
59 | for (final BaseViewBinder extends RecyclerViewItem> binder : mViewBinders) {
60 |
61 | if (binder.canBind(item)) {
62 | result = count;
63 | break;
64 | }
65 |
66 | count++;
67 | }
68 |
69 | if (result == INVALID_VIEWTYPE) {
70 | Log.w(TAG, "Could not get viewType for " + item);
71 | }
72 |
73 | return result;
74 | }
75 |
76 | public BaseViewBinder extends RecyclerViewItem> getBinder(int viewType) {
77 | if (viewType == INVALID_VIEWTYPE) {
78 | throw new IllegalArgumentException("Invalid viewType: " + viewType);
79 | }
80 |
81 | return mViewBinders.get(viewType);
82 | }
83 |
84 | @SuppressWarnings("TryWithIdenticalCatches")
85 | private static Object instantiate(
86 | final Class> clazz, View parentView) {
87 | try {
88 | final Constructor> constructor = clazz.getDeclaredConstructors()[0];
89 | return constructor.newInstance(parentView);
90 | } catch (InstantiationException e) {
91 | throw new IllegalStateException(e);
92 | } catch (IllegalAccessException e) {
93 | throw new IllegalStateException(e);
94 | } catch (InvocationTargetException e) {
95 | throw new IllegalStateException(e);
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/common/recyclerview/RecyclerViewItem.kt:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.common.recyclerview
2 |
3 | interface RecyclerViewItem
4 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/control/Exporter.java:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.control;
2 |
3 | import android.bluetooth.BluetoothGattCharacteristic;
4 | import android.bluetooth.BluetoothGattService;
5 | import android.content.Context;
6 |
7 | import java.util.List;
8 |
9 | import dev.alt236.bluetoothlelib.resolvers.GattAttributeResolver;
10 | import uk.co.alt236.btlescan.R;
11 |
12 | /*package*/ class Exporter {
13 | private final Context mContext;
14 |
15 | public Exporter(final Context context) {
16 | mContext = context.getApplicationContext();
17 | }
18 |
19 | public String generateExportString(final String deviceName,
20 | final String deviceAddress,
21 | final List gattServices) {
22 |
23 | final String unknownServiceString = mContext.getString(R.string.unknown_service);
24 | final String unknownCharaString = mContext.getString(R.string.unknown_characteristic);
25 | final StringBuilder exportBuilder = new StringBuilder();
26 |
27 | exportBuilder.append("Device Name: ");
28 | exportBuilder.append(deviceName);
29 | exportBuilder.append('\n');
30 | exportBuilder.append("Device Address: ");
31 | exportBuilder.append(deviceAddress);
32 | exportBuilder.append('\n');
33 | exportBuilder.append('\n');
34 |
35 | exportBuilder.append("Services:");
36 | exportBuilder.append("--------------------------");
37 | exportBuilder.append('\n');
38 |
39 | String uuid = null;
40 | for (final BluetoothGattService gattService : gattServices) {
41 | uuid = gattService.getUuid().toString();
42 |
43 | exportBuilder.append(GattAttributeResolver.getAttributeName(uuid, unknownServiceString));
44 | exportBuilder.append(" (");
45 | exportBuilder.append(uuid);
46 | exportBuilder.append(')');
47 | exportBuilder.append('\n');
48 |
49 | final List gattCharacteristics = gattService.getCharacteristics();
50 | for (final BluetoothGattCharacteristic gattCharacteristic : gattCharacteristics) {
51 | uuid = gattCharacteristic.getUuid().toString();
52 |
53 | exportBuilder.append('\t');
54 | exportBuilder.append(GattAttributeResolver.getAttributeName(uuid, unknownCharaString));
55 | exportBuilder.append(" (");
56 | exportBuilder.append(uuid);
57 | exportBuilder.append(')');
58 | exportBuilder.append('\n');
59 | }
60 |
61 | exportBuilder.append('\n');
62 | exportBuilder.append('\n');
63 | }
64 |
65 | exportBuilder.append("--------------------------");
66 | exportBuilder.append('\n');
67 |
68 | return exportBuilder.toString();
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/sample_app/src/main/java/uk/co/alt236/btlescan/ui/control/GattDataAdapterFactory.java:
--------------------------------------------------------------------------------
1 | package uk.co.alt236.btlescan.ui.control;
2 |
3 | import android.bluetooth.BluetoothGattCharacteristic;
4 | import android.bluetooth.BluetoothGattService;
5 | import android.content.Context;
6 | import android.widget.SimpleExpandableListAdapter;
7 |
8 | import java.util.ArrayList;
9 | import java.util.HashMap;
10 | import java.util.List;
11 | import java.util.Map;
12 |
13 | import dev.alt236.bluetoothlelib.resolvers.GattAttributeResolver;
14 | import uk.co.alt236.btlescan.R;
15 |
16 | /*package*/ class GattDataAdapterFactory {
17 | private static final String LIST_NAME = "NAME";
18 | private static final String LIST_UUID = "UUID";
19 |
20 | public static GattDataAdapter createAdapter(final Context context,
21 | final List gattServices) {
22 |
23 |
24 | final String unknownServiceString = context.getString(R.string.unknown_service);
25 | final String unknownCharaString = context.getString(R.string.unknown_characteristic);
26 | final List