android: routing - package scans off the main thread, no needless reconnects

- computeExcludedPackages() (full PackageManager scan) runs on Default,
  not Main.immediate, on config changes and package add/remove
- updateConfig() ignores no-op transforms and reconnects when routing is
  switched off too (direct apps kept bypassing the tunnel until then)
- config is volatile and read after its version, so an old config can no
  longer be cached under a new version
- Direct apps loads the app list once per entry, without GET_META_DATA

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 09:19:33 +07:00
1 parent 07fb02790c
commit ce80c36be1
3 files changed
+27 -14

No files matched your search

@@ -116,6 +116,8 @@ class RoutingManager private constructor(private val context: Context)
private val handler = Handler(Looper.getMainLooper())
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/* read from the charon and IO threads in computeExcludedPackages() */
@Volatile
private var config = RoutingConfig()
private var service: VpnStateService? = null
private var migratedOnlyProfiles = 0
@@ -158,7 +160,8 @@ class RoutingManager private constructor(private val context: Context)
override fun onReceive(context: Context?, intent: Intent?)
{
invalidateCache()
scope.launch { computeExcludedPackages() }
/* a full PackageManager scan: never on the main thread */
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
}
}
@@ -183,13 +186,23 @@ class RoutingManager private constructor(private val context: Context)
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
{
config = transform(config)
saveConfig(config)
val previous = config
val updated = transform(previous)
if (updated == previous)
{ /* e.g. a chip tapped that is already in that state: no reconnect */
return
}
config = updated
saveConfig(updated)
invalidateCache()
publish()
if (config.enabled)
{ /* warm the package cache so the connect path stays fast */
scope.launch { computeExcludedPackages() }
if (updated.enabled)
{ /* warm the package cache (off the main thread) so the connect path stays fast */
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
}
if (updated.enabled || previous.enabled)
{ /* also when routing was just switched off: the excluded apps keep
* bypassing the tunnel until it is re-established */
scheduleReconnect()
}
}
@@ -384,12 +397,15 @@ class RoutingManager private constructor(private val context: Context)
*/
fun computeExcludedPackages(): Set<String>
{
/* version before config: updateConfig() writes config first, so this
* order can at worst cache a newer config under an older version
* (recomputed next time), never an old config under the new one */
val version = configVersion
val current = config
if (!current.enabled)
{
return emptySet()
}
val version = configVersion
packageCache?.let { (cachedVersion, packages) ->
if (cachedVersion == version)
{
@@ -624,7 +640,7 @@ class RoutingManager private constructor(private val context: Context)
/** All installed apps that hold the INTERNET permission. */
fun loadInternetPackages(pm: PackageManager): Set<String> = try
{
pm.getInstalledApplications(PackageManager.GET_META_DATA)
pm.getInstalledApplications(0)
.asSequence()
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
.map { it.packageName }
@@ -74,6 +74,8 @@ import org.strongswan.android.ui.compose.theme.ShxSpacing
fun DirectAppsScreen(onBack: () -> Unit, viewModel: DirectAppsViewModel = viewModel())
{
val state by viewModel.ui.collectAsStateWithLifecycle()
/* the only load trigger (also re-entering the screen with a retained
* view model picks up newly installed apps) */
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.refresh() }
DirectAppsContent(
state = state,
@@ -114,11 +114,6 @@ class DirectAppsViewModel(app: Application) : AndroidViewModel(app)
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DirectAppsUiState())
init
{
refresh()
}
/** (Re)loads the installed apps; the previous list stays visible meanwhile. */
fun refresh()
{
@@ -156,7 +151,7 @@ object DirectApps
fun loadInstalledApps(pm: PackageManager, ownPackage: String): List<InstalledApp>
{
val collator = Collator.getInstance()
return pm.getInstalledApplications(PackageManager.GET_META_DATA)
return pm.getInstalledApplications(0)
.asSequence()
.filter { it.packageName != ownPackage }
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }