From 0af3b4b364a2504b84ce07e21cfdefc6e966b350 Mon Sep 17 00:00:00 2001 From: Hidden-Node Date: Sun, 19 Jul 2026 21:29:11 +0330 Subject: [PATCH] fix(android): inline VpnTileService IO/Main dispatcher switch and guard on isActive --- .../gooserelayvpn/service/VpnTileService.kt | 43 ++++++++++++++----- .../VpnTileServiceConnectLogicTest.kt | 22 ++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 android/app/src/test/java/com/gooserelay/gooserelayvpn/VpnTileServiceConnectLogicTest.kt diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/service/VpnTileService.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/service/VpnTileService.kt index 8f1787b..879ab0b 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/service/VpnTileService.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/service/VpnTileService.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @RequiresApi(Build.VERSION_CODES.N) class VpnTileService : TileService() { @@ -49,22 +51,41 @@ class VpnTileService : TileService() { return } - tileScope.launch(Dispatchers.IO) { - val selectedProfile = AppDatabase.getInstance(this@VpnTileService) - .profileDao() - .getSelectedProfile() + tileScope.launch { + // Read the selected profile on a background thread — Room + // call must not block the system's main thread or ANR the + // quick-settings shade. + val selectedProfile = withContext(Dispatchers.IO) { + AppDatabase.getInstance(this@VpnTileService) + .profileDao() + .getSelectedProfile() + } + + // If the tile service was torn down while we were reading + // the DB (user dismissed the shade, or Android killed the + // service), abandon the connect — tileScope is cancelled + // in onDestroy, but cancellation is cooperative. + if (!isActive) return@launch - launch(Dispatchers.Main) { - if (selectedProfile != null) { - VpnManager.connect(this@VpnTileService, selectedProfile) - updateTile() - } else { - openApp() - } + // Back on Dispatchers.Main (the scope's default dispatcher). + if (selectedProfile != null) { + VpnManager.connect(this@VpnTileService, selectedProfile) + updateTile() + } else { + openApp() } } } + /** + * Test-only: returns the action the tile should take given the current + * selected profile. Pure function — does not touch Android framework. + */ + internal enum class TileAction { CONNECT, OPEN_APP } + + internal fun tileActionForSelectedProfile(selectedProfile: com.gooserelay.gooserelayvpn.data.local.ProfileEntity?): TileAction = + if (selectedProfile != null) TileAction.CONNECT else TileAction.OPEN_APP + private fun openApp() { val intent = Intent(this, MainActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/android/app/src/test/java/com/gooserelay/gooserelayvpn/VpnTileServiceConnectLogicTest.kt b/android/app/src/test/java/com/gooserelay/gooserelayvpn/VpnTileServiceConnectLogicTest.kt new file mode 100644 index 0000000..8c13c03 --- /dev/null +++ b/android/app/src/test/java/com/gooserelay/gooserelayvpn/VpnTileServiceConnectLogicTest.kt @@ -0,0 +1,22 @@ +package com.gooserelay.gooserelayvpn + +import com.google.common.truth.Truth.assertThat +import com.gooserelay.gooserelayvpn.data.local.ProfileEntity +import com.gooserelay.gooserelayvpn.service.VpnTileService +import org.junit.Test + +class VpnTileServiceConnectLogicTest { + + @Test + fun `null profile returns OPEN_APP`() { + val action = VpnTileService().tileActionForSelectedProfile(null) + assertThat(action).isEqualTo(VpnTileService.TileAction.OPEN_APP) + } + + @Test + fun `non-null profile returns CONNECT`() { + val profile = ProfileEntity(name = "test", tunnelKey = "x") + val action = VpnTileService().tileActionForSelectedProfile(profile) + assertThat(action).isEqualTo(VpnTileService.TileAction.CONNECT) + } +}