Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {
minSdk = 29
targetSdk = 35

versionCode = 1708536372
versionName = "0.29.2-beta"
versionCode = 1708536373
versionName = "0.29.3-beta"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.UUID
Expand Down Expand Up @@ -147,11 +148,11 @@ class BleScannerHelper(
BluetoothProfile.STATE_DISCONNECTING -> {
Timber.tag(TAG_CONNECT).d("Disconnecting from device $address")
trySend(DeviceConnectResult.Disconnecting)
gatt.close()
}
BluetoothProfile.STATE_DISCONNECTED -> {
Timber.tag(TAG_CONNECT).d("Disconnected from device $address")
handleDisconnect(status, gatt)
close(gatt)
}
else -> {
Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status")
Expand Down Expand Up @@ -198,10 +199,10 @@ class BleScannerHelper(

awaitClose {
Timber.tag(TAG_CONNECT).d("Closing connection to device $address")
if (requireBluetoothManager().getConnectionState(device, BluetoothProfile.GATT) != BluetoothProfile.STATE_DISCONNECTED) {
if (isDeviceConnected(device)) {
gatt.disconnect()
} else {
gatt.close()
close(gatt)
}
}
}
Expand All @@ -220,19 +221,72 @@ class BleScannerHelper(
}

@SuppressLint("MissingPermission")
fun close(gatt: BluetoothGatt) {
Timber.tag(TAG_CONNECT).d("Closing connection to device ${gatt.device.address}")
fun close(gatt: BluetoothGatt, tag: String = TAG_CONNECT) {
Timber.tag(tag).i("Closing connection to device ${gatt.device.address}")
if (isDeviceConnected(gatt.device)) {
Timber.tag(tag).e("Trying to close connection for device ${gatt.device.address} while it is still connected.")
}
gatt.close()
connections.remove(gatt.device.address)
}

fun closeDeviceConnection(address: String) {
connections[address]?.let(::close)
connections.remove(address)
}

fun closeAllConnections() {
connections.values.forEach(::close)
@SuppressLint("MissingPermission")
fun isDeviceConnected(device: BluetoothDevice): Boolean {
return requireBluetoothManager().getConnectionState(device, BluetoothProfile.GATT) == BluetoothProfile.STATE_CONNECTED
}

@SuppressLint("MissingPermission")
fun isDeviceDisconnected(device: BluetoothDevice): Boolean {
return requireBluetoothManager().getConnectionState(device, BluetoothProfile.GATT) == BluetoothProfile.STATE_DISCONNECTED
}

@SuppressLint("MissingPermission")
suspend fun hardDisconnectDevice(device: BluetoothDevice, tag: String = TAG_CONNECT) {
return callbackFlow<Unit> {
Timber.tag(tag).i("Trying to close connection to device ${device.address}")
val gatt = device.connectGatt(appContext, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
super.onConnectionStateChange(gatt, status, newState)
Timber.tag(tag).i("Connection state change for device ${gatt.device.address}. Status: $status, newState: $newState")
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Timber.tag(tag).i("Try disconnect from ${gatt.device.address}")
gatt.disconnect()
}
BluetoothProfile.STATE_DISCONNECTED -> {
Timber.tag(tag).i("Disconnected. Closing connection ${gatt.device.address}")
gatt.close()
trySend(Unit)
[email protected]()
}
}
}
})

awaitClose {
if (isDeviceConnected(device)) {
Timber.tag(tag).e("Device ${gatt.device.address} is still connected")
}
}
}.first()
}

@SuppressLint("MissingPermission")
suspend fun hardCloseAllConnections(tag: String = TAG_CONNECT) {
connections.values.forEach { close(it, tag) }
connections.clear()
val otherConnections = requireBluetoothManager().getConnectedDevices(BluetoothProfile.GATT)
Timber.tag(tag).i("Found ${otherConnections.size} other connections")
otherConnections.forEach { device ->
hardDisconnectDevice(device, tag)
}
System.gc()
val stillConnected = requireBluetoothManager().getConnectedDevices(BluetoothProfile.GATT)
Timber.tag(tag).i("Hard close all connections done. ${stillConnected.size} connections left")
}

@SuppressLint("MissingPermission")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ class DeviceServicesFetchingPlanner(
) {

private var parallelProcessingBatches = PARALLEL_BATCH_COUNT
private var maxPossibleConnections = PARALLEL_BATCH_COUNT
private var cooldown: Long? = null
private var cooldownStartedAt: Long? = null
private var lastJournalReportTime: Long = 0

suspend fun scheduleFetchServiceInfo(devices: List<SavedDeviceHandle>): List<SavedDeviceHandle> = coroutineScope {

val cooldown = this@DeviceServicesFetchingPlanner.cooldown
val cooldown = this@DeviceServicesFetchingPlanner.cooldownStartedAt
if (cooldown != null && System.currentTimeMillis() - cooldown < MIN_COOLDOWN_DURATION_MINS.minutes.inWholeMilliseconds) {
Timber.tag(TAG).i("Device services fetching is on cooldown due to a high errors rate, current batch will be skipped")
return@coroutineScope devices
Expand Down Expand Up @@ -137,14 +137,31 @@ class DeviceServicesFetchingPlanner(
total: Int,
) {
Timber.tag(TAG).i("Deep analysis finished. Candidates: $updateNeeded (updated: $updated, timeouts: $timeouts, errors: $errors), total $total devices")
if (updateNeeded > 5 && errors / updateNeeded > 0.7) {
val report = JournalEntry.Report.Error(
title = "Too many errors during deep analysis. Restart bluetooth or disable deep analysis in settings",
stackTrace = "Errors: $errors, timeouts: $timeouts, updated: $updated, in total: $updateNeeded"
)
saveReportInteractor.execute(report)
cooldown = System.currentTimeMillis()
val errorsRate: Float = errors / (errors + timeouts + updated).toFloat()
if (updateNeeded > 5 && errorsRate > 0.75f) {
Timber.tag(TAG).e("Too many errors during deep analysis. Will try to reset ble stack and remove all connections")

reportJournalEntity(updateNeeded, updated, timeouts, errors)
bleScannerHelper.hardCloseAllConnections(TAG)
cooldownStartedAt = System.currentTimeMillis()
}
}

private suspend fun reportJournalEntity(
updateNeeded: Int,
updated: Int,
timeouts: Int,
errors: Int,
) {
if (System.currentTimeMillis() - lastJournalReportTime < JOURNAL_REPORT_COOLDOWN_MIN.minutes.inWholeMilliseconds) {
return
}
val report = JournalEntry.Report.Error(
title = "Too many errors during deep analysis. Restart bluetooth or disable deep analysis in settings",
stackTrace = "Errors: $errors, timeouts: $timeouts, updated: $updated, in total: $updateNeeded"
)
saveReportInteractor.execute(report)
lastJournalReportTime = System.currentTimeMillis()
}

private suspend fun <T> softTimeout(timeout: Duration, onTimeout: suspend () -> T, block: suspend () -> T): T = coroutineScope {
Expand Down Expand Up @@ -179,22 +196,23 @@ class DeviceServicesFetchingPlanner(
|| !recentlyChecked
}

private fun tooMachConnections() {
maxPossibleConnections = parallelProcessingBatches - 1
parallelProcessingBatches = max(1, (parallelProcessingBatches * 0.5).toInt())
bleScannerHelper.closeAllConnections()
private fun decreaseMaxConnections() {
parallelProcessingBatches = max(MIN_PARALLEL_CONNECTIONS, parallelProcessingBatches - 1)
}

private fun increaseConnections() {
parallelProcessingBatches = min(max(1, (parallelProcessingBatches * 1.2).toInt()), maxPossibleConnections)
parallelProcessingBatches = min(parallelProcessingBatches + 1, MAX_PARALLEL_CONNECTIONS)
}

companion object {
private const val PARALLEL_BATCH_COUNT = 10
private const val PARALLEL_BATCH_COUNT = 7 // usually 7 parallel connections are stable
private const val MIN_PARALLEL_CONNECTIONS = 2
private const val MAX_PARALLEL_CONNECTIONS = 15
private const val CHECK_INTERVAL_PER_DEVICE_MIN = 10
private const val DEVICE_FETCH_TIMEOUT_SEC = 5
private const val JOURNAL_REPORT_COOLDOWN_MIN = 30
private const val DEVICE_FETCH_TIMEOUT_SEC = 8
private const val TOTAL_FETCH_TIMEOUT_SEC = 30
private const val MIN_COOLDOWN_DURATION_MINS = 5
private const val MIN_COOLDOWN_DURATION_MINS = 1
private const val TAG = "DeviceServicesFetchingPlanner"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import f.cking.software.domain.model.DeviceData
import f.cking.software.domain.model.DeviceMetadata
import f.cking.software.domain.model.DeviceMetadata.CharacteristicType
import f.cking.software.domain.model.DeviceMetadata.ServiceTypes
import f.cking.software.domain.model.isNullOrEmpty
import f.cking.software.fromBase64
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
Expand Down Expand Up @@ -56,6 +57,7 @@ class FetchDeviceServiceInfo(
suspend fun submitMetadata() {
Timber.tag(TAG).i("Closing connection ${device.address}")
gatt?.let(bleScannerHelper::close)
gatt = null // to be sure
job?.cancel()
emit(metadata)
}
Expand All @@ -71,6 +73,12 @@ class FetchDeviceServiceInfo(
}
}

fun throwIfMetadataNotUpdated(e: Exception) {
if (metadata.isNullOrEmpty() || metadata == device.metadata) {
throw e
}
}

bleScannerHelper.connectToDevice(device.address)
.collect { event ->
when (event) {
Expand Down Expand Up @@ -160,38 +168,38 @@ class FetchDeviceServiceInfo(
// Error handling
is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError -> {
Timber.tag(TAG).e("Unspecified connection error from ${device.address}.")
disconnect(event.gatt)
throw BluetoothConnectionException.UnspecifiedConnectionError(event.errorCode)
throwIfMetadataNotUpdated(BluetoothConnectionException.UnspecifiedConnectionError(event.errorCode))
submitMetadata()
}

is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionTimeout -> {
Timber.tag(TAG).e("Connection timeout error from ${device.address}")
disconnect(event.gatt)
throw BluetoothConnectionException.ConnectionTimeoutException(event.errorCode)
throwIfMetadataNotUpdated(BluetoothConnectionException.ConnectionTimeoutException(event.errorCode))
submitMetadata()
}

is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionFailedToEstablish -> {
Timber.tag(TAG).e("Connection failed to establish error from ${device.address}")
disconnect(event.gatt)
throw BluetoothConnectionException.ConnectionFailedToEstablish(event.errorCode)
throwIfMetadataNotUpdated(BluetoothConnectionException.ConnectionFailedToEstablish(event.errorCode))
submitMetadata()
}

is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionFailedBeforeInitializing -> {
Timber.tag(TAG).e("Connection initializing failed error from ${device.address}")
disconnect(event.gatt)
throw BluetoothConnectionException.ConnectionInitializingFailed(event.errorCode)
throwIfMetadataNotUpdated(BluetoothConnectionException.ConnectionInitializingFailed(event.errorCode))
submitMetadata()
}

is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionTerminated -> {
Timber.tag(TAG).e("Connection terminated error from ${device.address}. Probably max GATT connections reached")
disconnect(event.gatt)
throw BluetoothConnectionException.ConnectionTerminated(event.errorCode)
throwIfMetadataNotUpdated(BluetoothConnectionException.ConnectionTerminated(event.errorCode))
submitMetadata()
}

is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionFailedTooManyClients -> {
Timber.tag(TAG).e("Connection failed due to too many clients error from ${device.address}")
disconnect(event.gatt)
throw BluetoothConnectionException.TooManyClients(event.errorCode)
throwIfMetadataNotUpdated(BluetoothConnectionException.TooManyClients(event.errorCode))
submitMetadata()
}

else -> {
Expand Down