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 <noreply@anthropic.com>
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 handler = Handler(Looper.getMainLooper())
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/* read from the charon and IO threads in computeExcludedPackages() */
@Volatile
private var config = RoutingConfig() private var config = RoutingConfig()
private var service: VpnStateService? = null private var service: VpnStateService? = null
private var migratedOnlyProfiles = 0 private var migratedOnlyProfiles = 0
@@ -158,7 +160,8 @@ class RoutingManager private constructor(private val context: Context)
override fun onReceive(context: Context?, intent: Intent?) override fun onReceive(context: Context?, intent: Intent?)
{ {
invalidateCache() 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) fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
{ {
config = transform(config) val previous = config
saveConfig(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() invalidateCache()
publish() publish()
if (config.enabled) if (updated.enabled)
{ /* warm the package cache so the connect path stays fast */ { /* warm the package cache (off the main thread) so the connect path stays fast */
scope.launch { computeExcludedPackages() } 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() scheduleReconnect()
} }
} }
@@ -384,12 +397,15 @@ class RoutingManager private constructor(private val context: Context)
*/ */
fun computeExcludedPackages(): Set<String> 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 val current = config
if (!current.enabled) if (!current.enabled)
{ {
return emptySet() return emptySet()
} }
val version = configVersion
packageCache?.let { (cachedVersion, packages) -> packageCache?.let { (cachedVersion, packages) ->
if (cachedVersion == version) if (cachedVersion == version)
{ {
@@ -624,7 +640,7 @@ class RoutingManager private constructor(private val context: Context)
/** All installed apps that hold the INTERNET permission. */ /** All installed apps that hold the INTERNET permission. */
fun loadInternetPackages(pm: PackageManager): Set<String> = try fun loadInternetPackages(pm: PackageManager): Set<String> = try
{ {
pm.getInstalledApplications(PackageManager.GET_META_DATA) pm.getInstalledApplications(0)
.asSequence() .asSequence()
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED } .filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
.map { it.packageName } .map { it.packageName }
@@ -74,6 +74,8 @@ import org.strongswan.android.ui.compose.theme.ShxSpacing
fun DirectAppsScreen(onBack: () -> Unit, viewModel: DirectAppsViewModel = viewModel()) fun DirectAppsScreen(onBack: () -> Unit, viewModel: DirectAppsViewModel = viewModel())
{ {
val state by viewModel.ui.collectAsStateWithLifecycle() 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() } androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.refresh() }
DirectAppsContent( DirectAppsContent(
state = state, state = state,
@@ -114,11 +114,6 @@ class DirectAppsViewModel(app: Application) : AndroidViewModel(app)
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DirectAppsUiState()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DirectAppsUiState())
init
{
refresh()
}
/** (Re)loads the installed apps; the previous list stays visible meanwhile. */ /** (Re)loads the installed apps; the previous list stays visible meanwhile. */
fun refresh() fun refresh()
{ {
@@ -156,7 +151,7 @@ object DirectApps
fun loadInstalledApps(pm: PackageManager, ownPackage: String): List<InstalledApp> fun loadInstalledApps(pm: PackageManager, ownPackage: String): List<InstalledApp>
{ {
val collator = Collator.getInstance() val collator = Collator.getInstance()
return pm.getInstalledApplications(PackageManager.GET_META_DATA) return pm.getInstalledApplications(0)
.asSequence() .asSequence()
.filter { it.packageName != ownPackage } .filter { it.packageName != ownPackage }
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED } .filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }