Commit aa0af79f authored by stopcovid@lunabee.com's avatar stopcovid@lunabee.com
Browse files

Update to 1.1.0

parent 67199bfb
......@@ -17,8 +17,8 @@ android {
defaultConfig {
minSdkVersion 21
targetSdkVersion 29
versionCode 9
versionName "1.1.0"
versionCode 12
versionName "1.3.0"
}
compileOptions {
......@@ -49,22 +49,22 @@ dependencies {
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation "org.robolectric:robolectric:4.3.1"
def mockitoVersion = '2.25.0'
def mockitoVersion = '3.3.3'
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
def androidXTestVersion = '1.3.0-beta01'
def androidXTestVersion = '1.3.0-rc01'
testImplementation "androidx.test:core-ktx:${androidXTestVersion}"
testImplementation "androidx.test:monitor:${androidXTestVersion}"
testImplementation "androidx.test:runner:${androidXTestVersion}"
testImplementation "androidx.test:rules:${androidXTestVersion}"
testImplementation "androidx.test.ext:truth:${androidXTestVersion}"
def androidXTestJUnitVersion = '1.1.2-beta01'
def androidXTestJUnitVersion = '1.1.2-rc01'
testImplementation "androidx.test.ext:junit:${androidXTestJUnitVersion}"
def coroutines_version = "1.3.6"
def coroutines_version = "1.3.7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
......
......@@ -8,7 +8,8 @@
~ Created by Orange / Date - 2020/04/27 - for the STOP-COVID project
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.orange.proximitynotification">
......@@ -29,5 +30,4 @@
</application>
</manifest>
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* Authors
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Created by Orange / Date - 2020/04/27 - for the STOP-COVID project
*/
package com.orange.proximitynotification
import com.orange.proximitynotification.ble.BleProximityMetadata
import java.util.Date
import kotlin.math.ceil
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.min
/**
* ProximityInfo risk computer
*
* @see ProximityInfo
*/
class ProximityInfoRiskComputer {
/**
* Compute ProximityInfoRisk for a list of ProximityInfo
*
* @param proximityInfos ProximityInfo list
* @param from The start date to compute the risk from
* @param durationInSeconds The period over which the risk will be computed
* @return ProximityInfoRisk computed
* @see ProximityInfoRisk
*/
fun computeRisk(proximityInfos: List<ProximityInfo>, from: Date, durationInSeconds: Long): ProximityInfoRisk {
if (durationInSeconds <= 0) {
return ProximityInfoRisk(0.0)
}
// Initialization
val durationInMinutes = ceil(durationInSeconds / 60.0).toInt()
val deltas = listOf(39.0, 27.0, 23.0, 21.0, 20.0, 15.0)
val po = -66.0
val groupedRssis = List(durationInMinutes) { mutableListOf<Int>() }
// Fading compensation
val timestampedRssis = proximityInfos.mapNotNull { proximityInfo ->
val metadata = proximityInfo.metadata as? BleProximityMetadata
return@mapNotNull metadata?.let {
val timestampDelta = (proximityInfo.timestamp.time - from.time) / 1_000
val minute = floor(timestampDelta / 60.0).toInt()
return@let if (minute < durationInMinutes) Pair(minute, metadata.calibratedRssi) else null
}
}
timestampedRssis.forEach { (minute, rssi) ->
if (minute < groupedRssis.size) {
groupedRssis[minute].add(rssi)
}
}
// Average RSSI and risk scoring
val range = 0 until groupedRssis.lastIndex
val score = range.fold(0.0) { partialScore, minute ->
val rssis = groupedRssis[minute] + groupedRssis[minute + 1]
return@fold if (rssis.isEmpty()) {
0.0
} else {
val averageRssi = softmax(rssis)
val gamma = (averageRssi - po) / deltas[min(rssis.size - 1, deltas.lastIndex)]
val risk = gamma.coerceIn(0.0, 1.0)
partialScore + risk
}
}
return ProximityInfoRisk(score)
}
private fun softmax(inputs: List<Int>): Double {
val a = 4.342
val exponentialSum = inputs.fold(0.0) { accumulator, input -> accumulator + exp(input / a) }
return if (inputs.isEmpty()) 0.0 else a * ln(exponentialSum / inputs.size)
}
}
......@@ -23,6 +23,11 @@ data class ProximityNotificationError(val type: Type, val rootErrorCode: Int? =
*/
BLE_SCANNER,
/**
* BLE gatt error
*/
BLE_GATT,
/**
* BLE proximity notification component error
*/
......
......@@ -24,6 +24,7 @@ import com.orange.proximitynotification.tools.CoroutineContextProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
class BleProximityNotification(
private val bleScanner: BleScanner,
......@@ -39,7 +40,8 @@ class BleProximityNotification(
}
private val bleRecordProviderForScanWithPayload = RecordProviderForScanWithPayload()
private val bleRecordProviderForScanWithoutPayload = RecordProviderForScanWithoutPayload(settings)
private val bleRecordProviderForScanWithoutPayload =
RecordProviderForScanWithoutPayload(settings)
private val bleRecordMapper = BleRecordMapper(settings)
private lateinit var proximityPayloadProvider: ProximityPayloadProvider
......@@ -81,7 +83,7 @@ class BleProximityNotification(
}
private fun startAdvertiser() {
bleAdvertiser.start(
val status = bleAdvertiser.start(
data = buildPayload(),
callback = object : BleAdvertiser.Callback {
override fun onError(errorCode: Int) {
......@@ -93,13 +95,22 @@ class BleProximityNotification(
)
}
})
if (!status) {
callback.onError(
ProximityNotificationError(
ProximityNotificationError.Type.BLE_ADVERTISER,
cause = "Failed to start advertiser"
)
)
}
}
private fun startGattServer() {
bleGattManager.start(callback = object : BleGattManager.Callback {
override fun onWritePayloadRequest(device: BluetoothDevice, value: ByteArray) {
val status = bleGattManager.start(callback = object : BleGattManager.Callback {
override suspend fun onWritePayloadRequest(device: BluetoothDevice, value: ByteArray) {
coroutineScope.launch(coroutineContextProvider.default) {
withContext(coroutineContextProvider.default) {
decodePayload(value)?.let { payload ->
......@@ -107,20 +118,36 @@ class BleProximityNotification(
bleRecordProviderForScanWithoutPayload.fromPayload(device, payload) ?: run {
// If not try to request remote rssi to complete record
bleGattManager.requestRemoteRssi(device)?.let { rssi ->
bleGattManager.requestRemoteRssi(device, false)?.let { rssi ->
val scannedDevice =
BleScannedDevice(device = device, rssi = rssi)
bleRecordProviderForScanWithoutPayload.fromScan(scannedDevice, payload)
bleRecordProviderForScanWithoutPayload.fromScan(
scannedDevice,
payload
)
}
}
}?.let { notifyProximity(it) }
}?.let {
// Notify in another coroutine in order to free the gatt callback
coroutineScope.launch(coroutineContextProvider.default) { notifyProximity(it) }
}
}
}
})
if (!status) {
callback.onError(
ProximityNotificationError(
ProximityNotificationError.Type.BLE_GATT,
cause = "Failed to start GATT"
)
)
}
}
private fun startScanner() {
bleScanner.start(callback = object : BleScanner.Callback {
val status = bleScanner.start(callback = object : BleScanner.Callback {
override fun onResult(results: List<BleScannedDevice>) {
if (results.isNotEmpty()) {
......@@ -131,7 +158,12 @@ class BleProximityNotification(
if (serviceData != null) {
// Android case
decodePayload(serviceData)
?.let { bleRecordProviderForScanWithPayload.fromScan(scannedDevice, it) }
?.let {
bleRecordProviderForScanWithPayload.fromScan(
scannedDevice,
it
)
}
} else {
// iOS case
bleRecordProviderForScanWithoutPayload.fromScan(scannedDevice, null)
......@@ -150,6 +182,15 @@ class BleProximityNotification(
)
}
})
if (!status) {
callback.onError(
ProximityNotificationError(
ProximityNotificationError.Type.BLE_SCANNER,
cause = "Failed to start scanner"
)
)
}
}
private fun stopAdvertiser() {
......
......@@ -38,7 +38,7 @@ object BleProximityNotificationFactory {
val bleGattClientProvider = BleGattClientProviderImpl(context)
val bleAdvertiser = BleAdvertiserImpl(settings, bluetoothAdapter.bluetoothLeAdvertiser)
val bleScanner = BleScannerImpl(settings, BluetoothLeScannerCompat.getScanner())
val bleGattManager = BleGattManagerImpl(settings, context, bluetoothManager, bleGattClientProvider, coroutineScope)
val bleGattManager = BleGattManagerImpl(settings, context, bluetoothManager, bleGattClientProvider)
return BleProximityNotification(
bleScanner,
......
......@@ -19,6 +19,6 @@ interface BleAdvertiser {
fun onError(errorCode: Int)
}
fun start(data: ByteArray, callback: Callback)
fun start(data: ByteArray, callback: Callback): Boolean
fun stop()
}
\ No newline at end of file
......@@ -25,11 +25,11 @@ class BleAdvertiserImpl(
private var advertiseCallback: InnerAdvertiseCallback? = null
override fun start(data: ByteArray, callback: BleAdvertiser.Callback) {
override fun start(data: ByteArray, callback: BleAdvertiser.Callback): Boolean {
Timber.d("Starting Advertising")
doStop()
doStart(data, callback)
return doStart(data, callback)
}
override fun stop() {
......@@ -37,19 +37,23 @@ class BleAdvertiserImpl(
doStop()
}
private fun doStart(data: ByteArray, callback: BleAdvertiser.Callback) {
advertiseCallback = InnerAdvertiseCallback(callback).also {
private fun doStart(data: ByteArray, callback: BleAdvertiser.Callback): Boolean {
advertiseCallback = InnerAdvertiseCallback(callback)
return runCatching {
bluetoothAdvertiser.startAdvertising(
buildAdvertiseSettings(),
buildAdvertiseData(data),
it
advertiseCallback
)
}
}.isSuccess
}
private fun doStop() {
advertiseCallback?.let {
bluetoothAdvertiser.stopAdvertising(advertiseCallback)
runCatching {
bluetoothAdvertiser.stopAdvertising(advertiseCallback)
}
}
advertiseCallback = null
}
......
......@@ -15,4 +15,6 @@ object BleRssiCalibration {
fun calibrate(rssi: Int, rxCompensationGain: Int, txCompensationGain: Int): Int {
return rssi - txCompensationGain - rxCompensationGain
}
}
\ No newline at end of file
}
......@@ -42,6 +42,7 @@ internal class BleGattClientImpl(
override suspend fun open() {
bluetoothGatt = bluetoothDevice.connectGattCompat(context, Callback())
checkNotNull(bluetoothGatt)
connectionStateChannel.receive() // suspends until connectionStateChanged is received
check(isConnected)
}
......@@ -53,8 +54,10 @@ internal class BleGattClientImpl(
}
private fun doClose() {
bluetoothGatt?.disconnect()
bluetoothGatt?.close()
bluetoothGatt?.runCatching {
disconnect()
close()
}
isClosed = true
_isConnected = false
......@@ -102,7 +105,7 @@ private fun <E> SendChannel<E>.safeOffer(element: E) {
private fun BluetoothDevice.connectGattCompat(
context: Context,
callback: BluetoothGattCallback
): BluetoothGatt {
): BluetoothGatt? {
return when {
SDK_INT >= M -> connectGatt(context, false, callback, TRANSPORT_LE)
else -> connectGatt(context, false, callback)
......
......@@ -17,11 +17,11 @@ interface BleGattManager {
val settings: BleSettings
interface Callback {
fun onWritePayloadRequest(device: BluetoothDevice, value: ByteArray)
suspend fun onWritePayloadRequest(device: BluetoothDevice, value: ByteArray)
}
fun start(callback: Callback)
fun start(callback: Callback): Boolean
fun stop()
suspend fun requestRemoteRssi(device: BluetoothDevice): Int?
suspend fun requestRemoteRssi(device: BluetoothDevice, close: Boolean): Int?
}
\ No newline at end of file
......@@ -20,30 +20,33 @@ import android.bluetooth.BluetoothManager
import android.content.Context
import com.orange.proximitynotification.ble.BleSettings
import com.orange.proximitynotification.tools.CoroutineContextProvider
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.channels.receiveOrNull
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
internal class BleGattManagerImpl(
override val settings: BleSettings,
private val context: Context,
private val bluetoothManager: BluetoothManager,
private val gattClientProvider: BleGattClientProvider,
private val coroutineScope: CoroutineScope,
private val coroutineContextProvider: CoroutineContextProvider = CoroutineContextProvider.Default()
) : BleGattManager {
) : BleGattManager, CoroutineScope {
private lateinit var executionChannel: Channel<suspend () -> Unit>
private var executionJob: Job? = null
private var job: Job? = null
override val coroutineContext: CoroutineContext
get() = coroutineContextProvider.io + (job ?: EmptyCoroutineContext)
private val bleOperationLock = Mutex()
private var bluetoothGattServer: BluetoothGattServer? = null
......@@ -54,59 +57,73 @@ internal class BleGattManagerImpl(
BluetoothGattCharacteristic.PERMISSION_WRITE
)
override fun start(callback: BleGattManager.Callback) {
override fun start(callback: BleGattManager.Callback): Boolean {
Timber.d("Starting GATT server")
stop()
bluetoothGattServer =
bluetoothManager.openGattServer(context, GattServerCallback(callback)).apply {
clearServices()
addService(buildGattService())
}
executionJob = executionJob()
job = SupervisorJob()
return runCatching {
bluetoothGattServer =
bluetoothManager.openGattServer(context, GattServerCallback(callback))?.apply {
try {
clearServices()
addService(buildGattService())
} catch (t: Throwable) {
close()
throw t
}
}
bluetoothGattServer != null
}.getOrDefault(false)
}
override fun stop() {
Timber.d("Stopping GATT server")
executionJob?.cancel()
executionJob = null
bluetoothGattServer?.apply {
clearServices()
close()
runCatching {
bluetoothGattServer?.apply {
clearServices()
close()
}
}
bluetoothGattServer = null
}
override suspend fun requestRemoteRssi(device: BluetoothDevice): Int? {
bluetoothGattServer = null
val rssiChannel = Channel<Int>()
job?.cancel()
job = null
}
execute {
override suspend fun requestRemoteRssi(device: BluetoothDevice, close: Boolean): Int? {
return withContext(coroutineContextProvider.io) {
val client = gattClientProvider.fromDevice(device)
try {
withTimeout(settings.connectionTimeout) {
client.open()
}
withTimeout(settings.connectionTimeout) { client.open() }
val rssi = client.readRemoteRssi()
rssiChannel.send(rssi)
bleOperation { client.readRemoteRssi() }
} catch (e: TimeoutCancellationException) {
Timber.d(e, "Request remote rssi failed. Connection timeout")
rssiChannel.close()
null
} catch (t: Throwable) {
Timber.d(t, "Request remote rssi failed with exception")
rssiChannel.close()
null
} finally {
client.close()
if (close) {
bleOperation { client.close() }
}
}
}
return rssiChannel.receiveOrNull()
}
private suspend fun <T> bleOperation(operation: suspend () -> T): T {
return bleOperationLock.withLock {
withContext(coroutineContextProvider.main) {
operation()
}
}
}
private fun buildGattService(): BluetoothGattService {
......@@ -118,22 +135,6 @@ internal class BleGattManagerImpl(
}
}
private fun executionJob() = coroutineScope.launch {
executionChannel = Channel(UNLIMITED)
executionChannel
.receiveAsFlow()
.flowOn(coroutineContextProvider.io)
.collect {
it()
}
}.apply { invokeOnCompletion { executionChannel.close(it) } }
private fun execute(block: suspend () -> Unit) {
runCatching {
executionChannel.offer(block)
}
}
private inner class GattServerCallback(private val callback: BleGattManager.Callback) :
BluetoothGattServerCallback() {
......@@ -145,38 +146,58 @@ internal class BleGattManagerImpl(
responseNeeded: Boolean,
offset: Int,
value: ByteArray?
) = execute {
super.onCharacteristicWriteRequest(
device,
requestId,
characteristic,
preparedWrite,
responseNeeded,
offset,