From f2d04fe130c727c244973dba0509b699ad61d8a3 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:12:23 -0500 Subject: [PATCH] Fix BLE scan recovery after already-started failure The error 1 recovery cleared the scanning flag before calling stopScanning, so the stack-level stop was always skipped. A new callback on every start also meant an earlier registration could never be stopped or detected. Reuse one ScanCallback and force a stack-level stop on every scan failure. --- .../mesh/BluetoothGattClientManager.kt | 16 +- ...lientManagerScanRecoveryRobolectricTest.kt | 137 ++++++++++++++++++ 2 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/mesh/BluetoothGattClientManagerScanRecoveryRobolectricTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt index a0f7c207b..419872a93 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt @@ -214,7 +214,9 @@ class BluetoothGattClientManager( val scanFilters = listOf(scanFilter) - scanCallback = object : ScanCallback() { + // Reuse one callback: the stack tracks scans by callback identity, so a replaced callback + // could never stop or recognize a scan registered under the previous one. + scanCallback = scanCallback ?: object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { handleScanResult(result) } @@ -228,12 +230,14 @@ class BluetoothGattClientManager( override fun onScanFailed(errorCode: Int) { isCurrentlyScanning = false lastScanStopTime = System.currentTimeMillis() + // The stack keeps a registration that fails after startScan; release it regardless + // of the flag so the next start doesn't report already-started. + stopScanning(force = true) when (errorCode) { 1 -> { - // Already started: the stack thinks a scan is running. Re-arm from a clean - // state so we don't stay wedged (stop then restart with backoff). - stopScanning() + // Already started: the stack thought a scan was running. It was stopped + // above, so re-arm with backoff from a clean state. scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS) } 2 -> { @@ -277,10 +281,10 @@ class BluetoothGattClientManager( * Stop scanning */ @Suppress("DEPRECATION") - private fun stopScanning() { + private fun stopScanning(force: Boolean = false) { if (!permissionManager.hasBluetoothPermissions() || bleScanner == null) return - if (isCurrentlyScanning) { + if (isCurrentlyScanning || force) { try { scanCallback?.let { bleScanner.stopScan(it) diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothGattClientManagerScanRecoveryRobolectricTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothGattClientManagerScanRecoveryRobolectricTest.kt new file mode 100644 index 000000000..82347f4dd --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothGattClientManagerScanRecoveryRobolectricTest.kt @@ -0,0 +1,137 @@ +package com.bitchat.android.mesh + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanSettings +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import org.junit.After +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class BluetoothGattClientManagerScanRecoveryRobolectricTest { + private val scheduler = TestCoroutineScheduler() + private val scope = CoroutineScope(StandardTestDispatcher(scheduler)) + private val scanner = mock() + private lateinit var manager: BluetoothGattClientManager + + @Before + fun setUp() { + val adapter = mock { + on { isEnabled } doReturn true + on { bluetoothLeScanner } doReturn scanner + } + val bluetoothManager = mock { + on { this.adapter } doReturn adapter + } + val context = mock { + on { getSystemService(Context.BLUETOOTH_SERVICE) } doReturn bluetoothManager + } + val permissionManager = mock { + on { hasBluetoothPermissions() } doReturn true + } + val resolved = PowerProfileResolver.resolve( + batteryLevel = 100, + isCharging = true, + isBackground = false, + hasDirectPeers = false + ) + val continuousProfile = resolved.copy(ble = resolved.ble.copy(continuousScan = true)) + val powerManager = mock { + on { profile } doReturn MutableStateFlow(continuousProfile) + on { getScanSettings() } doReturn ScanSettings.Builder().build() + } + manager = BluetoothGattClientManager( + context, + scope, + mock(), + permissionManager, + powerManager, + null + ) + + manager.start() + scheduler.runCurrent() + } + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `already started failure stops the registered scan before restarting it`() { + val callback = startedCallback() + + callback.onScanFailed(ScanCallback.SCAN_FAILED_ALREADY_STARTED) + allowNextScanStart() + scheduler.advanceTimeBy(ALREADY_STARTED_RETRY_MS + 1) + + inOrder(scanner) { + verify(scanner).startScan(any>(), any(), any()) + verify(scanner).stopScan(callback) + verify(scanner).startScan(any>(), any(), any()) + } + } + + @Test + fun `restarted scan keeps the callback the stack has registered`() { + val first = startedCallback() + + manager.onScanStateChanged(false) + allowNextScanStart() + manager.onScanStateChanged(true) + + val started = argumentCaptor() + verify(scanner, times(2)).startScan(any>(), any(), started.capture()) + assertSame(first, started.secondValue) + } + + @Test + fun `failure reported after registration releases the scan`() { + val callback = startedCallback() + + callback.onScanFailed(ScanCallback.SCAN_FAILED_INTERNAL_ERROR) + + verify(scanner).stopScan(callback) + } + + private fun startedCallback(): ScanCallback { + val started = argumentCaptor() + verify(scanner).startScan(any>(), any(), started.capture()) + return started.firstValue + } + + /** The rate limit reads the wall clock, so clear the last start instead of sleeping past it. */ + private fun allowNextScanStart() { + BluetoothGattClientManager::class.java + .getDeclaredField("lastScanStartTime") + .apply { isAccessible = true } + .setLong(manager, 0L) + } + + private companion object { + const val ALREADY_STARTED_RETRY_MS = 3_000L + } +}