diff --git a/README.md b/README.md index 9d57222..3490c6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +# The new Herald Protocol is being integrated into COVIDSafe + +Even though we have made a range of significant improvements to COVIDSafe, we continue to look for ways we can improve the app further. Herald, a Bluetooth communication and range finding protocol that supports contact tracing, is one such improvement. + +The Herald Protocol employs several techniques to improve Bluetooth communication across a wide range of mobile devices. This provides contact tracing apps with regular and accurate proximity information that helps make them highly effective, especially in background on iOS devices. + +Herald is a VMware-originated open source project. It is part of VMware’s ongoing contribution towards the Linux Foundation Public Health initiative. The initiative aims to use open source technologies to help public health authorities across the world combat COVID-19. + +Find out more about Herald and COVIDSafe: [https://www.dta.gov.au/news/covidsafe-captures-close-contacts-new-herald-protocol](https://www.dta.gov.au/news/covidsafe-captures-close-contacts-new-herald-protocol) + + # COVIDSafe app Thank you for viewing the GitHub repository for the COVIDSafe app by the Australian Government. diff --git a/app/build.gradle b/app/build.gradle index 6cf02ff..f15f64f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,8 +29,8 @@ android { applicationId "au.gov.health.covidsafe" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 80 - versionName "1.14.0" + versionCode 89 + versionName "2.0" buildConfigField "String", "GITHASH", "\"${getGitHash()}\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -62,6 +62,7 @@ android { buildConfigField "long", "HEALTH_CHECK_INTERVAL", HEALTH_CHECK_INTERVAL buildConfigField "long", "CONNECTION_TIMEOUT", CONNECTION_TIMEOUT buildConfigField "long", "BLACKLIST_DURATION", BLACKLIST_DURATION + buildConfigField "long", "PERIPHERAL_PAYLOAD_SAVE_INTERVAL", PERIPHERAL_PAYLOAD_SAVE_INTERVAL buildConfigField "long", "ADVERTISING_DURATION", ADVERTISING_DURATION buildConfigField "long", "ADVERTISING_INTERVAL", ADVERTISING_INTERVAL @@ -84,6 +85,9 @@ android { buildTypes { debug { buildConfigField "String", "BLE_SSID", STAGING_SERVICE_UUID + buildConfigField "String", "BLE_ANDROIDSIGNALCHARACTERISTIC", STAGING_ANDROIDSIGNALCHARACTERISTICUUID + buildConfigField "String", "BLE_IOSSIGNALCHARACTERISTIC", STAGING_IOSSIGNALCHARACTERISTICUUID + buildConfigField "String", "BLE_PAYLOADCHARACTERISTIC", SRAGING_PAYLOADCHARACTERISTICUUID buildConfigField "boolean", "ENABLE_DEBUG_SCREEN", "true" buildConfigField "String", "END_POINT_PREFIX", TEST_END_POINT_PREFIX buildConfigField "String", "BASE_URL", TEST_BASE_URL @@ -100,6 +104,9 @@ android { staging { buildConfigField "String", "BLE_SSID", STAGING_SERVICE_UUID + buildConfigField "String", "BLE_ANDROIDSIGNALCHARACTERISTIC", STAGING_ANDROIDSIGNALCHARACTERISTICUUID + buildConfigField "String", "BLE_IOSSIGNALCHARACTERISTIC", STAGING_IOSSIGNALCHARACTERISTICUUID + buildConfigField "String", "BLE_PAYLOADCHARACTERISTIC", SRAGING_PAYLOADCHARACTERISTICUUID buildConfigField "boolean", "ENABLE_DEBUG_SCREEN", "true" buildConfigField "String", "END_POINT_PREFIX", STAGING_END_POINT_PREFIX buildConfigField "String", "BASE_URL", STAGING_BASE_URL @@ -125,6 +132,9 @@ android { release { buildConfigField "String", "BLE_SSID", PRODUCTION_SERVICE_UUID + buildConfigField "String", "BLE_ANDROIDSIGNALCHARACTERISTIC", PRODUCTION_ANDROIDSIGNALCHARACTERISTICUUID + buildConfigField "String", "BLE_IOSSIGNALCHARACTERISTIC", PRODUCTION_IOSSIGNALCHARACTERISTICUUID + buildConfigField "String", "BLE_PAYLOADCHARACTERISTIC", PRODUCTION_PAYLOADCHARACTERISTICUUID buildConfigField "String", "END_POINT_PREFIX", PRODUCTION_END_POINT_PREFIX buildConfigField "String", "BASE_URL", PROD_BASE_URL buildConfigField "String", "IOS_BACKGROUND_UUID", PRODUCTION_BACKGROUND_IOS_SERVICE_UUID diff --git a/app/schemas/au.gov.dta.covidsafe.streetpass.persistence.StreetPassRecordDatabase/2.json b/app/schemas/au.gov.dta.covidsafe.streetpass.persistence.StreetPassRecordDatabase/2.json deleted file mode 100644 index 3315850..0000000 --- a/app/schemas/au.gov.dta.covidsafe.streetpass.persistence.StreetPassRecordDatabase/2.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "9a95fc8ad88c160bf76c0ba4747db316", - "entities": [ - { - "tableName": "record_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `v` INTEGER NOT NULL, `msg` TEXT NOT NULL, `org` TEXT NOT NULL, `modelP` TEXT NOT NULL, `modelC` TEXT NOT NULL, `rssi` INTEGER NOT NULL, `txPower` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "v", - "columnName": "v", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "msg", - "columnName": "msg", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "org", - "columnName": "org", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelP", - "columnName": "modelP", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelC", - "columnName": "modelC", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rssi", - "columnName": "rssi", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "txPower", - "columnName": "txPower", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "status_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `msg` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "msg", - "columnName": "msg", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a95fc8ad88c160bf76c0ba4747db316')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/au.gov.dta.covidtrace.streetpass.persistence.StreetPassRecordDatabase/2.json b/app/schemas/au.gov.dta.covidtrace.streetpass.persistence.StreetPassRecordDatabase/2.json deleted file mode 100644 index 3315850..0000000 --- a/app/schemas/au.gov.dta.covidtrace.streetpass.persistence.StreetPassRecordDatabase/2.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "9a95fc8ad88c160bf76c0ba4747db316", - "entities": [ - { - "tableName": "record_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `v` INTEGER NOT NULL, `msg` TEXT NOT NULL, `org` TEXT NOT NULL, `modelP` TEXT NOT NULL, `modelC` TEXT NOT NULL, `rssi` INTEGER NOT NULL, `txPower` INTEGER)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "v", - "columnName": "v", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "msg", - "columnName": "msg", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "org", - "columnName": "org", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelP", - "columnName": "modelP", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelC", - "columnName": "modelC", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rssi", - "columnName": "rssi", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "txPower", - "columnName": "txPower", - "affinity": "INTEGER", - "notNull": false - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - }, - { - "tableName": "status_table", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `msg` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "msg", - "columnName": "msg", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "columnNames": [ - "id" - ], - "autoGenerate": true - }, - "indices": [], - "foreignKeys": [] - } - ], - "views": [], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a95fc8ad88c160bf76c0ba4747db316')" - ] - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/au/gov/health/covidsafe/sensor/ble/filter/AdvertParserTests.java b/app/src/androidTest/java/au/gov/health/covidsafe/sensor/ble/filter/AdvertParserTests.java new file mode 100644 index 0000000..9ef7e7f --- /dev/null +++ b/app/src/androidTest/java/au/gov/health/covidsafe/sensor/ble/filter/AdvertParserTests.java @@ -0,0 +1,166 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class AdvertParserTests { + + // MARK: Low level individual parsing functions + + @Test + public void testDataSubsetBigEndian() throws Exception { + byte[] data = new byte[]{0,1,5,6,7,8,12,13,14}; + assertEquals(5, data[2]); + assertEquals(6, data[3]); + assertEquals(7, data[4]); + assertEquals(8, data[5]); + byte[] result = BLEAdvertParser.subDataBigEndian(data,2,4); + assertNotNull(result); + assertEquals(4, result.length); + assertEquals(5, result[0]); + assertEquals(6, result[1]); + assertEquals(7, result[2]); + assertEquals(8, result[3]); + } + + @Test + public void testDataSubsetLittleEndian() throws Exception { + byte[] data = new byte[]{0,1,5,6,7,8,12,13,14}; + byte[] result = BLEAdvertParser.subDataLittleEndian(data,2,4); + assertNotNull(result); + assertEquals(4, result.length); + assertEquals(8, result[0]); + assertEquals(7, result[1]); + assertEquals(6, result[2]); + assertEquals(5, result[3]); + } + + @Test + public void testDataSubsetBigEndianOverflow() throws Exception { + byte[] data = new byte[]{0,1,5,6,7}; + byte[] result = BLEAdvertParser.subDataBigEndian(data,2,4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetLittleEndianOverflow() throws Exception { + byte[] data = new byte[]{0, 1, 5, 6, 7}; + byte[] result = BLEAdvertParser.subDataLittleEndian(data, 2, 4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetBigEndianLowIndex() throws Exception { + byte[] data = new byte[]{0,1,5,6,7}; + byte[] result = BLEAdvertParser.subDataBigEndian(data,-1,4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetLittleEndianLowIndex() throws Exception { + byte[] data = new byte[]{0,1,5,6,7}; + byte[] result = BLEAdvertParser.subDataLittleEndian(data,-1,4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetBigEndianHighIndex() throws Exception { + byte[] data = new byte[]{0,1,5,6,7}; + byte[] result = BLEAdvertParser.subDataBigEndian(data,5,4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetLittleEndianHighIndex() throws Exception { + byte[] data = new byte[]{0,1,5,6,7}; + byte[] result = BLEAdvertParser.subDataLittleEndian(data,5,4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetBigEndianLargeLength() throws Exception { + byte[] data = new byte[]{0,1,5,6,7}; + byte[] result = BLEAdvertParser.subDataBigEndian(data,2,4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetLittleEndianLargeLength() throws Exception { + byte[] data = new byte[]{0,1,5,6,7}; + byte[] result = BLEAdvertParser.subDataLittleEndian(data,2,4); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetBigEndianEmptyData() throws Exception { + byte[] data = new byte[]{}; + byte[] result = BLEAdvertParser.subDataBigEndian(data,0,1); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetLittleEndianEmptyData() throws Exception { + byte[] data = new byte[]{}; + byte[] result = BLEAdvertParser.subDataLittleEndian(data,0,1); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetBigEndianNullData() throws Exception { + byte[] result = BLEAdvertParser.subDataBigEndian(null,0,1); + assertNotNull(result); + assertEquals(0, result.length); + } + + @Test + public void testDataSubsetLittleNullEmptyData() throws Exception { + byte[] result = BLEAdvertParser.subDataLittleEndian(null,0,1); + assertNotNull(result); + assertEquals(0, result.length); + } + + // MARK: HIGH LEVEL FULL PACKET METHODS + + @Test + public void testAppleTVFG() throws Exception { + byte[] data = new byte[]{2, 1, 26, 2, 10, 8, + (byte)0x0c, (byte)0xff, (byte)0x4c, (byte)0x00, + (byte)0x10, (byte)0x07, (byte)0x33, + (byte)0x1f, (byte)0x2c, (byte)0x30, (byte)0x2f, (byte)0x92, + (byte)0x58 + }; + assertEquals("02011a020a080cff4c001007331f2c302f9258", BLEAdvertParser.hex(data)); + BLEScanResponseData result = BLEAdvertParser.parseScanResponse(data,0); + assertNotNull(result); + + assertEquals(3,result.segments.size()); + List manu = BLEAdvertParser.extractManufacturerData(result.segments); + assertNotNull(manu); + assertEquals(1, manu.size()); + + byte[] manuData = manu.get(0).data; + assertNotNull(manuData); + assertEquals(9, manuData.length); + assertEquals(16, manuData[0]); // int 16 = byte 10 + assertEquals(7, manuData[1]); // int 7 = byte 07 + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/au/gov/health/covidsafe/sensor/ble/filter/BLEDeviceFilterTests.java b/app/src/androidTest/java/au/gov/health/covidsafe/sensor/ble/filter/BLEDeviceFilterTests.java new file mode 100644 index 0000000..6801749 --- /dev/null +++ b/app/src/androidTest/java/au/gov/health/covidsafe/sensor/ble/filter/BLEDeviceFilterTests.java @@ -0,0 +1,245 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import au.gov.health.covidsafe.sensor.datatype.Data; + +import org.junit.Test; + +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class BLEDeviceFilterTests { + + @Test + public void testHexTransform() throws Exception { + final Random random = new Random(0); + for (int i = 0; i < 1000; i++) { + final byte[] expected = new byte[i]; + random.nextBytes(expected); + final String hex = new Data(expected).hexEncodedString(); + final byte[] actual = Data.fromHexEncodedString(hex).value; + assertArrayEquals(expected, actual); + } + } + + @Test + public void testExtractMessages_iPhoneX_F() throws Exception { + final Data raw = Data.fromHexEncodedString("02011A020A0C0BFF4C001006071EA3DD89E014FF4C0001000000000000000000002000000000000000000000000000000000000000000000000000000000"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(2, messages.size()); + assertEquals("1006071EA3DD89E0", messages.get(0).hexEncodedString()); + assertEquals("0100000000000000000000200000000000", messages.get(1).hexEncodedString()); + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....1E"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + } + + @Test + public void testExtractMessages_iOS12() throws Exception { + final Data raw = Data.fromHexEncodedString("02011A14FF4C0001000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("0100000000000000000000200000000000", messages.get(0).hexEncodedString()); + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + } + + @Test + public void testExtractMessages_iPhoneX_J() throws Exception { + final Data raw = Data.fromHexEncodedString("02011A020A0C0BFF4C0010060C1E4FDE4DF714FF4C0001000000000000000000002000000000000000000000000000000000000000000000000000000000"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(2, messages.size()); + assertEquals("10060C1E4FDE4DF7", messages.get(0).hexEncodedString()); + assertEquals("0100000000000000000000200000000000", messages.get(1).hexEncodedString()); + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....1E", "^10....14"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + } + + @Test + public void testExtractMessages_MacBookPro_F() throws Exception { + final Data raw = Data.fromHexEncodedString("0201060AFF4C001005421C1E616A000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("1005421C1E616A", messages.get(0).hexEncodedString()); + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....1C", "^10....14"}), raw)); + } + + @Test + public void testExtractMessages_iPhoneSE1_with_Herald() throws Exception { + // iPhoneSE 1st gen w/ Herald + final Data raw = Data.fromHexEncodedString("02011a020a0c11079bfd5bd672451e80d3424647af328142"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertNull(messages); + } + + @Test + public void testExtractMessages_iPhoneSE1_Background() throws Exception { + // iPhoneSE 1st gen background + final Data raw = Data.fromHexEncodedString("02011a020a0c14ff4c000100000000000000000000200000000000"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("0100000000000000000000200000000000", messages.get(0).hexEncodedString()); + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + } + + @Test + public void testExtractMessages_iPhoneX_A() throws Exception { + // iPhoneX + final Data raw = Data.fromHexEncodedString("1eff4c001219006d17255505df2aec6ef580be0ddeba8bb034c996de5b0200"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("1219006d17255505df2aec6ef580be0ddeba8bb034c996de5b0200".toUpperCase(), messages.get(0).hexEncodedString()); + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14", "^12"}), raw)); + } + + @Test + public void testExtractMessages_iPhone7_A() throws Exception { + // iPhone7 + final Data raw = Data.fromHexEncodedString("0bff4c001006061a396363ce"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("1006061a396363ce".toUpperCase(), messages.get(0).hexEncodedString()); + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14", "^10....1a"}), raw)); + } + + @Test + public void testExtractMessages_iPhone_nRFApp() throws Exception { + // nRFConnect app running on iPhone - a Valid device + final Data raw = Data.fromHexEncodedString("1bff4c000c0e00c857ac085510515d52cf3862211006551eee51497a"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(2, messages.size()); // AFC This returns 1 if parsed incorrectly + assertEquals("0c0e00c857ac085510515d52cf386221".toUpperCase(), messages.get(0).hexEncodedString()); // AFC too long - Current implementation incorrectly ignores the apple segment length, 0e + assertEquals("1006551eee51497a".toUpperCase(), messages.get(1).hexEncodedString()); // AFC This is not returned as a separate segment + + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^0100"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14", "^10....1E"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14", "^10....1e"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14", "^0C"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14", "^0c"}), raw)); + } + + @Test + public void testExtractMessages_AppleTV() throws Exception { + final Data raw = Data.fromHexEncodedString("02011a020a0c0aff4c00100508141bba69"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("100508141BBA69", messages.get(0).hexEncodedString()); + + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + } + + @Test + public void testExtractMessages_Coincidence() throws Exception { + final Data raw = Data.fromHexEncodedString("02011a020a0c0aff4c0010050814ff4c00"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("10050814FF4C00", messages.get(0).hexEncodedString()); + + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + } + + @Test + public void testExtractMessages_MultipleAppleSegments() throws Exception { + final Data raw = Data.fromHexEncodedString("02011a0dff4c0010050814123456100101"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(2, messages.size()); + assertEquals("10050814123456", messages.get(0).hexEncodedString()); + assertEquals("100101", messages.get(1).hexEncodedString()); + + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10..01"}), raw)); + } + + @Test + public void testExtractMessages_LegacyZeroOne() throws Exception { + final Data raw = Data.fromHexEncodedString("02011a020a0c0aff4c001005031c8ba89d14ff4c000100200000000000000000000000000000000000000000000000000000000000000000000000000000"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(2, messages.size()); + assertEquals("1005031c8ba89d".toUpperCase(), messages.get(0).hexEncodedString()); + assertEquals("0100200000000000000000000000000000", messages.get(1).hexEncodedString()); + + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....1C"}), raw)); + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^01[0-9A-F]{32}$"}), raw)); + } + + @Test + public void testExtractMessages_MacbookPro_A() throws Exception { + final Data raw = Data.fromHexEncodedString("02011a0aff4c001005031c0b4cac"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("1005031C0B4CAC", messages.get(0).hexEncodedString()); + + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....1C"}), raw)); + } + + @Test + public void testExtractMessages_MacbookProUnderflow() throws Exception { + final Data raw = Data.fromHexEncodedString("02011a0aff4c001005031c0b4c"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertNull(messages); + } + + @Test + public void testExtractMessages_MacbookProOverflow() throws Exception { + final Data raw = Data.fromHexEncodedString("02011a0aff4c001005031c0b4cac02011a0aff4c00100503"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(1, messages.size()); + assertEquals("1005031C0B4CAC", messages.get(0).hexEncodedString()); + + assertNotNull(BLEDeviceFilter.match(BLEDeviceFilter.compilePatterns(new String[]{"^10....1C"}), raw)); + } + + + @Test + public void testCompilePatterns() throws Exception { + final List filterPatterns = BLEDeviceFilter.compilePatterns(new String[]{"^10....04", "^10....14"}); + assertEquals(2, filterPatterns.size()); + assertNotNull(BLEDeviceFilter.match(filterPatterns, "10060C044FDE4DF7")); + assertNotNull(BLEDeviceFilter.match(filterPatterns, "10060C144FDE4DF7")); + + // Ignoring dots + assertNotNull(BLEDeviceFilter.match(filterPatterns, "10XXXX044FDE4DF7")); + assertNotNull(BLEDeviceFilter.match(filterPatterns, "10XXXX144FDE4DF7")); + + // Not correct values + assertNull(BLEDeviceFilter.match(filterPatterns, "10060C054FDE4DF7")); + assertNull(BLEDeviceFilter.match(filterPatterns, "10060C154FDE4DF7")); + + // Not start of pattern + assertNull(BLEDeviceFilter.match(filterPatterns, "010060C054FDE4DF7")); + assertNull(BLEDeviceFilter.match(filterPatterns, "010060C154FDE4DF7")); + } + + @Test + public void testMatch_iPhoneX_F() throws Exception { + final Data raw = Data.fromHexEncodedString("02011A020A0C0BFF4C001006071EA3DD89E014FF4C0001000000000000000000002000000000000000000000000000000000000000000000000000000000"); + final List messages = BLEDeviceFilter.extractMessages(raw.value); + assertEquals(2, messages.size()); + assertEquals("1006071EA3DD89E0", messages.get(0).hexEncodedString()); + assertEquals("0100000000000000000000200000000000", messages.get(1).hexEncodedString()); + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/au/gov/health/covidsafe/sensor/datatype/PayloadDataTests.java b/app/src/androidTest/java/au/gov/health/covidsafe/sensor/datatype/PayloadDataTests.java new file mode 100644 index 0000000..99ad2a8 --- /dev/null +++ b/app/src/androidTest/java/au/gov/health/covidsafe/sensor/datatype/PayloadDataTests.java @@ -0,0 +1,21 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; + +public class PayloadDataTests { + + @Test + public void testShortName() throws Exception { + for (int i=0; i<600; i++) { + final PayloadData payloadData = new PayloadData((byte) 0, i); + assertNotNull(payloadData); + } + } + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ecd9195..2934ee6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,10 @@ + + + + () var appUpdateAvailableMessageResponseLiveData = MutableLiveData() @@ -136,4 +137,30 @@ class HomeActivity : FragmentActivity(), NetworkConnectionCheck.NetworkConnectio previousInternetConnection = isAvailable } + override fun sensor(sensor: SensorType?, didDetect: TargetIdentifier?) { + } + + override fun sensor(sensor: SensorType?, didRead: PayloadData?, fromTarget: TargetIdentifier?) { + } + + override fun sensor(sensor: SensorType?, didShare: MutableList?, fromTarget: TargetIdentifier?) { + } + + override fun sensor(sensor: SensorType?, didMeasure: Proximity?, fromTarget: TargetIdentifier?) { + } + + override fun sensor(sensor: SensorType?, didVisit: Location?) { + } + + override fun sensor(sensor: SensorType?, didMeasure: Proximity?, fromTarget: TargetIdentifier?, withPayload: PayloadData?, device: BLEDevice) { + } + override fun sensor(sensor: SensorType?, didMeasure: Proximity?, fromTarget: TargetIdentifier?, withPayload: PayloadData?) { + } + + override fun sensor(sensor: SensorType?, didUpdateState: SensorState?) { + } + + override fun sensor(sensor: SensorType?, didRead: PayloadData?, fromTarget: TargetIdentifier?, atProximity: Proximity?, withTxPower: Int, device: BLEDevice) { + } + } \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/app/TracerApp.kt b/app/src/main/java/au/gov/health/covidsafe/app/TracerApp.kt index 71f357e..e58d0ac 100644 --- a/app/src/main/java/au/gov/health/covidsafe/app/TracerApp.kt +++ b/app/src/main/java/au/gov/health/covidsafe/app/TracerApp.kt @@ -4,40 +4,35 @@ import android.app.Application import android.content.Context import android.os.Build import au.gov.health.covidsafe.BuildConfig -import com.atlassian.mobilekit.module.feedback.FeedbackModule - import au.gov.health.covidsafe.logging.CentralLog import au.gov.health.covidsafe.services.BluetoothMonitoringService import au.gov.health.covidsafe.streetpass.CentralDevice import au.gov.health.covidsafe.streetpass.PeripheralDevice +import com.atlassian.mobilekit.module.feedback.FeedbackModule class TracerApp : Application() { override fun onCreate() { super.onCreate() - AppContext = applicationContext + AppContext = this.applicationContext FeedbackModule.init(this) - -// GetMessagesScheduler.scheduleGetMessagesJob() } companion object { - private const val TAG = "TracerApp" const val ORG = BuildConfig.ORG const val protocolVersion = BuildConfig.PROTOCOL_VERSION lateinit var AppContext: Context - fun thisDeviceMsg(): String { + fun thisDeviceMsg(): String? { BluetoothMonitoringService.broadcastMessage?.let { CentralLog.i(TAG, "Retrieved BM for storage: $it") return it } - CentralLog.e(TAG, "No local Broadcast Message") - return BluetoothMonitoringService.broadcastMessage!! + return "" } fun asPeripheralDevice(): PeripheralDevice { @@ -47,6 +42,5 @@ class TracerApp : Application() { fun asCentralDevice(): CentralDevice { return CentralDevice(Build.MODEL, "SELF") } - } } diff --git a/app/src/main/java/au/gov/health/covidsafe/bluetooth/gatt/GattServer.kt b/app/src/main/java/au/gov/health/covidsafe/bluetooth/gatt/GattServer.kt deleted file mode 100644 index d963b49..0000000 --- a/app/src/main/java/au/gov/health/covidsafe/bluetooth/gatt/GattServer.kt +++ /dev/null @@ -1,306 +0,0 @@ -package au.gov.health.covidsafe.bluetooth.gatt - -import android.bluetooth.* -import android.bluetooth.BluetoothGatt.GATT_FAILURE -import android.bluetooth.BluetoothGatt.GATT_SUCCESS -import android.content.Context -import au.gov.health.covidsafe.app.TracerApp -import au.gov.health.covidsafe.ui.utils.Utils -import au.gov.health.covidsafe.logging.CentralLog -import au.gov.health.covidsafe.streetpass.CentralDevice -import au.gov.health.covidsafe.streetpass.ConnectionRecord -import au.gov.health.covidsafe.streetpass.persistence.Encryption -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import java.util.* -import kotlin.properties.Delegates - -class GattServer constructor(val context: Context, serviceUUIDString: String) { - - private val TAG = "GattServer" - private var bluetoothManager: BluetoothManager by Delegates.notNull() - - private var serviceUUID: UUID by Delegates.notNull() - var bluetoothGattServer: BluetoothGattServer? = null - - val gson: Gson = GsonBuilder().disableHtmlEscaping().create() - - - init { - bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - this.serviceUUID = UUID.fromString(serviceUUIDString) - } - - private val gattServerCallback = object : BluetoothGattServerCallback() { - - val writeDataPayload: MutableMap = HashMap() - val readPayloadMap: MutableMap = HashMap() - - override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) { - when (newState) { - BluetoothProfile.STATE_CONNECTED -> { - CentralLog.i(TAG, "${device?.address} Connected to local GATT server") - device?.let { - bluetoothManager.getConnectedDevices(BluetoothProfile.GATT).contains(device) - } - } - - BluetoothProfile.STATE_DISCONNECTED -> { - CentralLog.i(TAG, "${device?.address} Disconnected from local GATT server.") - readPayloadMap.remove(device?.address) - device?.let { - Utils.broadcastDeviceDisconnected(context, device) - } - - } - - else -> { - CentralLog.i(TAG, "Connection status: $newState - ${device?.address}") - } - } - } - - override fun onCharacteristicReadRequest( - device: BluetoothDevice?, - requestId: Int, - offset: Int, - characteristic: BluetoothGattCharacteristic? - ) { - - device?.let { - - CentralLog.i(TAG, "onCharacteristicReadRequest from ${device.address}") - - if (serviceUUID == characteristic?.uuid) { - - if (Utils.bmValid(context)) { - val peripheral = TracerApp.asPeripheralDevice() - val readRequest = ReadRequestEncryptedPayload( - System.currentTimeMillis() / 1000L, - peripheral.modelP, - TracerApp.thisDeviceMsg() - ) - val plainRecord = gson.toJson(readRequest) - - CentralLog.d(TAG, "onCharacteristicReadRequest plainRecord = $plainRecord") - - val plainRecordByteArray = plainRecord.toByteArray(Charsets.UTF_8) - val remoteBlob = Encryption.encryptPayload(plainRecordByteArray) - val base = readPayloadMap.getOrPut(device.address, { - ReadRequestPayload( - v = TracerApp.protocolVersion, - msg = remoteBlob, - org = TracerApp.ORG, - modelP = null //This is going to be stored as empty in the db as DUMMY value - ).getPayload() - }) - - val value = base.copyOfRange(offset, base.size) - - CentralLog.i( - TAG, - "onCharacteristicReadRequest from ${device.address} - $requestId- $offset - ${String( - value, - Charsets.UTF_8 - )}" - ) - - bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, value) - } else { - CentralLog.i( - TAG, - "onCharacteristicReadRequest from ${device.address} - $requestId- $offset - BM Expired" - ) - bluetoothGattServer?.sendResponse( - device, - requestId, - GATT_FAILURE, - 0, - ByteArray(0) - ) - } - } else { - CentralLog.i(TAG, "incorrect serviceUUID from ${device.address}") - bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null) - } - } - - if (device == null) { - CentralLog.i(TAG, "No device") - } - - } - - inner class ReadRequestEncryptedPayload(val timestamp: Long, val modelP: String, val msg: String) - - - override fun onCharacteristicWriteRequest( - device: BluetoothDevice?, - requestId: Int, - characteristic: BluetoothGattCharacteristic, - preparedWrite: Boolean, - responseNeeded: Boolean, - offset: Int, - value: ByteArray? - ) { - - - device?.let { - CentralLog.i( - TAG, - "onCharacteristicWriteRequest - ${device.address} - preparedWrite: $preparedWrite" - ) - - CentralLog.i( - TAG, - "onCharacteristicWriteRequest from ${device.address} - $requestId - $offset" - ) - - if (serviceUUID == characteristic.uuid) { - var valuePassed = "" - value?.let { - valuePassed = String(value, Charsets.UTF_8) - } - CentralLog.i( - TAG, - "onCharacteristicWriteRequest from ${device.address} - $valuePassed" - ) - if (value != null) { - var dataBuffer = writeDataPayload[device.address] - - if (dataBuffer == null) { - dataBuffer = ByteArray(0) - } - - dataBuffer = dataBuffer.plus(value) - writeDataPayload[device.address] = dataBuffer - - CentralLog.i( - TAG, - "Accumulated characteristic: ${String( - dataBuffer, - Charsets.UTF_8 - )}" - ) - - if (responseNeeded) { - CentralLog.i(TAG, "Sending response offset: ${dataBuffer.size}") - bluetoothGattServer?.sendResponse( - device, - requestId, - GATT_SUCCESS, - dataBuffer.size, - value - ) - } - } - } else { - CentralLog.i(TAG, "no data from ${device.address}") - bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null) - } - - if (!preparedWrite) { - CentralLog.i( - TAG, - "onCharacteristicWriteRequest - ${device.address} - preparedWrite: $preparedWrite" - ) - - saveDataSaved(device) - bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null) - } - } - - if (device == null) { - CentralLog.e(TAG, "Write stopped - no device") - } - } - - override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) { - super.onExecuteWrite(device, requestId, execute) - val data = writeDataPayload[device.address] - - data.let { dataBuffer -> - - if (dataBuffer != null) { - CentralLog.i( - TAG, - "onExecuteWrite - $requestId- ${device.address} - ${String( - dataBuffer, - Charsets.UTF_8 - )}" - ) - saveDataSaved(device) - bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null) - - } else { - bluetoothGattServer?.sendResponse(device, requestId, GATT_FAILURE, 0, null) - } - } - } - - fun saveDataSaved(device: BluetoothDevice) { - val data = writeDataPayload[device.address] - - data?.let { - try { - val dataWritten = WriteRequestPayload.createReadRequestPayload(data) - device.let { - val centralDevice: CentralDevice? - - try { - centralDevice = CentralDevice(dataWritten.modelC, device.address) - val connectionRecord = ConnectionRecord( - version = dataWritten.v, - msg = dataWritten.msg, - org = dataWritten.org, - peripheral = TracerApp.asPeripheralDevice(), - central = centralDevice, - rssi = dataWritten.rssi, - txPower = dataWritten.txPower - ) - - Utils.broadcastStreetPassReceived( - context, - connectionRecord - ) - } catch (e: Throwable) { - CentralLog.e(TAG, "caught error here ${e.message}") - } - } - } catch (e: Throwable) { - CentralLog.e(TAG, "Failed to save write payload - ${e.message}") - } - - Utils.broadcastDeviceProcessed(context, device.address) - writeDataPayload.remove(device.address) - readPayloadMap.remove(device.address) - - } - } - } - - fun startServer(): Boolean { - - bluetoothGattServer = bluetoothManager.openGattServer(context, gattServerCallback) - - bluetoothGattServer?.let { - it.clearServices() - return true - } - return false - } - - fun addService(service: GattService) { - bluetoothGattServer?.addService(service.gattService) - } - - fun stop() { - try { - bluetoothGattServer?.clearServices() - bluetoothGattServer?.close() - } catch (e: Throwable) { - CentralLog.e(TAG, "GATT server can't be closed elegantly ${e.localizedMessage}") - } - } - -} diff --git a/app/src/main/java/au/gov/health/covidsafe/bluetooth/gatt/GattService.kt b/app/src/main/java/au/gov/health/covidsafe/bluetooth/gatt/GattService.kt deleted file mode 100644 index 5787b94..0000000 --- a/app/src/main/java/au/gov/health/covidsafe/bluetooth/gatt/GattService.kt +++ /dev/null @@ -1,34 +0,0 @@ -package au.gov.health.covidsafe.bluetooth.gatt - -import android.bluetooth.BluetoothGattCharacteristic -import android.bluetooth.BluetoothGattService -import android.content.Context -import java.util.* -import kotlin.properties.Delegates - -class GattService constructor(val context: Context, serviceUUIDString: String) { - - private var serviceUUID = UUID.fromString(serviceUUIDString) - - var gattService: BluetoothGattService by Delegates.notNull() - - private var devicePropertyCharacteristic: BluetoothGattCharacteristic by Delegates.notNull() - - init { - gattService = BluetoothGattService(serviceUUID, BluetoothGattService.SERVICE_TYPE_PRIMARY) - devicePropertyCharacteristic = BluetoothGattCharacteristic( - serviceUUID, - BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE, - BluetoothGattCharacteristic.PERMISSION_READ or BluetoothGattCharacteristic.PERMISSION_WRITE - ) - gattService.addCharacteristic(devicePropertyCharacteristic) - } - - fun setValue(value: String) { - setValue(value.toByteArray(Charsets.UTF_8)) - } - - fun setValue(value: ByteArray) { - devicePropertyCharacteristic.value = value - } -} \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/extensions/PermissionExtensions.kt b/app/src/main/java/au/gov/health/covidsafe/extensions/PermissionExtensions.kt index d537e0a..30c12e8 100644 --- a/app/src/main/java/au/gov/health/covidsafe/extensions/PermissionExtensions.kt +++ b/app/src/main/java/au/gov/health/covidsafe/extensions/PermissionExtensions.kt @@ -1,6 +1,6 @@ package au.gov.health.covidsafe.extensions -import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.Manifest.permission.* import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.content.Context diff --git a/app/src/main/java/au/gov/health/covidsafe/notifications/NotificationTemplates.kt b/app/src/main/java/au/gov/health/covidsafe/notifications/NotificationTemplates.kt index 4e283f9..e3f18c9 100644 --- a/app/src/main/java/au/gov/health/covidsafe/notifications/NotificationTemplates.kt +++ b/app/src/main/java/au/gov/health/covidsafe/notifications/NotificationTemplates.kt @@ -4,12 +4,10 @@ import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.widget.RemoteViews import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import au.gov.health.covidsafe.HomeActivity import au.gov.health.covidsafe.R -import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.DAILY_UPLOAD_NOTIFICATION_CODE import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_ACTIVITY import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_WIZARD_REQ_CODE diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/DefaultSensorDelegate.java b/app/src/main/java/au/gov/health/covidsafe/sensor/DefaultSensorDelegate.java new file mode 100644 index 0000000..7b654b9 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/DefaultSensorDelegate.java @@ -0,0 +1,55 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor; + +import au.gov.health.covidsafe.sensor.ble.BLEDevice; +import au.gov.health.covidsafe.sensor.datatype.Location; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.Proximity; +import au.gov.health.covidsafe.sensor.datatype.SensorState; +import au.gov.health.covidsafe.sensor.datatype.SensorType; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; + +import java.util.List; + +/// Default implementation of SensorDelegate for making all interface methods optional. +public abstract class DefaultSensorDelegate implements SensorDelegate { + + @Override + public void sensor(SensorType sensor, TargetIdentifier didDetect) { + } + + @Override + public void sensor(SensorType sensor, PayloadData didRead, TargetIdentifier fromTarget) { + } + + @Override + public void sensor(SensorType sensor, List didShare, TargetIdentifier fromTarget) { + } + + @Override + public void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget) { + } + + @Override + public void sensor(SensorType sensor, Location didVisit) { + } + + @Override + public void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget, PayloadData withPayload, BLEDevice device) { + } + + @Override + public void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget, PayloadData withPayload) { + } + + @Override + public void sensor(SensorType sensor, SensorState didUpdateState) { + } + + @Override + public void sensor(SensorType sensor, PayloadData didRead, TargetIdentifier fromTarget, Proximity atProximity, int withTxPower, BLEDevice device) { + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/Sensor.java b/app/src/main/java/au/gov/health/covidsafe/sensor/Sensor.java new file mode 100644 index 0000000..6fe8483 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/Sensor.java @@ -0,0 +1,17 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor; + +/// Sensor for detecting and tracking various kinds of disease transmission vectors, e.g. contact with people, time at location. +public interface Sensor { + /// Add delegate for responding to sensor events. + void add(SensorDelegate delegate); + + /// Start sensing. + void start(); + + /// Stop sensing. + void stop(); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/SensorArray.java b/app/src/main/java/au/gov/health/covidsafe/sensor/SensorArray.java new file mode 100644 index 0000000..447bff7 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/SensorArray.java @@ -0,0 +1,93 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import au.gov.health.covidsafe.BuildConfig; +import au.gov.health.covidsafe.sensor.ble.ConcreteBLESensor; +import au.gov.health.covidsafe.sensor.data.BatteryLog; +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger; +import au.gov.health.covidsafe.sensor.data.ContactLog; +import au.gov.health.covidsafe.sensor.data.DetectionLog; +import au.gov.health.covidsafe.sensor.data.SensorLogger; +import au.gov.health.covidsafe.sensor.data.StatisticsLog; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.PayloadTimestamp; +import au.gov.health.covidsafe.sensor.payload.PayloadDataSupplier; + +import java.util.ArrayList; +import java.util.List; + +/// Sensor array for combining multiple detection and tracking methods. +public class SensorArray implements Sensor { + private final Context context; + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "SensorArray"); + private final List sensorArray = new ArrayList<>(); + + private final PayloadData payloadData; + public final static String deviceDescription = android.os.Build.MODEL + " (Android " + android.os.Build.VERSION.SDK_INT + ")"; + + + public SensorArray(Context context, PayloadDataSupplier payloadDataSupplier) { + this.context = context; + // Ensure logger has been initialised (should have happened in AppDelegate already) + ConcreteSensorLogger.context(context); + logger.debug("init"); + + // Start foreground service to enable background scan +// final Intent intent = new Intent(context, ForegroundService.class); + //final Intent intentBleService = new Intent(context, BluetoothMonitoringService.class); + //context.startService(intentBleService); +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { +// context.startForegroundService(intent); +// } else { +// context.startService(intent); +// } + + // Define sensor array + sensorArray.add(new ConcreteBLESensor(context, payloadDataSupplier)); + + // Loggers + payloadData = payloadDataSupplier.payload(new PayloadTimestamp()); + if (BuildConfig.DEBUG) { + add(new ContactLog(context, "contacts.csv")); + add(new StatisticsLog(context, "statistics.csv", payloadData)); + add(new DetectionLog(context,"detection.csv", payloadData)); + new BatteryLog(context, "battery.csv"); + } + //Get Device payload + logger.info("DEVICE (payload={},description={})", payloadData.shortName(), deviceDescription); + } + + public final PayloadData payloadData() { + return payloadData; + } + + @Override + public void add(final SensorDelegate delegate) { + for (Sensor sensor : sensorArray) { + sensor.add(delegate); + } + } + + @Override + public void start() { + logger.debug("start"); + for (Sensor sensor : sensorArray) { + sensor.start(); + } + } + + @Override + public void stop() { + logger.debug("stop"); + for (Sensor sensor : sensorArray) { + sensor.stop(); + } + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/SensorDelegate.java b/app/src/main/java/au/gov/health/covidsafe/sensor/SensorDelegate.java new file mode 100644 index 0000000..b9136ab --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/SensorDelegate.java @@ -0,0 +1,44 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor; + +import au.gov.health.covidsafe.sensor.ble.BLEDevice; +import au.gov.health.covidsafe.sensor.datatype.Location; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.Proximity; +import au.gov.health.covidsafe.sensor.datatype.SensorState; +import au.gov.health.covidsafe.sensor.datatype.SensorType; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; + +import java.util.List; + +/// Sensor delegate for receiving sensor events. +public interface SensorDelegate { + /// Detection of a target with an ephemeral identifier, e.g. BLE central detecting a BLE peripheral. + void sensor(SensorType sensor, TargetIdentifier didDetect); + + /// Read payload data from target, e.g. encrypted device identifier from BLE peripheral after successful connection. + void sensor(SensorType sensor, PayloadData didRead, TargetIdentifier fromTarget); + + /// Read payload data of other targets recently acquired by a target, e.g. Android peripheral sharing payload data acquired from nearby iOS peripherals. + void sensor(SensorType sensor, List didShare, TargetIdentifier fromTarget); + + /// Measure proximity to target, e.g. a sample of RSSI values from BLE peripheral. + void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget); + + /// Detection of time spent at location, e.g. at specific restaurant between 02/06/2020 19:00 and 02/06/2020 21:00 + void sensor(SensorType sensor, Location didVisit); + + /// Measure proximity to target with payload data. Combines didMeasure and didRead into a single convenient delegate method + void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget, PayloadData withPayload, BLEDevice device); + + void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget, PayloadData withPayload); + + /// Sensor state update + void sensor(SensorType sensor, SensorState didUpdateState); + + /// Measure proximity to target with payload data. Combines didMeasure and didRead into a single convenient delegate method + void sensor(SensorType sensor, PayloadData didRead, TargetIdentifier fromTarget, Proximity atProximity, int withTxPower, BLEDevice device); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDatabase.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDatabase.java new file mode 100644 index 0000000..f61ede2 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDatabase.java @@ -0,0 +1,38 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.le.ScanResult; + +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.PayloadSharingData; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; + +import java.util.List; + +/// Registry for collating fragments of information from asynchronous BLE operations. +public interface BLEDatabase { + /// Add delegate for handling database events + void add(BLEDatabaseDelegate delegate); + + /// Get or create device for collating information from asynchronous BLE operations. + BLEDevice device(ScanResult scanResult); + + /// Get or create device for collating information from asynchronous BLE operations. + BLEDevice device(BluetoothDevice bluetoothDevice); + + /// Get or create device for collating information from asynchronous BLE operations. + BLEDevice device(PayloadData payloadData); + + /// Get all devices + List devices(); + + /// Delete + void delete(TargetIdentifier identifier); + + /// Get payload sharing data for a peer + PayloadSharingData payloadSharingData(BLEDevice peer); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDatabaseDelegate.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDatabaseDelegate.java new file mode 100644 index 0000000..39f45e8 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDatabaseDelegate.java @@ -0,0 +1,14 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +/// Delegate for receiving registry create/update/delete events +public interface BLEDatabaseDelegate { + void bleDatabaseDidCreate(BLEDevice device); + + void bleDatabaseDidUpdate(BLEDevice device, BLEDeviceAttribute attribute); + + void bleDatabaseDidDelete(BLEDevice device); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDevice.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDevice.java new file mode 100644 index 0000000..cb42e96 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDevice.java @@ -0,0 +1,330 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.le.ScanRecord; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.PseudoDeviceAddress; +import au.gov.health.covidsafe.sensor.datatype.RSSI; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; +import au.gov.health.covidsafe.sensor.datatype.TimeInterval; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Queue; + +public class BLEDevice { + /// Device registration timestamp + public final Date createdAt; + /// Last time anything changed, e.g. attribute update + public Date lastUpdatedAt; + /// Ephemeral device identifier, e.g. peripheral identifier UUID + public final TargetIdentifier identifier; + /// Pseudo device address for tracking Android devices that change address constantly. + private PseudoDeviceAddress pseudoDeviceAddress; + /// Delegate for listening to attribute updates events. + private final BLEDeviceDelegate delegate; + /// Android Bluetooth device object for interacting with this device. + private BluetoothDevice peripheral; + /// Bluetooth device connection state. + private BLEDeviceState state = BLEDeviceState.disconnected; + /// Device operating system, this is necessary for selecting different interaction procedures for each platform. + private BLEDeviceOperatingSystem operatingSystem = BLEDeviceOperatingSystem.unknown; + /// Payload data acquired from the device via payloadCharacteristic read, e.g. C19X beacon code or Sonar encrypted identifier + private PayloadData payloadData; + /// Most recent RSSI measurement taken by readRSSI or didDiscover. + private RSSI rssi; + /// Transmit power data where available (only provided by Android devices) + private BLE_TxPower txPower; + /// Is device receive only? + private boolean receiveOnly = false; + /// Ignore logic + private TimeInterval ignoreForDuration = null; + private Date ignoreUntil = null; + private ScanRecord scanRecord = null; + /// BLE characteristics + private BluetoothGattCharacteristic signalCharacteristic = null; + private BluetoothGattCharacteristic payloadCharacteristic = null; + private BluetoothGattCharacteristic legacyPayloadCharacteristic = null; + protected byte[] signalCharacteristicWriteValue = null; + protected Queue signalCharacteristicWriteQueue = null; + + /// Track connection timestamps + private Date lastDiscoveredAt = null; + private Date lastConnectedAt = null; + + /// Payload data already shared with this peer + protected final List payloadSharingData = new ArrayList<>(); + + /// Track write timestamps + private Date lastWritePayloadAt = null; + private Date lastWriteRssiAt = null; + private Date lastWritePayloadSharingAt = null; + private Date payloadDataLastUpdatedAt = null; + + public TimeInterval timeIntervalSinceConnected() { + if (state() != BLEDeviceState.connected) { + return TimeInterval.zero; + } + if (lastConnectedAt == null) { + return TimeInterval.zero; + } + return new TimeInterval((new Date().getTime() - lastConnectedAt.getTime()) / 1000); + } + + /// Time interval since last attribute value update, this is used to identify devices that may have expired and should be removed from the database. + public TimeInterval timeIntervalSinceLastUpdate() { + return new TimeInterval((new Date().getTime() - lastUpdatedAt.getTime()) / 1000); + } + + public String description() { + return "BLEDevice[id=" + identifier + ",os=" + operatingSystem + ",payload=" + payloadData() + ",address=" + pseudoDeviceAddress() + "]"; + } + + public BLEDevice(TargetIdentifier identifier, BLEDeviceDelegate delegate) { + this.createdAt = new Date(); + this.identifier = identifier; + this.delegate = delegate; + this.lastUpdatedAt = createdAt; + } + + /// Create a clone of an existing device + public BLEDevice(BLEDevice device, BluetoothDevice bluetoothDevice) { + this.createdAt = device.createdAt; + this.lastUpdatedAt = new Date(); + this.identifier = new TargetIdentifier(bluetoothDevice); + this.pseudoDeviceAddress = device.pseudoDeviceAddress; + this.delegate = device.delegate; + this.state = device.state; + this.operatingSystem = device.operatingSystem; + this.payloadData = device.payloadData; + this.rssi = device.rssi; + this.txPower = device.txPower; + this.receiveOnly = device.receiveOnly; + this.ignoreForDuration = device.ignoreForDuration; + this.ignoreUntil = device.ignoreUntil; + this.scanRecord = device.scanRecord; + this.signalCharacteristic = device.signalCharacteristic; + this.payloadCharacteristic = device.payloadCharacteristic; + this.signalCharacteristicWriteValue = device.signalCharacteristicWriteValue; + this.signalCharacteristicWriteQueue = device.signalCharacteristicWriteQueue; + this.legacyPayloadCharacteristic = device.legacyPayloadCharacteristic; + this.lastDiscoveredAt = device.lastDiscoveredAt; + this.lastConnectedAt = device.lastConnectedAt; + this.payloadSharingData.addAll(device.payloadSharingData); + this.lastWritePayloadAt = device.lastWritePayloadAt; + this.lastWriteRssiAt = device.lastWriteRssiAt; + this.lastWritePayloadSharingAt = device.lastWritePayloadSharingAt; + this.payloadDataLastUpdatedAt = device.payloadDataLastUpdatedAt; + } + + public PseudoDeviceAddress pseudoDeviceAddress() { + return pseudoDeviceAddress; + } + + public void pseudoDeviceAddress(PseudoDeviceAddress pseudoDeviceAddress) { + if (this.pseudoDeviceAddress == null || !this.pseudoDeviceAddress.equals(pseudoDeviceAddress)) { + this.pseudoDeviceAddress = pseudoDeviceAddress; + lastUpdatedAt = new Date(); + } + } + + public BluetoothDevice peripheral() { + return peripheral; + } + + public void peripheral(BluetoothDevice peripheral) { + if (this.peripheral != peripheral) { + this.peripheral = peripheral; + lastUpdatedAt = new Date(); + } + } + + public BLEDeviceState state() { + return state; + } + + public void state(BLEDeviceState state) { + this.state = state; + lastUpdatedAt = new Date(); + if (state == BLEDeviceState.connected) { + lastConnectedAt = lastUpdatedAt; + } + delegate.device(this, BLEDeviceAttribute.state); + } + + public BLEDeviceOperatingSystem operatingSystem() { + return operatingSystem; + } + + public void operatingSystem(BLEDeviceOperatingSystem operatingSystem) { + lastUpdatedAt = new Date(); + // Set ignore timer + if (operatingSystem == BLEDeviceOperatingSystem.ignore) { + if (ignoreForDuration == null) { + ignoreForDuration = TimeInterval.minute; + } else if (ignoreForDuration.value < TimeInterval.minutes(3).value) { + ignoreForDuration = new TimeInterval(Math.round(ignoreForDuration.value * 1.2)); + } + ignoreUntil = new Date(lastUpdatedAt.getTime() + ignoreForDuration.millis()); + } else { + ignoreForDuration = null; + ignoreUntil = null; + } + if (this.operatingSystem != operatingSystem) { + this.operatingSystem = operatingSystem; + delegate.device(this, BLEDeviceAttribute.operatingSystem); + } + } + + /// Should ignore this device for now. + public boolean ignore() { + if (ignoreUntil == null) { + return false; + } + if (new Date().getTime() < ignoreUntil.getTime()) { + return true; + } + return false; + } + + public PayloadData payloadData() { + return payloadData; + } + + public void payloadData(PayloadData payloadData) { + this.payloadData = payloadData; + lastUpdatedAt = new Date(); + payloadDataLastUpdatedAt = lastUpdatedAt; + delegate.device(this, BLEDeviceAttribute.payloadData); + } + + public RSSI rssi() { + return rssi; + } + + //Please check the places that call it and save data + public void rssi(RSSI rssi) { + this.rssi = rssi; + lastUpdatedAt = new Date(); + delegate.device(this, BLEDeviceAttribute.rssi); + } + + public void legacyPayloadCharacteristic(BluetoothGattCharacteristic characteristic) { + this.legacyPayloadCharacteristic = characteristic; + lastUpdatedAt = new Date(); + } + + public BluetoothGattCharacteristic getLegacyPayloadCharacteristic() { + return legacyPayloadCharacteristic; + } + + public BLE_TxPower txPower() { + return txPower; + } + + public void txPower(BLE_TxPower txPower) { + this.txPower = txPower; + lastUpdatedAt = new Date(); + delegate.device(this, BLEDeviceAttribute.txPower); + } + + public boolean receiveOnly() { + return receiveOnly; + } + + public void receiveOnly(boolean receiveOnly) { + this.receiveOnly = receiveOnly; + lastUpdatedAt = new Date(); + } + + public void invalidateCharacteristics() { + signalCharacteristic = null; + payloadCharacteristic = null; + legacyPayloadCharacteristic = null; + } + + public BluetoothGattCharacteristic signalCharacteristic() { + return signalCharacteristic; + } + + public void signalCharacteristic(BluetoothGattCharacteristic characteristic) { + this.signalCharacteristic = characteristic; + lastUpdatedAt = new Date(); + } + + public BluetoothGattCharacteristic payloadCharacteristic() { + return payloadCharacteristic; + } + + public void payloadCharacteristic(BluetoothGattCharacteristic characteristic) { + this.payloadCharacteristic = characteristic; + lastUpdatedAt = new Date(); + } + + public void registerDiscovery() { + lastDiscoveredAt = new Date(); + lastUpdatedAt = lastDiscoveredAt; + } + + public void registerWritePayload() { + lastUpdatedAt = new Date(); + lastWritePayloadAt = lastUpdatedAt; + } + + public TimeInterval timeIntervalSinceLastWritePayload() { + if (lastWritePayloadAt == null) { + return TimeInterval.never; + } + return new TimeInterval((new Date().getTime() - lastWritePayloadAt.getTime()) / 1000); + } + + public void registerWriteRssi() { + lastUpdatedAt = new Date(); + lastWriteRssiAt = lastUpdatedAt; + } + + public TimeInterval timeIntervalSinceLastWriteRssi() { + if (lastWriteRssiAt == null) { + return TimeInterval.never; + } + return new TimeInterval((new Date().getTime() - lastWriteRssiAt.getTime()) / 1000); + } + + public TimeInterval timeIntervalSinceLastPayloadUpdate() { + if (payloadDataLastUpdatedAt == null) { + return TimeInterval.never; + } + return new TimeInterval((new Date().getTime() - payloadDataLastUpdatedAt.getTime()) / 1000); + } + + public void registerWritePayloadSharing() { + lastUpdatedAt = new Date(); + lastWritePayloadSharingAt = lastUpdatedAt; + } + + public TimeInterval timeIntervalSinceLastWritePayloadSharing() { + if (lastWritePayloadSharingAt == null) { + return TimeInterval.never; + } + return new TimeInterval((new Date().getTime() - lastWritePayloadSharingAt.getTime()) / 1000); + } + + public void scanRecord(ScanRecord scanRecord) { + this.scanRecord = scanRecord; + } + + public ScanRecord scanRecord() { + return scanRecord; + } + + @Override + public String toString() { + return description(); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceAttribute.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceAttribute.java new file mode 100644 index 0000000..7492971 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceAttribute.java @@ -0,0 +1,9 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +public enum BLEDeviceAttribute { + peripheral, state, operatingSystem, payloadData, rssi, txPower +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceDelegate.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceDelegate.java new file mode 100644 index 0000000..f1a15c1 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceDelegate.java @@ -0,0 +1,10 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +public interface BLEDeviceDelegate { + void device(BLEDevice device, BLEDeviceAttribute didUpdate); +} + diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceOperatingSystem.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceOperatingSystem.java new file mode 100644 index 0000000..cb94837 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceOperatingSystem.java @@ -0,0 +1,9 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +public enum BLEDeviceOperatingSystem { + android_tbc, android, ios_tbc, ios, ignore, shared, unknown +} \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceState.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceState.java new file mode 100644 index 0000000..b9f9d12 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEDeviceState.java @@ -0,0 +1,10 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +/// BLE device connection state +public enum BLEDeviceState { + connecting, connected, disconnected +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEReceiver.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEReceiver.java new file mode 100644 index 0000000..0b2cd86 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLEReceiver.java @@ -0,0 +1,18 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import au.gov.health.covidsafe.sensor.Sensor; +import au.gov.health.covidsafe.sensor.SensorDelegate; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Beacon receiver scans for peripherals with fixed service UUID. + */ +public interface BLEReceiver extends Sensor { + Queue delegates = new ConcurrentLinkedQueue<>(); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLESensor.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLESensor.java new file mode 100644 index 0000000..d3c2eda --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLESensor.java @@ -0,0 +1,10 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import au.gov.health.covidsafe.sensor.Sensor; + +public interface BLESensor extends Sensor { +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLESensorConfiguration.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLESensorConfiguration.java new file mode 100644 index 0000000..dc9073b --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLESensorConfiguration.java @@ -0,0 +1,67 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import au.gov.health.covidsafe.BuildConfig; +import au.gov.health.covidsafe.sensor.data.SensorLoggerLevel; +import au.gov.health.covidsafe.sensor.datatype.TimeInterval; + +import java.util.UUID; + +/// Defines BLE sensor configuration data, e.g. service and characteristic UUIDs +public class BLESensorConfiguration { + public final static SensorLoggerLevel logLevel = SensorLoggerLevel.debug; + /** + * Service UUID for beacon service. This is a fixed UUID to enable iOS devices to find each other even + * in background mode. Android devices will need to find Apple devices first using the manufacturer code + * then discover services to identify actual beacons. + */ + public final static UUID serviceUUID = UUID.fromString(BuildConfig.BLE_SSID); + /// Signaling characteristic for controlling connection between peripheral and central, e.g. keep each other from suspend state + public final static UUID androidSignalCharacteristicUUID = UUID.fromString(BuildConfig.BLE_ANDROIDSIGNALCHARACTERISTIC); + /// Signaling characteristic for controlling connection between peripheral and central, e.g. keep each other from suspend state + public final static UUID iosSignalCharacteristicUUID = UUID.fromString(BuildConfig.BLE_IOSSIGNALCHARACTERISTIC); + /// Primary payload characteristic (read) for distributing payload data from peripheral to central, e.g. identity data + public final static UUID payloadCharacteristicUUID = UUID.fromString(BuildConfig.BLE_PAYLOADCHARACTERISTIC); + public final static UUID legacyCovidsafePayloadCharacteristicUUID = UUID.fromString(BuildConfig.BLE_SSID); + /// Expiry time for shared payloads, to ensure only recently seen payloads are shared, Sharing disabled for now as location permisssion on ios will allow scanning to work + public static TimeInterval payloadSharingExpiryTimeInterval = TimeInterval.zero; + /// Manufacturer data is being used on Android to store pseudo device address + public final static int manufacturerIdForSensor = 65530; + /// Advert refresh time interval + public final static TimeInterval advertRefreshTimeInterval = TimeInterval.minutes(15); + + /// Signal characteristic action code for write payload, expect 1 byte action code followed by 2 byte little-endian Int16 integer value for payload data length, then payload data + public final static byte signalCharacteristicActionWritePayload = (byte) 1; + /// Signal characteristic action code for write RSSI, expect 1 byte action code followed by 4 byte little-endian Int32 integer value for RSSI value + public final static byte signalCharacteristicActionWriteRSSI = (byte) 2; + /// Signal characteristic action code for write payload, expect 1 byte action code followed by 2 byte little-endian Int16 integer value for payload sharing data length, then payload sharing data + public final static byte signalCharacteristicActionWritePayloadSharing = (byte) 3; + + // BLE advert manufacturer ID for Apple, for scanning of background iOS devices + public final static int manufacturerIdForApple = 76; + + /// Filter duplicate payload data and suppress sensor(didRead:fromTarget) delegate calls + public static TimeInterval filterDuplicatePayloadData = TimeInterval.minutes(30); + + /// Define device filtering rules based on message patterns + /// - Avoids connections to devices that cannot host sensor services + /// - Matches against every manufacturer specific data message (Hex format) in advert + /// - Java regular expression patterns, case insensitive, find pattern anywhere in message + /// - Remember to include ^ to match from start of message + /// - Use deviceFilterTrainingEnabled in development environment to identify patterns + public static String[] deviceFilterFeaturePatterns = new String[]{ + "^10....04", + "^10....14", + "^10....1C" + ,"^07","^05","^09" + ,"^00","^05","^09" + //Apple TV + ,"^0101000000000000000000000000000000" + //no service + ,"^0100000000000000000000000000000000" + ,"^1002","^06","^07","^08","^03","^0C","^0D","^0F","^0E","^0B" + }; +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETimer.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETimer.java new file mode 100644 index 0000000..0a2e42f --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETimer.java @@ -0,0 +1,123 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import android.content.Context; +import android.os.PowerManager; + +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger; +import au.gov.health.covidsafe.sensor.data.SensorLogger; +import au.gov.health.covidsafe.sensor.datatype.Sample; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Steady one second timer for controlling BLE operations. Having a reliable timer for starting + * and stopping scans is fundamental for reliable detection and tracking. Methods that have been + * tested and failed included : + * 1. Handler.postDelayed loop backed by MainLooper + * - Actual delay time can drift to 10+ minutes for a 4 second request. + * 2. Handler.postDelayed loop backed by dedicated looper backed by dedicated HandlerThread + * - Slightly better than option (1) but actual delay time can still drift to several minutes for a 4 second request. + * 3. Timer scheduled task loop + * - Timer can drift + *

+ * Test impact of power management by ... + *

+ * 1. Run app on device + * 2. Keep one device connected via USB only + * 3. Put app in background mode and lock device + * 4. Go to terminal + * 5. cd ~/Library/Android/sdk/platform-tools + *

+ * Test DOZE mode + * 1. ./adb shell dumpsys battery unplug + * 2. Expect "powerSource=battery" on log + * 3. ./adb shell dumpsys deviceidle force-idle + * 4. Expect "idle=true" on log + *

+ * Exit DOZE mode + * 1. ./adb shell dumpsys deviceidle unforce + * 2. ./adb shell dumpsys battery reset + * 3. Expect "idle=false" and "powerSource=usb/ac" on log + *

+ * Test APP STANDBY mode + * 1. ./adb shell dumpsys battery unplug + * 2. Expect "powerSource=battery" + * 3. ./adb shell am set-inactive au.gov.health.covidsafe true + *

+ * Exit APP STANDBY mode + * 1. ./adb shell am set-inactive au.gov.health.covidsafe false + * 2. ./adb shell am get-inactive au.gov.health.covidsafe + * 3. Expect "idle=false" on terminal + * 4. ./adb shell dumpsys battery reset + * 5. Expect "powerSource=usb/ac" on log + */ +public class BLETimer { + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BLETimer"); + private final Sample sample = new Sample(); + private final ExecutorService executorService = Executors.newSingleThreadExecutor(); + private final PowerManager.WakeLock wakeLock; + private final AtomicLong now = new AtomicLong(0); + private final Queue delegates = new ConcurrentLinkedQueue<>(); + private final Runnable runnable = new Runnable() { + @Override + public void run() { + for (BLETimerDelegate delegate : delegates) { + try { + delegate.bleTimer(now.get()); + } catch (Throwable e) { + logger.fault("delegate execution failed", e); + } + } + } + }; + + public BLETimer(Context context) { + final PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Sensor:BLETimer"); + wakeLock.acquire(); + final Thread timerThread = new Thread(new Runnable() { + private long last = 0; + + @Override + public void run() { + while (true) { + now.set(System.currentTimeMillis()); + final long elapsed = now.get() - last; + if (elapsed >= 1000) { + if (last != 0) { + sample.add(elapsed); + executorService.execute(runnable); + } + last = now.get(); + } + try { + Thread.sleep(500); + } catch (Throwable e) { + logger.fault("Timer interrupted", e); + } + } + } + }); + timerThread.setPriority(Thread.MAX_PRIORITY); + timerThread.setName("Sensor.BLETimer"); + timerThread.start(); + } + + @Override + protected void finalize() { + wakeLock.release(); + } + + /// Add delegate for time notification + public void add(BLETimerDelegate delegate) { + delegates.add(delegate); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETimerDelegate.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETimerDelegate.java new file mode 100644 index 0000000..3631b03 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETimerDelegate.java @@ -0,0 +1,10 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +public interface BLETimerDelegate { + + void bleTimer(long currentTimeMillis); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETransmitter.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETransmitter.java new file mode 100644 index 0000000..dc1e120 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLETransmitter.java @@ -0,0 +1,39 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import au.gov.health.covidsafe.sensor.Sensor; +import au.gov.health.covidsafe.sensor.SensorDelegate; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Beacon transmitter broadcasts a fixed service UUID to enable background scan by iOS. When iOS + * enters background mode, the UUID will disappear from the broadcast, so Android devices need to + * search for Apple devices and then connect and discover services to read the UUID. + */ +public interface BLETransmitter extends Sensor { + /** + * Delegates for receiving beacon detection events. This is necessary because some Android devices (Samsung J6) + * does not support BLE transmit, thus making the beacon characteristic writable offers a mechanism for such devices + * to detect a beacon transmitter and make their own presence known by sending its own beacon code and RSSI as + * data to the transmitter. + */ + Queue delegates = new ConcurrentLinkedQueue<>(); + + /** + * Get current payload. + */ + PayloadData payloadData(); + + /** + * Is transmitter supported. + * + * @return True if BLE advertising is supported. + */ + boolean isSupported(); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLE_TxPower.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLE_TxPower.java new file mode 100644 index 0000000..b652f50 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BLE_TxPower.java @@ -0,0 +1,13 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +public class BLE_TxPower { + public final int value; + + public BLE_TxPower(int value) { + this.value = value; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BluetoothStateManager.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BluetoothStateManager.java new file mode 100644 index 0000000..27c4388 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BluetoothStateManager.java @@ -0,0 +1,16 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import au.gov.health.covidsafe.sensor.datatype.BluetoothState; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public interface BluetoothStateManager { + Queue delegates = new ConcurrentLinkedQueue<>(); + + BluetoothState state(); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BluetoothStateManagerDelegate.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BluetoothStateManagerDelegate.java new file mode 100644 index 0000000..be92db0 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/BluetoothStateManagerDelegate.java @@ -0,0 +1,11 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import au.gov.health.covidsafe.sensor.datatype.BluetoothState; + +public interface BluetoothStateManagerDelegate { + void bluetoothStateManager(BluetoothState didUpdateState); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLEDatabase.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLEDatabase.java new file mode 100644 index 0000000..21803e6 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLEDatabase.java @@ -0,0 +1,350 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import android.bluetooth.BluetoothDevice; +import android.bluetooth.le.ScanRecord; +import android.bluetooth.le.ScanResult; +import android.util.Log; + +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger; +import au.gov.health.covidsafe.sensor.data.SensorLogger; +import au.gov.health.covidsafe.sensor.datatype.Data; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.PayloadSharingData; +import au.gov.health.covidsafe.sensor.datatype.PseudoDeviceAddress; +import au.gov.health.covidsafe.sensor.datatype.RSSI; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; +import au.gov.health.covidsafe.streetpass.persistence.Encryption; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class ConcreteBLEDatabase implements BLEDatabase, BLEDeviceDelegate { + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BLE.ConcreteBLEDatabase"); + private final Queue delegates = new ConcurrentLinkedQueue<>(); + private final Map database = new ConcurrentHashMap<>(); + private final ExecutorService queue = Executors.newSingleThreadExecutor(); + private static final String LOG_TAG = ConcreteBLEDatabase.class.getSimpleName(); + + @Override + public void add(BLEDatabaseDelegate delegate) { + delegates.add(delegate); + } + + @Override + public BLEDevice device(ScanResult scanResult) { + final BluetoothDevice bluetoothDevice = scanResult.getDevice(); + // Get pseudo device address + final PseudoDeviceAddress pseudoDeviceAddress = pseudoDeviceAddress(scanResult); + if (pseudoDeviceAddress == null) { + // Get device based on peripheral only + return device(bluetoothDevice); + } + // Identify all existing devices with the same pseudo device address + final List candidates = new ArrayList<>(); + for (final BLEDevice device : database.values()) { + if (device.pseudoDeviceAddress() == null) { + continue; + } + if (device.pseudoDeviceAddress().equals(pseudoDeviceAddress)) { + candidates.add(device); + } + } + // No existing device matching pseudo device address, create new device + if (candidates.size() == 0) { + final BLEDevice device = device(bluetoothDevice); + device.pseudoDeviceAddress(pseudoDeviceAddress); + return device; + } + // Find device with the same target identifier + final TargetIdentifier targetIdentifier = new TargetIdentifier(bluetoothDevice); + final BLEDevice existingDevice = database.get(targetIdentifier); + if (existingDevice != null) { + existingDevice.pseudoDeviceAddress(pseudoDeviceAddress); + shareDataAcrossDevices(pseudoDeviceAddress); + return existingDevice; + } + // Get most recent version of the device and clone to enable attachment to new peripheral + Collections.sort(candidates, new Comparator() { + @Override + public int compare(BLEDevice d0, BLEDevice d1) { + return Long.compare(d1.lastUpdatedAt.getTime(), d0.lastUpdatedAt.getTime()); + } + }); + final BLEDevice cloneSource = candidates.get(0); + final BLEDevice newDevice = new BLEDevice(cloneSource, scanResult.getDevice()); + database.put(newDevice.identifier, newDevice); + queue.execute(new Runnable() { + @Override + public void run() { + logger.debug("create (device={},pseudoAddress={})", newDevice.identifier, pseudoDeviceAddress); + for (BLEDatabaseDelegate delegate : delegates) { + delegate.bleDatabaseDidCreate(newDevice); + } + } + }); + newDevice.peripheral(scanResult.getDevice()); + final PayloadData payloadData = shareDataAcrossDevices(pseudoDeviceAddress); + if (payloadData != null) { + newDevice.payloadData(payloadData); + } + return newDevice; + } + + /// Get pseudo device address for Android devices + private PseudoDeviceAddress pseudoDeviceAddress(final ScanResult scanResult) { + Log.d(LOG_TAG, "PseudoDeviceAddress"); + final ScanRecord scanRecord = scanResult.getScanRecord(); + if (scanRecord == null) { + return null; + } + final byte[] data = scanRecord.getManufacturerSpecificData(BLESensorConfiguration.manufacturerIdForSensor); +// if (data == null || data.length != 6) { +// return null; +// } + if (data == null) { + return null; + } + return new PseudoDeviceAddress(data); + } + + /// Share information across devices with the same pseudo device address + private PayloadData shareDataAcrossDevices(final PseudoDeviceAddress pseudoDeviceAddress) { + // Get all devices with the same pseudo device address + final List devices = new ArrayList<>(); + for (final BLEDevice device : database.values()) { + if (device.pseudoDeviceAddress() == null) { + continue; + } + if (device.pseudoDeviceAddress().equals(pseudoDeviceAddress)) { + devices.add(device); + } + } + // Get most recent version of payload data + Collections.sort(devices, new Comparator() { + @Override + public int compare(BLEDevice d0, BLEDevice d1) { + return Long.compare(d1.lastUpdatedAt.getTime(), d0.lastUpdatedAt.getTime()); + } + }); + PayloadData payloadData = null; + for (BLEDevice device : devices) { + if (device.payloadData() != null) { + payloadData = device.payloadData(); + break; + } + } + // Distribute payload to all devices with the same pseudo address + if (payloadData != null) { + // Share it amongst devices within advert refresh time limit + final long timeLimit = new Date().getTime() - BLESensorConfiguration.advertRefreshTimeInterval.millis(); + for (BLEDevice device : devices) { + if (device.payloadData() == null && device.createdAt.getTime() >= timeLimit) { + device.payloadData(payloadData); + } + } + } + // Get the most complete operating system + BLEDeviceOperatingSystem operatingSystem = null; + for (BLEDevice device : devices) { + if (device.operatingSystem() == BLEDeviceOperatingSystem.android || device.operatingSystem() == BLEDeviceOperatingSystem.ios) { + operatingSystem = device.operatingSystem(); + break; + } + } + // Distribute operating system to all devices with the same pseudo address + if (operatingSystem != null) { + for (BLEDevice device : devices) { + if (device.operatingSystem() == BLEDeviceOperatingSystem.unknown + || device.operatingSystem() == BLEDeviceOperatingSystem.android_tbc + || device.operatingSystem() == BLEDeviceOperatingSystem.ios_tbc) { + device.operatingSystem(operatingSystem); + } + } + } + return payloadData; + } + + + @Override + public BLEDevice device(BluetoothDevice bluetoothDevice) { + final TargetIdentifier identifier = new TargetIdentifier(bluetoothDevice); + BLEDevice device = database.get(identifier); + if (device == null) { + final BLEDevice newDevice = new BLEDevice(identifier, this); + device = newDevice; + database.put(identifier, newDevice); + queue.execute(new Runnable() { + @Override + public void run() { + logger.debug("create (device={})", identifier); + for (BLEDatabaseDelegate delegate : delegates) { + delegate.bleDatabaseDidCreate(newDevice); + } + } + }); + } + device.peripheral(bluetoothDevice); + return device; + } + + @Override + public BLEDevice device(PayloadData payloadData) { + BLEDevice device = null; + for (BLEDevice candidate : database.values()) { + if (payloadData.equals(candidate.payloadData())) { + device = candidate; + break; + } + } + if (device == null) { + final TargetIdentifier identifier = new TargetIdentifier(); + final BLEDevice newDevice = new BLEDevice(identifier, this); + device = newDevice; + database.put(identifier, newDevice); + queue.execute(new Runnable() { + @Override + public void run() { + logger.debug("create (device={})", identifier); + for (BLEDatabaseDelegate delegate : delegates) { + delegate.bleDatabaseDidCreate(newDevice); + } + } + }); + } + device.payloadData(payloadData); + return device; + } + + @Override + public List devices() { + return new ArrayList<>(database.values()); + } + + @Override + public void delete(final TargetIdentifier identifier) { + final BLEDevice device = database.remove(identifier); + if (device != null) { + queue.execute(new Runnable() { + @Override + public void run() { + logger.debug("delete (device={})", identifier); + for (final BLEDatabaseDelegate delegate : delegates) { + delegate.bleDatabaseDidDelete(device); + } + } + }); + } + } + + @Override + public PayloadSharingData payloadSharingData(final BLEDevice peer) { + final RSSI rssi = peer.rssi(); + if (rssi == null) { + return new PayloadSharingData(new RSSI(127), new Data(new byte[0])); + } + // Get other devices that were seen recently by this device + final List unknownDevices = new ArrayList<>(); + final List knownDevices = new ArrayList<>(); + for (BLEDevice device : database.values()) { + // Device was seen recently + if (device.timeIntervalSinceLastUpdate().value >= BLESensorConfiguration.payloadSharingExpiryTimeInterval.value) { + continue; + } + // Device has payload + if (device.payloadData() == null) { + continue; + } + // Device is iOS or receive only (Samsung J6) + if (!(device.operatingSystem() == BLEDeviceOperatingSystem.ios || device.receiveOnly())) { + continue; + } + // Payload is not the peer itself + if (peer.payloadData() != null && (Arrays.equals(device.payloadData().value, peer.payloadData().value))) { + continue; + } + // Payload is new to peer + if (peer.payloadSharingData.contains(device.payloadData())) { + knownDevices.add(device); + } else { + unknownDevices.add(device); + } + } + // Most recently seen unknown devices first + final List devices = new ArrayList<>(); + Collections.sort(unknownDevices, new Comparator() { + @Override + public int compare(BLEDevice d0, BLEDevice d1) { + return Long.compare(d1.lastUpdatedAt.getTime(), d0.lastUpdatedAt.getTime()); + } + }); + Collections.sort(knownDevices, new Comparator() { + @Override + public int compare(BLEDevice d0, BLEDevice d1) { + return Long.compare(d1.lastUpdatedAt.getTime(), d0.lastUpdatedAt.getTime()); + } + }); + devices.addAll(unknownDevices); + if (devices.size() == 0) { + return new PayloadSharingData(new RSSI(127), new Data(new byte[0])); + } + // Limit how much to share to avoid oversized data transfers over BLE + // (512 bytes limit according to spec, 510 with response, iOS requires response) + final Set sharedPayloads = new HashSet<>(devices.size()); + final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + for (BLEDevice device : devices) { + final PayloadData payloadData = device.payloadData(); + if (payloadData == null) { + continue; + } + // Eliminate duplicates (this happens when the same device has changed address but the old version has not expired yet) + if (sharedPayloads.contains(payloadData)) { + continue; + } + // Limit payload sharing by BLE transfer limit + if (payloadData.value.length + byteArrayOutputStream.toByteArray().length > 510) { + break; + } + try { + byteArrayOutputStream.write(payloadData.value); + peer.payloadSharingData.add(payloadData); + sharedPayloads.add(payloadData); + } catch (Throwable e) { + logger.fault("Failed to append payload sharing data", e); + } + } + final Data data = new Data(byteArrayOutputStream.toByteArray()); + return new PayloadSharingData(rssi, data); + } + + // MARK:- BLEDeviceDelegate + + @Override + public void device(final BLEDevice device, final BLEDeviceAttribute didUpdate) { + queue.execute(new Runnable() { + @Override + public void run() { + logger.debug("update (device={},attribute={})", device.identifier, didUpdate.name()); + for (BLEDatabaseDelegate delegate : delegates) { + delegate.bleDatabaseDidUpdate(device, didUpdate); + } + } + }); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLEReceiver.kt b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLEReceiver.kt new file mode 100644 index 0000000..9670b0c --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLEReceiver.kt @@ -0,0 +1,931 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// +package au.gov.health.covidsafe.sensor.ble + +import android.bluetooth.* +import android.bluetooth.le.* +import android.content.Context +import android.os.Build +import android.os.ParcelUuid +import au.gov.health.covidsafe.app.TracerApp +import au.gov.health.covidsafe.bluetooth.gatt.WriteRequestPayload +import au.gov.health.covidsafe.factory.NetworkFactory +import au.gov.health.covidsafe.logging.CentralLog +import au.gov.health.covidsafe.sensor.SensorDelegate +import au.gov.health.covidsafe.sensor.ble.filter.BLEDeviceFilter +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger +import au.gov.health.covidsafe.sensor.data.SensorLogger +import au.gov.health.covidsafe.sensor.datatype.* +import au.gov.health.covidsafe.streetpass.StreetPassPairingFix +import au.gov.health.covidsafe.streetpass.persistence.Encryption +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext + +class ConcreteBLEReceiver(private val context: Context, private val bluetoothStateManager: BluetoothStateManager, timer: BLETimer, private val database: BLEDatabase, private val transmitter: BLETransmitter) : BluetoothGattCallback(), BLEReceiver, CoroutineScope { + private val logger: SensorLogger = ConcreteSensorLogger("Sensor", "BLE.ConcreteBLEReceiver") + private val operationQueue = Executors.newSingleThreadExecutor() + private val scanResults: Queue = ConcurrentLinkedQueue() + private val awsClient = NetworkFactory.awsClient + private val deviceFilter = BLEDeviceFilter() + + private enum class NextTask { + nothing, readPayload, writePayload, writeRSSI, writePayloadSharing + } + + //Result come here + private val scanCallback: ScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, scanResult: ScanResult) { + logger.debug("onScanResult (result={})", scanResult) + scanResults.add(scanResult) + // Create or update device in database + val device = database.device(scanResult) + device.registerDiscovery() + // Read RSSI from scan result + device.rssi(RSSI(scanResult.rssi)) + } + + + override fun onBatchScanResults(results: List) { + for (scanResult in results) { + onScanResult(0, scanResult) + } + } + + override fun onScanFailed(errorCode: Int) { + logger.fault("onScanFailed (error={})", onScanFailedErrorCodeToString(errorCode)) + super.onScanFailed(errorCode) + } + } + + // MARK:- BLEReceiver + override fun add(delegate: SensorDelegate) { + BLEReceiver.delegates.add(delegate) + } + + override fun start() { + logger.debug("start") + // scanLoop is started by Bluetooth state + } + + override fun stop() { + logger.debug("stop") + // scanLoop is stopped by Bluetooth state + } + + // MARK:- Scan loop for startScan-wait-stopScan-processScanResults-wait-repeat + private enum class ScanLoopState { + scanStarting, scanStarted, scanStopping, scanStopped, processing, processed + } + + private inner class ScanLoopTask : BLETimerDelegate { + private var scanLoopState = ScanLoopState.processed + private var lastStateChangeAt = System.currentTimeMillis() + private fun state(now: Long, state: ScanLoopState) { + val elapsed = now - lastStateChangeAt + logger.debug("scanLoopTask, state change (from={},to={},elapsed={}ms)", scanLoopState, state, elapsed) + scanLoopState = state + lastStateChangeAt = now + } + + private fun timeSincelastStateChange(now: Long): Long { + return now - lastStateChangeAt + } + + private fun bluetoothLeScanner(): BluetoothLeScanner? { + val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + if (bluetoothAdapter == null) { + logger.fault("ScanLoop denied, Bluetooth adapter unavailable") + return null + } + val bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner + if (bluetoothLeScanner == null) { + logger.fault("ScanLoop denied, Bluetooth LE scanner unavailable") + return null + } + return bluetoothLeScanner + } + + override fun bleTimer(now: Long) { + when (scanLoopState) { + ScanLoopState.processed -> { + if (bluetoothStateManager.state() == BluetoothState.poweredOn) { + val period = timeSincelastStateChange(now) + if (period >= scanOffDurationMillis) { + logger.debug("scanLoopTask, start scan (process={}ms)", period) + val bluetoothLeScanner = bluetoothLeScanner() + if (bluetoothLeScanner == null) { + logger.fault("scanLoopTask, start scan denied, Bluetooth LE scanner unavailable") + return + } + state(now, ScanLoopState.scanStarting) + startScan(bluetoothLeScanner, Callback { value -> value?.let { state(now, if (value) ScanLoopState.scanStarted else ScanLoopState.scanStopped) } }) + } + } + return + } + ScanLoopState.scanStarted -> { + val period = timeSincelastStateChange(now) + if (period >= scanOnDurationMillis) { + logger.debug("scanLoopTask, stop scan (scan={}ms)", period) + val bluetoothLeScanner = bluetoothLeScanner() + if (bluetoothLeScanner == null) { + logger.fault("scanLoopTask, stop scan denied, Bluetooth LE scanner unavailable") + return + } + state(now, ScanLoopState.scanStopping) + stopScan(bluetoothLeScanner, Callback { value -> value?.let { state(now, ScanLoopState.scanStopped) } }) + } + return + } + ScanLoopState.scanStopped -> { + if (bluetoothStateManager.state() == BluetoothState.poweredOn) { + val period = timeSincelastStateChange(now) + if (period >= scanRestDurationMillis) { + logger.debug("scanLoopTask, start processing (stop={}ms)", period) + state(now, ScanLoopState.processing) + processScanResults(object : Callback { + override fun accept(value: Boolean?) { + value?.let { state(now, ScanLoopState.processed) } + } + }) + } + } + return + } + } + } + } + + /// Get BLE scanner and start scan + private fun startScan(bluetoothLeScanner: BluetoothLeScanner, callback: Callback?) { + logger.debug("startScan") + operationQueue.execute { + try { + scanForPeripherals(bluetoothLeScanner) + logger.debug("startScan successful") + callback?.let { callback.accept(true) } + } catch (e: Throwable) { + logger.fault("startScan failed", e) + callback?.let { callback.accept(false) } + } + } + } + + /// Scan for devices advertising sensor service and all Apple devices as + // iOS background advert does not include service UUID. There is a risk + // that the sensor will spend time communicating with Apple devices that + // are not running the sensor code repeatedly, but there is no reliable + // way of filtering this as the service may be absent only because of + // transient issues. This will be handled in taskConnect. + private fun scanForPeripherals(bluetoothLeScanner: BluetoothLeScanner) { + logger.debug("scanForPeripherals") + val filter: MutableList = ArrayList(2) + filter.add(ScanFilter.Builder().setManufacturerData( + BLESensorConfiguration.manufacturerIdForApple, ByteArray(0), ByteArray(0)).build()) + filter.add(ScanFilter.Builder().setServiceUuid( + ParcelUuid(BLESensorConfiguration.serviceUUID), + ParcelUuid(UUID(-0x1L, 0))) + .build()) + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setReportDelay(0) + .build() + bluetoothLeScanner.startScan(filter, settings, scanCallback) + } + + private fun processScanResults(callback: Callback) { + logger.debug("processScanResults") + operationQueue.execute { + try { + processScanResults() + logger.debug("processScanResults, processed scan results") + } catch (e: Throwable) { + logger.fault("processScanResults warning, processScanResults error", e) + callback.accept(false) + } + logger.debug("processScanResults successful") + callback.accept(true) + } + } + + /// Get BLE scanner and stop scan + private fun stopScan(bluetoothLeScanner: BluetoothLeScanner, callback: Callback) { + logger.debug("stopScan") + operationQueue.execute { + try { + bluetoothLeScanner.stopScan(scanCallback) + logger.debug("stopScan, stopped scanner") + } catch (e: Throwable) { + logger.fault("stopScan warning, bluetoothLeScanner.stopScan error", e) + } + try { + val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + bluetoothAdapter?.cancelDiscovery() + logger.debug("stopScan, cancelled discovery") + } catch (e: Throwable) { + logger.fault("stopScan warning, bluetoothAdapter.cancelDiscovery error", e) + } + logger.debug("stopScan successful") + callback.accept(true) + } + } + + // MARK:- Process scan results + /// Process scan results. + private fun processScanResults() { + val t0 = System.currentTimeMillis() + logger.debug("processScanResults (results={})", scanResults.size) + // Identify devices discovered in last scan + val didDiscover = didDiscover() + taskRemoveExpiredDevices() + taskCorrectConnectionStatus() + taskConnect(didDiscover) + val t1 = System.currentTimeMillis() + logger.debug("processScanResults (results={},devices={},elapsed={}ms)", scanResults.size, didDiscover.size, t1 - t0) + } + // MARK:- didDiscover + /** + * Process scan results to ... + * 1. Create BLEDevice from scan result for new devices + * 2. Read RSSI + * 3. Identify operating system where possible + */ + private fun didDiscover(): List { + // Take current copy of concurrently modifiable scan results + val scanResultList: MutableList = ArrayList(scanResults.size) + while (scanResults.size > 0) { + scanResultList.add(scanResults.poll()) + } + + // Process scan results and return devices created/updated in scan results + logger.debug("didDiscover (scanResults={})", scanResultList.size) + val deviceSet: MutableSet = HashSet() + val devices: MutableList = ArrayList() + for (scanResult in scanResultList) { + val device = database.device(scanResult) + if (deviceSet.add(device)) { + logger.debug("didDiscover (device={})", device) + devices.add(device) + } + // Set scan record + device.scanRecord(scanResult.scanRecord) + // Set TX power level + if (device.scanRecord() != null) { + val txPowerLevel = device.scanRecord().txPowerLevel + if (txPowerLevel != Int.MIN_VALUE) { + device.txPower(BLE_TxPower(txPowerLevel)) + } + } + // Identify operating system from scan record where possible + // - Sensor service found + Manufacturer is Apple -> iOS (Foreground) + // - Sensor service found + Manufacturer not Apple -> Android + // - Sensor service not found + Manufacturer is Apple -> iOS (Background) or Apple device not advertising sensor service, to be resolved later + // - Sensor service not found + Manufacturer not Apple -> Ignore (shouldn't be possible as we are scanning for Apple or with service) + val hasSensorService = hasSensorService(scanResult) + val isAppleDevice = isAppleDevice(scanResult) + if (hasSensorService && isAppleDevice) { + // Definitely iOS device offering sensor service in foreground mode + device.operatingSystem(BLEDeviceOperatingSystem.ios) + } else if (hasSensorService) { // !isAppleDevice implied + // Definitely Android device offering sensor service + if (device.operatingSystem() != BLEDeviceOperatingSystem.android) { + device.operatingSystem(BLEDeviceOperatingSystem.android_tbc) + } + } else if (isAppleDevice) { // !hasSensorService implied + // Filter device by advert messages unless it is already confirmed ios device + val matchingPattern: BLEDeviceFilter.MatchingPattern? = deviceFilter.match(device) + if (device.operatingSystem() !== BLEDeviceOperatingSystem.ios && matchingPattern != null) { + logger.fault("didDiscover, ignoring filtered device (device={},pattern={},message={})", device, matchingPattern.filterPattern.regularExpression, matchingPattern.message) + device.operatingSystem(BLEDeviceOperatingSystem.ignore) + } + // Possibly an iOS device offering sensor service in background mode, + // can't be sure without additional checks after connection, so + // only set operating system if it is unknown to offer a guess. + if (device.operatingSystem() == BLEDeviceOperatingSystem.unknown) { + device.operatingSystem(BLEDeviceOperatingSystem.ios_tbc) + } + } else { + // Sensor service not found + Manufacturer not Apple should be impossible + // as we are scanning for devices with sensor service or Apple device. + logger.fault("didDiscover, invalid non-Apple device without sensor service (device={})", device) + if (!(device.operatingSystem() == BLEDeviceOperatingSystem.ios || device.operatingSystem() == BLEDeviceOperatingSystem.android)) { + device.operatingSystem(BLEDeviceOperatingSystem.ignore) + } + } + } + return devices + } + + // MARK:- House keeping tasks + /// Remove devices that have not been updated for over 15 minutes, as the UUID + // is likely to have changed after being out of range for over 20 minutes, + // so it will require discovery. Discovery is fast and cheap on Android. + private fun taskRemoveExpiredDevices() { + val devicesToRemove: MutableList = ArrayList() + for (device in database.devices()) { + if (device.timeIntervalSinceLastUpdate().value > TimeInterval.minutes(15).value) { + devicesToRemove.add(device) + } + } + for (device in devicesToRemove) { + logger.debug("taskRemoveExpiredDevices (remove={})", device) + database.delete(device.identifier) + } + } + + /// Connections should not be held for more than 1 minute, likely to have not received onConnectionStateChange callback. + private fun taskCorrectConnectionStatus() { + for (device in database.devices()) { + if (device.state() == BLEDeviceState.connected && device.timeIntervalSinceConnected().value > TimeInterval.minute.value) { + logger.debug("taskCorrectConnectionStatus (device={})", device) + device.state(BLEDeviceState.disconnected) + } + } + } + + // MARK:- Connect task + private fun taskConnect(discovered: List) { + // Clever connection prioritisation is pointless here as devices + // like the Samsung A10 and A20 changes mac address on every scan + // call, so optimising new device handling is more effective. + val timeStart = System.currentTimeMillis() + var devicesProcessed = 0 + for (device in discovered) { + // Stop process if exceeded time limit + val elapsedTime = System.currentTimeMillis() - timeStart + if (elapsedTime >= scanProcessDurationMillis) { + logger.debug("taskConnect, reached time limit (elapsed={}ms,limit={}ms)", elapsedTime, scanProcessDurationMillis) + break + } + if (devicesProcessed > 0) { + val predictedElapsedTime = Math.round(elapsedTime / devicesProcessed.toDouble() * (devicesProcessed + 1)) + if (predictedElapsedTime > scanProcessDurationMillis) { + logger.debug("taskConnect, likely to exceed time limit soon (elapsed={}ms,devicesProcessed={},predicted={}ms,limit={}ms)", elapsedTime, devicesProcessed, predictedElapsedTime, scanProcessDurationMillis) + break + } + } + if (nextTaskForDevice(device) == NextTask.nothing) { + logger.debug("taskConnect, no pending action (device={})", device) + continue + } + taskConnectDevice(device) + devicesProcessed++ + } + } + + private fun taskConnectDevice(device: BLEDevice) { + if (device.state() == BLEDeviceState.connected) { + logger.debug("taskConnectDevice, already connected to transmitter (device={})", device) + return + } + // Connect (timeout at 95% = 2 SD) + val timeConnect = System.currentTimeMillis() + logger.debug("taskConnectDevice, connect (device={})", device) + device.state(BLEDeviceState.connecting) + //val gatt = device.peripheral().connectGatt(context, false, this) + val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + device.peripheral().connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE) + } else { + // use reflection to call connectGatt(Context context, boolean autoConnect, BluetoothGattCallback callback, int transport) + try { + device.javaClass.getMethod( + "connectGatt", Context::class.java, Boolean::class.java, BluetoothGattCallback::class.java, Int::class.java + ).invoke( + // BluetoothDevice.TRANSPORT_LE = 2 + device, context, false, this, 2 ) as BluetoothGatt + } catch (e: Exception) { + logger.fault("Reflection call of connectGatt() failed.", e) + // reflection failed; call connectGatt(Context context, boolean autoConnect, BluetoothGattCallback callback) instead + device.peripheral().connectGatt(context, false, this) + } + } + if (gatt == null) { + logger.fault("taskConnectDevice, connect failed (device={})", device) + device.state(BLEDeviceState.disconnected) + return + } + // Wait for connection + while (device.state() != BLEDeviceState.connected && device.state() != BLEDeviceState.disconnected && System.currentTimeMillis() - timeConnect < timeToConnectDeviceLimitMillis) { + try { + Thread.sleep(200) + } catch (e: Throwable) { + logger.fault("Timer interrupted", e) + } + } + if (device.state() != BLEDeviceState.connected) { + logger.fault("taskConnectDevice, connect timeout (device={})", device) + try { + gatt.close() + } catch (e: Throwable) { + logger.fault("taskConnectDevice, close failed (device={})", device, e) + } + return + } else { + val connectElapsed = System.currentTimeMillis() - timeConnect + // Add sample to adaptive connection timeout + timeToConnectDevice.add(connectElapsed.toDouble()) + logger.debug("taskConnectDevice, connected (device={},elapsed={}ms,statistics={})", device, connectElapsed, timeToConnectDevice) + } + // Wait for disconnection + while (device.state() != BLEDeviceState.disconnected && System.currentTimeMillis() - timeConnect < scanProcessDurationMillis) { + try { + Thread.sleep(500) + } catch (e: Throwable) { + logger.fault("Timer interrupted", e) + } + } + var success = true + // Timeout connection if required, and always set state to disconnected + if (device.state() != BLEDeviceState.disconnected) { + logger.fault("taskConnectDevice, disconnect timeout (device={})", device) + try { + gatt.close() + } catch (e: Throwable) { + logger.fault("taskConnectDevice, close failed (device={})", device, e) + } + success = false + } + device.state(BLEDeviceState.disconnected) + val timeDisconnect = System.currentTimeMillis() + val timeElapsed = timeDisconnect - timeConnect + if (success) { + timeToProcessDevice.add(timeElapsed.toDouble()) + logger.debug("taskConnectDevice, complete (success=true,device={},elapsed={}ms,statistics={})", device, timeElapsed, timeToProcessDevice) + } else { + logger.fault("taskConnectDevice, complete (success=false,device={},elapsed={}ms)", device, timeElapsed) + } + } + + // MARK:- BluetoothStateManagerDelegate + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + val device = database.device(gatt.device) + logger.debug("onConnectionStateChange (device={},status={},state={})", device, bleStatus(status), bleState(newState)) + if (newState == BluetoothProfile.STATE_CONNECTED) { + device.state(BLEDeviceState.connected) + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + gatt.close() + device.state(BLEDeviceState.disconnected) + if (status != 0) { + if (!(device.operatingSystem() == BLEDeviceOperatingSystem.ios || device.operatingSystem() == BLEDeviceOperatingSystem.android)) { + device.operatingSystem(BLEDeviceOperatingSystem.ignore) + } + } + } else { + logger.debug("onConnectionStateChange (device={},status={},state={})", device, bleStatus(status), bleState(newState)) + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + val device = database.device(gatt.device) + logger.debug("onServicesDiscovered (device={},status={})", device, bleStatus(status)) + val service = gatt.getService(BLESensorConfiguration.serviceUUID) + if (service == null) { + logger.fault("onServicesDiscovered, missing sensor service (device={})", device) + // Ignore device for a while unless it is a confirmed iOS or Android device + if (!(device.operatingSystem() == BLEDeviceOperatingSystem.ios || device.operatingSystem() == BLEDeviceOperatingSystem.android)) { + device.operatingSystem(BLEDeviceOperatingSystem.ignore) + } + gatt.disconnect() + return + } + logger.debug("onServicesDiscovered, found sensor service (device={})", device) + device.invalidateCharacteristics() + var readService: Boolean = false + for (characteristic in service.characteristics) { + // Confirm operating system with signal characteristic + if (characteristic.uuid == BLESensorConfiguration.androidSignalCharacteristicUUID) { + logger.debug("onServicesDiscovered, found Android signal characteristic (device={})", device) + device.operatingSystem(BLEDeviceOperatingSystem.android) + device.signalCharacteristic(characteristic) + } else if (characteristic.uuid == BLESensorConfiguration.iosSignalCharacteristicUUID) { + logger.debug("onServicesDiscovered, found iOS signal characteristic (device={})", device) + device.operatingSystem(BLEDeviceOperatingSystem.ios) + device.signalCharacteristic(characteristic) + } else if (characteristic.uuid == BLESensorConfiguration.payloadCharacteristicUUID) { + logger.debug("onServicesDiscovered, found payload characteristic (device={})", device) + device.payloadCharacteristic(characteristic) + readService = true + } else if (characteristic.uuid == BLESensorConfiguration.legacyCovidsafePayloadCharacteristicUUID && !readService) { + logger.debug("onServicesDiscovered, found covidsafe legacy payload characteristic (device={})", device) + //If they have the legacy characteristic we know it a COVID app and can set the OS to be confirmed + if(device.operatingSystem() == BLEDeviceOperatingSystem.android_tbc) { + device.operatingSystem(BLEDeviceOperatingSystem.android) + }else if(device.operatingSystem() == BLEDeviceOperatingSystem.ios_tbc) { + device.operatingSystem(BLEDeviceOperatingSystem.ios) + } + device.payloadCharacteristic(characteristic) + device.legacyPayloadCharacteristic(characteristic) + } + } + nextTask(gatt) + } + + private fun nextTaskForDevice(device: BLEDevice): NextTask { + // No task for devices marked as .ignore + if (device.ignore()) { + return NextTask.nothing + } + // If marked as ignore but ignore has expired, change to unknown + if (device.operatingSystem() == BLEDeviceOperatingSystem.ignore) { + logger.debug("nextTaskForDevice, switching ignore to unknown (device={},reason=ignoreExpired)", device) + device.operatingSystem(BLEDeviceOperatingSystem.unknown) + } + // No task for devices marked as receive only (no advert to connect to) + if (device.receiveOnly()) { + return NextTask.nothing + } + // Resolve or confirm operating system by reading payload which + // triggers characteristic discovery to confirm the operating system + if (device.operatingSystem() == BLEDeviceOperatingSystem.unknown || + device.operatingSystem() == BLEDeviceOperatingSystem.ios_tbc) { + logger.debug("nextTaskForDevice (device={},task=readPayload|OS)", device) + return NextTask.readPayload + } + // Get payload as top priority + if (device.payloadData() == null) { + logger.debug("nextTaskForDevice (device={},task=readPayload)", device) + return NextTask.readPayload + } + if (device.timeIntervalSinceLastPayloadUpdate().millis() > payloadDataUpdateTimeInterval) { + logger.debug("nextTaskForDevice (device={},task=readPayloadUpdate)", device) + return NextTask.readPayload + } + + // Write payload, rssi and payload sharing data if this device cannot transmit + if (!transmitter.isSupported) { + // Write payload data as top priority + if (device.timeIntervalSinceLastWritePayload().value > TimeInterval.minutes(5).value) { + logger.debug("nextTaskForDevice (device={},task=writePayload,elapsed={})", device, device.timeIntervalSinceLastWritePayload()) + return NextTask.writePayload + } + // Write payload sharing data to iOS device if there is data to be shared (alternate between payload sharing and write RSSI) + val payloadSharingData = database.payloadSharingData(device) + if (device.operatingSystem() == BLEDeviceOperatingSystem.ios + && payloadSharingData.data.value.size > 0 + && device.timeIntervalSinceLastWritePayloadSharing().value >= TimeInterval.seconds(15).value + && device.timeIntervalSinceLastWritePayloadSharing().value >= device.timeIntervalSinceLastWriteRssi().value) { + logger.debug("nextTaskForDevice (device={},task=writePayloadSharing,dataLength={},elapsed={})", device, payloadSharingData.data.value.size, + device.timeIntervalSinceLastWritePayloadSharing()) + return NextTask.writePayloadSharing + } + // Write RSSI as frequently as reasonable + if (device.rssi() != null + && device.timeIntervalSinceLastWriteRssi().value >= TimeInterval.seconds(15).value + && (device.timeIntervalSinceLastWritePayload().millis() < payloadDataUpdateTimeInterval + || device.timeIntervalSinceLastWriteRssi().value >= device.timeIntervalSinceLastWritePayload().value)) { + logger.debug("nextTaskForDevice (device={},task=writeRSSI,elapsed={})", device, device.timeIntervalSinceLastWriteRssi()) + return NextTask.writeRSSI + } + // Write payload update if required + if (device.timeIntervalSinceLastWritePayload().millis() > payloadDataUpdateTimeInterval) { + logger.debug("nextTaskForDevice (device={},task=writePayloadUpdate,elapsed={})", device, device.timeIntervalSinceLastWritePayload()); + return NextTask.writePayload; + } + } + // Write payload sharing data to iOS + if (device.operatingSystem() == BLEDeviceOperatingSystem.ios) { + // Write payload sharing data to iOS device if there is data to be shared + val payloadSharingData = database.payloadSharingData(device) + if (device.operatingSystem() == BLEDeviceOperatingSystem.ios && payloadSharingData.data.value.size > 0 && device.timeIntervalSinceLastWritePayloadSharing().value >= TimeInterval.seconds(15).value) { + logger.debug("nextTaskForDevice (device={},task=writePayloadSharing,dataLength={},elapsed={})", device, payloadSharingData.data.value.size, device.timeIntervalSinceLastWritePayloadSharing()) + return NextTask.writePayloadSharing + } + } + return NextTask.nothing + } + + private fun nextTask(gatt: BluetoothGatt) { + val device = database.device(gatt.device) + val nextTask = nextTaskForDevice(device) + when (nextTask) { + NextTask.readPayload -> { + val payloadCharacteristic = device.payloadCharacteristic() + + if (payloadCharacteristic == null) { + logger.fault("nextTask failed (task=readPayload,device={},reason=missingPayloadCharacteristic)", device) + gatt.disconnect() + return // => onConnectionStateChange + } + StreetPassPairingFix.bypassAuthenticationRetry(gatt); + if (!gatt.readCharacteristic(payloadCharacteristic)) { + logger.fault("nextTask failed (task=readPayload,device={},reason=readCharacteristicFailed)", device) + gatt.disconnect() + return // => onConnectionStateChange + } + logger.debug("nextTask (task=readPayload,device={})", device) + return // => onCharacteristicRead | timeout + } + NextTask.writePayload -> { + val payloadData = transmitter.payloadData() + if (payloadData == null || payloadData.value == null || payloadData.value.size == 0) { + logger.fault("nextTask failed (task=writePayload,device={},reason=missingPayloadData)", device) + gatt.disconnect() + return // => onConnectionStateChange + } + val data = SignalCharacteristicData.encodeWritePayload(transmitter.payloadData()) + logger.debug("nextTask (task=writePayload,device={},dataLength={})", device, data.value.size) + writeSignalCharacteristic(gatt, NextTask.writePayload, data.value) + return + } + NextTask.writePayloadSharing -> { + val payloadSharingData = database.payloadSharingData(device) + if (payloadSharingData == null) { + logger.fault("nextTask failed (task=writePayloadSharing,device={},reason=missingPayloadSharingData)", device) + gatt.disconnect() + return + } + val data = SignalCharacteristicData.encodeWritePayloadSharing(payloadSharingData) + logger.debug("nextTask (task=writePayloadSharing,device={},dataLength={})", device, data.value.size) + writeSignalCharacteristic(gatt, NextTask.writePayloadSharing, data.value) + return + } + NextTask.writeRSSI -> { + val signalCharacteristic = device.signalCharacteristic() + if (signalCharacteristic == null) { + logger.fault("nextTask failed (task=writeRSSI,device={},reason=missingSignalCharacteristic)", device) + gatt.disconnect() + return + } + val rssi = device.rssi() + if (rssi == null) { + logger.fault("nextTask failed (task=writeRSSI,device={},reason=missingRssiData)", device) + gatt.disconnect() + return + } + val data = SignalCharacteristicData.encodeWriteRssi(rssi) + logger.debug("nextTask (task=writeRSSI,device={},dataLength={})", device, data.value.size) + writeSignalCharacteristic(gatt, NextTask.writeRSSI, data.value) + return + } + } + logger.debug("nextTask (task=nothing,device={})", device) + gatt.disconnect() + } + + inner class EncryptedWriteRequestPayload(val timestamp: Long, val modelC: String, val rssi: Int, val txPower: Int?, val msg: String?) + + private fun getWritePayloadForCentral(device: BLEDevice): WriteRequestPayload? { + val thisCentralDevice = TracerApp.asCentralDevice() + val gson = GsonBuilder().disableHtmlEscaping().create() + + val DUMMY_DEVICE = "" + val DUMMY_RSSI = 999 + val DUMMY_TXPOWER = 999 + + val rssi = if (device.rssi() != null) device.rssi().value else return null + val txPower = if (device.txPower() != null) device.txPower().value else DUMMY_TXPOWER + + val plainRecord = gson.toJson(EncryptedWriteRequestPayload( + System.currentTimeMillis() / 1000L, + thisCentralDevice.modelC, + rssi, + txPower, + TracerApp.thisDeviceMsg())) + + val remoteBlob = Encryption.encryptPayload(plainRecord.toByteArray(Charsets.UTF_8)) + + val writedata = WriteRequestPayload( + v = TracerApp.protocolVersion, + msg = remoteBlob, + org = TracerApp.ORG, + modelC = DUMMY_DEVICE, + rssi = DUMMY_RSSI, + txPower = DUMMY_TXPOWER + ) + + return writedata + } + + private fun writeSignalCharacteristic(gatt: BluetoothGatt, task: NextTask, data: ByteArray?) { + val device = database.device(gatt.device) + val signalCharacteristic = device.signalCharacteristic() + if (signalCharacteristic == null) { + logger.fault("writeSignalCharacteristic failed (task={},device={},reason=missingSignalCharacteristic)", task, device) + gatt.disconnect() + return + } + if (data == null || data.size == 0) { + logger.fault("writeSignalCharacteristic failed (task={},device={},reason=missingData)", task, device) + gatt.disconnect() + return + } + if (signalCharacteristic.uuid == BLESensorConfiguration.iosSignalCharacteristicUUID) { + device.signalCharacteristicWriteValue = data + device.signalCharacteristicWriteQueue = null + signalCharacteristic.value = data + signalCharacteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + StreetPassPairingFix.bypassAuthenticationRetry(gatt) + if (!gatt.writeCharacteristic(signalCharacteristic)) { + logger.fault("writeSignalCharacteristic to iOS failed (task={}},device={},reason=writeCharacteristicFailed)", task, device) + gatt.disconnect() + } else { + logger.debug("writeSignalCharacteristic to iOS (task={},dataLength={},device={})", task, data.size, device) + // => onCharacteristicWrite + } + return + } + if (signalCharacteristic.uuid == BLESensorConfiguration.androidSignalCharacteristicUUID) { + device.signalCharacteristicWriteValue = data + device.signalCharacteristicWriteQueue = fragmentDataByMtu(data) + if (writeAndroidSignalCharacteristic(gatt) == WriteAndroidSignalCharacteristicResult.failed) { + logger.fault("writeSignalCharacteristic to Android failed (task={}},device={},reason=writeCharacteristicFailed)", task, device) + gatt.disconnect() + } else { + logger.debug("writeSignalCharacteristic to Android (task={},dataLength={},device={})", task, data.size, device) + // => onCharacteristicWrite + } + } + } + + private enum class WriteAndroidSignalCharacteristicResult { + moreToWrite, complete, failed + } + + private fun writeAndroidSignalCharacteristic(gatt: BluetoothGatt): WriteAndroidSignalCharacteristicResult { + val device = database.device(gatt.device) + val signalCharacteristic = device.signalCharacteristic() + if (signalCharacteristic == null) { + logger.fault("writeAndroidSignalCharacteristic failed (device={},reason=missingSignalCharacteristic)", device) + return WriteAndroidSignalCharacteristicResult.failed + } + if (device.signalCharacteristicWriteQueue == null || device.signalCharacteristicWriteQueue.size == 0) { + logger.debug("writeAndroidSignalCharacteristic completed (device={})", device) + return WriteAndroidSignalCharacteristicResult.complete + } + logger.debug("writeAndroidSignalCharacteristic (device={},queue={})", device, device.signalCharacteristicWriteQueue.size) + val data = device.signalCharacteristicWriteQueue.poll() + signalCharacteristic.value = data + signalCharacteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + StreetPassPairingFix.bypassAuthenticationRetry(gatt) + return if (!gatt.writeCharacteristic(signalCharacteristic)) { + logger.fault("writeAndroidSignalCharacteristic failed (device={},reason=writeCharacteristicFailed)", device) + WriteAndroidSignalCharacteristicResult.failed + } else { + logger.debug("writeAndroidSignalCharacteristic (device={},remaining={})", device, device.signalCharacteristicWriteQueue.size) + WriteAndroidSignalCharacteristicResult.moreToWrite + } + } + + /// Split data into fragments, where each fragment has length <= mtu + private fun fragmentDataByMtu(data: ByteArray): Queue { + val fragments: Queue = ConcurrentLinkedQueue() + var i = 0 + while (i < data.size) { + val fragment = ByteArray(Math.min(defaultMTU, data.size - i)) + System.arraycopy(data, i, fragment, 0, fragment.size) + fragments.add(fragment) + i += defaultMTU + } + return fragments + } + + override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + val device = database.device(gatt.device) + val success = status == BluetoothGatt.GATT_SUCCESS + logger.debug("onCharacteristicRead (device={},status={})", device, bleStatus(status)) + if (characteristic.uuid == BLESensorConfiguration.payloadCharacteristicUUID || characteristic.uuid == BLESensorConfiguration.legacyCovidsafePayloadCharacteristicUUID) { + val payloadData = if (characteristic.value != null) PayloadData(characteristic.value) else null + if (success) { + if (payloadData != null) { + logger.debug("onCharacteristicRead, read payload data success (device={},payload={})", device, payloadData.shortName()) + device.payloadData(payloadData) + } else { + logger.fault("onCharacteristicRead, read payload data failed, no data (device={})", device) + } + } else { + logger.fault("onCharacteristicRead, read payload data failed (device={})", device) + } + } + nextTask(gatt) + } + + override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + val device = database.device(gatt.device) + logger.debug("onCharacteristicWrite (device={},status={})", device, bleStatus(status)) + val signalCharacteristic = device.signalCharacteristic() + val success = status == BluetoothGatt.GATT_SUCCESS + if (signalCharacteristic.uuid == BLESensorConfiguration.androidSignalCharacteristicUUID) { + if (success && writeAndroidSignalCharacteristic(gatt) == WriteAndroidSignalCharacteristicResult.moreToWrite) { + return + } + } + val signalCharacteristicDataType = SignalCharacteristicData.detect(Data(device.signalCharacteristicWriteValue)) + signalCharacteristic.value = ByteArray(0) + device.signalCharacteristicWriteValue = null + device.signalCharacteristicWriteQueue = null + when (signalCharacteristicDataType) { + SignalCharacteristicDataType.payload -> { + if (success) { + logger.debug("onCharacteristicWrite, write payload success (device={})", device) + device.registerWritePayload() + } else { + logger.fault("onCharacteristicWrite, write payload failed (device={})", device) + } + return + } + SignalCharacteristicDataType.rssi -> { + if (success) { + logger.debug("onCharacteristicWrite, write RSSI success (device={})", device) + device.registerWriteRssi() + } else { + logger.fault("onCharacteristicWrite, write RSSI failed (device={})", device) + } + return + } + SignalCharacteristicDataType.payloadSharing -> { + if (success) { + logger.debug("onCharacteristicWrite, write payload sharing success (device={})", device) + device.registerWritePayloadSharing() + } else { + logger.fault("onCharacteristicWrite, write payload sharing failed (device={})", device) + } + return + } + else -> { + logger.fault("onCharacteristicWrite, write unknown data (device={},success={})", device, success) + return + } + } + nextTask(gatt) + } + + companion object { + // Scan ON/OFF/PROCESS durations + private val scanOnDurationMillis = TimeInterval.seconds(4).millis() + private val scanRestDurationMillis = TimeInterval.seconds(1).millis() + private val scanProcessDurationMillis = TimeInterval.seconds(60).millis() + private val scanOffDurationMillis = TimeInterval.seconds(2).millis() + private val timeToConnectDeviceLimitMillis = TimeInterval.seconds(12).millis() + private val timeToConnectDevice = Sample() + private val timeToProcessDevice = Sample() + private const val defaultMTU = 20 + private val payloadDataUpdateTimeInterval = TimeInterval.minutes(5).millis() + + /// Does scan result include advert for sensor service? + private fun hasSensorService(scanResult: ScanResult): Boolean { + val scanRecord = scanResult.scanRecord ?: return false + val serviceUuids = scanRecord.serviceUuids + if (serviceUuids == null || serviceUuids.size == 0) { + return false + } + for (serviceUuid in serviceUuids) { + if (serviceUuid.uuid == BLESensorConfiguration.serviceUUID) { + return true + } + } + return false + } + + /// Does scan result indicate device was manufactured by Apple? + private fun isAppleDevice(scanResult: ScanResult): Boolean { + val scanRecord = scanResult.scanRecord ?: return false + val data = scanRecord.getManufacturerSpecificData(BLESensorConfiguration.manufacturerIdForApple) + return data != null + } + + // MARK:- Bluetooth code transformers + private fun bleStatus(status: Int): String { + return if (status == BluetoothGatt.GATT_SUCCESS) { + "GATT_SUCCESS" + } else { + "GATT_FAILURE" + } + } + + private fun bleState(state: Int): String { + return when (state) { + BluetoothProfile.STATE_CONNECTED -> "STATE_CONNECTED" + BluetoothProfile.STATE_DISCONNECTED -> "STATE_DISCONNECTED" + else -> "UNKNOWN_STATE_$state" + } + } + + private fun onScanFailedErrorCodeToString(errorCode: Int): String { + return when (errorCode) { + ScanCallback.SCAN_FAILED_ALREADY_STARTED -> "SCAN_FAILED_ALREADY_STARTED" + ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED" + ScanCallback.SCAN_FAILED_INTERNAL_ERROR -> "SCAN_FAILED_INTERNAL_ERROR" + ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED -> "SCAN_FAILED_FEATURE_UNSUPPORTED" + else -> "UNKNOWN_ERROR_CODE_$errorCode" + } + } + } + + /** + * Receiver starts automatically when Bluetooth is enabled. + */ + init { + timer.add(ScanLoopTask()) + } + private var job: Job = Job() + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + job +} \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLESensor.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLESensor.java new file mode 100644 index 0000000..ad28356 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLESensor.java @@ -0,0 +1,194 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import android.content.Context; +import android.util.Log; + +import com.atlassian.mobilekit.module.feedback.commands.SendFeedbackCommand; + +import au.gov.health.covidsafe.sensor.SensorDelegate; +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger; +import au.gov.health.covidsafe.sensor.data.SensorLogger; +import au.gov.health.covidsafe.sensor.datatype.BluetoothState; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.Proximity; +import au.gov.health.covidsafe.sensor.datatype.ProximityMeasurementUnit; +import au.gov.health.covidsafe.sensor.datatype.RSSI; +import au.gov.health.covidsafe.sensor.datatype.SensorState; +import au.gov.health.covidsafe.sensor.datatype.SensorType; +import au.gov.health.covidsafe.sensor.datatype.TimeInterval; +import au.gov.health.covidsafe.sensor.payload.PayloadDataSupplier; + +import java.util.Date; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class ConcreteBLESensor implements BLESensor, BLEDatabaseDelegate, BluetoothStateManagerDelegate { + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BLE.ConcreteBLESensor"); + private final Queue delegates = new ConcurrentLinkedQueue<>(); + private final BLETransmitter transmitter; + private final BLEReceiver receiver; + private final ExecutorService operationQueue = Executors.newSingleThreadExecutor(); + private static final String LOG_TAG = ConcreteBLESensor.class.getSimpleName(); + + // Record payload data to enable de-duplication + private final Map didReadPayloadData = new ConcurrentHashMap<>(); + + public ConcreteBLESensor(Context context, PayloadDataSupplier payloadDataSupplier) { + final BluetoothStateManager bluetoothStateManager = new ConcreteBluetoothStateManager(context); + final BLEDatabase database = new ConcreteBLEDatabase(); + final BLETimer timer = new BLETimer(context); + bluetoothStateManager.delegates.add(this); + transmitter = new ConcreteBLETransmitter(context, bluetoothStateManager, timer, payloadDataSupplier, database); + receiver = new ConcreteBLEReceiver(context, bluetoothStateManager, timer, database, transmitter); + database.add(this); + } + + @Override + public void add(SensorDelegate delegate) { + delegates.add(delegate); + transmitter.add(delegate); + receiver.add(delegate); + } + + @Override + public void start() { + logger.debug("start"); + // BLE transmitter and receivers start on powerOn event + transmitter.start(); + receiver.start(); + } + + @Override + public void stop() { + logger.debug("stop"); + // BLE transmitter and receivers stops on powerOff event + transmitter.stop(); + receiver.stop(); + } + + // MARK:- BLEDatabaseDelegate + + @Override + public void bleDatabaseDidCreate(final BLEDevice device) { + logger.debug("didDetect (device={},payloadData={})", device.identifier, device.payloadData()); + operationQueue.execute(new Runnable() { + @Override + public void run() { + for (SensorDelegate delegate : delegates) { + delegate.sensor(SensorType.BLE, device.identifier); + } + } + }); + } + + @Override + public void bleDatabaseDidUpdate(final BLEDevice device, BLEDeviceAttribute attribute) { + //Save in database here + Log.d(LOG_TAG, "attribute:" + attribute); + Log.d(LOG_TAG, "device:" + device); + switch (attribute) { + case rssi: { + final RSSI rssi = device.rssi(); + if (rssi == null) { + return; + } + final Proximity proximity = new Proximity(ProximityMeasurementUnit.RSSI, (double) rssi.value); + Log.d(LOG_TAG, "device.payloadData():" + device.payloadData()); + Log.d(LOG_TAG, "device:" + device); + Log.d(LOG_TAG, "proximity.description():" + proximity.description()); + // We receive payload here + logger.debug("didMeasure (device={},payloadData={},proximity={})", device, device.payloadData(), proximity.description()); + operationQueue.execute(new Runnable() { + @Override + public void run() { + for (SensorDelegate delegate : delegates) { + delegate.sensor(SensorType.BLE, proximity, device.identifier); + } + } + }); + final PayloadData payloadData = device.payloadData(); + if (payloadData == null) { + return; + } + operationQueue.execute(new Runnable() { + @Override + public void run() { + for (SensorDelegate delegate : delegates) { + delegate.sensor(SensorType.BLE, proximity, device.identifier, payloadData,device); + } + } + }); + break; + } + case payloadData: { + final PayloadData payloadData = device.payloadData(); + if (payloadData == null) { + return; + } + // De-duplicate payload in recent time + if (BLESensorConfiguration.filterDuplicatePayloadData != TimeInterval.never) { + final long removePayloadDataBefore = new Date().getTime() - BLESensorConfiguration.filterDuplicatePayloadData.millis(); + for (Map.Entry entry : didReadPayloadData.entrySet()) { + if (entry.getValue().getTime() < removePayloadDataBefore) { + didReadPayloadData.remove(entry.getKey()); + } + } + final Date lastReportedAt = didReadPayloadData.get(payloadData); + if (lastReportedAt != null) { + logger.debug("didRead, filtered duplicate (device={},payloadData={},lastReportedAt={})", device, device.payloadData().shortName(), lastReportedAt); + return; + } + didReadPayloadData.put(payloadData, new Date()); + } + // Notify delegates + logger.debug("didRead (device={},payloadData={},payloadData={})", device, device.payloadData(), payloadData.shortName()); + final RSSI rssi = device.rssi(); + final Proximity proximity = new Proximity(ProximityMeasurementUnit.RSSI, (double) rssi.value); + operationQueue.execute(new Runnable() { + @Override + public void run() { + for (SensorDelegate delegate : delegates) { + if (device.txPower() != null) { + delegate.sensor(SensorType.BLE, payloadData, device.identifier, proximity, device.txPower().value, device); + } else { + delegate.sensor(SensorType.BLE, payloadData, device.identifier, proximity, 999, device); + } + } + } + }); + break; + } + default: { + } + } + } + + @Override + public void bleDatabaseDidDelete(BLEDevice device) { + logger.debug("didDelete (device={})", device.identifier); + } + + // MARK:- BluetoothStateManagerDelegate + + @Override + public void bluetoothStateManager(BluetoothState didUpdateState) { + logger.debug("didUpdateState (state={})", didUpdateState); + SensorState sensorState = SensorState.off; + if (didUpdateState == BluetoothState.poweredOn) { + sensorState = SensorState.on; + } else if (didUpdateState == BluetoothState.unsupported) { + sensorState = SensorState.unavailable; + } + for (SensorDelegate delegate : delegates) { + delegate.sensor(SensorType.BLE, sensorState); + } + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLETransmitter.kt b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLETransmitter.kt new file mode 100644 index 0000000..4a331d3 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBLETransmitter.kt @@ -0,0 +1,558 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// +package au.gov.health.covidsafe.sensor.ble + +import android.bluetooth.* +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.content.Context +import android.os.ParcelUuid +import au.gov.health.covidsafe.sensor.SensorDelegate +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger +import au.gov.health.covidsafe.sensor.data.SensorLogger +import au.gov.health.covidsafe.sensor.datatype.* +import au.gov.health.covidsafe.sensor.payload.PayloadDataSupplier +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +class ConcreteBLETransmitter( + private val context: Context, + private val bluetoothStateManager: BluetoothStateManager, + timer: BLETimer, + private val payloadDataSupplier: PayloadDataSupplier, + private val database: BLEDatabase) : BLETransmitter, BluetoothStateManagerDelegate { + private val logger: SensorLogger = ConcreteSensorLogger("Sensor", "BLE.ConcreteBLETransmitter") + private val operationQueue = Executors.newSingleThreadExecutor() + + override fun add(delegate: SensorDelegate) { + BLETransmitter.delegates.add(delegate) + } + + override fun start() { + logger.debug("start (supported={})", isSupported) + // advertLoop is started by Bluetooth state + } + + override fun stop() { + logger.debug("stop") + // advertLoop is stopped by Bluetooth state + } + + // MARK:- Advert loop + private enum class AdvertLoopState { + starting, started, stopping, stopped + } + + /// Get Bluetooth LE advertiser + private fun bluetoothLeAdvertiser(): BluetoothLeAdvertiser? { + val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() + ?: //logger.fault("Bluetooth adapter unavailable") + return null + return if (!bluetoothAdapter.isMultipleAdvertisementSupported) { + //logger.fault("Bluetooth advertisement unsupported") + null + } else bluetoothAdapter.bluetoothLeAdvertiser + ?: //logger.fault("Bluetooth advertisement unavailable") + return null + } + + private inner class AdvertLoopTask : BLETimerDelegate { + private var advertLoopState = AdvertLoopState.stopped + private var lastStateChangeAt = System.currentTimeMillis() + private var advertiseCallback: AdvertiseCallback? = null + private fun state(now: Long, state: AdvertLoopState) { + val elapsed = now - lastStateChangeAt + logger.debug("advertLoopTask, state change (from={},to={},elapsed={}ms)", advertLoopState, state, elapsed) + advertLoopState = state + lastStateChangeAt = now + } + + private fun timeSincelastStateChange(now: Long): Long { + return now - lastStateChangeAt + } + + override fun bleTimer(now: Long) { + if (!isSupported || bluetoothStateManager.state() == BluetoothState.poweredOff) { + if (advertLoopState != AdvertLoopState.stopped) { + advertiseCallback = null + bluetoothGattServer = null + state(now, AdvertLoopState.stopped) + logger.debug("advertLoopTask, stop advert (advert={}ms)", timeSincelastStateChange(now)) + } + return + } + when (advertLoopState) { + AdvertLoopState.stopped -> { + if (bluetoothStateManager.state() == BluetoothState.poweredOn) { + val period = timeSincelastStateChange(now) + if (period >= advertOffDurationMillis) { + logger.debug("advertLoopTask, start advert (stop={}ms)", period) + val bluetoothLeAdvertiser = bluetoothLeAdvertiser() + if (bluetoothLeAdvertiser == null) { + logger.fault("advertLoopTask, start advert denied, Bluetooth LE advertiser unavailable") + return + } + state(now, AdvertLoopState.starting) + startAdvert(bluetoothLeAdvertiser, Callback { value -> + advertiseCallback = value.b + bluetoothGattServer = value.c + state(now, if (value.a) AdvertLoopState.started else AdvertLoopState.stopped) + }) + } + } + return + } + AdvertLoopState.started -> { + val period = timeSincelastStateChange(now) + if (period >= BLESensorConfiguration.advertRefreshTimeInterval.millis()) { + logger.debug("advertLoopTask, stop advert (advert={}ms)", period) + val bluetoothLeAdvertiser = bluetoothLeAdvertiser() + if (bluetoothLeAdvertiser == null) { + logger.fault("advertLoopTask, stop advert denied, Bluetooth LE advertiser unavailable") + return + } + state(now, AdvertLoopState.stopping) + stopAdvert(bluetoothLeAdvertiser, advertiseCallback, bluetoothGattServer, object : Callback { + override fun accept(value: Boolean) { + advertiseCallback = null + bluetoothGattServer = null + state(now, AdvertLoopState.stopped) + } + }) + } + return + } + } + } + } + + // MARK:- Start and stop advert + private fun startAdvert(bluetoothLeAdvertiser: BluetoothLeAdvertiser, callback: Callback>) { + logger.debug("startAdvert") + operationQueue.execute(Runnable { + var result = true + var bluetoothGattServer: BluetoothGattServer? = null + try { + bluetoothGattServer = startGattServer(logger, context, payloadDataSupplier, database) + } catch (e: Throwable) { + logger.fault("startAdvert failed to start GATT server", e) + result = false + } + if (bluetoothGattServer == null) { + result = false + } else { + try { + setGattService(logger, context, bluetoothGattServer) + } catch (e: Throwable) { + logger.fault("startAdvert failed to set GATT service", e) + bluetoothGattServer = try { + bluetoothGattServer.clearServices() + bluetoothGattServer.close() + null + } catch (e2: Throwable) { + logger.fault("startAdvert failed to stop GATT server", e2) + null + } + result = false + } + } + if (!result) { + logger.fault("startAdvert failed") + callback.accept(Triple(false, null, null)) + return@Runnable + } + try { + val bluetoothGattServerConfirmed = bluetoothGattServer + val advertiseCallback: AdvertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + logger.debug("startAdvert successful") + callback.accept(Triple(true, this, bluetoothGattServerConfirmed)) + } + + override fun onStartFailure(errorCode: Int) { + logger.fault("startAdvert failed (errorCode={})", onStartFailureErrorCodeToString(errorCode)) + callback.accept(Triple(false, this, bluetoothGattServerConfirmed)) + } + } + startAdvertising(bluetoothLeAdvertiser, advertiseCallback) + } catch (e: Throwable) { + logger.fault("startAdvert failed") + callback.accept(Triple(false, null, null)) + } + }) + } + + private fun stopAdvert(bluetoothLeAdvertiser: BluetoothLeAdvertiser, advertiseCallback: AdvertiseCallback?, bluetoothGattServer: BluetoothGattServer?, callback: Callback) { + logger.debug("stopAdvert") + operationQueue.execute { + var result = true + try { + if (advertiseCallback != null) { + bluetoothLeAdvertiser.stopAdvertising(advertiseCallback) + } + } catch (e: Throwable) { + logger.fault("stopAdvert failed to stop advertising", e) + result = false + } + try { + if (bluetoothGattServer != null) { + bluetoothGattServer.clearServices() + bluetoothGattServer.close() + } + } catch (e: Throwable) { + logger.fault("stopAdvert failed to stop GATT server", e) + result = false + } + if (result) { + logger.debug("stopAdvert successful") + } else { + logger.fault("stopAdvert failed") + } + callback.accept(result) + } + } + + override fun payloadData(): PayloadData { + return payloadDataSupplier.payload(PayloadTimestamp(Date())) + } + + override fun isSupported(): Boolean { + return bluetoothLeAdvertiser() != null + } + + override fun bluetoothStateManager(didUpdateState: BluetoothState) { + logger.debug("didUpdateState (state={})", didUpdateState) + if (didUpdateState == BluetoothState.poweredOn) { + start() + } else if (didUpdateState == BluetoothState.poweredOff) { + stop() + } + } + + private fun startAdvertising(bluetoothLeAdvertiser: BluetoothLeAdvertiser, advertiseCallback: AdvertiseCallback) { + logger.debug("startAdvertising") + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW) + .build() + val pseudoDeviceAddress = PseudoDeviceAddress() + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .setIncludeTxPowerLevel(false) + .addServiceUuid(ParcelUuid(BLESensorConfiguration.serviceUUID)) + .addManufacturerData(BLESensorConfiguration.manufacturerIdForSensor, pseudoDeviceAddress.data) + .build() + bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback) + logger.debug("startAdvertising successful (pDeviceAddress={},settings={})", pseudoDeviceAddress, settings) + } + + companion object { + private val TAG = "ConcreteBLETransmitter" + val gson: Gson = GsonBuilder().disableHtmlEscaping().create() + var bluetoothGattServer: BluetoothGattServer? = null + + private val advertOffDurationMillis = TimeInterval.seconds(4).millis() + private fun startGattServer(logger: SensorLogger, context: Context, payloadDataSupplier: PayloadDataSupplier, database: BLEDatabase): BluetoothGattServer? { + logger.debug("startGattServer") + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + if (bluetoothManager == null) { + logger.fault("Bluetooth unsupported") + return null + } + // Data = rssi (4 bytes int) + payload (remaining bytes) + val server = AtomicReference(null) + val callback: BluetoothGattServerCallback = object : BluetoothGattServerCallback() { + + //this should be a table + //in order to handle many connections from different mac addresses + //val writeDataPayload: MutableMap = HashMap() + //val readPayloadMap: MutableMap = HashMap() + + ////in order to handle many connections from different mac addresses + //Implement as Same as GattServer in Covid here + private val onCharacteristicReadPayloadData: MutableMap = ConcurrentHashMap() + private val onCharacteristicWriteSignalData: MutableMap = ConcurrentHashMap() + private fun onCharacteristicReadPayloadData(device: BluetoothDevice): PayloadData? { + logger.debug("startGattServer") + //Come here if othe phone is older version + val key = device.address + if (onCharacteristicReadPayloadData.containsKey(key)) { + return onCharacteristicReadPayloadData[key] + } + val payloadData = payloadDataSupplier.payload(PayloadTimestamp()) + onCharacteristicReadPayloadData[key] = payloadData + return payloadData + } + + private fun onCharacteristicWriteSignalData(device: BluetoothDevice, value: ByteArray?): ByteArray { + logger.debug("startGattServer") + val key = device.address + var partialData = onCharacteristicWriteSignalData[key] + if (partialData == null) { + partialData = ByteArray(0) + } + val data = ByteArray(partialData.size + (value?.size ?: 0)) + System.arraycopy(partialData, 0, data, 0, partialData.size) + if (value != null) { + System.arraycopy(value, 0, data, partialData.size, value.size) + } + onCharacteristicWriteSignalData[key] = data + return data + } + + private fun removeData(device: BluetoothDevice) { + val deviceAddress = device.address + for (deviceRequestId in ArrayList(onCharacteristicReadPayloadData.keys)) { + if (deviceRequestId.startsWith(deviceAddress)) { + onCharacteristicReadPayloadData.remove(deviceRequestId) + } + } + for (deviceRequestId in ArrayList(onCharacteristicWriteSignalData.keys)) { + if (deviceRequestId.startsWith(deviceAddress)) { + onCharacteristicWriteSignalData.remove(deviceRequestId) + } + } + } + + override fun onConnectionStateChange(bluetoothDevice: BluetoothDevice?, status: Int, newState: Int) { + val device = database.device(bluetoothDevice) + logger.debug("onConnectionStateChange (device={},status={},newState={})", + device, status, onConnectionStateChangeStatusToString(newState)) + if (newState == BluetoothProfile.STATE_CONNECTED) { + //Save data here + device.state(BLEDeviceState.connected) + if (bluetoothDevice != null) { + bluetoothManager.getConnectedDevices(BluetoothProfile.GATT).contains(bluetoothDevice) + } + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + device.state(BLEDeviceState.disconnected) + bluetoothDevice?.let { removeData(bluetoothDevice) } + } + } + + // TODO We receive payload here + override fun onCharacteristicWriteRequest(device: BluetoothDevice?, requestId: Int, characteristic: BluetoothGattCharacteristic, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray?) { + device?.let { + val targetDevice = database.device(device) + val targetIdentifier = targetDevice.identifier + logger.debug("didReceiveWrite (central={},requestId={},offset={},characteristic={},value={})", + targetDevice, requestId, offset, + if (characteristic.uuid == BLESensorConfiguration.androidSignalCharacteristicUUID) "signal" else "unknown", + value?.size ?: "null" + ) + if (characteristic.uuid !== BLESensorConfiguration.androidSignalCharacteristicUUID) { + if (responseNeeded) { + server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED, offset, value) + } + return + } + val data = Data(onCharacteristicWriteSignalData(device, value)) + if (characteristic.uuid == BLESensorConfiguration.legacyCovidsafePayloadCharacteristicUUID) { + val payloadData = SignalCharacteristicData.decodeWritePayload(data) + ?: // Fragmented payload data may be incomplete + return + logger.debug("didReceiveWrite (dataType=payload,central={},payload={})", targetDevice, payloadData) + // Only receive-only Android devices write payload + targetDevice.operatingSystem(BLEDeviceOperatingSystem.android) + targetDevice.receiveOnly(true) + targetDevice.payloadData(payloadData) + onCharacteristicWriteSignalData.remove(device.address) + if (responseNeeded) { + server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + return + } + when (SignalCharacteristicData.detect(data)) { + SignalCharacteristicDataType.rssi -> { + val rssi = SignalCharacteristicData.decodeWriteRSSI(data) + if (rssi == null) { + logger.fault("didReceiveWrite, invalid request (central={},action=writeRSSI)", targetDevice) + return + } + logger.debug("didReceiveWrite (dataType=rssi,central={},rssi={})", targetDevice, rssi) + // Only receive-only Android devices write RSSI + targetDevice.operatingSystem(BLEDeviceOperatingSystem.android) + targetDevice.receiveOnly(true) + targetDevice.rssi(rssi) + return + } + SignalCharacteristicDataType.payload -> { + val payloadData = SignalCharacteristicData.decodeWritePayload(data) + ?: // Fragmented payload data may be incomplete + return + logger.debug("didReceiveWrite (dataType=payload,central={},payload={})", targetDevice, payloadData) + // Only receive-only Android devices write payload + targetDevice.operatingSystem(BLEDeviceOperatingSystem.android) + targetDevice.receiveOnly(true) + targetDevice.payloadData(payloadData) + onCharacteristicWriteSignalData.remove(device.address) + if (responseNeeded) { + server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED, offset, value) + } + return + } + SignalCharacteristicDataType.payloadSharing -> { + val payloadSharingData = SignalCharacteristicData.decodeWritePayloadSharing(data) + ?: // Fragmented payload sharing data may be incomplete + return + val didSharePayloadData = payloadDataSupplier.payload(payloadSharingData.data) + for (delegate in BLETransmitter.delegates) { + delegate.sensor(SensorType.BLE, didSharePayloadData, targetIdentifier) + } + // Only Android devices write payload sharing + targetDevice.operatingSystem(BLEDeviceOperatingSystem.android) + targetDevice.rssi(payloadSharingData.rssi) + logger.debug("didReceiveWrite (dataType=payloadSharing,central={},payloadSharingData={})", targetDevice, didSharePayloadData) + for (payloadData in didSharePayloadData) { + val sharedDevice = database.device(payloadData) + sharedDevice.operatingSystem(BLEDeviceOperatingSystem.shared) + sharedDevice.rssi(payloadSharingData.rssi) + } + return + } + } + if (responseNeeded) { + server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } + } + + inner class ReadRequestEncryptedPayload(val timestamp: Long, val modelP: String, val msg: String?) + + override fun onCharacteristicReadRequest(device: BluetoothDevice?, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic?) { + device?.let { + //Come here if other phone is older version + val targetDevice = database.device(device) + if (characteristic?.uuid === BLESensorConfiguration.payloadCharacteristicUUID || characteristic?.uuid === BLESensorConfiguration.legacyCovidsafePayloadCharacteristicUUID) { + val payloadData = onCharacteristicReadPayloadData(device) + payloadData?.let { + if (offset > payloadData.value.size) { + logger.fault("didReceiveRead, invalid offset (central={},requestId={},offset={},characteristic=payload,dataLength={})", targetDevice, requestId, offset, payloadData.value.size) + server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_INVALID_OFFSET, offset, null) + } else { + val value = Arrays.copyOfRange(payloadData.value, offset, payloadData.value.size) + server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + logger.debug("didReceiveRead (central={},requestId={},offset={},characteristic=payload)", targetDevice, requestId, offset) + } + } + } +// else if (characteristic?.uuid === BLESensorConfiguration.serviceUUID) { +// //write here +// val peripheral = TracerApp.asPeripheralDevice() +// val readRequest = ReadRequestEncryptedPayload( +// System.currentTimeMillis() / 1000L, +// peripheral.modelP, +// TracerApp.thisDeviceMsg() +// ) +// val plainRecord = gson.toJson(readRequest) +// CentralLog.d(TAG, "onCharacteristicReadRequest plainRecord = $plainRecord") +// +// val plainRecordByteArray = plainRecord.toByteArray(Charsets.UTF_8) +// val remoteBlob = Encryption.encryptPayload(plainRecordByteArray) +// val base = readPayloadMap.getOrPut(device.address, { +// ReadRequestPayload( +// v = TracerApp.protocolVersion, +// msg = remoteBlob, +// org = TracerApp.ORG, +// modelP = null //This is going to be stored as empty in the db as DUMMY value +// ).getPayload() +// }) +// val value = base.copyOfRange(offset, base.size) +// server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, value) +// +// } + else { + logger.fault("didReceiveRead (central={},characteristic=unknown)", targetDevice) + server.get()?.sendResponse(device, requestId, BluetoothGatt.GATT_REQUEST_NOT_SUPPORTED, 0, null) + } + } + } + } + server.set(bluetoothManager.openGattServer(context, callback)) + logger.debug("startGattServer successful") + return server.get() + } + + //Here + private fun setGattService(logger: SensorLogger, context: Context, bluetoothGattServer: BluetoothGattServer?) { + logger.debug("setGattService") + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + if (bluetoothManager == null) { + logger.fault("Bluetooth unsupported") + return + } + if (bluetoothGattServer == null) { + logger.fault("Bluetooth LE advertiser unsupported") + return + } + for (device in bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)) { + bluetoothGattServer.cancelConnection(device) + } + for (device in bluetoothManager.getConnectedDevices(BluetoothProfile.GATT_SERVER)) { + bluetoothGattServer.cancelConnection(device) + } + bluetoothGattServer.clearServices() + val service = BluetoothGattService(BLESensorConfiguration.serviceUUID, BluetoothGattService.SERVICE_TYPE_PRIMARY) + val signalCharacteristic = BluetoothGattCharacteristic( + BLESensorConfiguration.androidSignalCharacteristicUUID, + BluetoothGattCharacteristic.PROPERTY_WRITE, + BluetoothGattCharacteristic.PERMISSION_WRITE) + signalCharacteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + val payloadCharacteristic = BluetoothGattCharacteristic( + BLESensorConfiguration.payloadCharacteristicUUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ) + val legacyPayloadCharacteristicUUID = BluetoothGattCharacteristic( + BLESensorConfiguration.legacyCovidsafePayloadCharacteristicUUID, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ) + service.addCharacteristic(signalCharacteristic) + service.addCharacteristic(payloadCharacteristic) + service.addCharacteristic(legacyPayloadCharacteristicUUID) + bluetoothGattServer.addService(service) + logger.debug("setGattService successful (service={},signalCharacteristic={},payloadCharacteristic={})", + service.uuid, signalCharacteristic.uuid, payloadCharacteristic.uuid) + } + + private fun onConnectionStateChangeStatusToString(state: Int): String { + + + return when (state) { + BluetoothProfile.STATE_CONNECTED -> "STATE_CONNECTED" + BluetoothProfile.STATE_CONNECTING -> "STATE_CONNECTING" + BluetoothProfile.STATE_DISCONNECTING -> "STATE_DISCONNECTING" + BluetoothProfile.STATE_DISCONNECTED -> "STATE_DISCONNECTED" + else -> "UNKNOWN_STATE_$state" + } + } + + private fun onStartFailureErrorCodeToString(errorCode: Int): String { + return when (errorCode) { + AdvertiseCallback.ADVERTISE_FAILED_DATA_TOO_LARGE -> "ADVERTISE_FAILED_DATA_TOO_LARGE" + AdvertiseCallback.ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS" + AdvertiseCallback.ADVERTISE_FAILED_ALREADY_STARTED -> "ADVERTISE_FAILED_ALREADY_STARTED" + AdvertiseCallback.ADVERTISE_FAILED_INTERNAL_ERROR -> "ADVERTISE_FAILED_INTERNAL_ERROR" + AdvertiseCallback.ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "ADVERTISE_FAILED_FEATURE_UNSUPPORTED" + else -> "UNKNOWN_ERROR_CODE_$errorCode" + } + } + } + + /** + * Transmitter starts automatically when Bluetooth is enabled. + */ + init { + BluetoothStateManager.delegates.add(this) + bluetoothStateManager(bluetoothStateManager.state()) + timer.add(AdvertLoopTask()) + } +} \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBluetoothStateManager.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBluetoothStateManager.java new file mode 100644 index 0000000..2968df8 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/ConcreteBluetoothStateManager.java @@ -0,0 +1,86 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble; + +import android.bluetooth.BluetoothAdapter; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; + +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger; +import au.gov.health.covidsafe.sensor.data.SensorLogger; +import au.gov.health.covidsafe.sensor.datatype.BluetoothState; + +/** + * Monitors bluetooth state changes. + */ +public class ConcreteBluetoothStateManager implements BluetoothStateManager { + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BLE.ConcreteBluetoothStateManager"); + private BluetoothState state; + private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (BluetoothAdapter.ACTION_STATE_CHANGED.equals(intent.getAction())) { + try { + final int nativeState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR); + logger.debug("Bluetooth state changed (nativeState={})", nativeState); + switch (nativeState) { + case BluetoothAdapter.STATE_ON: + logger.debug("Power ON"); + state = BluetoothState.poweredOn; + for (BluetoothStateManagerDelegate delegate : delegates) { + delegate.bluetoothStateManager(BluetoothState.poweredOn); + } + break; + case BluetoothAdapter.STATE_OFF: + logger.debug("Power OFF"); + state = BluetoothState.poweredOff; + for (BluetoothStateManagerDelegate delegate : delegates) { + delegate.bluetoothStateManager(BluetoothState.poweredOff); + } + break; + } + } catch (Throwable e) { + logger.fault("Bluetooth state change exception", e); + } + } + } + }; + + /** + * Monitors bluetooth state changes. + */ + public ConcreteBluetoothStateManager(Context context) { + state = state(); + + final IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED); + context.registerReceiver(broadcastReceiver, intentFilter); + } + + @Override + public BluetoothState state() { + if (state == null) { + final BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter == null) { + state = BluetoothState.unsupported; + return state; + } + switch (BluetoothAdapter.getDefaultAdapter().getState()) { + case BluetoothAdapter.STATE_ON: + state = BluetoothState.poweredOn; + break; + case BluetoothAdapter.STATE_OFF: + case BluetoothAdapter.STATE_TURNING_OFF: + case BluetoothAdapter.STATE_TURNING_ON: + default: + state = BluetoothState.poweredOff; + break; + } + } + return state; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertAppleManufacturerSegment.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertAppleManufacturerSegment.java new file mode 100644 index 0000000..a8eaa2c --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertAppleManufacturerSegment.java @@ -0,0 +1,26 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import au.gov.health.covidsafe.sensor.datatype.Data; + +public class BLEAdvertAppleManufacturerSegment { + public final int type; + public final int reportedLength; + public final byte[] data; // BIG ENDIAN (network order) AT THIS POINT + public final Data raw; + + public BLEAdvertAppleManufacturerSegment(int type, int reportedLength, byte[] dataBigEndian, Data raw) { + this.type = type; + this.reportedLength = reportedLength; + this.data = dataBigEndian; + this.raw = raw; + } + + @Override + public String toString() { + return raw.hexEncodedString(); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertManufacturerData.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertManufacturerData.java new file mode 100644 index 0000000..ceb8ce7 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertManufacturerData.java @@ -0,0 +1,28 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import au.gov.health.covidsafe.sensor.datatype.Data; + +public class BLEAdvertManufacturerData { + public final int manufacturer; + public final byte[] data; // BIG ENDIAN (network order) AT THIS POINT + public final Data raw; + + public BLEAdvertManufacturerData(final int manufacturer, final byte[] dataBigEndian, final Data raw) { + this.manufacturer = manufacturer; + this.data = dataBigEndian; + this.raw = raw; + } + + @Override + public String toString() { + return "BLEAdvertManufacturerData{" + + "manufacturer=" + manufacturer + + ", data=" + new Data(data).hexEncodedString() + + ", raw=" + raw.hexEncodedString() + + '}'; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertParser.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertParser.java new file mode 100644 index 0000000..46c651f --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertParser.java @@ -0,0 +1,162 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import au.gov.health.covidsafe.sensor.datatype.Data; +import au.gov.health.covidsafe.sensor.datatype.UInt8; + +import java.util.ArrayList; +import java.util.List; + +public class BLEAdvertParser { + public static BLEScanResponseData parseScanResponse(byte[] raw, int offset) { + // Multiple segments until end of binary data + return new BLEScanResponseData(raw.length - offset, extractSegments(raw, offset)); + } + + public static List extractSegments(byte[] raw, int offset) { + int position = offset; + ArrayList segments = new ArrayList(); + int segmentLength; + int segmentType; + byte[] segmentData; + Data rawData; + int c; + + while (position < raw.length) { + if ((position + 2) <= raw.length) { + segmentLength = (byte)raw[position++] & 0xff; + segmentType = (byte)raw[position++] & 0xff; + // Note: Unsupported types are handled as 'unknown' + // check reported length with actual remaining data length + if ((position + segmentLength - 1) <= raw.length) { + segmentData = subDataBigEndian(raw, position, segmentLength - 1); // Note: type IS INCLUDED in length + rawData = new Data(subDataBigEndian(raw, position - 2, segmentLength + 1)); + position += segmentLength - 1; + segments.add(new BLEAdvertSegment(BLEAdvertSegmentType.typeFor(segmentType), segmentLength - 1, segmentData, rawData)); + } else { + // error in data length - advance to end + position = raw.length; + } + } else { + // invalid segment - advance to end + position = raw.length; + } + } + + return segments; + } + + public static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } + + public static String binaryString(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%8s", Integer.toBinaryString(b & 0xFF)).replace(' ', '0')); + result.append(" "); + } + return result.toString(); + } + + public static byte[] subDataBigEndian(byte[] raw, int offset, int length) { + if (raw == null) { + return new byte[]{}; + } + if (offset < 0 || length <= 0) { + return new byte[]{}; + } + if (length + offset > raw.length) { + return new byte[]{}; + } + byte[] data = new byte[length]; + int position = offset; + for (int c = 0;c < length;c++) { + data[c] = raw[position++]; + } + return data; + } + + public static byte[] subDataLittleEndian(byte[] raw, int offset, int length) { + if (raw == null) { + return new byte[]{}; + } + if (offset < 0 || length <= 0) { + return new byte[]{}; + } + if (length + offset > raw.length) { + return new byte[]{}; + } + byte[] data = new byte[length]; + int position = offset + length - 1; + for (int c = 0;c < length;c++) { + data[c] = raw[position--]; + } + return data; + } + + public static Integer extractTxPower(List segments) { + // find the txPower code segment in the list + for (BLEAdvertSegment segment : segments) { + if (segment.type == BLEAdvertSegmentType.txPowerLevel) { + return (new UInt8((int)segment.data[0])).value; + } + } + return null; + } + + public static List extractManufacturerData(List segments) { + // find the manufacturerData code segment in the list + List manufacturerData = new ArrayList<>(); + for (BLEAdvertSegment segment : segments) { + if (segment.type == BLEAdvertSegmentType.manufacturerData) { + // Ensure that the data area is long enough + if (segment.data.length < 2) { + continue; // there may be a valid segment of same type... Happens for manufacturer data + } + // Create a manufacturer data segment + int intValue = ((segment.data[1]&0xff) << 8) | (segment.data[0]&0xff); + manufacturerData.add(new BLEAdvertManufacturerData(intValue,subDataBigEndian(segment.data,2,segment.dataLength - 2), segment.raw)); + } + } + return manufacturerData; + } + + public static List extractAppleManufacturerSegments(List manuData) { + final List appleSegments = new ArrayList<>(); + for (BLEAdvertManufacturerData manu : manuData) { + int bytePos = 0; + while (bytePos < manu.data.length) { + final byte type = manu.data[bytePos]; + final int typeValue = type & 0xFF; + // "01" marks legacy service UUID encoding without length data + if (type == 0x01) { + final int length = manu.data.length - bytePos - 1; + final Data data = new Data(subDataBigEndian(manu.data, bytePos + 1, length)); + final Data raw = new Data(subDataBigEndian(manu.data, bytePos, manu.data.length - bytePos)); + final BLEAdvertAppleManufacturerSegment segment = new BLEAdvertAppleManufacturerSegment(typeValue, length, data.value, raw); + appleSegments.add(segment); + bytePos = manu.data.length; + } + // Parse according to Type-Length-Data + else { + final int length = manu.data[bytePos + 1] & 0xFF; + final int maxLength = (length < manu.data.length - bytePos - 2 ? length : manu.data.length - bytePos - 2); + final Data data = new Data(subDataBigEndian(manu.data, bytePos + 2, maxLength)); + final Data raw = new Data(subDataBigEndian(manu.data, bytePos, maxLength + 2)); + final BLEAdvertAppleManufacturerSegment segment = new BLEAdvertAppleManufacturerSegment(typeValue, length, data.value, raw); + appleSegments.add(segment); + bytePos += (maxLength + 2); + } + } + } + return appleSegments; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertSegment.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertSegment.java new file mode 100644 index 0000000..fbb5401 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertSegment.java @@ -0,0 +1,31 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import au.gov.health.covidsafe.sensor.datatype.Data; + +public class BLEAdvertSegment { + public final BLEAdvertSegmentType type; + public final int dataLength; + public final byte[] data; // BIG ENDIAN (network order) AT THIS POINT + public final Data raw; + + public BLEAdvertSegment(BLEAdvertSegmentType type, int dataLength, byte[] data, Data raw) { + this.type = type; + this.dataLength = dataLength; + this.data = data; + this.raw = raw; + } + + @Override + public String toString() { + return "BLEAdvertSegment{" + + "type=" + type + + ", dataLength=" + dataLength + + ", data=" + new Data(data).hexEncodedString() + + ", raw=" + raw.hexEncodedString() + + '}'; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertSegmentType.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertSegmentType.java new file mode 100644 index 0000000..4bc1e4f --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEAdvertSegmentType.java @@ -0,0 +1,66 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import java.util.HashMap; +import java.util.Map; + +/// BLE Advert types - Note: We only list those we use in Herald for some reason +/// See https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ +public enum BLEAdvertSegmentType { + unknown("unknown", 0x00), // Valid - this number is not assigned + serviceUUID16IncompleteList("serviceUUID16IncompleteList", 0x02), + serviceUUID16CompleteList("serviceUUID16CompleteList", 0x03), + serviceUUID32IncompleteList("serviceUUID32IncompleteList", 0x04), + serviceUUID32CompleteList("serviceUUID32CompleteList", 0x05), + serviceUUID128IncompleteList("serviceUUID128IncompleteList", 0x06), + serviceUUID128CompleteList("serviceUUID128CompleteList", 0x07), + deviceNameShortened("deviceNameShortened", 0x08), + deviceNameComplete("deviceNameComplete", 0x09), + txPowerLevel("txPower",0x0A), + deviceClass("deviceClass",0x0D), + simplePairingHash("simplePairingHash",0x0E), + simplePairingRandomiser("simplePairingRandomiser",0x0F), + deviceID("deviceID",0x10), + meshMessage("meshMessage",0x2A), + meshBeacon("meshBeacon",0x2B), + bigInfo("bigInfo",0x2C), + broadcastCode("broadcastCode",0x2D), + manufacturerData("manufacturerData", 0xFF) + ; + + private static final Map BY_LABEL = new HashMap<>(); + private static final Map BY_CODE = new HashMap<>(); + static { + for (BLEAdvertSegmentType e : values()) { + BY_LABEL.put(e.label, e); + BY_CODE.put(e.code, e); + } + } + + public final String label; + public final int code; + + private BLEAdvertSegmentType(String label, int code) { + this.label = label; + this.code = code; + } + + public static BLEAdvertSegmentType typeFor(int code) { + BLEAdvertSegmentType type = BY_CODE.get(code); + if (null == type) { + return BY_LABEL.get("unknown"); + } + return type; + } + + public static BLEAdvertSegmentType typeFor(String commonName) { + BLEAdvertSegmentType type = BY_LABEL.get(commonName); + if (null == type) { + return BY_LABEL.get("unknown"); + } + return type; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEDeviceFilter.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEDeviceFilter.java new file mode 100644 index 0000000..907f306 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEDeviceFilter.java @@ -0,0 +1,196 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import android.bluetooth.le.ScanRecord; + +import au.gov.health.covidsafe.sensor.ble.BLEDevice; +import au.gov.health.covidsafe.sensor.ble.BLESensorConfiguration; +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger; +import au.gov.health.covidsafe.sensor.data.SensorLogger; +import au.gov.health.covidsafe.sensor.datatype.Data; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// Device filter for avoiding connection to devices that definitely cannot +/// host sensor services. +public class BLEDeviceFilter { + private final static SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BLE.BLEDeviceFilter"); + private final List filterPatterns; + private final Map samples = new HashMap<>(); + + // Counter for training samples + private final static class ShouldIgnore { + public long yes = 0; + public long no = 0; + } + + // Pattern for filtering device based on message content + public final static class FilterPattern { + public final String regularExpression; + public final Pattern pattern; + public FilterPattern(final String regularExpression, final Pattern pattern) { + this.regularExpression = regularExpression; + this.pattern = pattern; + } + } + + // Match of a filter pattern + public final static class MatchingPattern { + public final FilterPattern filterPattern; + public final String message; + public MatchingPattern(FilterPattern filterPattern, String message) { + this.filterPattern = filterPattern; + this.message = message; + } + } + + /// BLE device filter for matching devices against filters defined + /// in BLESensorConfiguration.deviceFilterFeaturePatterns. + public BLEDeviceFilter() { + this(BLESensorConfiguration.deviceFilterFeaturePatterns); + } + + /// BLE device filter for matching devices against the given set of patterns + /// and writing advert data to file for analysis. + public BLEDeviceFilter(final String[] patterns) { + if (patterns == null || patterns.length == 0) { + filterPatterns = null; + } else { + filterPatterns = compilePatterns(patterns); + } + } + + // MARK:- Pattern matching functions + // Using regular expression over hex representation of feature data for maximum flexibility and usability + + /// Match message against all patterns in sequential order, returns matching pattern or null + protected static FilterPattern match(final List filterPatterns, final String message) { + final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BLE.BLEDeviceFilter"); + if (message == null) { + return null; + } + for (final FilterPattern filterPattern : filterPatterns) { + try { + final Matcher matcher = filterPattern.pattern.matcher(message); + if (matcher.find()) { + return filterPattern; + } + } catch (Throwable e) { + } + } + return null; + } + + /// Compile regular expressions into patterns. + protected static List compilePatterns(final String[] regularExpressions) { + final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BLE.BLEDeviceFilter"); + final List filterPatterns = new ArrayList<>(regularExpressions.length); + for (final String regularExpression : regularExpressions) { + try { + final Pattern pattern = Pattern.compile(regularExpression, Pattern.CASE_INSENSITIVE); + final FilterPattern filterPattern = new FilterPattern(regularExpression, pattern); + filterPatterns.add(filterPattern); + } catch (Throwable e) { + logger.fault("compilePatterns, invalid filter pattern (regularExpression={})", regularExpression); + } + } + return filterPatterns; + } + + /// Extract messages from manufacturer specific data + protected final static List extractMessages(final byte[] rawScanRecordData) { + // Parse raw scan record data in scan response data + if (rawScanRecordData == null || rawScanRecordData.length == 0) { + return null; + } + final BLEScanResponseData bleScanResponseData = BLEAdvertParser.parseScanResponse(rawScanRecordData, 0); + // Parse scan response data into manufacturer specific data + if (bleScanResponseData == null || bleScanResponseData.segments == null || bleScanResponseData.segments.isEmpty()) { + return null; + } + final List bleAdvertManufacturerDataList = BLEAdvertParser.extractManufacturerData(bleScanResponseData.segments); + // Parse manufacturer specific data into messages + if (bleAdvertManufacturerDataList == null || bleAdvertManufacturerDataList.isEmpty()) { + return null; + } + final List bleAdvertAppleManufacturerSegments = BLEAdvertParser.extractAppleManufacturerSegments(bleAdvertManufacturerDataList); + // Convert segments to messages + if (bleAdvertAppleManufacturerSegments == null || bleAdvertAppleManufacturerSegments.isEmpty()) { + return null; + } + final List messages = new ArrayList<>(bleAdvertAppleManufacturerSegments.size()); + for (BLEAdvertAppleManufacturerSegment segment : bleAdvertAppleManufacturerSegments) { + if (segment.raw != null && segment.raw.value.length > 0) { + messages.add(segment.raw); + } + } + return messages; + } + + // MARK:- Filtering functions + + /// Extract feature data from scan record + private List extractFeatures(final ScanRecord scanRecord) { + if (scanRecord == null) { + return null; + } + // Get message data + final List featureList = new ArrayList<>(); + final List messages = extractMessages(scanRecord.getBytes()); + if (messages != null) { + featureList.addAll(messages); + } + return featureList; + } + + /// Match filter patterns against data items, returning the first match + protected final static MatchingPattern match(final List patternList, final Data rawData) { + // No pattern to match against + if (patternList == null || patternList.isEmpty()) { + return null; + } + // Empty raw data + if (rawData == null || rawData.value.length == 0) { + return null; + } + // Extract messages + final List messages = extractMessages(rawData.value); + if (messages == null || messages.isEmpty()) { + return null; + } + for (Data message : messages) { + final String hexEncodedString = message.hexEncodedString(); + final FilterPattern pattern = match(patternList, hexEncodedString); + if (pattern != null) { + return new MatchingPattern(pattern, hexEncodedString); + } + } + return null; + } + + /// Match scan record messages against all registered patterns, returns matching pattern or null. + public MatchingPattern match(final BLEDevice device) { + try { + final ScanRecord scanRecord = device.scanRecord(); + // Cannot match device without any scan record data + if (scanRecord == null) { + return null; + } + final Data rawData = new Data(scanRecord.getBytes()); + return match(filterPatterns, rawData); + } catch (Throwable e) { + // Errors are expected to be common place due to corrupted or malformed advert data + return null; + } + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEScanResponseData.java b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEScanResponseData.java new file mode 100644 index 0000000..f266607 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/ble/filter/BLEScanResponseData.java @@ -0,0 +1,22 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.ble.filter; + +import java.util.List; + +public class BLEScanResponseData { + public int dataLength; + public List segments; + + public BLEScanResponseData(int dataLength, List segments) { + this.dataLength = dataLength; + this.segments = segments; + } + + @Override + public String toString() { + return segments.toString(); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/BatteryLog.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/BatteryLog.java new file mode 100644 index 0000000..99c91f6 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/BatteryLog.java @@ -0,0 +1,67 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; + +import au.gov.health.covidsafe.sensor.datatype.TimeInterval; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/// Battery log CSV is for debug purposes. This will be removed in the production build. +public class BatteryLog { + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "BatteryLog"); + private final static SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private final static TimeInterval updateInterval = TimeInterval.seconds(30); + private final Context context; + private final TextFile textFile; + + public BatteryLog(final Context context, final String filename) { + this.context = context; + textFile = new TextFile(context, filename); + if (textFile.empty()) { + textFile.write("time,source,level"); + } + new Thread(new Runnable() { + @Override + public void run() { + while (true) { + try { + update(); + } catch (Throwable e) { + logger.fault("Update failed", e); + } + try { + Thread.sleep(updateInterval.millis()); + } catch (Throwable e) { + logger.fault("Timer interrupted", e); + } + } + } + }).start(); + } + + private void update() { + final IntentFilter intentFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + final Intent batteryStatus = context.registerReceiver(null, intentFilter); + if (batteryStatus == null) { + return; + } + final int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + final boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL; + final int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + final int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + final float batteryLevel = level * 100 / (float) scale; + + final String powerSource = (isCharging ? "external" : "battery"); + final String timestamp = dateFormatter.format(new Date()); + textFile.write(timestamp + "," + powerSource + "," + batteryLevel); + logger.debug("update (powerSource={},batteryLevel={})", powerSource, batteryLevel); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/ConcreteSensorLogger.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/ConcreteSensorLogger.java new file mode 100644 index 0000000..0f0cb25 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/ConcreteSensorLogger.java @@ -0,0 +1,149 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +import android.content.Context; +import android.util.Log; + +import au.gov.health.covidsafe.BuildConfig; +import au.gov.health.covidsafe.sensor.ble.BLESensorConfiguration; + +import java.text.SimpleDateFormat; +import java.util.Date; + +/// Concrete Sensor log is for debug purposes. This will be removed in the production build. +public class ConcreteSensorLogger implements SensorLogger { + private final String subsystem, category; + private final static SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private static Context context; + private static TextFile logFile; + + public ConcreteSensorLogger(String subsystem, String category) { + this.subsystem = subsystem; + this.category = category; + } + + public static void context(final Context context) { + if (context != null && context != ConcreteSensorLogger.context) { + ConcreteSensorLogger.context = context; + if (BuildConfig.DEBUG) { + logFile = new TextFile(context, "log.txt"); + } + } + } + + private boolean suppress(SensorLoggerLevel level) { + switch (level) { + case debug: + return (BLESensorConfiguration.logLevel == SensorLoggerLevel.info || BLESensorConfiguration.logLevel == SensorLoggerLevel.fault); + case info: + return (BLESensorConfiguration.logLevel == SensorLoggerLevel.fault); + default: + return false; + } + } + + private void log(SensorLoggerLevel level, String message, final Object... values) { + if (!suppress(level)) { + outputLog(level, tag(subsystem, category), message, values); + outputStream(level, subsystem, category, message, values); + } + } + + public void debug(String message, final Object... values) { + log(SensorLoggerLevel.debug, message, values); + } + + public void info(String message, final Object... values) { + log(SensorLoggerLevel.info, message, values); + } + + public void fault(String message, final Object... values) { + log(SensorLoggerLevel.fault, message, values); + } + + private static String tag(String subsystem, String category) { + return subsystem + "::" + category; + } + + private static void outputLog(final SensorLoggerLevel level, final String tag, final String message, final Object... values) { + final Throwable throwable = getThrowable(values); + switch (level) { + case debug: { + if (throwable == null) { + Log.d(tag, render(message, values)); + } else { + Log.d(tag, render(message, values), throwable); + } + break; + } + case info: { + if (throwable == null) { + Log.i(tag, render(message, values)); + } else { + Log.i(tag, render(message, values), throwable); + } + break; + } + case fault: { + if (throwable == null) { + Log.w(tag, render(message, values)); + } else { + Log.w(tag, render(message, values), throwable); + } + break; + } + } + } + + private static void outputStream(final SensorLoggerLevel level, final String subsystem, final String category, final String message, final Object... values) { + if (logFile == null) { + return; + } + final String timestamp = dateFormatter.format(new Date()); + final String csvMessage = render(message, values).replace('\"', '\''); + final String quotedMessage = (message.contains(",") ? "\"" + csvMessage + "\"" : csvMessage); + final String entry = timestamp + "," + level + "," + subsystem + "," + category + "," + quotedMessage; + logFile.write(entry); + } + + + private static Throwable getThrowable(final Object... values) { + if (values.length > 0 && values[values.length - 1] instanceof Throwable) { + return (Throwable) values[values.length - 1]; + } else { + return null; + } + } + + private static String render(final String message, final Object... values) { + if (values.length == 0) { + return message; + } else { + final StringBuilder stringBuilder = new StringBuilder(); + + int valueIndex = 0; + int start = 0; + int end = message.indexOf("{}"); + while (end > 0) { + stringBuilder.append(message.substring(start, end)); + if (values.length > valueIndex) { + if (values[valueIndex] == null) { + stringBuilder.append("NULL"); + } else { + stringBuilder.append(values[valueIndex].toString()); + } + } + valueIndex++; + start = end + 2; + end = message.indexOf("{}", start); + } + stringBuilder.append(message.substring(start)); + + return stringBuilder.toString(); + } + } + +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/ContactLog.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/ContactLog.java new file mode 100644 index 0000000..80216e2 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/ContactLog.java @@ -0,0 +1,69 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +import android.content.Context; + +import au.gov.health.covidsafe.sensor.DefaultSensorDelegate; +import au.gov.health.covidsafe.sensor.datatype.Location; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.Proximity; +import au.gov.health.covidsafe.sensor.datatype.SensorType; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +/// Contact log CSV is for debug purposes. This will be removed in the production build. +public class ContactLog extends DefaultSensorDelegate { + private final static SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private final TextFile textFile; + + public ContactLog(final Context context, final String filename) { + textFile = new TextFile(context, filename); + if (textFile.empty()) { + textFile.write("time,sensor,id,detect,read,measure,share,visit,data"); + } + } + + private String timestamp() { + return dateFormatter.format(new Date()); + } + + private String csv(String value) { + return TextFile.csv(value); + } + + // MARK:- SensorDelegate + + @Override + public void sensor(SensorType sensor, TargetIdentifier didDetect) { + textFile.write(timestamp() + "," + sensor.name() + "," + csv(didDetect.value) + ",1,,,,,"); + } + + @Override + public void sensor(SensorType sensor, PayloadData didRead, TargetIdentifier fromTarget) { + textFile.write(timestamp() + "," + sensor.name() + "," + csv(fromTarget.value) + ",,2,,,," + csv(didRead.shortName())); + } + + @Override + public void sensor(SensorType sensor, List didShare, TargetIdentifier fromTarget) { + final String prefix = timestamp() + "," + sensor.name() + "," + csv(fromTarget.value); + for (PayloadData payloadData : didShare) { + textFile.write(prefix + ",,,,4,," + csv(payloadData.shortName())); + } + } + + @Override + public void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget) { + textFile.write(timestamp() + "," + sensor.name() + "," + csv(fromTarget.value) + ",,,3,,," + csv(didMeasure.description())); + } + + @Override + public void sensor(SensorType sensor, Location didVisit) { + textFile.write(timestamp() + "," + sensor.name() + ",,,,,,5," + csv(didVisit.description())); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/DetectionLog.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/DetectionLog.java new file mode 100644 index 0000000..9bd0d4b --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/DetectionLog.java @@ -0,0 +1,87 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +import android.content.Context; + +import au.gov.health.covidsafe.sensor.DefaultSensorDelegate; +import au.gov.health.covidsafe.sensor.ble.BLEDevice; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.Proximity; +import au.gov.health.covidsafe.sensor.datatype.SensorType; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/// Detection log CSV is for debug purposes. This will be removed in the production build. +public class DetectionLog extends DefaultSensorDelegate { + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "Data.DetectionLog"); + private final TextFile textFile; + private final PayloadData payloadData; + private final String deviceName = android.os.Build.MODEL; + private final String deviceOS = Integer.toString(android.os.Build.VERSION.SDK_INT); + private final Map payloads = new ConcurrentHashMap<>(); + + public DetectionLog(final Context context, final String filename, final PayloadData payloadData) { + textFile = new TextFile(context, filename); + this.payloadData = payloadData; + write(); + } + + private String csv(String value) { + return TextFile.csv(value); + } + + private void write() { + final StringBuilder content = new StringBuilder(); + content.append(csv(deviceName)); + content.append(','); + content.append("Android"); + content.append(','); + content.append(csv(deviceOS)); + content.append(','); + content.append(csv(payloadData.shortName())); + final List payloadList = new ArrayList<>(payloads.size()); + for (String payload : payloads.keySet()) { + if (payload.equals(payloadData.shortName())) { + continue; + } + payloadList.add(payload); + } + Collections.sort(payloadList); + for (String payload : payloadList) { + content.append(','); + content.append(payload); + } + logger.debug("write (content={})", content.toString()); + content.append("\n"); + textFile.overwrite(content.toString()); + } + + + // MARK:- SensorDelegate + + @Override + public void sensor(SensorType sensor, PayloadData didRead, TargetIdentifier fromTarget) { + if (payloads.put(didRead.shortName(), fromTarget.value) == null) { + logger.debug("didRead (payload={})", payloadData.shortName()); + write(); + } + } + + @Override + public void sensor(SensorType sensor, List didShare, TargetIdentifier fromTarget) { + for (PayloadData payloadData : didShare) { + if (payloads.put(payloadData.shortName(), fromTarget.value) == null) { + logger.debug("didShare (payload={})", payloadData.shortName()); + write(); + } + } + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/SensorLogger.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/SensorLogger.java new file mode 100644 index 0000000..bd519fc --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/SensorLogger.java @@ -0,0 +1,14 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +public interface SensorLogger { + + void debug(String message, final Object... values); + + void info(String message, final Object... values); + + void fault(String message, final Object... values); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/SensorLoggerLevel.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/SensorLoggerLevel.java new file mode 100644 index 0000000..a043277 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/SensorLoggerLevel.java @@ -0,0 +1,9 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +public enum SensorLoggerLevel { + debug, info, fault +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/StatisticsLog.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/StatisticsLog.java new file mode 100644 index 0000000..50c3cd3 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/StatisticsLog.java @@ -0,0 +1,116 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +import android.content.Context; + +import au.gov.health.covidsafe.sensor.DefaultSensorDelegate; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.Proximity; +import au.gov.health.covidsafe.sensor.datatype.Sample; +import au.gov.health.covidsafe.sensor.datatype.SensorType; +import au.gov.health.covidsafe.sensor.datatype.TargetIdentifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/// Statistics log CSV is for debug purposes. This will be removed in the production build. +public class StatisticsLog extends DefaultSensorDelegate { + private final TextFile textFile; + private final PayloadData payloadData; + private final Map identifierToPayload = new ConcurrentHashMap<>(); + private final Map payloadToTime = new ConcurrentHashMap<>(); + private final Map payloadToSample = new ConcurrentHashMap<>(); + + public StatisticsLog(final Context context, final String filename, final PayloadData payloadData) { + textFile = new TextFile(context, filename); + this.payloadData = payloadData; + } + + private String csv(String value) { + return TextFile.csv(value); + } + + private void add(TargetIdentifier identifier) { + final String payload = identifierToPayload.get(identifier); + if (payload == null) { + return; + } + add(payload); + } + + private void add(String payload) { + final Date time = payloadToTime.get(payload); + final Sample sample = payloadToSample.get(payload); + if (time == null || sample == null) { + payloadToTime.put(payload, new Date()); + payloadToSample.put(payload, new Sample()); + return; + } + final Date now = new Date(); + payloadToTime.put(payload, now); + sample.add((now.getTime() - time.getTime()) / 1000d); + write(); + } + + private void write() { + final StringBuilder content = new StringBuilder("payload,count,mean,sd,min,max\n"); + final List payloadList = new ArrayList<>(); + for (String payload : payloadToSample.keySet()) { + if (payload.equals(payloadData.shortName())) { + continue; + } + payloadList.add(payload); + } + Collections.sort(payloadList); + for (String payload : payloadList) { + final Sample sample = payloadToSample.get(payload); + if (sample == null) { + continue; + } + if (sample.mean() == null || sample.standardDeviation() == null || sample.min() == null || sample.max() == null) { + continue; + } + content.append(csv(payload)); + content.append(','); + content.append(sample.count()); + content.append(','); + content.append(sample.mean()); + content.append(','); + content.append(sample.standardDeviation()); + content.append(','); + content.append(sample.min()); + content.append(','); + content.append(sample.max()); + content.append('\n'); + } + textFile.overwrite(content.toString()); + } + + + // MARK:- SensorDelegate + + @Override + public void sensor(SensorType sensor, PayloadData didRead, TargetIdentifier fromTarget) { + identifierToPayload.put(fromTarget, didRead.shortName()); + add(fromTarget); + } + + @Override + public void sensor(SensorType sensor, Proximity didMeasure, TargetIdentifier fromTarget) { + add(fromTarget); + } + + @Override + public void sensor(SensorType sensor, List didShare, TargetIdentifier fromTarget) { + for (PayloadData payload : didShare) { + add(payload.shortName()); + } + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/data/TextFile.java b/app/src/main/java/au/gov/health/covidsafe/sensor/data/TextFile.java new file mode 100644 index 0000000..07688e2 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/data/TextFile.java @@ -0,0 +1,91 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.data; + +import android.content.Context; +import android.media.MediaScannerConnection; +import android.os.Environment; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class TextFile { + private final SensorLogger logger = new ConcreteSensorLogger("Sensor", "Data.TextFile"); + private final File file; + + public TextFile(final Context context, final String filename) { + final File folder = new File(getRootFolder(context), "Sensor"); + if (!folder.exists()) { + if (!folder.mkdirs()) { + logger.fault("Make folder failed (folder={})", folder); + } + } + file = new File(folder, filename); + final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1); + executorService.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null); + } + }, 30, 30, TimeUnit.SECONDS); + } + + /** + * Get root folder for SD card or emulated external storage. + * + * @param context Application context. + * @return Root folder. + */ + private static File getRootFolder(final Context context) { + // Get SD card or emulated external storage. By convention (really!?) + // SD card is reported after emulated storage, so select the last folder + final File[] externalMediaDirs = context.getExternalMediaDirs(); + if (externalMediaDirs.length > 0) { + return externalMediaDirs[externalMediaDirs.length - 1]; + } else { + return Environment.getExternalStorageDirectory(); + } + } + + public synchronized boolean empty() { + return !file.exists() || file.length() == 0; + } + + /// Append line to new or existing file + public synchronized void write(String line) { + try { + final FileOutputStream fileOutputStream = new FileOutputStream(file, true); + fileOutputStream.write((line + "\n").getBytes()); + fileOutputStream.flush(); + fileOutputStream.close(); + } catch (Throwable e) { + logger.fault("write failed (file={})", file, e); + } + } + + /// Overwrite file content + public synchronized void overwrite(String content) { + try { + final FileOutputStream fileOutputStream = new FileOutputStream(file); + fileOutputStream.write(content.getBytes()); + fileOutputStream.flush(); + fileOutputStream.close(); + } catch (Throwable e) { + logger.fault("overwrite failed (file={})", file, e); + } + } + + /// Quote value for CSV output if required. + public static String csv(String value) { + if (value.contains(",") || value.contains("\"") || value.contains("'") || value.contains("’")) { + return "\"" + value + "\""; + } else { + return value; + } + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Base64.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Base64.java new file mode 100644 index 0000000..293d1f1 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Base64.java @@ -0,0 +1,96 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import java.io.ByteArrayOutputStream; + +/// Base64 encoding and decoding without relying on Android API 26+ +public class Base64 { + private final static char[] encodeTable = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', + 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'}; + + private final static int[] decodeTable = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, + 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, + 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, + 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, + 48, 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}; + + public static String encode(byte[] data) { + final StringBuilder buffer = new StringBuilder(); + int pad = 0; + for (int i = 0; i < data.length; i += 3) { + int b = ((data[i] & 0xFF) << 16) & 0xFFFFFF; + if (i + 1 < data.length) { + b |= (data[i + 1] & 0xFF) << 8; + } else { + pad++; + } + if (i + 2 < data.length) { + b |= (data[i + 2] & 0xFF); + } else { + pad++; + } + for (int j = 0; j < 4 - pad; j++) { + int c = (b & 0xFC0000) >> 18; + buffer.append(encodeTable[c]); + b <<= 6; + } + } + for (int j = 0; j < pad; j++) { + buffer.append("="); + } + return buffer.toString(); + } + + public static byte[] decode(String data) { + final byte[] bytes = data.getBytes(); + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + for (int i = 0; i < bytes.length; ) { + int b = 0; + if (decodeTable[bytes[i]] != -1) { + b = (decodeTable[bytes[i]] & 0xFF) << 18; + } else { + // skip unknown characters + i++; + continue; + } + int num = 0; + if (i + 1 < bytes.length && decodeTable[bytes[i + 1]] != -1) { + b = b | ((decodeTable[bytes[i + 1]] & 0xFF) << 12); + num++; + } + if (i + 2 < bytes.length && decodeTable[bytes[i + 2]] != -1) { + b = b | ((decodeTable[bytes[i + 2]] & 0xFF) << 6); + num++; + } + if (i + 3 < bytes.length && decodeTable[bytes[i + 3]] != -1) { + b = b | (decodeTable[bytes[i + 3]] & 0xFF); + num++; + } + while (num > 0) { + int c = (b & 0xFF0000) >> 16; + buffer.write((char) c); + b <<= 8; + num--; + } + i += 4; + } + return buffer.toByteArray(); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/BluetoothState.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/BluetoothState.java new file mode 100644 index 0000000..52a2856 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/BluetoothState.java @@ -0,0 +1,9 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +public enum BluetoothState { + unsupported, poweredOn, poweredOff, resetting +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Callback.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Callback.java new file mode 100644 index 0000000..b896749 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Callback.java @@ -0,0 +1,10 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Generic callback function +public interface Callback { + void accept(T value); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Data.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Data.java new file mode 100644 index 0000000..d2d7f8a --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Data.java @@ -0,0 +1,117 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import java.util.Arrays; + +/// Raw byte array data +public class Data { + private final static char[] hexChars = "0123456789ABCDEF".toCharArray(); + public byte[] value = null; + + public Data() { + this(new byte[0]); + } + + public Data(byte[] value) { + this.value = value; + } + + public Data(final Data data) { + final byte[] value = new byte[data.value.length]; + System.arraycopy(data.value, 0, value, 0, data.value.length); + this.value = value; + } + + public Data(byte repeating, int count) { + this.value = new byte[count]; + for (int i=count; i-->0;) { + this.value[i] = repeating; + } + } + + public Data(String base64EncodedString) { + this.value = Base64.decode(base64EncodedString); + } + + public String base64EncodedString() { + return Base64.encode(value); + } + + public String hexEncodedString() { + if (value == null) { + return ""; + } + final StringBuilder stringBuilder = new StringBuilder(value.length * 2); + for (int i = 0; i < value.length; i++) { + final int v = value[i] & 0xFF; + stringBuilder.append(hexChars[v >>> 4]); + stringBuilder.append(hexChars[v & 0x0F]); + } + return stringBuilder.toString(); + } + + public final static Data fromHexEncodedString(String hexEncodedString) { + final int length = hexEncodedString.length(); + final byte[] value = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + value[i / 2] = (byte) ((Character.digit(hexEncodedString.charAt(i), 16) << 4) + + Character.digit(hexEncodedString.charAt(i+1), 16)); + } + return new Data(value); + } + + public String description() { + return base64EncodedString(); + } + + /// Get subdata from offset to end + public Data subdata(int offset) { + if (offset < value.length) { + final byte[] offsetValue = new byte[value.length - offset]; + System.arraycopy(value, offset, offsetValue, 0, offsetValue.length); + return new Data(offsetValue); + } else { + return null; + } + } + + /// Get subdata from offset to offset + length + public Data subdata(int offset, int length) { + if (offset + length <= value.length) { + final byte[] offsetValue = new byte[length]; + System.arraycopy(value, offset, offsetValue, 0, length); + return new Data(offsetValue); + } else { + return null; + } + } + + /// Append data to end of this data. + public void append(Data data) { + final byte[] concatenated = new byte[value.length + data.value.length]; + System.arraycopy(value, 0, concatenated, 0, value.length); + System.arraycopy(data.value, 0, concatenated, value.length, data.value.length); + value = concatenated; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Data data = (Data) o; + return Arrays.equals(value, data.value); + } + + @Override + public int hashCode() { + return Arrays.hashCode(value); + } + + @Override + public String toString() { + return hexEncodedString(); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Location.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Location.java new file mode 100644 index 0000000..90eed20 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Location.java @@ -0,0 +1,26 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import java.util.Date; + +/// Raw location data for estimating indirect exposure +public class Location { + /// Measurement values, e.g. GPS coordinates in comma separated string format for latitude and longitude + public final LocationReference value; + /// Time spent at location. + public final Date start, end; + + public Location(LocationReference value, Date start, Date end) { + this.value = value; + this.start = start; + this.end = end; + } + + /// Get plain text description of proximity data + public String description() { + return value.description() + ":[from=" + start + ",to=" + end + "]"; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/LocationReference.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/LocationReference.java new file mode 100644 index 0000000..2b1526d --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/LocationReference.java @@ -0,0 +1,10 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Raw location data for estimating indirect exposure, e.g. WGS84 coordinates +public interface LocationReference { + String description(); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadData.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadData.java new file mode 100644 index 0000000..86c8764 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadData.java @@ -0,0 +1,41 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Encrypted payload data received from target. This is likely to be an encrypted datagram of the target's actual permanent identifier. +public class PayloadData extends Data { + public PayloadData(byte[] value) { + super(value); + } + + public PayloadData(String base64EncodedString) { + super(base64EncodedString); + } + + public PayloadData(byte repeating, int count) { + super(repeating, count); + } + + public PayloadData() { + this(new byte[0]); + } + + public String shortName() { + if (value.length == 0) { + return ""; + } + if (!(value.length > 3)) { + return Base64.encode(value); + } + final Data subdata = subdata(3, value.length - 3); + final byte[] suffix = (subdata == null || subdata.value == null ? new byte[0] : subdata.value); + final String base64EncodedString = Base64.encode(suffix); + return base64EncodedString.substring(0, Math.min(6, base64EncodedString.length())); + } + + public String toString() { + return shortName(); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadSharingData.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadSharingData.java new file mode 100644 index 0000000..2bedc8b --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadSharingData.java @@ -0,0 +1,21 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +public class PayloadSharingData { + public final RSSI rssi; + public final Data data; + + /** + * Payload sharing data + * + * @param rssi RSSI between self and peer. + * @param data Payload data of devices being shared by self to peer. + */ + public PayloadSharingData(final RSSI rssi, final Data data) { + this.rssi = rssi; + this.data = data; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadTimestamp.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadTimestamp.java new file mode 100644 index 0000000..436cf58 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PayloadTimestamp.java @@ -0,0 +1,20 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import java.util.Date; + +/// Payload timestamp, should normally be Date, but it may change to UInt64 in the future to use server synchronised relative timestamp. +public class PayloadTimestamp { + public final Date value; + + public PayloadTimestamp(Date value) { + this.value = value; + } + + public PayloadTimestamp() { + this(new Date()); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PlacenameLocationReference.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PlacenameLocationReference.java new file mode 100644 index 0000000..84ed5aa --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PlacenameLocationReference.java @@ -0,0 +1,18 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Free text place name. +public class PlacenameLocationReference implements LocationReference { + public final String name; + + public PlacenameLocationReference(String name) { + this.name = name; + } + + public String description() { + return "PLACE(name=" + name + ")"; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Proximity.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Proximity.java new file mode 100644 index 0000000..7d44782 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Proximity.java @@ -0,0 +1,31 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import androidx.annotation.NonNull; + +/// Raw data for estimating proximity between sensor and target, e.g. RSSI for BLE. +public class Proximity { + /// Unit of measurement, e.g. RSSI + public final ProximityMeasurementUnit unit; + /// Measured value, e.g. raw RSSI value. + public final Double value; + + public Proximity(ProximityMeasurementUnit unit, Double value) { + this.unit = unit; + this.value = value; + } + + /// Get plain text description of proximity data + public String description() { + return unit + ":" + value; + } + + @NonNull + @Override + public String toString() { + return description(); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/ProximityMeasurementUnit.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/ProximityMeasurementUnit.java new file mode 100644 index 0000000..290508a --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/ProximityMeasurementUnit.java @@ -0,0 +1,13 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Measurement unit for interpreting the proximity data values. +public enum ProximityMeasurementUnit { + /// Received signal strength indicator, e.g. BLE signal strength as proximity estimator. + RSSI, + /// Roundtrip time, e.g. Audio signal echo time duration as proximity estimator. + RTT +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PseudoDeviceAddress.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PseudoDeviceAddress.java new file mode 100644 index 0000000..051aa90 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/PseudoDeviceAddress.java @@ -0,0 +1,57 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import android.util.Base64; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Objects; + +/// Pseudo device address to enable caching of device payload without relying on device mac address +// that may change frequently like the A10 and A20. +public class PseudoDeviceAddress { + public final long address; + public final byte[] data; + + public PseudoDeviceAddress() { + // Bluetooth device address is 48-bit (6 bytes), using + // the same length to offer the same collision avoidance + address = Math.round(Math.random() * Math.pow(2, 48)); + final ByteBuffer byteBuffer = ByteBuffer.allocate(8); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + byteBuffer.putLong(0, address); + // Only taking last 6 bytes as that is the maximum value + data = new byte[6]; + System.arraycopy(byteBuffer.array(), 2, data, 0, data.length); + } + + public PseudoDeviceAddress(final byte[] data) { + this.data = data; + final ByteBuffer byteBuffer = ByteBuffer.allocate(8); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + byteBuffer.position(2); + byteBuffer.put(data); + this.address = byteBuffer.getLong(0); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PseudoDeviceAddress that = (PseudoDeviceAddress) o; + return address == that.address; + } + + @Override + public int hashCode() { + return Objects.hash(address); + } + + @Override + public String toString() { + return Base64.encodeToString(data, Base64.DEFAULT | Base64.NO_WRAP); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/RSSI.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/RSSI.java new file mode 100644 index 0000000..2156b6f --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/RSSI.java @@ -0,0 +1,34 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// RSSI in dBm. +public class RSSI { + public final int value; + + public RSSI(int value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + RSSI rssi = (RSSI) o; + return value == rssi.value; + } + + @Override + public int hashCode() { + return value; + } + + @Override + public String toString() { + return "RSSI{" + + "value=" + value + + '}'; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Sample.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Sample.java new file mode 100644 index 0000000..67ec750 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Sample.java @@ -0,0 +1,74 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +public class Sample { + protected long n; + protected double m1, m2, m3, m4; + private double min = Double.MAX_VALUE, max = -Double.MAX_VALUE; + + public Sample() { + } + + public synchronized void add(final double x) { + final long n1 = n; + n++; + final double delta = x - m1; + final double delta_n = delta / n; + final double delta_n2 = delta_n * delta_n; + final double term1 = delta * delta_n * n1; + m1 += delta_n; + m4 += term1 * delta_n2 * (n * n - 3 * n + 3) + 6 * delta_n2 * m2 - 4 * delta_n * m3; + m3 += term1 * delta_n * (n - 2) - 3 * delta_n * m2; + m2 += term1; + if (x < min) { + min = x; + } + if (x > max) { + max = x; + } + } + + public long count() { + return n; + } + + public Double mean() { + if (n > 0) { + return m1; + } else { + return null; + } + } + + public Double standardDeviation() { + if (n > 1) { + return StrictMath.sqrt(m2 / (n - 1d)); + } else { + return null; + } + } + + public Double min() { + if (n > 0) { + return min; + } else { + return null; + } + } + + public Double max() { + if (n > 0) { + return max; + } else { + return null; + } + } + + @Override + public String toString() { + return "[count=" + count() + ",mean=" + mean() + ",sd=" + standardDeviation() + ",min=" + min() + ",max=" + max() + "]"; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SensorState.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SensorState.java new file mode 100644 index 0000000..c24f474 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SensorState.java @@ -0,0 +1,15 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Sensor state +public enum SensorState { + /// Sensor is powered on, active and operational + on, + /// Sensor is powered off, inactive and not operational + off, + /// Sensor is not available + unavailable +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SensorType.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SensorType.java new file mode 100644 index 0000000..2ec7184 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SensorType.java @@ -0,0 +1,17 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Sensor type as qualifier for target identifier. +public enum SensorType { + /// Bluetooth Low Energy (BLE) + BLE, + /// GPS location sensor + GPS +// /// Physical beacon, e.g. iBeacon +// BEACON, +// /// Ultrasound audio beacon. +// ULTRASOUND +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SignalCharacteristicData.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SignalCharacteristicData.java new file mode 100644 index 0000000..7d4d31a --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SignalCharacteristicData.java @@ -0,0 +1,152 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import au.gov.health.covidsafe.sensor.ble.BLESensorConfiguration; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/// Codec for signal characteristic data bundles +public class SignalCharacteristicData { + + /// Encode write RSSI data bundle + // writeRSSI data format + // 0-0 : actionCode + // 1-2 : rssi value (Int16) + public static Data encodeWriteRssi(final RSSI rssi) { + final ByteBuffer byteBuffer = ByteBuffer.allocate(3); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + byteBuffer.put(0, BLESensorConfiguration.signalCharacteristicActionWriteRSSI); + byteBuffer.putShort(1, (short) rssi.value); + return new Data(byteBuffer.array()); + } + + /// Decode write RSSI data bundle + public static RSSI decodeWriteRSSI(final Data data) { + if (signalDataActionCode(data.value) != BLESensorConfiguration.signalCharacteristicActionWriteRSSI) { + return null; + } + if (data.value.length != 3) { + return null; + } + final Short rssiValue = int16(data.value, 1); + if (rssiValue == null) { + return null; + } + return new RSSI(rssiValue.intValue()); + } + + /// Encode write payload data bundle + // writePayload data format + // 0-0 : actionCode + // 1-2 : payload data count in bytes (Int16) + // 3.. : payload data + public static Data encodeWritePayload(final PayloadData payloadData) { + final ByteBuffer byteBuffer = ByteBuffer.allocate(3 + payloadData.value.length); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + byteBuffer.put(0, BLESensorConfiguration.signalCharacteristicActionWritePayload); + byteBuffer.putShort(1, (short) payloadData.value.length); + byteBuffer.position(3); + byteBuffer.put(payloadData.value); + return new Data(byteBuffer.array()); + } + + /// Decode write payload data bundle + public static PayloadData decodeWritePayload(final Data data) { + if (signalDataActionCode(data.value) != BLESensorConfiguration.signalCharacteristicActionWritePayload) { + return null; + } + if (data.value.length < 3) { + return null; + } + final Short payloadDataCount = int16(data.value, 1); + if (payloadDataCount == null) { + return null; + } + if (data.value.length != (3 + payloadDataCount.intValue())) { + return null; + } + final Data payloadDataBytes = new Data(data.value).subdata(3); + if (payloadDataBytes == null) { + return null; + } + return new PayloadData(payloadDataBytes.value); + } + + /// Encode write payload sharing data bundle + // writePayloadSharing data format + // 0-0 : actionCode + // 1-2 : rssi value (Int16) + // 3-4 : payload sharing data count in bytes (Int16) + // 5.. : payload sharing data + public static Data encodeWritePayloadSharing(final PayloadSharingData payloadSharingData) { + final ByteBuffer byteBuffer = ByteBuffer.allocate(5 + payloadSharingData.data.value.length); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + byteBuffer.put(0, BLESensorConfiguration.signalCharacteristicActionWritePayloadSharing); + byteBuffer.putShort(1, (short) payloadSharingData.rssi.value); + byteBuffer.putShort(3, (short) payloadSharingData.data.value.length); + byteBuffer.position(5); + byteBuffer.put(payloadSharingData.data.value); + return new Data(byteBuffer.array()); + } + + /// Decode write payload data bundle + public static PayloadSharingData decodeWritePayloadSharing(final Data data) { + if (signalDataActionCode(data.value) != BLESensorConfiguration.signalCharacteristicActionWritePayloadSharing) { + return null; + } + if (data.value.length < 5) { + return null; + } + final Short rssiValue = int16(data.value, 1); + if (rssiValue == null) { + return null; + } + final Short payloadSharingDataCount = int16(data.value, 3); + if (payloadSharingDataCount == null) { + return null; + } + if (data.value.length != (5 + payloadSharingDataCount.intValue())) { + return null; + } + final Data payloadSharingDataBytes = new Data(data.value).subdata(5); + if (payloadSharingDataBytes == null) { + return null; + } + return new PayloadSharingData(new RSSI(rssiValue.intValue()), payloadSharingDataBytes); + } + + /// Detect signal characteristic data bundle type + public static SignalCharacteristicDataType detect(Data data) { + switch (signalDataActionCode(data.value)) { + case BLESensorConfiguration.signalCharacteristicActionWriteRSSI: + return SignalCharacteristicDataType.rssi; + case BLESensorConfiguration.signalCharacteristicActionWritePayload: + return SignalCharacteristicDataType.payload; + case BLESensorConfiguration.signalCharacteristicActionWritePayloadSharing: + return SignalCharacteristicDataType.payloadSharing; + default: + return SignalCharacteristicDataType.unknown; + } + } + + private static byte signalDataActionCode(byte[] signalData) { + if (signalData == null || signalData.length == 0) { + return 0; + } + return signalData[0]; + } + + private static Short int16(byte[] data, int index) { + if (index < data.length - 1) { + final ByteBuffer byteBuffer = ByteBuffer.wrap(data); + byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + return byteBuffer.getShort(index); + } else { + return null; + } + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SignalCharacteristicDataType.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SignalCharacteristicDataType.java new file mode 100644 index 0000000..cffc606 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/SignalCharacteristicDataType.java @@ -0,0 +1,9 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +public enum SignalCharacteristicDataType { + rssi, payload, payloadSharing, unknown +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/TargetIdentifier.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/TargetIdentifier.java new file mode 100644 index 0000000..16ddfaf --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/TargetIdentifier.java @@ -0,0 +1,44 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import android.bluetooth.BluetoothDevice; + +import java.util.Objects; +import java.util.UUID; + +/// Ephemeral identifier for detected target (e.g. smartphone, beacon, place). +// This is likely to be an UUID but using String for variable identifier length. +public class TargetIdentifier { + public final String value; + + /// Create target identifier based on bluetooth device address + public TargetIdentifier(BluetoothDevice bluetoothDevice) { + this.value = bluetoothDevice.getAddress(); + } + + /// Create random target identifier + public TargetIdentifier() { + this.value = UUID.randomUUID().toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TargetIdentifier that = (TargetIdentifier) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/TimeInterval.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/TimeInterval.java new file mode 100644 index 0000000..d52046d --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/TimeInterval.java @@ -0,0 +1,37 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// Time interval in seconds. +public class TimeInterval { + public final long value; + public static final TimeInterval minute = new TimeInterval(60); + public static final TimeInterval zero = new TimeInterval(0); + public static final TimeInterval never = new TimeInterval(Long.MAX_VALUE); + + public TimeInterval(long seconds) { + this.value = seconds; + } + + public static TimeInterval minutes(long minutes) { + return new TimeInterval(minute.value * minutes); + } + + public static TimeInterval seconds(long seconds) { + return new TimeInterval(seconds); + } + + public long millis() { + return value * 1000; + } + + @Override + public String toString() { + if (value == never.value) { + return "never"; + } + return Long.toString(value); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Triple.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Triple.java new file mode 100644 index 0000000..d034980 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Triple.java @@ -0,0 +1,26 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +public class Triple { + public final A a; + public final B b; + public final C c; + + public Triple(A a, B b, C c) { + this.a = a; + this.b = b; + this.c = c; + } + + @Override + public String toString() { + return "Triple{" + + "a=" + a + + ", b=" + b + + ", c=" + c + + '}'; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Tuple.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Tuple.java new file mode 100644 index 0000000..8f5c375 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/Tuple.java @@ -0,0 +1,27 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +public class Tuple { + private final String labelA, labelB; + public final A a; + public final B b; + + public Tuple(A a, B b) { + this("a", a, "b", b); + } + + public Tuple(String labelA, A a, String labelB, B b) { + this.labelA = labelA; + this.labelB = labelB; + this.a = a; + this.b = b; + } + + @Override + public String toString() { + return "(" + labelA + "=" + a + "," + labelB + "=" + b + ")"; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/UInt8.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/UInt8.java new file mode 100644 index 0000000..bc013b7 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/UInt8.java @@ -0,0 +1,23 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/// Unsigned integer (8 bits) +public class UInt8 { + public final int value; + public final Data bigEndian; + + public UInt8(int value) { + assert(value >= 0); + this.value = value; + final ByteBuffer byteBuffer = ByteBuffer.allocate(1); + byteBuffer.order(ByteOrder.BIG_ENDIAN); + byteBuffer.put((byte) value); + this.bigEndian = new Data(byteBuffer.array()); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/WGS84CircularAreaLocationReference.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/WGS84CircularAreaLocationReference.java new file mode 100644 index 0000000..00d0c3d --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/WGS84CircularAreaLocationReference.java @@ -0,0 +1,21 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// GPS coordinates and region radius, e.g. latitude and longitude in decimal format and radius in meters. +public class WGS84CircularAreaLocationReference implements LocationReference { + public final Double latitude, longitude, altitude, radius; + + public WGS84CircularAreaLocationReference(Double latitude, Double longitude, Double altitude, Double radius) { + this.latitude = latitude; + this.longitude = longitude; + this.altitude = altitude; + this.radius = radius; + } + + public String description() { + return "WGS84(lat=" + latitude + ",lon=" + longitude + ",alt=" + altitude + ",radius=" + radius + ")"; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/WGS84PointLocationReference.java b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/WGS84PointLocationReference.java new file mode 100644 index 0000000..5e63cc3 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/datatype/WGS84PointLocationReference.java @@ -0,0 +1,20 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.datatype; + +/// GPS coordinates (latitude,longitude,altitude) in WGS84 decimal format and meters from sea level. +public class WGS84PointLocationReference implements LocationReference { + public final Double latitude, longitude, altitude; + + public WGS84PointLocationReference(Double latitude, Double longitude, Double altitude) { + this.latitude = latitude; + this.longitude = longitude; + this.altitude = altitude; + } + + public String description() { + return "WGS84(lat=" + latitude + ",lon=" + longitude + ",alt=" + altitude + ")"; + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/payload/DefaultPayloadDataSupplier.java b/app/src/main/java/au/gov/health/covidsafe/sensor/payload/DefaultPayloadDataSupplier.java new file mode 100644 index 0000000..38c9b8c --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/payload/DefaultPayloadDataSupplier.java @@ -0,0 +1,33 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.payload; + +import au.gov.health.covidsafe.sensor.datatype.Data; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.PayloadTimestamp; + +import java.util.ArrayList; +import java.util.List; + +/// Default payload data supplier implementing fixed length payload splitting method. +public abstract class DefaultPayloadDataSupplier implements PayloadDataSupplier { + + @Override + public List payload(Data data) { + // Get fixed length payload data + final PayloadData fixedLengthPayloadData = payload(new PayloadTimestamp()); + final int payloadDataLength = fixedLengthPayloadData.value.length; + // Split raw data comprising of concatenated payloads into individual payloads + final List payloads = new ArrayList<>(); + final byte[] bytes = data.value; + for (int index = 0; (index + payloadDataLength) <= bytes.length; index += payloadDataLength) { + final byte[] payloadBytes = new byte[payloadDataLength]; + System.arraycopy(bytes, index, payloadBytes, 0, payloadDataLength); + payloads.add(new PayloadData(payloadBytes)); + } + return payloads; + } + +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/payload/PayloadDataSupplier.java b/app/src/main/java/au/gov/health/covidsafe/sensor/payload/PayloadDataSupplier.java new file mode 100644 index 0000000..d523c55 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/payload/PayloadDataSupplier.java @@ -0,0 +1,20 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: MIT +// + +package au.gov.health.covidsafe.sensor.payload; + +import au.gov.health.covidsafe.sensor.datatype.Data; +import au.gov.health.covidsafe.sensor.datatype.PayloadData; +import au.gov.health.covidsafe.sensor.datatype.PayloadTimestamp; + +import java.util.List; + +/// Payload data supplier, e.g. BeaconCodes in C19X and BroadcastPayloadSupplier in Sonar. +public interface PayloadDataSupplier { + /// Get payload for given timestamp. Use this for integration with any payload generator, e.g. BeaconCodes or SonarBroadcastPayloadService + PayloadData payload(PayloadTimestamp timestamp); + + /// Parse raw data into payloads + List payload(Data data); +} diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/service/ForegroundService.java b/app/src/main/java/au/gov/health/covidsafe/sensor/service/ForegroundService.java new file mode 100644 index 0000000..572c6b6 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/service/ForegroundService.java @@ -0,0 +1,46 @@ +package au.gov.health.covidsafe.sensor.service; + +import android.app.Notification; +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import au.gov.health.covidsafe.sensor.data.ConcreteSensorLogger; +import au.gov.health.covidsafe.sensor.data.SensorLogger; +import au.gov.health.covidsafe.sensor.datatype.Tuple; + + +/// Foreground service for enabling continuous BLE operation in background +public class ForegroundService extends Service { + private final SensorLogger logger = new ConcreteSensorLogger("App", "ForegroundService"); + + @Override + public void onCreate() { + logger.debug("onCreate"); + super.onCreate(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + logger.debug("onStartCommand"); + +// final NotificationService notificationService = NotificationService.shared(getApplication()); +// final Tuple notification = notificationService.notification("Contact Tracing", "Sensor is working"); +// if (notification.a != null && notification.b != null) { +// startForeground(notification.a, notification.b); +// super.onStartCommand(intent, flags, startId); +// } + return START_STICKY; + } + + @Override + public void onDestroy() { + logger.debug("onDestroy"); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/sensor/service/NotificationService.java b/app/src/main/java/au/gov/health/covidsafe/sensor/service/NotificationService.java new file mode 100644 index 0000000..e7aa875 --- /dev/null +++ b/app/src/main/java/au/gov/health/covidsafe/sensor/service/NotificationService.java @@ -0,0 +1,81 @@ +package au.gov.health.covidsafe.sensor.service; + +import android.app.Application; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import au.gov.health.covidsafe.R; +import au.gov.health.covidsafe.sensor.datatype.Triple; +import au.gov.health.covidsafe.sensor.datatype.Tuple; + +/// Notification service for enabling foreground service (notification must be displayed to show app is running in the background). +public class NotificationService { + private static NotificationService shared; + private static Application application; + private final Context context; + private final static String notificationChannelName = "NotificationChannel"; + private final int notificationChannelId = notificationChannelName.hashCode(); + private Triple notificationContent = new Triple<>(null, null, null); + + private NotificationService(final Application application) { + this.application = application; + this.context = application.getApplicationContext(); + createNotificationChannel(); + } + + /// Get shared global instance of notification service + public final static NotificationService shared(final Application application) { + if (shared == null) { + shared = new NotificationService(application); + } + return shared; + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + final int importance = NotificationManager.IMPORTANCE_DEFAULT; + final NotificationChannel channel = new NotificationChannel(notificationChannelName, notificationChannelName, importance); + channel.setDescription(notificationChannelName); + final NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + public Tuple notification(final String title, final String body) { + if (title != null && body != null) { + final String existingTitle = notificationContent.a; + final String existingBody = notificationContent.b; + if (!title.equals(existingTitle) || !body.equals(existingBody)) { + createNotificationChannel(); + final Intent intent = new Intent(context, application.getClass()); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + final NotificationCompat.Builder builder = new NotificationCompat.Builder(context, notificationChannelName) + .setSmallIcon(R.drawable.ic_notification_icon) + .setContentTitle(title) + .setContentText(body) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT); + final Notification notification = builder.build(); + final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.notify(notificationChannelId, notification); + notificationContent = new Triple<>(title, body, notification); + return new Tuple<>(notificationChannelId, notification); + } + } else { + final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.deleteNotificationChannel(notificationChannelName); + notificationContent = new Triple<>(null, null, null); + } + return new Tuple<>(notificationChannelId, null); + } +} diff --git a/app/src/main/java/au/gov/health/covidsafe/services/BluetoothMonitoringService.kt b/app/src/main/java/au/gov/health/covidsafe/services/BluetoothMonitoringService.kt index ab78fba..01fc13c 100644 --- a/app/src/main/java/au/gov/health/covidsafe/services/BluetoothMonitoringService.kt +++ b/app/src/main/java/au/gov/health/covidsafe/services/BluetoothMonitoringService.kt @@ -14,71 +14,48 @@ import android.os.PowerManager import androidx.annotation.Keep import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LifecycleService -import androidx.localbroadcastmanager.content.LocalBroadcastManager import au.gov.health.covidsafe.BuildConfig import au.gov.health.covidsafe.R import au.gov.health.covidsafe.app.TracerApp -import au.gov.health.covidsafe.bluetooth.BLEAdvertiser -import au.gov.health.covidsafe.bluetooth.gatt.ACTION_RECEIVED_STATUS -import au.gov.health.covidsafe.bluetooth.gatt.ACTION_RECEIVED_STREETPASS -import au.gov.health.covidsafe.bluetooth.gatt.STATUS -import au.gov.health.covidsafe.bluetooth.gatt.STREET_PASS +import au.gov.health.covidsafe.bluetooth.gatt.ReadRequestPayload import au.gov.health.covidsafe.extensions.isLocationEnabledOnDevice import au.gov.health.covidsafe.factory.NetworkFactory import au.gov.health.covidsafe.interactor.usecase.UpdateBroadcastMessageAndPerformScanWithExponentialBackOff import au.gov.health.covidsafe.logging.CentralLog import au.gov.health.covidsafe.notifications.NotificationTemplates import au.gov.health.covidsafe.preference.Preference -import au.gov.health.covidsafe.receivers.PrivacyCleanerReceiver -import au.gov.health.covidsafe.status.Status -import au.gov.health.covidsafe.status.persistence.StatusRecord -import au.gov.health.covidsafe.status.persistence.StatusRecordStorage -import au.gov.health.covidsafe.streetpass.ConnectionRecord -import au.gov.health.covidsafe.streetpass.StreetPassScanner -import au.gov.health.covidsafe.streetpass.StreetPassServer -import au.gov.health.covidsafe.streetpass.StreetPassWorker +import au.gov.health.covidsafe.sensor.Sensor +import au.gov.health.covidsafe.sensor.SensorArray +import au.gov.health.covidsafe.sensor.SensorDelegate +import au.gov.health.covidsafe.sensor.ble.BLEDevice +import au.gov.health.covidsafe.sensor.ble.BLESensorConfiguration +import au.gov.health.covidsafe.sensor.ble.BLE_TxPower +import au.gov.health.covidsafe.sensor.datatype.* +import au.gov.health.covidsafe.sensor.payload.PayloadDataSupplier import au.gov.health.covidsafe.streetpass.persistence.Encryption import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase -import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase.Companion.DUMMY_DEVICE -import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase.Companion.DUMMY_RSSI -import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase.Companion.DUMMY_TXPOWER -import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase.Companion.ENCRYPTED_EMPTY_DICT -import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase.Companion.VERSION_ONE import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordStorage import au.gov.health.covidsafe.ui.utils.LocalBlobV2 import au.gov.health.covidsafe.ui.utils.Utils -import com.google.gson.Gson -import com.google.gson.GsonBuilder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import pub.devrel.easypermissions.EasyPermissions import java.lang.ref.WeakReference +import java.util.* +import kotlin.collections.HashMap import kotlin.coroutines.CoroutineContext private const val POWER_SAVE_WHITELIST_CHANGED = "android.os.action.POWER_SAVE_WHITELIST_CHANGED" @Keep -class BluetoothMonitoringService : LifecycleService(), CoroutineScope { +class BluetoothMonitoringService : LifecycleService(), CoroutineScope, SensorDelegate, PayloadDataSupplier { @Keep - private lateinit var serviceUUID: String - - private var streetPassServer: StreetPassServer? = null - private var streetPassScanner: StreetPassScanner? = null - private var advertiser: BLEAdvertiser? = null - - private var worker: StreetPassWorker? = null - - private val streetPassReceiver = StreetPassReceiver() - private val statusReceiver = StatusReceiver() private val bluetoothStatusReceiver = BluetoothStatusReceiver() - private lateinit var streetPassRecordStorage: StreetPassRecordStorage - private lateinit var statusRecordStorage: StatusRecordStorage - private var job: Job = Job() override val coroutineContext: CoroutineContext @@ -86,52 +63,42 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { private lateinit var commandHandler: CommandHandler - private lateinit var localBroadcastManager: LocalBroadcastManager - private val awsClient = NetworkFactory.awsClient - private val gson: Gson = GsonBuilder().disableHtmlEscaping().create() + // Sensor for proximity detection + private var sensor: Sensor? = null + private lateinit var streetPassRecordStorage: StreetPassRecordStorage + private var appDelegate: BluetoothMonitoringService = this + + private var recentSaves: MutableMap = HashMap() override fun onCreate() { super.onCreate() - localBroadcastManager = LocalBroadcastManager.getInstance(this) + AppContext = applicationContext setup() } private fun setup() { + streetPassRecordStorage = StreetPassRecordStorage(applicationContext) val pm = getSystemService(Context.POWER_SERVICE) as PowerManager CentralLog.setPowerManager(pm) - commandHandler = CommandHandler(WeakReference(this)) - CentralLog.d(TAG, "Creating service - BluetoothMonitoringService") - serviceUUID = BuildConfig.BLE_SSID - - worker = StreetPassWorker(this.applicationContext) - + broadcastMessage = Utils.retrieveBroadcastMessage(this.applicationContext) unregisterReceivers() registerReceivers() - streetPassRecordStorage = StreetPassRecordStorage(this.applicationContext) - statusRecordStorage = StatusRecordStorage(this.applicationContext) - PrivacyCleanerReceiver.startAlarm(this.applicationContext) setupNotifications() - broadcastMessage = Utils.retrieveBroadcastMessage(this.applicationContext) } fun teardown() { - streetPassServer?.tearDown() - streetPassServer = null - - streetPassScanner?.stopScan() - streetPassScanner = null - commandHandler.removeCallbacksAndMessages(null) Utils.cancelBMUpdateCheck(this.applicationContext) Utils.cancelNextScan(this.applicationContext) Utils.cancelNextAdvertise(this.applicationContext) + sensor?.stop() } private fun setupNotifications() { @@ -239,18 +206,15 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { when (cmd) { Command.ACTION_START -> { - setupService() actionStart() Utils.scheduleNextHealthCheck(this.applicationContext, healthCheckInterval) Utils.scheduleBMUpdateCheck(this.applicationContext, bmCheckInterval) } Command.ACTION_SCAN -> { - actionScan() } Command.ACTION_ADVERTISE -> { - actionAdvertise() } Command.ACTION_UPDATE_BM -> { @@ -269,17 +233,6 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { } } - private fun actionStop() { - stopForeground(true) - stopSelf() - CentralLog.w(TAG, "Service Stopping") - } - - private fun actionHealthCheck() { - Utils.scheduleNextHealthCheck(this.applicationContext, healthCheckInterval) - performHealthCheck() - } - private fun actionStart() { if (Preference.isOnBoarded(this)) { CentralLog.d(TAG, "Service Starting ") @@ -299,17 +252,47 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { params = null, onSuccess = { broadcastMessage = it.tempId - setupCycles() + sensorStart() }, onFailure = { } ) - } else if (Preference.isOnBoarded(this)) { - setupCycles() } + sensorStart() } } + fun sensorStart() { + if (broadcastMessage != null) { + streetPassRecordStorage = StreetPassRecordStorage(applicationContext) + sensor = SensorArray(applicationContext, this) + getAppDelegate().sensor()?.add(this) + // Sensor will start and stop with Bluetooth power on / off events + sensor?.start() + } + } + + /// Get app delegate + fun getAppDelegate(): BluetoothMonitoringService { + return appDelegate + } + + /// Get sensor + fun sensor(): Sensor? { + return sensor + } + + private fun actionStop() { + stopForeground(true) + stopSelf() + CentralLog.w(TAG, "Service Stopping") + } + + private fun actionHealthCheck() { + Utils.scheduleNextHealthCheck(this.applicationContext, healthCheckInterval) + performHealthCheck() + } + private fun actionUpdateBm() { Utils.scheduleBMUpdateCheck(this.applicationContext, bmCheckInterval) @@ -330,102 +313,6 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { } } - private fun calcPhaseShift(min: Long, max: Long): Long { - return (min + (Math.random() * (max - min))).toLong() - } - - private fun actionScan() { - if (Preference.isOnBoarded(this) && Utils.needToUpdate(this.applicationContext) || broadcastMessage == null) { - //need to pull new BM - UpdateBroadcastMessageAndPerformScanWithExponentialBackOff(awsClient, applicationContext, lifecycle).invoke( - params = null, - onSuccess = { - broadcastMessage = it.tempId - performScanAndScheduleNextScan() - }, - onFailure = { - } - ) - } else if (Preference.isOnBoarded(this)) { - performScanAndScheduleNextScan() - } - } - - private fun actionAdvertise() { - setupAdvertiser() - - if (isBluetoothEnabled()) { - advertiser?.startAdvertising(advertisingDuration) - } else { - CentralLog.w(TAG, "Unable to start advertising, bluetooth is off") - } - - commandHandler.scheduleNextAdvertise(advertisingDuration + advertisingGap) - } - - private fun setupService() { - streetPassServer = - streetPassServer ?: StreetPassServer(this.applicationContext, serviceUUID) - setupScanner() - setupAdvertiser() - } - - private fun setupScanner() { - streetPassScanner = streetPassScanner ?: StreetPassScanner( - this, - serviceUUID, - scanDuration - ) - } - - private fun setupAdvertiser() { - advertiser = advertiser ?: BLEAdvertiser(serviceUUID) - } - - private fun setupCycles() { - setupScanCycles() - setupAdvertisingCycles() - } - - private fun setupScanCycles() { - actionScan() - } - - private fun setupAdvertisingCycles() { - actionAdvertise() - } - - private fun performScanAndScheduleNextScan() { - - setupScanner() - - commandHandler.scheduleNextScan( - scanDuration + calcPhaseShift( - minScanInterval, - maxScanInterval - ) - ) - - startScan() - - } - - private fun startScan() { - - if (isBluetoothEnabled()) { - - streetPassScanner?.let { scanner -> - if (!scanner.isScanning()) { - scanner.startScan() - } else { - CentralLog.e(TAG, "Already scanning!") - } - } - } else { - CentralLog.w(TAG, "Unable to start scan - bluetooth is off") - } - } - private fun performHealthCheck() { CentralLog.i(TAG, "Performing self diagnosis") @@ -443,29 +330,6 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { CHANNEL_ID ) ) - - //ensure our service is there - setupService() - - if (!commandHandler.hasScanScheduled()) { - CentralLog.w(TAG, "Missing Scan Schedule - rectifying") - setupScanCycles() - } else { - CentralLog.w(TAG, "Scan Schedule present") - } - - if (!commandHandler.hasAdvertiseScheduled()) { - CentralLog.w(TAG, "Missing Advertise Schedule - rectifying") - setupAdvertisingCycles() - } else { - CentralLog.w( - TAG, - "Advertise Schedule present. Should be advertising?: ${ - advertiser?.shouldBeAdvertising - ?: false - }. Is Advertising?: ${advertiser?.isAdvertising ?: false}" - ) - } } override fun onDestroy() { @@ -475,9 +339,6 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { teardown() unregisterReceivers() - worker?.terminateConnections() - worker?.unregisterReceivers() - job.cancel() CentralLog.i(TAG, "BluetoothMonitoringService destroyed") @@ -540,43 +401,7 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { } } - private fun registerLocationChangeReceiver() { - registerReceiver(gpsSwitchStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)) - } - - private fun registerPowerModeChangeReceiver() { - registerReceiver(powerStateChangeReceiver, IntentFilter(POWER_SAVE_WHITELIST_CHANGED)) - } - - private fun registerReceivers() { - val recordAvailableFilter = IntentFilter(ACTION_RECEIVED_STREETPASS) - localBroadcastManager.registerReceiver(streetPassReceiver, recordAvailableFilter) - - val statusReceivedFilter = IntentFilter(ACTION_RECEIVED_STATUS) - localBroadcastManager.registerReceiver(statusReceiver, statusReceivedFilter) - - val bluetoothStatusReceivedFilter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) - registerReceiver(bluetoothStatusReceiver, bluetoothStatusReceivedFilter) - - registerLocationChangeReceiver() - registerPowerModeChangeReceiver() - - CentralLog.i(TAG, "Receivers registered") - } - private fun unregisterReceivers() { - try { - localBroadcastManager.unregisterReceiver(streetPassReceiver) - } catch (e: Throwable) { - CentralLog.w(TAG, "streetPassReceiver is not registered?") - } - - try { - localBroadcastManager.unregisterReceiver(statusReceiver) - } catch (e: Throwable) { - CentralLog.w(TAG, "statusReceiver is not registered?") - } - try { unregisterReceiver(bluetoothStatusReceiver) @@ -604,6 +429,24 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { } } + private fun registerLocationChangeReceiver() { + registerReceiver(gpsSwitchStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)) + } + + private fun registerPowerModeChangeReceiver() { + registerReceiver(powerStateChangeReceiver, IntentFilter(POWER_SAVE_WHITELIST_CHANGED)) + } + + private fun registerReceivers() { + val bluetoothStatusReceivedFilter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + registerReceiver(bluetoothStatusReceiver, bluetoothStatusReceivedFilter) + + registerLocationChangeReceiver() + registerPowerModeChangeReceiver() + + CentralLog.i(TAG, "Receivers registered") + } + inner class BluetoothStatusReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -634,83 +477,6 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { } } - inner class StreetPassReceiver : BroadcastReceiver() { - - private val TAG = "StreetPassReceiver" - - override fun onReceive(context: Context, intent: Intent) { - - if (ACTION_RECEIVED_STREETPASS == intent.action) { - val connRecord: ConnectionRecord? = intent.getParcelableExtra(STREET_PASS) - CentralLog.d(TAG, "StreetPass received: $connRecord") - - if (connRecord != null && connRecord.msg.isNotEmpty()) { - - val remoteBlob: String = if (connRecord.version == VERSION_ONE) { - with(receiver = connRecord) { - val plainRecordByteArray = gson.toJson(StreetPassRecordDatabase.Companion.EncryptedRecord( - peripheral.modelP, central.modelC, rssi, txPower, msg = msg)) - .toByteArray(Charsets.UTF_8) - Encryption.encryptPayload(plainRecordByteArray) - } - } else { - //For version after version 1, the message is already encrypted in msg and we can store it as remote BLOB - connRecord.msg - } - val localBlob: String = if (connRecord.version == VERSION_ONE) { - ENCRYPTED_EMPTY_DICT - } else { - with(receiver = connRecord) { - val modelP = if (DUMMY_DEVICE == peripheral.modelP) null else peripheral.modelP - val modelC = if (DUMMY_DEVICE == central.modelC) null else central.modelC - val rssi = if (rssi == DUMMY_RSSI) null else rssi - val txPower = if (txPower == DUMMY_TXPOWER) null else txPower - val plainLocalBlob = gson.toJson(LocalBlobV2(modelP, modelC, rssi, txPower)) - .toByteArray(Charsets.UTF_8) - Encryption.encryptPayload(plainLocalBlob) - } - } - - val record = StreetPassRecord( - v = if (connRecord.version == 1) TracerApp.protocolVersion else (connRecord.version), - org = connRecord.org, - localBlob = localBlob, - remoteBlob = remoteBlob - ) - - launch { - CentralLog.d( - TAG, - "Coroutine - Saving StreetPassRecord: ${Utils.getDate(record.timestamp)} $record") - - streetPassRecordStorage.saveRecord(record) - } - } - } - } - } - - inner class StatusReceiver : BroadcastReceiver() { - private val TAG = "StatusReceiver" - - override fun onReceive(context: Context, intent: Intent) { - - if (ACTION_RECEIVED_STATUS == intent.action) { - val status: Status? = intent.getParcelableExtra(STATUS) - status?.let { - CentralLog.d(TAG, "Status received: ${it.msg}") - - if (it.msg.isNotEmpty()) { - val statusRecord = StatusRecord(it.msg) - launch { - statusRecordStorage.saveRecord(statusRecord) - } - } - } - } - } - } - enum class Command(val index: Int, val string: String) { INVALID(-1, "INVALID"), ACTION_START(0, "START"), @@ -726,6 +492,128 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { } } + override fun sensor(sensor: SensorType?, didDetect: TargetIdentifier?) { + CentralLog.d(TAG, "${sensor?.name} ,didDetect= $didDetect") + } + + override fun sensor(sensor: SensorType?, didRead: PayloadData?, fromTarget: TargetIdentifier?) { + CentralLog.d(TAG, "${sensor?.name} ,didRead= ${didRead?.shortName()} ,fromTarget= $fromTarget") + } + + override fun sensor(sensor: SensorType?, didShare: MutableList?, fromTarget: TargetIdentifier?) { + val payloads: MutableList = ArrayList(didShare!!.size) + for (payloadData in didShare) { + payloads.add(payloadData.shortName()) + } + CentralLog.d(TAG, "${sensor?.name} ,didShare= $payloads ,fromTarget= $fromTarget") + } + + override fun sensor(sensor: SensorType?, didMeasure: Proximity?, fromTarget: TargetIdentifier?) { + CentralLog.d(TAG, "${sensor?.name} ,didMeasure= ${didMeasure?.description()} ,fromTarget= $fromTarget") + } + + override fun sensor(sensor: SensorType?, didVisit: Location?) { + CentralLog.d(TAG, "${sensor?.name} ,didVisit= ${didVisit?.description()}") + } + + override fun sensor(sensor: SensorType?, didMeasure: Proximity?, fromTarget: TargetIdentifier?, withPayload: PayloadData?, device: BLEDevice) { + CentralLog.d(TAG, "${sensor?.name} ,didMeasure= ${didMeasure?.description()} ,fromTarget= ${fromTarget} ,withPayload= ${withPayload?.shortName()}, withDevice=$device") + wrtieEncounterRecordToDB(device) + } + + override fun sensor(sensor: SensorType?, didMeasure: Proximity?, fromTarget: TargetIdentifier?, withPayload: PayloadData?){ + CentralLog.d(TAG, "${sensor?.name} ,didMeasure= ${didMeasure?.description()} ,fromTarget= ${fromTarget} ,withPayload= ${withPayload?.shortName()}") + } + + override fun sensor(sensor: SensorType?, didUpdateState: SensorState?) { + CentralLog.d(TAG, "${sensor?.name} ,didUpdateState= ${didUpdateState?.name}") + } + + override fun sensor(sensor: SensorType?, didRead: PayloadData?, fromTarget: TargetIdentifier?, atProximity: Proximity?, withTxPower: Int, device: BLEDevice) { + CentralLog.d(TAG, "${sensor?.name} ,fromTarget= $fromTarget , atProximity= ${atProximity}, withTxPower= $withTxPower") + //wrtieEncounterRecordToDB(device) + } + + private fun cleanRecentSaves() { + recentSaves = recentSaves.filter { (key, value) -> TimeInterval( Date().time - value.time).value < BuildConfig.PERIPHERAL_PAYLOAD_SAVE_INTERVAL} as MutableMap + } + + private fun wrtieEncounterRecordToDB(device: BLEDevice): Any { + + return try { + + var deviceId: String = if(device.pseudoDeviceAddress()==null)device.identifier.value else device.pseudoDeviceAddress().toString() + cleanRecentSaves() + if(device.payloadData() != null && !recentSaves.containsKey(deviceId)) { + recentSaves.put(deviceId,Date()) + + val didRead = device.payloadData() + val deviceRssi = device.rssi() + val withTxPower = device.txPower() + + val peripheralrecord = ReadRequestPayload.gson.fromJson(String(didRead.value), ReadRequestPayload::class.java) + val modelC = if (StreetPassRecordDatabase.DUMMY_DEVICE == TracerApp.asCentralDevice().modelC) null else TracerApp.asCentralDevice().modelC + val modelP = if (StreetPassRecordDatabase.DUMMY_DEVICE == peripheralrecord.modelP) null else peripheralrecord.modelP + val rssi = if (deviceRssi?.value == StreetPassRecordDatabase.DUMMY_RSSI) null else deviceRssi.value + val txPower = if (withTxPower == null || withTxPower.value == StreetPassRecordDatabase.DUMMY_TXPOWER) null else withTxPower.value + val plainLocalBlob = ReadRequestPayload.gson.toJson(LocalBlobV2( + modelP, + modelC, + txPower, + rssi + )).toByteArray(Charsets.UTF_8) + + val localBlob = Encryption.encryptPayload(plainLocalBlob) + val record = StreetPassRecord( + v = peripheralrecord.v, + org = peripheralrecord.org, + localBlob = localBlob, + remoteBlob = peripheralrecord.msg + ) + + launch { + streetPassRecordStorage.saveRecord(record) + } + }else{} + } catch (e: java.lang.Exception) { + CentralLog.d(TAG, "Json parsing failed = $e") + } + } + + override fun payload(data: Data?): MutableList { + // Split raw data comprising of concatenated payloads into individual payloads, in our case we will only every get one payload at a time + val payload = PayloadData(data?.value) + val payloads: MutableList = ArrayList() + payloads.add(payload) + return payloads + } + + inner class ReadRequestEncryptedPayload(val timestamp: Long, val modelP: String, val msg: String?) + + override fun payload(timestamp: PayloadTimestamp?): PayloadData { + val peripheral = TracerApp.asPeripheralDevice() + val readRequest = ReadRequestEncryptedPayload( + System.currentTimeMillis() / 1000L, + peripheral.modelP, + thisDeviceMsg() + ) + val plainRecord = ReadRequestPayload.gson.toJson(readRequest) + + CentralLog.d(TAG, "onCharacteristicReadRequest plainRecord = $plainRecord") + + val plainRecordByteArray = plainRecord.toByteArray(Charsets.UTF_8) + val remoteBlob = Encryption.encryptPayload(plainRecordByteArray) + val base = + ReadRequestPayload( + v = TracerApp.protocolVersion, + msg = remoteBlob, + org = TracerApp.ORG, + modelP = null //This is going to be stored as empty in the db as DUMMY value + ).getPayload() + val value = base.copyOfRange(0, base.size) + return PayloadData(value) + } + companion object { private const val TAG = "BTMService" @@ -744,25 +632,23 @@ class BluetoothMonitoringService : LifecycleService(), CoroutineScope { const val PENDING_WIZARD_REQ_CODE = 10 const val PENDING_BM_UPDATE = 11 const val PENDING_PRIVACY_CLEANER_CODE = 12 - const val DAILY_UPLOAD_NOTIFICATION_CODE = 13 - var broadcastMessage: String? = null - const val scanDuration: Long = BuildConfig.SCAN_DURATION - const val minScanInterval: Long = BuildConfig.MIN_SCAN_INTERVAL - const val maxScanInterval: Long = BuildConfig.MAX_SCAN_INTERVAL - - const val advertisingDuration: Long = BuildConfig.ADVERTISING_DURATION - const val advertisingGap: Long = BuildConfig.ADVERTISING_INTERVAL - const val maxQueueTime: Long = BuildConfig.MAX_QUEUE_TIME const val bmCheckInterval: Long = BuildConfig.BM_CHECK_INTERVAL const val healthCheckInterval: Long = BuildConfig.HEALTH_CHECK_INTERVAL - const val connectionTimeout: Long = BuildConfig.CONNECTION_TIMEOUT const val blacklistDuration: Long = BuildConfig.BLACKLIST_DURATION - + lateinit var AppContext: Context + fun thisDeviceMsg(): String? { + broadcastMessage?.let { + CentralLog.i(TAG, "Retrieved BM for storage: $it") + return it + } + CentralLog.e(TAG, "No local Broadcast Message") + return "" + } } } diff --git a/app/src/main/java/au/gov/health/covidsafe/streetpass/IBluetoothGattInvocationHandler.java b/app/src/main/java/au/gov/health/covidsafe/streetpass/IBluetoothGattInvocationHandler.java index e916cea..d92c278 100644 --- a/app/src/main/java/au/gov/health/covidsafe/streetpass/IBluetoothGattInvocationHandler.java +++ b/app/src/main/java/au/gov/health/covidsafe/streetpass/IBluetoothGattInvocationHandler.java @@ -4,7 +4,7 @@ import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import au.gov.health.covidsafe.logging.CentralLog; - /* This class is used by StreetPassPairingFix to proxy calls between BluetoothGatt and the underlying +/* This class is used by StreetPassPairingFix to proxy calls between BluetoothGatt and the underlying * IBluetoothGatt implementation (the interface between Android SDK's bluetooth API and the system * bluetooth daemon) diff --git a/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassPairingFix.kt b/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassPairingFix.kt index fd71aab..eb15f1f 100644 --- a/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassPairingFix.kt +++ b/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassPairingFix.kt @@ -149,20 +149,26 @@ object StreetPassPairingFix { // Instance - this is what is called to initiate a read/write to a preipheral val mService: Object = mServiceField!!.get(gatt) as Object - // Wrap the IBLuetoothGatt instance in a Proxy object in order to intercept calls to - // readCharacteristic and writeCharacteristic. IBluetoothGattInvocationHandler will catch - // calls to these functions and rewrite their authReq field to ensure no pairing attempts - // occur - val mServiceProxy = Proxy.newProxyInstance(gatt.javaClass.classLoader, - Array(1) { iBluetoothGattClass!! }, - IBluetoothGattInvocationHandler(mService)) - - // Write the proxy back to BluetoothGatt.mService - mServiceField!!.set(gatt, mServiceProxy) + // Ensure that mService isn't already a proxy, for instance if this method was called + // twice on the same BluetoothGatt instance. + if (Proxy.isProxyClass(mService.javaClass)) { + CentralLog.i(TAG, + "Not proxying this mService as it is already proxied!") + } else { + // Wrap the IBluetoothGatt instance in a Proxy object in order to intercept calls to + // readCharacteristic and writeCharacteristic. IBluetoothGattInvocationHandler will catch + // calls to these functions and rewrite their authReq field to ensure no pairing attempts + // occur + val mServiceProxy = Proxy.newProxyInstance(gatt.javaClass.classLoader, + Array(1) { iBluetoothGattClass!! }, + IBluetoothGattInvocationHandler(mService)) + // Write the proxy back to BluetoothGatt.mService + mServiceField!!.set(gatt, mServiceProxy) + } // Reset accessibility mServiceField!!.isAccessible = mServiceAccessible - } + } catch (e: IllegalAccessException) { // Field was inaccessible when written CentralLog.i(TAG, diff --git a/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassServer.kt b/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassServer.kt deleted file mode 100644 index 80e5401..0000000 --- a/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassServer.kt +++ /dev/null @@ -1,32 +0,0 @@ -package au.gov.health.covidsafe.streetpass - -import android.content.Context -import au.gov.health.covidsafe.bluetooth.gatt.GattServer -import au.gov.health.covidsafe.bluetooth.gatt.GattService - -class StreetPassServer constructor(val context: Context, serviceUUIDString: String) { - - private val TAG = "StreetPassServer" - private var gattServer: GattServer? = null - - init { - gattServer = setupGattServer(context, serviceUUIDString) - } - - private fun setupGattServer(context: Context, serviceUUIDString: String): GattServer? { - val gattServer = GattServer(context, serviceUUIDString) - val started = gattServer.startServer() - - if (started) { - val readService = GattService(context, serviceUUIDString) - gattServer.addService(readService) - return gattServer - } - return null - } - - fun tearDown() { - gattServer?.stop() - } - -} \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassWorker.kt b/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassWorker.kt index 4b14549..febc8d4 100644 --- a/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassWorker.kt +++ b/app/src/main/java/au/gov/health/covidsafe/streetpass/StreetPassWorker.kt @@ -310,7 +310,7 @@ class StreetPassWorker(val context: Context) { it.checklist.started.status = true it.checklist.started.timePerformed = System.currentTimeMillis() - it.startWork(context, gattCallback) + it.startWork(context, gattCallback) var connecting = it.gatt?.connect() ?: false @@ -646,7 +646,7 @@ class StreetPassWorker(val context: Context) { } } - inner class EncryptedWriteRequestPayload(val timestamp: Long, val modelC: String, val rssi: Int, val txPower: Int?, val msg: String) + inner class EncryptedWriteRequestPayload(val timestamp: Long, val modelC: String, val rssi: Int, val txPower: Int?, val msg: String?) override fun onCharacteristicWrite( gatt: BluetoothGatt, diff --git a/app/src/main/java/au/gov/health/covidsafe/streetpass/persistence/StreetPassRecordStorage.kt b/app/src/main/java/au/gov/health/covidsafe/streetpass/persistence/StreetPassRecordStorage.kt index 53c8536..ccf9591 100644 --- a/app/src/main/java/au/gov/health/covidsafe/streetpass/persistence/StreetPassRecordStorage.kt +++ b/app/src/main/java/au/gov/health/covidsafe/streetpass/persistence/StreetPassRecordStorage.kt @@ -25,5 +25,4 @@ class StreetPassRecordStorage(val context: Context) { fun getAllRecords(): List { return recordDao.getCurrentRecords() } - } \ No newline at end of file diff --git a/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt b/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt index a54218c..e54b0c1 100644 --- a/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt +++ b/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragment.kt @@ -16,7 +16,6 @@ import android.view.View.GONE import android.view.View.VISIBLE import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent -import androidx.constraintlayout.solver.GoalRow import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.fragment.app.viewModels @@ -100,10 +99,15 @@ class HomeFragment : BaseFragment(), EasyPermissions.PermissionCallbacks, Networ initializeRefreshButton() initializePullToRefresh() initialiseReRegistration() + setupHyperlink() NetworkConnectionCheck.addNetworkChangedListener(requireContext(), this) } + private fun setupHyperlink() { + txt_update_description.movementMethod = LinkMovementMethod.getInstance() + } + private fun initializeNoNetworkError() { no_network_error_text_view.setOnClickListener { startActivity(Intent(android.provider.Settings.ACTION_WIRELESS_SETTINGS)) @@ -235,6 +239,10 @@ class HomeFragment : BaseFragment(), EasyPermissions.PermissionCallbacks, Networ homeFragmentViewModel.collectionMessageVisible.value = false askForLocationPermission() } + btn_proceed.setOnClickListener { + layout_herald_upgrade.slideAnimation(SlideDirection.UP, SlideType.HIDE, 200) + homeFragmentViewModel.heraldUpgradeMessage.value = false + } } private fun initializeUploadTestDataNavigation() { diff --git a/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragmentViewModel.kt b/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragmentViewModel.kt index dff0518..77db4d7 100644 --- a/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragmentViewModel.kt +++ b/app/src/main/java/au/gov/health/covidsafe/ui/home/HomeFragmentViewModel.kt @@ -23,6 +23,7 @@ class HomeFragmentViewModel(application: Application) : AndroidViewModel(applica val caseStatisticsLiveData = MutableLiveData() val isRefreshing = MutableLiveData() val collectionMessageVisible = MutableLiveData() + val heraldUpgradeMessage = MutableLiveData() // Show = true and hide = false val turnCaseNumber = MutableLiveData() lateinit var context: Context @@ -72,10 +73,13 @@ class HomeFragmentViewModel(application: Application) : AndroidViewModel(applica val latestVersion = Preference.getBuildNumber(context) // When We want to show disclaimer to user after update, minVersionShowPolicy should be as same as the current version val minVersionShowPolicy = 74 + val minVersionHeraldPolicy = 89 val currentVersion = BuildConfig.VERSION_CODE if (latestVersion == 0) { collectionMessageVisible.value = true + heraldUpgradeMessage.value = true } else { + heraldUpgradeMessage.value = currentVersion <= minVersionHeraldPolicy && currentVersion > latestVersion collectionMessageVisible.value = currentVersion <= minVersionShowPolicy && currentVersion > latestVersion } Preference.putBuildNumber(context, currentVersion) diff --git a/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/permission/PermissionFragment.kt b/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/permission/PermissionFragment.kt index 1f6b140..d030242 100644 --- a/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/permission/PermissionFragment.kt +++ b/app/src/main/java/au/gov/health/covidsafe/ui/onboarding/fragment/permission/PermissionFragment.kt @@ -12,27 +12,25 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import au.gov.health.covidsafe.HomeActivity -import au.gov.health.covidsafe.preference.Preference import au.gov.health.covidsafe.R import au.gov.health.covidsafe.app.TracerApp import au.gov.health.covidsafe.extensions.* +import au.gov.health.covidsafe.preference.Preference import au.gov.health.covidsafe.talkback.setHeading import au.gov.health.covidsafe.ui.base.PagerChildFragment import au.gov.health.covidsafe.ui.base.UploadButtonLayout import kotlinx.android.synthetic.main.fragment_permission.* import pub.devrel.easypermissions.EasyPermissions +import java.util.* +import kotlin.collections.ArrayList class PermissionFragment : PagerChildFragment(), EasyPermissions.PermissionCallbacks { companion object { - - val requiredPermissions = arrayOf( - Manifest.permission.BLUETOOTH, - Manifest.permission.BLUETOOTH_ADMIN, - Manifest.permission.ACCESS_FINE_LOCATION - ) + val requiredPermissions = PermissionFragment().requestPermissions() } override var step: Int? = 4 @@ -72,6 +70,20 @@ class PermissionFragment : PagerChildFragment(), EasyPermissions.PermissionCallb } else super.onActivityResult(requestCode, resultCode, data) } + private fun requestPermissions(): MutableList { + // Check and request permissions + val requiredPermissions: MutableList = ArrayList() + requiredPermissions.add(Manifest.permission.BLUETOOTH) + requiredPermissions.add(Manifest.permission.BLUETOOTH_ADMIN) + requiredPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + requiredPermissions.add(Manifest.permission.FOREGROUND_SERVICE) + } + requiredPermissions.add(Manifest.permission.WAKE_LOCK) + + return requiredPermissions + } + private fun navigateToNextPage() { navigationStarted = false if (hasAllPermissionsAndBluetoothOn()) { diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 186ddd8..20ecfd2 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -124,7 +124,8 @@ app:layout_anchorGravity="center" android:background="@color/white" android:elevation="12dp" - visibility="@{viewModel.collectionMessageVisible}"> + visibility="@{viewModel.collectionMessageVisible}" + android:visibility="gone"> + + + + + + + + + + + + +