Skip to content

Commit 3c9b251

Browse files
Shut down PC Bluetooth outside PC UI (#2417)
- Stop PC Bluetooth discovery and live control sessions when PC UI leaves the foreground - Close in-flight BLE status-read GATT connections during discovery shutdown - Add explicit PC service controller disconnect cleanup - Cover screen and control lifecycle shutdown behavior with focused tests 🤖 Auto-generated Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent eff05fc commit 3c9b251

9 files changed

Lines changed: 250 additions & 36 deletions

File tree

app/src/main/java/com/enaboapps/switchify/pc/PcConnectionViewModel.kt

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.enaboapps.switchify.pc.bluetooth.PcBleDiscoveryService
77
import com.enaboapps.switchify.pc.bluetooth.SwitchifyPcBleClient
88
import kotlinx.coroutines.CoroutineDispatcher
99
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.Job
1011
import kotlinx.coroutines.flow.MutableStateFlow
1112
import kotlinx.coroutines.flow.StateFlow
1213
import kotlinx.coroutines.flow.asStateFlow
@@ -78,6 +79,7 @@ class PcConnectionViewModel(
7879
val uiState: StateFlow<PcConnectionUiState> = _uiState.asStateFlow()
7980

8081
private val rowStatuses = MutableStateFlow<Map<String, PcRowStatus>>(emptyMap())
82+
private var activeConnectionJob: Job? = null
8183

8284
// Bumped on every token store mutation so the combine pipeline re-reads
8385
// token-derived state (saved pairings, row actions); the store itself is not observable.
@@ -133,19 +135,22 @@ class PcConnectionViewModel(
133135
}
134136

135137
fun requestAccess(pc: DiscoveredPc) {
136-
viewModelScope.launch {
138+
activeConnectionJob?.cancel()
139+
activeConnectionJob = viewModelScope.launch {
137140
requestAccessInternal(pc)
138141
}
139142
}
140143

141144
fun connectWithSavedToken(pc: DiscoveredPc) {
142-
viewModelScope.launch {
145+
activeConnectionJob?.cancel()
146+
activeConnectionJob = viewModelScope.launch {
143147
connectWithSavedTokenInternal(pc)
144148
}
145149
}
146150

147151
fun connectSavedPairing(desktopId: String) {
148-
viewModelScope.launch {
152+
activeConnectionJob?.cancel()
153+
activeConnectionJob = viewModelScope.launch {
149154
val endpoint = tokenStore.getLastEndpointId(desktopId)
150155
?: run {
151156
showMessage("This PC is not nearby.")
@@ -202,9 +207,24 @@ class PcConnectionViewModel(
202207
}
203208
}
204209

205-
override fun onCleared() {
210+
fun stopPcBluetooth() {
211+
activeConnectionJob?.cancel()
212+
activeConnectionJob = null
206213
discoveryService.stopDiscovery()
207214
connector.close()
215+
PcConnectionStateHolder.setDisconnected()
216+
rowStatuses.update { emptyMap() }
217+
_uiState.update {
218+
it.copy(
219+
isBusy = false,
220+
approvalCode = null,
221+
isDiscovering = false
222+
)
223+
}
224+
}
225+
226+
override fun onCleared() {
227+
stopPcBluetooth()
208228
super.onCleared()
209229
}
210230

app/src/main/java/com/enaboapps/switchify/pc/PcServiceConnectionController.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,14 @@ class PcServiceConnectionController(
141141
}
142142

143143
fun cleanup() {
144+
disconnect()
145+
}
146+
147+
fun disconnect() {
144148
discovery.stopDiscovery()
145149
connector.close()
150+
PcConnectionStateHolder.setDisconnected()
151+
_state.value = PcServiceConnectionState.Disconnected
146152
}
147153

148154
private fun existingConnection(): PcServiceConnectResult.Connected? {

app/src/main/java/com/enaboapps/switchify/pc/bluetooth/PcBleDiscoveryService.kt

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class PcBleDiscoveryService(private val context: Context) : PcDiscovery {
2929
private val bluetoothManager = context.applicationContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
3030
private val discovered = linkedMapOf<String, DiscoveredPc>()
3131
private val resolvingAddresses = mutableSetOf<String>()
32+
private val resolvingGatts = mutableMapOf<String, BluetoothGatt>()
33+
private val resolvingLock = Any()
3234
private var scanCallback: ScanCallback? = null
3335

3436
private val _pcs = MutableStateFlow<List<DiscoveredPc>>(emptyList())
@@ -41,7 +43,10 @@ class PcBleDiscoveryService(private val context: Context) : PcDiscovery {
4143
override fun startDiscovery() {
4244
stopDiscovery()
4345
discovered.clear()
44-
resolvingAddresses.clear()
46+
synchronized(resolvingLock) {
47+
resolvingAddresses.clear()
48+
resolvingGatts.clear()
49+
}
4550
_pcs.value = emptyList()
4651
_status.value = PcDiscoveryStatus.Searching
4752

@@ -83,10 +88,20 @@ class PcBleDiscoveryService(private val context: Context) : PcDiscovery {
8388

8489
@SuppressLint("MissingPermission")
8590
override fun stopDiscovery() {
86-
val callback = scanCallback ?: return
91+
val callback = scanCallback
8792
val scanner = bluetoothManager.adapter?.bluetoothLeScanner
88-
runCatching { scanner?.stopScan(callback) }
93+
if (callback != null) {
94+
runCatching { scanner?.stopScan(callback) }
95+
}
8996
scanCallback = null
97+
val gatts = synchronized(resolvingLock) {
98+
resolvingAddresses.clear()
99+
resolvingGatts.values.toList().also { resolvingGatts.clear() }
100+
}
101+
gatts.forEach { gatt ->
102+
runCatching { gatt.disconnect() }
103+
runCatching { gatt.close() }
104+
}
90105
if (_pcs.value.isEmpty() && _status.value == PcDiscoveryStatus.Searching) {
91106
_status.value = PcDiscoveryStatus.Empty
92107
}
@@ -96,14 +111,20 @@ class PcBleDiscoveryService(private val context: Context) : PcDiscovery {
96111
private suspend fun resolve(result: ScanResult) {
97112
if (!context.hasBluetoothConnectPermission()) return
98113
val device = result.device ?: return
99-
if (!resolvingAddresses.add(device.address)) return
114+
val shouldResolve = synchronized(resolvingLock) {
115+
resolvingAddresses.add(device.address)
116+
}
117+
if (!shouldResolve) return
100118
val callback = StatusReadCallback(device.address, runCatching { device.name }.getOrNull())
101119
val gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
102120
device.connectGatt(context.applicationContext, false, callback, android.bluetooth.BluetoothDevice.TRANSPORT_LE)
103121
} else {
104122
@Suppress("DEPRECATION")
105123
device.connectGatt(context.applicationContext, false, callback)
106124
}
125+
synchronized(resolvingLock) {
126+
resolvingGatts[device.address] = gatt
127+
}
107128
callback.gatt = gatt
108129
}
109130

@@ -129,23 +150,20 @@ class PcBleDiscoveryService(private val context: Context) : PcDiscovery {
129150
if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothGatt.STATE_CONNECTED) {
130151
gatt.discoverServices()
131152
} else if (newState == BluetoothGatt.STATE_DISCONNECTED) {
132-
resolvingAddresses.remove(deviceAddress)
133-
runCatching { gatt.close() }
153+
finishGatt(gatt, disconnect = false)
134154
}
135155
}
136156

137157
@SuppressLint("MissingPermission")
138158
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
139159
if (status != BluetoothGatt.GATT_SUCCESS) {
140-
resolvingAddresses.remove(deviceAddress)
141-
gatt.disconnect()
160+
finishGatt(gatt)
142161
return
143162
}
144163
val characteristic = gatt.getService(PcBleConstants.serviceUuid)
145164
?.getCharacteristic(PcBleConstants.statusCharacteristicUuid)
146165
if (characteristic == null || !gatt.readCharacteristic(characteristic)) {
147-
resolvingAddresses.remove(deviceAddress)
148-
gatt.disconnect()
166+
finishGatt(gatt)
149167
}
150168
}
151169

@@ -174,8 +192,17 @@ class PcBleDiscoveryService(private val context: Context) : PcDiscovery {
174192
val rawStatus = String(value, StandardCharsets.UTF_8)
175193
PcBleStatusParser.parse(deviceAddress, deviceName, rawStatus)?.let(::publish)
176194
}
177-
resolvingAddresses.remove(deviceAddress)
178-
gatt.disconnect()
195+
finishGatt(gatt)
196+
}
197+
198+
@SuppressLint("MissingPermission")
199+
private fun finishGatt(gatt: BluetoothGatt, disconnect: Boolean = true) {
200+
synchronized(resolvingLock) {
201+
resolvingAddresses.remove(deviceAddress)
202+
resolvingGatts.remove(deviceAddress)
203+
}
204+
if (disconnect) runCatching { gatt.disconnect() }
205+
runCatching { gatt.close() }
179206
}
180207
}
181208
}

app/src/main/java/com/enaboapps/switchify/screens/pc/PcConnectionScreen.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.material3.OutlinedButton
1818
import androidx.compose.material3.Text
1919
import androidx.compose.material3.TextButton
2020
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.DisposableEffect
2122
import androidx.compose.runtime.LaunchedEffect
2223
import androidx.compose.runtime.collectAsState
2324
import androidx.compose.runtime.getValue
@@ -69,6 +70,12 @@ fun PcConnectionScreen(navController: NavController) {
6970
}
7071
}
7172

73+
DisposableEffect(Unit) {
74+
onDispose {
75+
viewModel.stopPcBluetooth()
76+
}
77+
}
78+
7279
BaseView(
7380
titleResId = R.string.pc_connection_title,
7481
navController = navController,

app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class PcMouseControlActivity : ComponentActivity() {
6767
override fun onPause() {
6868
scanModeSession?.close()
6969
scanModeSession = null
70+
viewModel.stopPcBluetooth()
7071
super.onPause()
7172
}
7273

app/src/main/java/com/enaboapps/switchify/screens/pc/PcMouseControlViewModel.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,23 @@ class PcMouseControlViewModel(
273273
_uiState.update { it.copy(message = null) }
274274
}
275275

276-
override fun onCleared() {
276+
fun stopPcBluetooth() {
277277
closeLiveConnection()
278278
connector.close()
279+
PcConnectionStateHolder.setDisconnected()
280+
movementSteps = FALLBACK_MOVEMENT_STEPS
281+
_uiState.update {
282+
it.copy(
283+
connectedDisplayName = null,
284+
movementStep = movementSteps.stepFor(it.selectedMovementSize),
285+
isBusy = false,
286+
busyCommand = null
287+
)
288+
}
289+
}
290+
291+
override fun onCleared() {
292+
stopPcBluetooth()
279293
super.onCleared()
280294
}
281295

app/src/test/java/com/enaboapps/switchify/pc/PcConnectionViewModelTest.kt

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlinx.coroutines.test.setMain
1212
import org.junit.After
1313
import org.junit.Assert.assertEquals
1414
import org.junit.Assert.assertNull
15+
import org.junit.Assert.assertTrue
1516
import org.junit.Before
1617
import org.junit.Test
1718

@@ -138,6 +139,58 @@ class PcConnectionViewModelTest {
138139
advanceUntilIdle()
139140
}
140141

142+
@Test
143+
fun stopPcBluetoothStopsDiscoveryAndClosesConnector() = runTest(dispatcher) {
144+
val discovery = FakeDiscovery(listOf(pc))
145+
val connector = FakeConnector()
146+
val viewModel = viewModel(discovery, FakeTokenStore(), connector)
147+
advanceUntilIdle()
148+
149+
viewModel.stopPcBluetooth()
150+
advanceUntilIdle()
151+
152+
assertEquals(1, discovery.stopDiscoveryCalls)
153+
assertEquals(1, connector.closeCalls)
154+
assertTrue(PcConnectionStateHolder.connectionState.value is PcConnectionState.Disconnected)
155+
}
156+
157+
@Test
158+
fun stopPcBluetoothClearsConnectedStateWithoutClearingToken() = runTest(dispatcher) {
159+
val tokens = FakeTokenStore(initialTokens = mutableMapOf("desktop-1" to "token"))
160+
val viewModel = viewModel(FakeDiscovery(listOf(pc)), tokens, FakeConnector())
161+
PcConnectionStateHolder.setConnected(PcAuthenticatedSession("desktop-1", "device-1", "AA:BB:CC:DD:EE:FF"), "Switchify PC")
162+
advanceUntilIdle()
163+
164+
viewModel.stopPcBluetooth()
165+
advanceUntilIdle()
166+
167+
assertTrue(PcConnectionStateHolder.connectionState.value is PcConnectionState.Disconnected)
168+
assertEquals("token", tokens.getToken("desktop-1"))
169+
}
170+
171+
@Test
172+
fun stopPcBluetoothDismissesApprovalAndBusyState() = runTest(dispatcher) {
173+
val discovery = FakeDiscovery(listOf(pc))
174+
val pairingDeferred = CompletableDeferred<PcPairingResult>()
175+
val connector = FakeConnector(pairingDeferred = pairingDeferred)
176+
val viewModel = viewModel(discovery, FakeTokenStore(), connector, requestNonceProvider = { "nonce-1" })
177+
advanceUntilIdle()
178+
179+
viewModel.requestAccess(pc)
180+
advanceUntilIdle()
181+
assertEquals("215918", viewModel.uiState.value.approvalCode?.verificationCode)
182+
183+
viewModel.stopPcBluetooth()
184+
advanceUntilIdle()
185+
186+
assertNull(viewModel.uiState.value.approvalCode)
187+
assertEquals(false, viewModel.uiState.value.isBusy)
188+
assertEquals(1, connector.closeCalls)
189+
pairingDeferred.complete(PcPairingResult.Paired("desktop-1", "token", "AA:BB:CC:DD:EE:FF"))
190+
advanceUntilIdle()
191+
assertTrue(PcConnectionStateHolder.connectionState.value is PcConnectionState.Disconnected)
192+
}
193+
141194
@Test
142195
fun pairingSuccessClearsApprovalCode() = runTest(dispatcher) {
143196
val discovery = FakeDiscovery(listOf(pc))
@@ -454,8 +507,11 @@ class PcConnectionViewModelTest {
454507
private class FakeDiscovery(initialPcs: List<DiscoveredPc>) : PcDiscovery {
455508
override val pcs = MutableStateFlow(initialPcs)
456509
override val status = MutableStateFlow(if (initialPcs.isEmpty()) PcDiscoveryStatus.Empty else PcDiscoveryStatus.Found)
510+
var stopDiscoveryCalls = 0
457511
override fun startDiscovery() = Unit
458-
override fun stopDiscovery() = Unit
512+
override fun stopDiscovery() {
513+
stopDiscoveryCalls++
514+
}
459515
}
460516

461517
private class FakeTokenStore(
@@ -511,6 +567,7 @@ class PcConnectionViewModelTest {
511567
val requestNonces = mutableListOf<String>()
512568
val approvalPcs = mutableListOf<DiscoveredPc>()
513569
val pingPcs = mutableListOf<DiscoveredPc>()
570+
var closeCalls = 0
514571

515572
override suspend fun requestApproval(pc: DiscoveredPc, requestNonce: String): PcPairingResult {
516573
requestApprovalCalls++
@@ -533,6 +590,8 @@ class PcConnectionViewModelTest {
533590
return PcCommandResult.Ack
534591
}
535592

536-
override fun close() = Unit
593+
override fun close() {
594+
closeCalls++
595+
}
537596
}
538597
}

0 commit comments

Comments
 (0)