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.
This commit is contained in:
callebtc 2026-11-08 02:12:23 -05:00
parent 1f24973b06
commit f2d04fe130
2 changed files with 147 additions and 6 deletions

View File

@ -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)

View File

@ -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<BluetoothLeScanner>()
private lateinit var manager: BluetoothGattClientManager
@Before
fun setUp() {
val adapter = mock<BluetoothAdapter> {
on { isEnabled } doReturn true
on { bluetoothLeScanner } doReturn scanner
}
val bluetoothManager = mock<BluetoothManager> {
on { this.adapter } doReturn adapter
}
val context = mock<Context> {
on { getSystemService(Context.BLUETOOTH_SERVICE) } doReturn bluetoothManager
}
val permissionManager = mock<BluetoothPermissionManager> {
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<PowerManager> {
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<List<ScanFilter>>(), any(), any<ScanCallback>())
verify(scanner).stopScan(callback)
verify(scanner).startScan(any<List<ScanFilter>>(), any(), any<ScanCallback>())
}
}
@Test
fun `restarted scan keeps the callback the stack has registered`() {
val first = startedCallback()
manager.onScanStateChanged(false)
allowNextScanStart()
manager.onScanStateChanged(true)
val started = argumentCaptor<ScanCallback>()
verify(scanner, times(2)).startScan(any<List<ScanFilter>>(), 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<ScanCallback>()
verify(scanner).startScan(any<List<ScanFilter>>(), 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
}
}