From 7e66c7f3225911c5e366fde67a67ee10f9bd9a27 Mon Sep 17 00:00:00 2001 From: semper-viventem Date: Mon, 31 Mar 2025 18:11:16 -0700 Subject: [PATCH 1/6] Improve deep analysis --- .../software/data/helpers/BleScannerHelper.kt | 61 +++++++++--- .../DeviceServicesFetchingPlanner.kt | 92 ++++++++++++------- .../interactor/FetchDeviceServiceInfo.kt | 39 ++++++-- .../devicedetails/DeviceDetailsViewModel.kt | 6 -- 4 files changed, 140 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt b/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt index 9203681c..2d9a5d12 100644 --- a/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt +++ b/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt @@ -129,12 +129,13 @@ class BleScannerHelper( } } - override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { super.onConnectionStateChange(gatt, status, newState) checkStatus(newState, gatt, status) } - private fun checkStatus(newState: Int, gatt: BluetoothGatt?, status: Int) { + private fun checkStatus(newState: Int, gatt: BluetoothGatt, status: Int) { + connections[address] = gatt when (newState) { BluetoothProfile.STATE_CONNECTING -> { Timber.tag(TAG_CONNECT).d("Connecting to device $address") @@ -142,7 +143,7 @@ class BleScannerHelper( } BluetoothProfile.STATE_CONNECTED -> { Timber.tag(TAG_CONNECT).d("Connected to device $address") - trySend(DeviceConnectResult.Connected(gatt!!)) + trySend(DeviceConnectResult.Connected(gatt)) } BluetoothProfile.STATE_DISCONNECTING -> { Timber.tag(TAG_CONNECT).d("Disconnecting from device $address") @@ -150,24 +151,46 @@ class BleScannerHelper( } BluetoothProfile.STATE_DISCONNECTED -> { Timber.tag(TAG_CONNECT).d("Disconnected from device $address") - if (status == 0x85) { - Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.MaxGattConnectionsReached) - } else { - trySend(DeviceConnectResult.Disconnected) - } + handleDisconnect(status) } else -> { Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.DisconnectedWithError(status)) - false + trySend(DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError(status)) + } + } + } + + private fun handleDisconnect(status: Int) { + when (status) { + BluetoothGatt.GATT_SUCCESS -> { + trySend(DeviceConnectResult.Disconnected) + } + CONNECTION_FAILED_TO_ESTABLISH -> { + Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionFailedToEstablish(status)) + } + CONNECTION_FAILED_BEFORE_INITIALIZING -> { + Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionFailedBeforeInitializing(status)) + } + CONNECTION_TERMINATED -> { + Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionTerminated(status)) + } + BluetoothGatt.GATT_CONNECTION_TIMEOUT -> { + Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionTimeout(status)) + } + else -> { + Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") + trySend(DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError(status)) } } } } Timber.tag(TAG_CONNECT).d("Connecting to device $address") - connections[address] = device.connectGatt(appContext, false, callback) + connections[address] = device.connectGatt(appContext, false, callback, BluetoothDevice.TRANSPORT_LE) awaitClose { Timber.tag(TAG_CONNECT).d("Closing connection to device $address") @@ -229,8 +252,15 @@ class BleScannerHelper( data class Connected(val gatt: BluetoothGatt) : DeviceConnectResult data object Disconnecting : DeviceConnectResult data object Disconnected : DeviceConnectResult - data class DisconnectedWithError(val errorCode: Int) : DeviceConnectResult - data object MaxGattConnectionsReached : DeviceConnectResult + sealed interface DisconnectedWithError : DeviceConnectResult { + val errorCode: Int + + class UnspecifiedConnectionError(override val errorCode: Int) : DisconnectedWithError + class ConnectionTimeout(override val errorCode: Int) : DisconnectedWithError + class ConnectionTerminated(override val errorCode: Int) : DisconnectedWithError + class ConnectionFailedToEstablish(override val errorCode: Int) : DisconnectedWithError + class ConnectionFailedBeforeInitializing(override val errorCode: Int) : DisconnectedWithError + } } fun isBluetoothEnabled(): Boolean { @@ -346,5 +376,8 @@ class BleScannerHelper( companion object { private const val TAG = "BleScannerHelper" private const val TAG_CONNECT = "BleScannerHelperConnect" + private const val CONNECTION_FAILED_BEFORE_INITIALIZING = 0x85 + private const val CONNECTION_FAILED_TO_ESTABLISH = 0x3E + private const val CONNECTION_TERMINATED = 0x16 } } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt index cdbfa64c..9ba8645a 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt @@ -8,12 +8,15 @@ import f.cking.software.mapParallel import f.cking.software.splitToBatchesEqual import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import timber.log.Timber +import kotlin.math.max +import kotlin.math.min +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -23,6 +26,8 @@ class DeviceServicesFetchingPlanner( ) { private var currentJob: Job? = null + private var parallelProcessingBatches = PARALLEL_BATCH_COUNT + private var maxPossibleConnections = PARALLEL_BATCH_COUNT suspend fun scheduleFetchServiceInfo(devices: List) = coroutineScope { currentJob?.cancel() @@ -35,44 +40,69 @@ class DeviceServicesFetchingPlanner( .reversed() Timber.tag(TAG).i("Scheduling fetch service info for ${metadataNeeded.size} devices, out of ${devices.size} total") - - fetchAllDevices(metadataNeeded) - Timber.tag(TAG).i("All devices processed") + try { + fetchAllDevices(metadataNeeded) +// increaseConnections() + } catch (e: FetchDeviceServiceInfo.BluetoothConnectionException.UnspecifiedConnectionError) { + Timber.tag(TAG).e(e, "Max connections reached") +// currentJob?.cancel() +// tooMachConnections() + } } } } - private suspend fun fetchAllDevices(metadataNeeded: List) { - try { - withTimeout(TOTAL_FETCH_TIMEOUT_SEC.seconds) { - Timber.tag(TAG).i("Fetching total devices") - metadataNeeded.splitToBatchesEqual(PARALLEL_BATCH_COUNT) - .filter { it.isNotEmpty() } - .mapParallel { batch -> - Timber.tag(TAG).i("Processing batch of ${batch.size} devices") - batch.forEach { device -> - fetchDevice(device) - } - } - } + private fun tooMachConnections() { + maxPossibleConnections = parallelProcessingBatches - 1 + parallelProcessingBatches = max(1, (parallelProcessingBatches * 0.5).toInt()) + bleScannerHelper.closeAllConnections() + } - } catch (e: TimeoutCancellationException) { - Timber.tag(TAG).e(e, "Timeout fetching total devices") - bleScannerHelper.closeAllConnections() + private fun increaseConnections() { + parallelProcessingBatches = min(max(1, (parallelProcessingBatches * 1.2).toInt()), maxPossibleConnections) + } + + private suspend fun fetchAllDevices(metadataNeeded: List) = coroutineScope { + softTimeout(TOTAL_FETCH_TIMEOUT_SEC.seconds, onTimeout = { + Timber.tag(TAG).e("Timeout fetching total devices") + }) { + metadataNeeded.splitToBatchesEqual(parallelProcessingBatches) + .filter { it.isNotEmpty() } + .mapParallel { batch -> + Timber.tag(TAG).i("Processing batch of ${batch.size} devices ($parallelProcessingBatches parallel)") + batch.forEach { device -> + fetchDevice(device) + } + } + Timber.tag(TAG).i("All devices processed") } } private suspend fun fetchDevice(device: DeviceData) { - try { - Timber.tag(TAG).i("Fetching device info for ${device.address}") - val result = withTimeout(DEVICE_FETCH_TIMEOUT_SEC.seconds) { fetchDeviceServiceInfo.execute(device) } - Timber.tag(TAG).i("Fetching complete for ${device.address}. Result: $result") - } catch (e: TimeoutCancellationException) { - Timber.tag(TAG).e(e, "Timeout fetching device info for ${device.address}") - bleScannerHelper.closeDeviceConnection(device.address) - } catch (e: FetchDeviceServiceInfo.MaxConnectionsReached) { - Timber.tag(TAG).e(e, "Max connections reached") - bleScannerHelper.closeAllConnections() + softTimeout(DEVICE_FETCH_TIMEOUT_SEC.seconds, onTimeout = { + Timber.tag(TAG).e("Timeout fetching device info for ${device.address}") + }) { + Timber.tag(TAG).i("Fetching device info for ${device.address}, distance: ${device.distance()}") + try { + val result = fetchDeviceServiceInfo.execute(device) + Timber.tag(TAG).i("Fetching complete for ${device.address}. Result: $result") + } catch (e: FetchDeviceServiceInfo.BluetoothConnectionException) { + Timber.tag(TAG).e(e, "Error when connecting to device ${device.address}") + } + } + } + + private suspend fun softTimeout(timeout: Duration, onTimeout: suspend () -> Unit, block: suspend () -> Unit) = coroutineScope { + var timeoutJob: Job? = null + val primaryJob = async { + block.invoke() + timeoutJob?.cancel() + } + + timeoutJob = async { + delay(timeout) + primaryJob.cancel() + onTimeout.invoke() } } diff --git a/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt b/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt index eae2a436..0de98019 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt @@ -157,15 +157,34 @@ class FetchDeviceServiceInfo( submitMetadata() } - is BleScannerHelper.DeviceConnectResult.DisconnectedWithError -> { - Timber.tag(TAG).e("Disconnected with error from ${device.address}") - submitMetadata() + is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError -> { + Timber.tag(TAG).e("Unspecified connection error from ${device.address}.") + bleScannerHelper.closeDeviceConnection(device.address) + throw BluetoothConnectionException.UnspecifiedConnectionError(event.errorCode) + } + + is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionTimeout -> { + Timber.tag(TAG).e("Connection timeout error from ${device.address}") + bleScannerHelper.closeDeviceConnection(device.address) + throw BluetoothConnectionException.ConnectionTimeoutException(event.errorCode) + } + + is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionFailedToEstablish -> { + Timber.tag(TAG).e("Connection failed to establish error from ${device.address}") + bleScannerHelper.closeDeviceConnection(device.address) + throw BluetoothConnectionException.ConnectionFailedToEstablish(event.errorCode) + } + + is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionFailedBeforeInitializing -> { + Timber.tag(TAG).e("Connection initializing failed error from ${device.address}") + bleScannerHelper.closeDeviceConnection(device.address) + throw BluetoothConnectionException.ConnectionInitializingFailed(event.errorCode) } - is BleScannerHelper.DeviceConnectResult.MaxGattConnectionsReached -> { - Timber.tag(TAG).e("Max GATT connections reached") + is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionTerminated -> { + Timber.tag(TAG).e("Connection terminated error from ${device.address}. Probably max GATT connections reached") bleScannerHelper.closeDeviceConnection(device.address) - throw MaxConnectionsReached() + throw BluetoothConnectionException.ConnectionTerminated(event.errorCode) } else -> { @@ -196,7 +215,13 @@ class FetchDeviceServiceInfo( return characteristics.filter { CharacteristicType.findByUuid(it.uuid.toString()) != null } } - class MaxConnectionsReached : RuntimeException() + sealed class BluetoothConnectionException(message: String) : RuntimeException(message) { + class UnspecifiedConnectionError(errorStatus: Int) : BluetoothConnectionException("Unspecified connection error (status: $errorStatus)") + class ConnectionTimeoutException(errorStatus: Int) : BluetoothConnectionException("Connection timeout (status: $errorStatus)") + class ConnectionFailedToEstablish(errorStatus: Int) : BluetoothConnectionException("Connection failed to establish (status: $errorStatus)") + class ConnectionInitializingFailed(errorStatus: Int) : BluetoothConnectionException("Connection initializing failed (code $errorStatus)") + class ConnectionTerminated(errorStatus: Int) : BluetoothConnectionException("Connection terminated (status: $errorStatus)") + } companion object { private const val TAG = "FetchDeviceServiceInfo" diff --git a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt index a19fe34d..b4e92e7d 100644 --- a/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt +++ b/app/src/main/java/f/cking/software/ui/devicedetails/DeviceDetailsViewModel.kt @@ -151,12 +151,6 @@ class DeviceDetailsViewModel( connectionJob?.cancel() } - is BleScannerHelper.DeviceConnectResult.MaxGattConnectionsReached -> { - Timber.e("Max GATT connections reached") - connectionStatus = ConnectionStatus.DISCONNECTED - connectionJob?.cancel() - } - // services update is BleScannerHelper.DeviceConnectResult.AvailableServices -> { addServices(result.services.map { mapService(it) }.toSet()) From 85f39eaf37b8756bfb78fd64a2a92e86ad4d9e70 Mon Sep 17 00:00:00 2001 From: semper-viventem Date: Mon, 31 Mar 2025 20:27:14 -0700 Subject: [PATCH 2/6] Improve deep analysis --- .../software/data/helpers/BleScannerHelper.kt | 52 +++--- .../DeviceServicesFetchingPlanner.kt | 161 +++++++++++++----- .../interactor/FetchDeviceServiceInfo.kt | 30 ++-- .../domain/interactor/InteractorsModule.kt | 2 +- .../interactor/SaveOrMergeBatchInteractor.kt | 4 +- 5 files changed, 170 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt b/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt index 2d9a5d12..1c249457 100644 --- a/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt +++ b/app/src/main/java/f/cking/software/data/helpers/BleScannerHelper.kt @@ -93,12 +93,11 @@ class BleScannerHelper( return callbackFlow { val services = mutableSetOf() val device = requireAdapter().getRemoteDevice(address) + var gatt: BluetoothGatt? = null val callback = object : BluetoothGattCallback() { override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { super.onServicesDiscovered(gatt, status) - connections.put(gatt.device.address, gatt) - if (status == BluetoothGatt.GATT_SUCCESS) { Timber.tag(TAG_CONNECT).d("Services discovered. ${gatt.services.size} services for device $address") services.addAll(gatt.services.orEmpty()) @@ -148,53 +147,62 @@ 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) + handleDisconnect(status, gatt) } else -> { Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError(status)) + trySend(DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError(gatt, status)) } } } - private fun handleDisconnect(status: Int) { + private fun handleDisconnect(status: Int, gatt: BluetoothGatt) { when (status) { BluetoothGatt.GATT_SUCCESS -> { trySend(DeviceConnectResult.Disconnected) } CONNECTION_FAILED_TO_ESTABLISH -> { Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.DisconnectedWithError.ConnectionFailedToEstablish(status)) + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionFailedToEstablish(gatt, status)) } CONNECTION_FAILED_BEFORE_INITIALIZING -> { Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.DisconnectedWithError.ConnectionFailedBeforeInitializing(status)) + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionFailedBeforeInitializing(gatt, status)) } CONNECTION_TERMINATED -> { Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.DisconnectedWithError.ConnectionTerminated(status)) + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionTerminated(gatt, status)) } BluetoothGatt.GATT_CONNECTION_TIMEOUT -> { Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.DisconnectedWithError.ConnectionTimeout(status)) + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionTimeout(gatt, status)) + } + BluetoothGatt.GATT_FAILURE -> { + Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") + trySend(DeviceConnectResult.DisconnectedWithError.ConnectionFailedTooManyClients(gatt, status)) } else -> { Timber.tag(TAG_CONNECT).e("Error while connecting to device $address. Error code: $status") - trySend(DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError(status)) + trySend(DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError(gatt, status)) } } } } Timber.tag(TAG_CONNECT).d("Connecting to device $address") - connections[address] = device.connectGatt(appContext, false, callback, BluetoothDevice.TRANSPORT_LE) + gatt = device.connectGatt(appContext, false, callback, BluetoothDevice.TRANSPORT_LE) awaitClose { Timber.tag(TAG_CONNECT).d("Closing connection to device $address") - closeDeviceConnection(address) + if (requireBluetoothManager().getConnectionState(device, BluetoothProfile.GATT) != BluetoothProfile.STATE_DISCONNECTED) { + gatt.disconnect() + } else { + gatt.close() + } } } } @@ -254,12 +262,14 @@ class BleScannerHelper( data object Disconnected : DeviceConnectResult sealed interface DisconnectedWithError : DeviceConnectResult { val errorCode: Int - - class UnspecifiedConnectionError(override val errorCode: Int) : DisconnectedWithError - class ConnectionTimeout(override val errorCode: Int) : DisconnectedWithError - class ConnectionTerminated(override val errorCode: Int) : DisconnectedWithError - class ConnectionFailedToEstablish(override val errorCode: Int) : DisconnectedWithError - class ConnectionFailedBeforeInitializing(override val errorCode: Int) : DisconnectedWithError + val gatt: BluetoothGatt + + class UnspecifiedConnectionError(override val gatt: BluetoothGatt, override val errorCode: Int) : DisconnectedWithError + class ConnectionTimeout(override val gatt: BluetoothGatt, override val errorCode: Int) : DisconnectedWithError + class ConnectionTerminated(override val gatt: BluetoothGatt, override val errorCode: Int) : DisconnectedWithError + class ConnectionFailedToEstablish(override val gatt: BluetoothGatt, override val errorCode: Int) : DisconnectedWithError + class ConnectionFailedBeforeInitializing(override val gatt: BluetoothGatt, override val errorCode: Int) : DisconnectedWithError + class ConnectionFailedTooManyClients(override val gatt: BluetoothGatt, override val errorCode: Int) : DisconnectedWithError } } @@ -336,10 +346,14 @@ class BleScannerHelper( } private fun tryToInitBluetoothScanner() { - bluetoothAdapter = appContext.getSystemService(BluetoothManager::class.java).adapter + bluetoothAdapter = requireBluetoothManager().adapter bluetoothScanner = bluetoothAdapter?.bluetoothLeScanner } + private fun requireBluetoothManager(): BluetoothManager { + return appContext.getSystemService(BluetoothManager::class.java) + } + private fun requireScanner(): BluetoothLeScanner { if (bluetoothScanner == null) { tryToInitBluetoothScanner() diff --git a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt index 9ba8645a..e46d0ee5 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt @@ -2,6 +2,7 @@ package f.cking.software.domain.interactor import f.cking.software.data.helpers.BleScannerHelper import f.cking.software.domain.model.DeviceData +import f.cking.software.domain.model.JournalEntry import f.cking.software.domain.model.SavedDeviceHandle import f.cking.software.domain.model.isNullOrEmpty import f.cking.software.mapParallel @@ -11,7 +12,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.math.max @@ -23,46 +25,66 @@ import kotlin.time.Duration.Companion.seconds class DeviceServicesFetchingPlanner( private val fetchDeviceServiceInfo: FetchDeviceServiceInfo, private val bleScannerHelper: BleScannerHelper, + private val saveReportInteractor: SaveReportInteractor, ) { - private var currentJob: Job? = null private var parallelProcessingBatches = PARALLEL_BATCH_COUNT private var maxPossibleConnections = PARALLEL_BATCH_COUNT + private var cooldown: Long? = null - suspend fun scheduleFetchServiceInfo(devices: List) = coroutineScope { - currentJob?.cancel() - currentJob = launch { - withContext(Dispatchers.IO) { - val metadataNeeded = devices - .filter { checkIfMetadataUpdateNeeded(it) } - .map { it.device } - .sortedBy { it.rssi } - .reversed() - - Timber.tag(TAG).i("Scheduling fetch service info for ${metadataNeeded.size} devices, out of ${devices.size} total") - try { - fetchAllDevices(metadataNeeded) -// increaseConnections() - } catch (e: FetchDeviceServiceInfo.BluetoothConnectionException.UnspecifiedConnectionError) { - Timber.tag(TAG).e(e, "Max connections reached") -// currentJob?.cancel() -// tooMachConnections() + suspend fun scheduleFetchServiceInfo(devices: List): List = coroutineScope { + + val cooldown = cooldown + if (cooldown != null && System.currentTimeMillis() - cooldown < MIN_COOLDOWN_DURATION_SEC.seconds.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 + } + + val result = devices + .map { it } + .associateBy { it.device.address } + .toMutableMap() + + var updatedCount = 0 + var errors = 0 + var timeouts = 0 + + withContext(Dispatchers.IO) { + val metadataNeeded = devices + .filter { checkIfMetadataUpdateNeeded(it) } + .map { it.device } + .sortedBy { it.rssi } + .reversed() + + Timber.tag(TAG).i("Scheduling fetch service info for ${metadataNeeded.size} devices, out of ${devices.size} total") + val updated = fetchAllDevices(metadataNeeded) + updated.forEach { fetchFeedback -> + when (fetchFeedback.feedback) { + FetchFeedback.SUCCESS -> { + fetchFeedback.device?.let { device -> + result[device.address]?.let { previousHandle -> + result[device.address] = previousHandle.copy(device = device) + } + updatedCount++ + } + } + FetchFeedback.TIMEOUT -> { + timeouts++ + } + FetchFeedback.ERROR -> { + errors++ + } } } - } - } - private fun tooMachConnections() { - maxPossibleConnections = parallelProcessingBatches - 1 - parallelProcessingBatches = max(1, (parallelProcessingBatches * 0.5).toInt()) - bleScannerHelper.closeAllConnections() + analyzeFeedback(metadataNeeded.size, updatedCount, timeouts, errors, devices.size) + result.values.toList() + } } - private fun increaseConnections() { - parallelProcessingBatches = min(max(1, (parallelProcessingBatches * 1.2).toInt()), maxPossibleConnections) - } + private suspend fun fetchAllDevices(metadataNeeded: List): List = coroutineScope { + val result = mutableListOf() - private suspend fun fetchAllDevices(metadataNeeded: List) = coroutineScope { softTimeout(TOTAL_FETCH_TIMEOUT_SEC.seconds, onTimeout = { Timber.tag(TAG).e("Timeout fetching total devices") }) { @@ -70,42 +92,78 @@ class DeviceServicesFetchingPlanner( .filter { it.isNotEmpty() } .mapParallel { batch -> Timber.tag(TAG).i("Processing batch of ${batch.size} devices ($parallelProcessingBatches parallel)") - batch.forEach { device -> - fetchDevice(device) + batch.map { device -> + result += fetchDevice(device) } } - Timber.tag(TAG).i("All devices processed") } + + result } - private suspend fun fetchDevice(device: DeviceData) { - softTimeout(DEVICE_FETCH_TIMEOUT_SEC.seconds, onTimeout = { + private suspend fun fetchDevice(device: DeviceData): DeviceFetchFeedback { + return softTimeout(DEVICE_FETCH_TIMEOUT_SEC.seconds, onTimeout = { Timber.tag(TAG).e("Timeout fetching device info for ${device.address}") + DeviceFetchFeedback(null, FetchFeedback.TIMEOUT) }) { Timber.tag(TAG).i("Fetching device info for ${device.address}, distance: ${device.distance()}") try { val result = fetchDeviceServiceInfo.execute(device) Timber.tag(TAG).i("Fetching complete for ${device.address}. Result: $result") + DeviceFetchFeedback(result?.let { device.copy(metadata = it) }, FetchFeedback.SUCCESS) } catch (e: FetchDeviceServiceInfo.BluetoothConnectionException) { Timber.tag(TAG).e(e, "Error when connecting to device ${device.address}") + DeviceFetchFeedback(null, FetchFeedback.ERROR) } } } - private suspend fun softTimeout(timeout: Duration, onTimeout: suspend () -> Unit, block: suspend () -> Unit) = coroutineScope { - var timeoutJob: Job? = null - val primaryJob = async { - block.invoke() - timeoutJob?.cancel() - } + private data class DeviceFetchFeedback( + val device: DeviceData?, + val feedback: FetchFeedback, + ) - timeoutJob = async { - delay(timeout) - primaryJob.cancel() - onTimeout.invoke() + private enum class FetchFeedback { + TIMEOUT, + ERROR, + SUCCESS, + } + + private suspend fun analyzeFeedback( + updateNeeded: Int, + updated: Int, + timeouts: Int, + errors: Int, + 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() } } + private suspend fun softTimeout(timeout: Duration, onTimeout: suspend () -> T, block: suspend () -> T): T = coroutineScope { + channelFlow { + var timeoutJob: Job? = null + val primaryJob = async { + val result = block.invoke() + timeoutJob?.cancel() + send(result) + } + + timeoutJob = async { + delay(timeout) + primaryJob.cancel() + send(onTimeout.invoke()) + } + }.first() + } + private fun checkIfMetadataUpdateNeeded(device: SavedDeviceHandle): Boolean { if (!device.device.isConnectable) { return false @@ -121,11 +179,22 @@ class DeviceServicesFetchingPlanner( || !recentlyChecked } + private fun tooMachConnections() { + maxPossibleConnections = parallelProcessingBatches - 1 + parallelProcessingBatches = max(1, (parallelProcessingBatches * 0.5).toInt()) + bleScannerHelper.closeAllConnections() + } + + private fun increaseConnections() { + parallelProcessingBatches = min(max(1, (parallelProcessingBatches * 1.2).toInt()), maxPossibleConnections) + } + companion object { - private const val PARALLEL_BATCH_COUNT = 6 + private const val PARALLEL_BATCH_COUNT = 10 private const val CHECK_INTERVAL_PER_DEVICE_MIN = 10 private const val DEVICE_FETCH_TIMEOUT_SEC = 5 private const val TOTAL_FETCH_TIMEOUT_SEC = 30 + private const val MIN_COOLDOWN_DURATION_SEC = 60 private const val TAG = "DeviceServicesFetchingPlanner" } } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt b/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt index 0de98019..f9ad72ce 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/FetchDeviceServiceInfo.kt @@ -60,9 +60,9 @@ class FetchDeviceServiceInfo( emit(metadata) } - fun disconnect() { + fun disconnect(gatt: BluetoothGatt) { Timber.tag(TAG).i("Disconnecting from ${device.address}") - gatt?.let(bleScannerHelper::disconnect) + bleScannerHelper.disconnect(gatt) job = this@coroutineScope.async { delay(100) @@ -90,11 +90,11 @@ class FetchDeviceServiceInfo( requestCharacteristic(event.gatt, relevantCharacteristics.first()) } else { Timber.tag(TAG).i("No relevant characteristics found for ${device.address}") - disconnect() + disconnect(event.gatt) } } else { Timber.tag(TAG).i("No services to request for ${device.address}") - disconnect() + disconnect(event.gatt) } } @@ -131,7 +131,7 @@ class FetchDeviceServiceInfo( if (pendingCharacteristics.isEmpty()) { Timber.tag(TAG).i("All characteristics read for ${device.address}, finishing fetching...") - disconnect() + disconnect(event.gatt) } else { Timber.tag(TAG).i("Still pending characteristics for ${device.address}: ${pendingCharacteristics.keys}") requestCharacteristic(event.gatt, pendingCharacteristics.values.first()) @@ -145,7 +145,7 @@ class FetchDeviceServiceInfo( if (pendingCharacteristics.isEmpty()) { Timber.tag(TAG).i("All characteristics read for ${device.address}, finishing fetching...") - disconnect() + disconnect(event.gatt) } else { Timber.tag(TAG).i("Still pending characteristics for ${device.address}: $pendingCharacteristics.keys") requestCharacteristic(event.gatt, pendingCharacteristics.values.first()) @@ -157,36 +157,43 @@ class FetchDeviceServiceInfo( submitMetadata() } + // Error handling is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.UnspecifiedConnectionError -> { Timber.tag(TAG).e("Unspecified connection error from ${device.address}.") - bleScannerHelper.closeDeviceConnection(device.address) + disconnect(event.gatt) throw BluetoothConnectionException.UnspecifiedConnectionError(event.errorCode) } is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionTimeout -> { Timber.tag(TAG).e("Connection timeout error from ${device.address}") - bleScannerHelper.closeDeviceConnection(device.address) + disconnect(event.gatt) throw BluetoothConnectionException.ConnectionTimeoutException(event.errorCode) } is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionFailedToEstablish -> { Timber.tag(TAG).e("Connection failed to establish error from ${device.address}") - bleScannerHelper.closeDeviceConnection(device.address) + disconnect(event.gatt) throw BluetoothConnectionException.ConnectionFailedToEstablish(event.errorCode) } is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionFailedBeforeInitializing -> { Timber.tag(TAG).e("Connection initializing failed error from ${device.address}") - bleScannerHelper.closeDeviceConnection(device.address) + disconnect(event.gatt) throw BluetoothConnectionException.ConnectionInitializingFailed(event.errorCode) } is BleScannerHelper.DeviceConnectResult.DisconnectedWithError.ConnectionTerminated -> { Timber.tag(TAG).e("Connection terminated error from ${device.address}. Probably max GATT connections reached") - bleScannerHelper.closeDeviceConnection(device.address) + disconnect(event.gatt) throw BluetoothConnectionException.ConnectionTerminated(event.errorCode) } + 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) + } + else -> { // do nothing } @@ -221,6 +228,7 @@ class FetchDeviceServiceInfo( class ConnectionFailedToEstablish(errorStatus: Int) : BluetoothConnectionException("Connection failed to establish (status: $errorStatus)") class ConnectionInitializingFailed(errorStatus: Int) : BluetoothConnectionException("Connection initializing failed (code $errorStatus)") class ConnectionTerminated(errorStatus: Int) : BluetoothConnectionException("Connection terminated (status: $errorStatus)") + class TooManyClients(errorStatus: Int) : BluetoothConnectionException("Too many clients (status: $errorStatus)") } companion object { diff --git a/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt b/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt index 2cdc7348..c7394e49 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/InteractorsModule.kt @@ -7,7 +7,7 @@ object InteractorsModule { val module = module { single { FilterCheckerImpl(get(), get(), get(), get(), get()) } - single { DeviceServicesFetchingPlanner(get(), get()) } + single { DeviceServicesFetchingPlanner(get(), get(), get()) } factory { ClearGarbageInteractor(get(), get(), get(), get()) } factory { GetAllDevicesInteractor(get()) } diff --git a/app/src/main/java/f/cking/software/domain/interactor/SaveOrMergeBatchInteractor.kt b/app/src/main/java/f/cking/software/domain/interactor/SaveOrMergeBatchInteractor.kt index c51bb6a3..280b735d 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/SaveOrMergeBatchInteractor.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/SaveOrMergeBatchInteractor.kt @@ -39,7 +39,7 @@ class SaveOrMergeBatchInteractor( devicesRepository.saveScanBatch(mergedDevices) - val savedBatch = mergedDevices.map { mergedDevice -> + var savedBatch = mergedDevices.map { mergedDevice -> SavedDeviceHandle( previouslySeenAtTime = existingDevices[mergedDevice.address]?.lastDetectTimeMs ?: mergedDevice.lastDetectTimeMs, device = mergedDevice, @@ -50,7 +50,7 @@ class SaveOrMergeBatchInteractor( } if (settingsRepository.getEnableDeepAnalysis()) { - deviceServicesFetchingPlanner.scheduleFetchServiceInfo(savedBatch) + savedBatch = deviceServicesFetchingPlanner.scheduleFetchServiceInfo(savedBatch) } val location = locationProvider.getFreshLocation() From 4c074858a154ab9d38b75d80d7dd9e9498fd1227 Mon Sep 17 00:00:00 2001 From: semper-viventem Date: Mon, 31 Mar 2025 20:29:11 -0700 Subject: [PATCH 3/6] Update app version --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7b7b890..83b11e1c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { minSdk = 29 targetSdk = 35 - versionCode = 1708536371 - versionName = "0.29.1-beta" + versionCode = 1708536372 + versionName = "0.29.2-beta" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 3f3e5cfe6695a7020f378fc230f7e4919333c01c Mon Sep 17 00:00:00 2001 From: semper-viventem Date: Mon, 31 Mar 2025 20:37:24 -0700 Subject: [PATCH 4/6] Update app version --- .../domain/interactor/BuildDeviceClassFromSystemInfo.kt | 5 ++++- .../domain/interactor/DeviceServicesFetchingPlanner.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/f/cking/software/domain/interactor/BuildDeviceClassFromSystemInfo.kt b/app/src/main/java/f/cking/software/domain/interactor/BuildDeviceClassFromSystemInfo.kt index eae81fa3..b9c92233 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/BuildDeviceClassFromSystemInfo.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/BuildDeviceClassFromSystemInfo.kt @@ -181,7 +181,10 @@ object BuildDeviceClassFromSystemInfo { " TV" to DeviceClass.AudioVideo.VideoDisplayAndLoudspeaker, "TV " to DeviceClass.AudioVideo.VideoDisplayAndLoudspeaker, "MacBook" to DeviceClass.Computer.Laptop, - "Mac" to DeviceClass.Computer.Desktop, + "iMac" to DeviceClass.Computer.Desktop, + "Macmini" to DeviceClass.Computer.Desktop, + "Mac1" to DeviceClass.Computer.Desktop, + "Mac2" to DeviceClass.Computer.Desktop, "iPad" to DeviceClass.Phone.Smartphone, ) } \ No newline at end of file diff --git a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt index e46d0ee5..f06d30a4 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt @@ -34,7 +34,7 @@ class DeviceServicesFetchingPlanner( suspend fun scheduleFetchServiceInfo(devices: List): List = coroutineScope { - val cooldown = cooldown + val cooldown = this@DeviceServicesFetchingPlanner.cooldown if (cooldown != null && System.currentTimeMillis() - cooldown < MIN_COOLDOWN_DURATION_SEC.seconds.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 From d9a43e586175be3b9f5c53c98124bbd961b6888b Mon Sep 17 00:00:00 2001 From: semper-viventem Date: Mon, 31 Mar 2025 20:41:01 -0700 Subject: [PATCH 5/6] Update app version --- .../domain/interactor/DeviceServicesFetchingPlanner.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt index f06d30a4..2eaff45c 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt @@ -35,7 +35,7 @@ class DeviceServicesFetchingPlanner( suspend fun scheduleFetchServiceInfo(devices: List): List = coroutineScope { val cooldown = this@DeviceServicesFetchingPlanner.cooldown - if (cooldown != null && System.currentTimeMillis() - cooldown < MIN_COOLDOWN_DURATION_SEC.seconds.inWholeMilliseconds) { + if (cooldown != null && System.currentTimeMillis() - cooldown < MIN_COOLDOWN_DURATION_MINS.seconds.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 } @@ -194,7 +194,7 @@ class DeviceServicesFetchingPlanner( private const val CHECK_INTERVAL_PER_DEVICE_MIN = 10 private const val DEVICE_FETCH_TIMEOUT_SEC = 5 private const val TOTAL_FETCH_TIMEOUT_SEC = 30 - private const val MIN_COOLDOWN_DURATION_SEC = 60 + private const val MIN_COOLDOWN_DURATION_MINS = 5 private const val TAG = "DeviceServicesFetchingPlanner" } } \ No newline at end of file From b0d29c8ec20e9405769480e0e9e8cd78429bdf44 Mon Sep 17 00:00:00 2001 From: semper-viventem Date: Mon, 31 Mar 2025 20:41:33 -0700 Subject: [PATCH 6/6] Update app version --- .../software/domain/interactor/DeviceServicesFetchingPlanner.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt index 2eaff45c..04b748e5 100644 --- a/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt +++ b/app/src/main/java/f/cking/software/domain/interactor/DeviceServicesFetchingPlanner.kt @@ -35,7 +35,7 @@ class DeviceServicesFetchingPlanner( suspend fun scheduleFetchServiceInfo(devices: List): List = coroutineScope { val cooldown = this@DeviceServicesFetchingPlanner.cooldown - if (cooldown != null && System.currentTimeMillis() - cooldown < MIN_COOLDOWN_DURATION_MINS.seconds.inWholeMilliseconds) { + 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 }