From cccfeefd7b76a3213d67c3ee592623246779ea32 Mon Sep 17 00:00:00 2001 From: okhsunrog Date: Thu, 20 Aug 2026 17:12:13 +0300 Subject: [PATCH] fix(picker): fail-soft app enumeration instead of a hard "unlock a profile" wall Root can enumerate every Android profile regardless of lock state (package install metadata is Device-Encrypted), so a failed/empty profile scan is almost always a bug, not a genuinely-unreadable profile. Stop hard-blocking the whole picker when one profile doesn't scan: show every app that could be read (the per-user root scan plus an in-process getInstalledApplications(0) backstop for user 0) plus a soft banner naming the un-scanned profile, and hard-fail only when nothing at all could be read. A partial-scan Save preserves settings -- roles and auto-hidden state -- for packages the picker couldn't see this run, so nothing is dropped. Stacks on the ARG_MAX streaming fix that already landed. --- ...fixed-the-app-picker-now-shows-the-3f7b.md | 9 ++ docs/storage.md | 11 +- lsposed/README.md | 7 +- .../dev/okhsunrog/vpnhide/AppListCache.kt | 80 ++++++++++++- .../dev/okhsunrog/vpnhide/AppPickerData.kt | 17 ++- .../dev/okhsunrog/vpnhide/AppPickerScreen.kt | 1 + .../okhsunrog/vpnhide/DebugShellSnapshot.kt | 1 + .../okhsunrog/vpnhide/PackageInventoryData.kt | 84 ++++++++++++-- .../okhsunrog/vpnhide/TargetPickerScaffold.kt | 32 +++++- .../dev/okhsunrog/vpnhide/TargetsCache.kt | 2 +- .../app/src/main/res/values-ru/strings.xml | 5 +- .../src/main/res/values-zh-rCN/strings.xml | 5 +- lsposed/app/src/main/res/values/strings.xml | 5 +- .../okhsunrog/vpnhide/AppPickerDataTest.kt | 78 +++++++++++++ .../vpnhide/PackageInventoryDataTest.kt | 106 +++++++++++++++++- 15 files changed, 417 insertions(+), 26 deletions(-) create mode 100644 changelog.d/fixed-the-app-picker-now-shows-the-3f7b.md diff --git a/changelog.d/fixed-the-app-picker-now-shows-the-3f7b.md b/changelog.d/fixed-the-app-picker-now-shows-the-3f7b.md new file mode 100644 index 0000000..fe67f7a --- /dev/null +++ b/changelog.d/fixed-the-app-picker-now-shows-the-3f7b.md @@ -0,0 +1,9 @@ +_2026-08-20_ + +## English + +The app picker now shows the apps it can read plus a note about any profile it couldn't scan, instead of blocking the whole list; settings for un-scanned profiles are kept. + +## Русский + +Пикер приложений теперь показывает то, что удалось прочитать, и помечает непрочитанные профили, вместо блокировки всего списка; настройки непрочитанных профилей сохраняются. diff --git a/docs/storage.md b/docs/storage.md index b0421da..80c22e7 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -296,8 +296,15 @@ zygisk/ # cdylib — the injected .so. deps: protocol (+ sha wait so the UI cannot hang forever. It lists Android users first and resolves every user separately instead of relying on the OEM-dependent `--user all` aggregation. If any user scan fails, activation stops before - replacing runtime state with a partial target set. Then it reads the - canonical and writes its channel. + replacing runtime state with a partial target set — this is the native + activator's own (Rust/C) runtime save-safety and is unrelated to the app + UI below. Then it reads the canonical and writes its channel. + The app-side target picker enumerates the same way but is fail-soft: a + failed profile scan (other than user 0, which also gets an in-process + `getInstalledApplications(0)` backstop) shows a banner instead of blocking + the whole app list, and Save preserves settings for packages it couldn't + see. Root can read every profile regardless of lock state, so the picker + only hard-fails when nothing could be enumerated from any source at all. - **Bundle integrity:** the app's batched root snapshot checks each installed module's `activator` directly and distinguishes an absent file from a non-executable one. Enabled modules with either failure are marked broken on diff --git a/lsposed/README.md b/lsposed/README.md index 1cb2ea7..62de861 100644 --- a/lsposed/README.md +++ b/lsposed/README.md @@ -40,8 +40,11 @@ re-entering them. The APK includes a Compose UI for managing target apps across all vpnhide modules: - Lists installed apps from every Android user/profile with icons, names, and - package names. Each user is queried separately; an incomplete profile scan is - rejected instead of being presented as a complete app list. + package names. Each user is queried separately, plus an in-process backstop + for user 0. A profile that didn't scan cleanly is shown as a soft banner + instead of blocking the whole list — root can read every profile regardless + of lock state, so the picker only hard-fails when nothing could be read at + all. Settings for un-scanned profiles are preserved on Save. - Text search filter - System apps toggle (selected system apps always visible) - One row per app with the current roles: diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppListCache.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppListCache.kt index da2b9b1..9429212 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppListCache.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppListCache.kt @@ -41,6 +41,17 @@ internal fun AppSummary.toAutoHideSignal(): AppAutoHideSignal = internal fun looksLikeVpnAppName(label: String): Boolean = label.uppercase(Locale.ROOT).contains("VPN") +/** + * Surfaced by [AppListCache.scanWarning] when the merged inventory is + * [PackageInventory.partial] — some profile other than user 0 (the + * in-process backstop covers user 0) didn't scan cleanly. Distinct from + * [StateCache.error]: the app list itself loaded fine, this only flags that + * part of it may be missing. + */ +internal data class PackageScanWarning( + val failedUserIds: Set, +) + /** * Append a profile list to an app label so users can tell that * Telegram-in-Second-Space and Telegram-in-main are the same target. @@ -92,6 +103,14 @@ internal object AppListCache : StateCache>( private val _userNames = MutableStateFlow>(emptyMap()) val userNames: StateFlow> = _userNames.asStateFlow() + /** Non-null when the last load's inventory was partial for a profile + * other than user 0. `StateCache.error` conflates "load failed" with + * "load succeeded", so partiality needs its own flow — the picker keeps + * showing the list and renders a soft banner instead of the hard-fail + * card. */ + private val _scanWarning = MutableStateFlow(null) + val scanWarning: StateFlow = _scanWarning.asStateFlow() + @Volatile private var appContext: Context? = null /** Kick off an initial load if not already loaded or loading. */ @@ -109,10 +128,16 @@ internal object AppListCache : StateCache>( context: Context, ) { appContext = context.applicationContext + _scanWarning.value = null RootSnapshotCache.invalidate() forceRefresh(scope) } + override fun invalidate() { + _scanWarning.value = null + super.invalidate() + } + suspend fun loadForAgent( context: Context, force: Boolean, @@ -133,9 +158,24 @@ internal object AppListCache : StateCache>( return withContext(Dispatchers.IO) { val pm = appContext.packageManager val vpnServicePkgs = queryVpnServiceProviders(pm) - val inventory = requireCompletePackageInventory(RootSnapshotCache.getOrLoad().sections) + val sections = RootSnapshotCache.getOrLoad().sections + val rawInventory = + parsePackageInventory( + packagesRaw = sections["pm_packages"].orEmpty(), + usersRaw = sections["pm_users"].orEmpty(), + ) + // 100_000: Android's per-user UID stride (also used by labelWithUsers below). + val currentUserId = Process.myUid() / 100_000 + val mergedPackages = + mergeUser0Backstop( + packages = rawInventory.packages, + user0Packages = queryUser0Backstop(pm), + currentUserId = currentUserId, + ) + val inventory = rawInventory.copy(packages = mergedPackages).requireNonEmpty() _userNames.value = inventory.profiles.mapValues { (_, profile) -> profileDisplayName(appContext, profile) } + updateScanWarning(inventory, currentUserId, _userNames.value) inventory.packages.entries .map { (pkg, meta) -> val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() @@ -167,6 +207,44 @@ internal object AppListCache : StateCache>( } } + /** + * Log-only + [scanWarning] surface for [PackageInventory.partial]. The + * backstop already covers user 0, so only failures for other profiles + * are worth flagging — those are the ones the app genuinely couldn't + * enumerate this run. + */ + private fun updateScanWarning( + inventory: PackageInventory, + user0Id: Int, + profileNames: Map, + ) { + val otherProfileFailures = inventory.failedUserIds - user0Id + if (otherProfileFailures.isNotEmpty()) { + VpnHideLog.w(LogTags.APP_LIST, inventory.partialMessage(profileNames)) + } + _scanWarning.value = otherProfileFailures.takeIf { it.isNotEmpty() }?.let(::PackageScanWarning) + } + + /** + * Cheap in-process backstop so the picker still shows *something* even + * when the per-user root scan comes back completely empty for user 0 — + * e.g. a total root-shell failure. Root can still see more (other + * profiles, apps hidden from this process), which is why the per-user + * scan stays primary; this only fills the user-0 gap. + */ + private fun queryUser0Backstop(pm: PackageManager): List { + val infos = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getInstalledApplications(PackageManager.ApplicationInfoFlags.of(0L)) + } else { + @Suppress("DEPRECATION") + pm.getInstalledApplications(0) + } + return infos.map { info -> + BackstopPackage(packageName = info.packageName, apkPath = info.sourceDir, uid = info.uid) + } + } + private fun queryVpnServiceProviders(pm: PackageManager): Set { val intent = Intent(VpnService.SERVICE_INTERFACE) val resolveInfos = diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerData.kt index 424d482..fa3c7fd 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerData.kt @@ -136,6 +136,7 @@ internal fun buildCanonicalConfigForAppPickerSave( selections: Collection, snapshot: TargetsSnapshot?, autoHideSignals: Collection = emptyList(), + partial: Boolean = false, ): CanonicalConfig { val base = canonicalBaseForSave(debug, snapshot) val visiblePkgs = selections.mapTo(mutableSetOf()) { it.packageName } @@ -177,17 +178,31 @@ internal fun buildCanonicalConfigForAppPickerSave( config = canonical, selfPkg = selfPkg, signals = autoHideSignals, + partial = partial, ) } +/** + * [partial] is true when the picker's app-list scan (see + * [PackageInventory.partial]) was partial — some profile other than user 0 + * didn't scan this run. [signals] then covers only the packages the picker + * could see, so a package auto-hidden in a previous save but absent from + * [signals] gets its `autoHiddenPackages` membership preserved (decision A: + * a VPN app that lives only in an un-scanned profile must not get un-hidden + * by a partial-scan Save) instead of being recomputed away. + */ internal fun applyAutoHiddenPackages( config: CanonicalConfig, selfPkg: String, signals: Collection, + partial: Boolean = false, ): CanonicalConfig { val observerPkgs = config.apps.filterValues { it.appHiding }.keys val manualHiddenPkgs = config.apps.filterValues { it.hidden }.keys - config.settings.autoHiddenPackages - val autoHiddenPkgs = resolveAutoHiddenPackages(signals, config.settings, selfPkg) + val signalPkgs = signals.mapTo(mutableSetOf()) { it.packageName } + val preservedAutoHiddenPkgs = + if (partial) config.settings.autoHiddenPackages - signalPkgs else emptySet() + val autoHiddenPkgs = resolveAutoHiddenPackages(signals, config.settings, selfPkg) + preservedAutoHiddenPkgs val hiddenPkgs = resolveHiddenPackages( existing = manualHiddenPkgs + autoHiddenPkgs, diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt index 472e8b1..78e2549 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/AppPickerScreen.kt @@ -386,6 +386,7 @@ private suspend fun persistUnifiedSelection( selections = selections, snapshot = TargetsCache.snapshot.value, autoHideSignals = autoHideSignals, + partial = ctx.partial, ) return CanonicalConfigRepository.commit( canonical, diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DebugShellSnapshot.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DebugShellSnapshot.kt index e97f131..f6c4a20 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DebugShellSnapshot.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/DebugShellSnapshot.kt @@ -359,6 +359,7 @@ internal fun buildDebugShellSnapshotCommand(): String = rm -f "${'$'}OUT_FILE" echo "user=${'$'}U running=${'$'}RUN exit=${'$'}EX package_lines=${'$'}TOTAL with_uid=${'$'}WUID with_path=${'$'}WPATH stderr=[${'$'}ERR]" done + echo "inprocess_backstop=app also unions getInstalledApplications(0) into user 0" ' emit_cmd network_addr ip -d addr diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PackageInventoryData.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PackageInventoryData.kt index 33ac950..5edc008 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PackageInventoryData.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/PackageInventoryData.kt @@ -25,11 +25,31 @@ internal data class PackageInventory( ) { val complete: Boolean get() = userListComplete && failedUserIds.isEmpty() && packages.isNotEmpty() + /** True when the scan yielded at least one package but some profile + * didn't come back clean — the picker shows what it has plus a soft + * banner instead of the hard "unlock a profile" wall. */ + val partial: Boolean get() = packages.isNotEmpty() && (!userListComplete || failedUserIds.isNotEmpty()) + + /** The only condition that should ever hard-fail the picker: nothing + * could be enumerated from any source (root scan or the in-process + * backstop). */ + val isEmpty: Boolean get() = packages.isEmpty() + fun incompleteMessage(): String { if (!userListComplete) return "Android user list was incomplete" if (packages.isEmpty()) return "PackageManager returned no installed packages" return "package scan failed for Android user(s): ${failedUserIds.sorted().joinToString()}" } + + /** Diagnostic-only message naming the profiles that didn't scan cleanly — + * never shown verbatim in the UI (the picker uses a localized string + * resource instead), just useful for logs. No "unlock/start" wording: + * root can read every profile regardless of lock state. */ + fun partialMessage(profileNames: Map): String { + if (failedUserIds.isEmpty()) return "Android user list was incomplete" + val names = failedUserIds.sorted().joinToString { profileNames[it] ?: it.toString() } + return "package scan didn't complete for profile(s): $names" + } } internal data class ParsedPackageUidLine( @@ -125,14 +145,62 @@ internal fun parsePackageInventory( ) } -internal fun requireCompletePackageInventory(sections: Map): PackageInventory { - val inventory = - parsePackageInventory( - packagesRaw = sections["pm_packages"].orEmpty(), - usersRaw = sections["pm_users"].orEmpty(), - ) - if (!inventory.complete) throw RootSnapshotException(inventory.incompleteMessage()) - return inventory +internal fun requireNonEmptyPackageInventory(sections: Map): PackageInventory = + parsePackageInventory( + packagesRaw = sections["pm_packages"].orEmpty(), + usersRaw = sections["pm_users"].orEmpty(), + ).requireNonEmpty() + +/** + * The one hard-fail gate: root can read every Android profile regardless of + * lock state, so a failed/incomplete profile is almost always a bug, not a + * genuinely-unreadable one — see [PackageInventory.partial]. Only a globally + * empty inventory (nothing from any source) still throws. + */ +internal fun PackageInventory.requireNonEmpty(): PackageInventory { + if (isEmpty) throw RootSnapshotException(incompleteMessage()) + return this +} + +/** + * A minimal, Android-free view of one row from the in-process + * `getInstalledApplications(0)` backstop — see [mergeUser0Backstop]. + */ +internal data class BackstopPackage( + val packageName: String, + val apkPath: String?, + val uid: Int, +) + +/** + * Union the in-process user-0 backstop into a per-user root scan's package + * map. Pure and Android-free (no [android.content.Context] / `PackageManager` + * in the signature) so it's unit-testable without Robolectric — the caller + * resolves [user0Packages] and [currentUserId] (`Process.myUid() / 100_000`) + * beforehand. + * + * A package the root scan already has for [currentUserId] is left untouched; + * one it's missing (root scan failed/incomplete, or never ran) gets that + * user id added, with `apkPath` filled in only if the scan didn't already + * have one. This is what lets the picker still show *something* even when + * the whole per-user root scan came back empty. + */ +internal fun mergeUser0Backstop( + packages: Map, + user0Packages: List, + currentUserId: Int, +): Map { + val merged = packages.toMutableMap() + user0Packages.forEach { backstop -> + val existing = merged[backstop.packageName] + if (existing != null && currentUserId in existing.uidsByUser) return@forEach + merged[backstop.packageName] = + PackageInventoryEntry( + apkPath = existing?.apkPath ?: backstop.apkPath, + uidsByUser = existing?.uidsByUser.orEmpty() + (currentUserId to listOf(backstop.uid)), + ) + } + return merged } /** diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetPickerScaffold.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetPickerScaffold.kt index c4bac55..b6dbd94 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetPickerScaffold.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetPickerScaffold.kt @@ -103,11 +103,16 @@ internal data class MergeResult( /** * Everything a Save needs beyond the row entries themselves. The scaffold * supplies the self package (always a hidden Java/native target) and current - * debug flag; UID resolution happens in the native activator. + * debug flag; UID resolution happens in the native activator. [partial] is + * true when [AppListCache.scanWarning] is set at Save time — some profile + * other than user 0 didn't scan this run, so the visible entries don't cover + * every previously-configured package; screens use it to avoid dropping + * settings for packages the picker simply couldn't see. */ internal data class SaveContext( val selfPkg: String, val debug: Boolean, + val partial: Boolean = false, ) /** @@ -161,6 +166,7 @@ internal fun TargetPickerScreen( val cachedApps by AppListCache.apps.collectAsState() val appListError by AppListCache.error.collectAsState() val userNames by AppListCache.userNames.collectAsState() + val scanWarning by AppListCache.scanWarning.collectAsState() val targets by TargetsCache.snapshot.collectAsState() val targetsError by TargetsCache.error.collectAsState() @@ -191,6 +197,10 @@ internal fun TargetPickerScreen( // Surface either cache's failure: a failed app-list scan used to leave // the picker stuck on an endless spinner (it had no error state at all). + // AppListCache now only throws (appListError != null) when the merged + // inventory is globally empty — a partial scan (some profile other than + // user 0 failed) still yields a value and is surfaced separately via + // `scanWarning` below, not as a hard block. if ((targetsError != null && targets == null) || (appListError != null && cachedApps == null)) { val packageScanFailed = appListError != null && cachedApps == null TargetsLoadErrorCard( @@ -269,6 +279,21 @@ internal fun TargetPickerScreen( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) } + // Some profile other than user 0 didn't scan cleanly this run — the + // list below is still everything that could be read (root scan + + // the user-0 backstop). Name it instead of blocking the whole list; + // no "unlock/start" wording since root doesn't need the profile + // unlocked to read it. + scanWarning?.let { warning -> + val profileNames = + warning.failedUserIds.sorted().joinToString { userNames[it] ?: it.toString() } + StatusBanner( + text = stringResource(R.string.profile_scan_partial_message, profileNames), + containerColor = StatusColors.warningContainer(), + contentColor = StatusColors.warningHeader(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } if (loading) { Box( modifier = Modifier.fillMaxSize(), @@ -378,8 +403,9 @@ internal fun TargetPickerScreen( val selfPkg = context.packageName val ctx = SaveContext( - selfPkg, - targets?.canonicalConfig?.debugSwitch ?: (targets?.canonicalConfig?.debug ?: false), + selfPkg = selfPkg, + debug = targets?.canonicalConfig?.debugSwitch ?: (targets?.canonicalConfig?.debug ?: false), + partial = scanWarning != null, ) try { val result = persist(entries, ctx) diff --git a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt index 88073c2..3f084b8 100644 --- a/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt +++ b/lsposed/app/src/main/kotlin/dev/okhsunrog/vpnhide/TargetsCache.kt @@ -110,7 +110,7 @@ internal object TargetsCache : StateCache( @Suppress("UNUSED_PARAMETER") force: Boolean, ): TargetsSnapshot { val rootSnapshot = RootSnapshotCache.getOrLoad() - requireCompletePackageInventory(rootSnapshot.sections) + requireNonEmptyPackageInventory(rootSnapshot.sections) return parseTargetsSnapshot(rootSnapshot) } } diff --git a/lsposed/app/src/main/res/values-ru/strings.xml b/lsposed/app/src/main/res/values-ru/strings.xml index ef7f3f8..cae665e 100644 --- a/lsposed/app/src/main/res/values-ru/strings.xml +++ b/lsposed/app/src/main/res/values-ru/strings.xml @@ -26,9 +26,10 @@ Приватное пространство Пользователь %d Профиль %d - Не удалос прочитать все профили Android - VPN Hide не получил полный список приложений из одного или нескольких профилей. Неполный список не загружен. При необходимости разблокируйте или запустите профиль, затем нажмите «Повторить». + Не удалось прочитать список приложений + VPN Hide не получил ни одного списка приложений. Настройки не удалялись. Нажмите «Повторить». Не удалось обновить все профили Android. Показан прежний полный список; настройки не удалялись. + Не удалось прочитать: %1$s. Показаны все приложения, которые удалось найти; настройки для %1$s сохранены. Как это работает Java-хуки diff --git a/lsposed/app/src/main/res/values-zh-rCN/strings.xml b/lsposed/app/src/main/res/values-zh-rCN/strings.xml index be9bd72..20581f6 100644 --- a/lsposed/app/src/main/res/values-zh-rCN/strings.xml +++ b/lsposed/app/src/main/res/values-zh-rCN/strings.xml @@ -27,9 +27,10 @@ 隐私空间 用户 %d 资料 %d - 未能扫描全部 Android 用户资料 - VPN Hide 没有从一个或多个用户资料拿到完整的应用列表,也没有加载不完整的列表。如有需要,请先解锁或启动对应资料,再点按“重试”。 + 未能读取任何已安装应用 + VPN Hide 没有拿到任何应用列表。没有删除任何设置。请点按“重试”。 未能刷新全部 Android 用户资料。正在显示上一次的完整应用列表;没有删除任何设置。 + 未能扫描:%1$s。已显示 VPN Hide 能读取到的全部应用;%1$s 的设置予以保留。 工作原理 Java 钩子 原生钩子 diff --git a/lsposed/app/src/main/res/values/strings.xml b/lsposed/app/src/main/res/values/strings.xml index 763a9e4..8257d39 100644 --- a/lsposed/app/src/main/res/values/strings.xml +++ b/lsposed/app/src/main/res/values/strings.xml @@ -26,9 +26,10 @@ Private space User %d Profile %d - Couldn’t scan every Android profile - VPN Hide did not receive a complete app list from one or more profiles. No partial list was loaded. Unlock or start the profile if needed, then tap Retry. + Couldn’t read any installed apps + VPN Hide did not receive any app list. No settings were removed. Tap Retry. Couldn’t refresh every Android profile. Showing the previous complete app list; no settings were removed. + Couldn’t scan %1$s. Showing every app VPN Hide could read; settings for %1$s are kept. How it works J N diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt index bca286d..55834b0 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/AppPickerDataTest.kt @@ -581,6 +581,84 @@ class AppPickerDataTest { assertEquals(setOf("com.happproxy"), result.settings.autoHiddenPackages) } + @Test + fun `save does not drop targets for un-scanned profiles`() { + // Simulates a partial scan: com.unscanned lives only in a profile + // that didn't come back this run, so it's absent from both the + // visible selections and the auto-hide signals — same shape as the + // existing "missing from current picker list" case, just under a + // partial save. + val snapshot = + snapshotWithCanonical( + "com.unscanned" to + CanonicalApp(java = true, native = NativeRole.All, appHiding = true, ports = true), + ) + + val cfg = + buildCanonicalConfigForAppPickerSave( + debug = false, + selfPkg = self, + selections = emptyList(), + snapshot = snapshot, + partial = true, + ) + + val unscanned = cfg.apps.getValue("com.unscanned") + assertEquals(true, unscanned.java) + assertEquals(NativeRole.All, unscanned.native) + assertEquals(true, unscanned.appHiding) + assertEquals(true, unscanned.ports) + } + + @Test + fun `partial save preserves an auto hidden package missing from the scan`() { + // com.happproxy was auto-hidden on a previous (complete) save. This + // save's signals don't include it because its profile didn't scan — + // decision A: a partial save must not un-hide it just because it's + // not visible this run. + val snapshot = + snapshotWithCanonical( + "com.happproxy" to CanonicalApp(hidden = true), + settings = CanonicalSettings(autoHiddenPackages = setOf("com.happproxy")), + ) + + val cfg = + buildCanonicalConfigForAppPickerSave( + debug = false, + selfPkg = self, + selections = emptyList(), + snapshot = snapshot, + autoHideSignals = emptyList(), + partial = true, + ) + + assertEquals(true, cfg.apps.getValue("com.happproxy").hidden) + assertEquals(setOf("com.happproxy"), cfg.settings.autoHiddenPackages) + } + + @Test + fun `non partial save still drops an auto hidden package missing from the scan`() { + // Baseline: without `partial`, behavior is unchanged from before this + // feature — an auto-hidden package absent from signals gets dropped. + val snapshot = + snapshotWithCanonical( + "com.happproxy" to CanonicalApp(hidden = true), + settings = CanonicalSettings(autoHiddenPackages = setOf("com.happproxy")), + ) + + val cfg = + buildCanonicalConfigForAppPickerSave( + debug = false, + selfPkg = self, + selections = emptyList(), + snapshot = snapshot, + autoHideSignals = emptyList(), + ) + + assertEquals(false, cfg.apps.containsKey("com.happproxy")) + assertEquals(emptySet(), cfg.settings.autoHiddenPackages) + } + private fun snapshotWithCanonical( vararg apps: Pair, settings: CanonicalSettings = CanonicalSettings(), diff --git a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PackageInventoryDataTest.kt b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PackageInventoryDataTest.kt index db2758e..47289f1 100644 --- a/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PackageInventoryDataTest.kt +++ b/lsposed/app/src/test/kotlin/dev/okhsunrog/vpnhide/PackageInventoryDataTest.kt @@ -37,7 +37,11 @@ class PackageInventoryDataTest { } @Test - fun `reports a failed profile instead of accepting a partial list`() { + fun `a failed profile is partial, not empty, and keeps the other profile's packages`() { + // Root can read every profile regardless of lock state, so a failed + // profile is fail-soft: the picker still shows what it has (here, + // user 0's package) plus a banner naming the failed profile — it no + // longer hard-blocks the whole list. val users = """ ${PM_USERS_STATUS_PREFIX}plain:0 @@ -57,8 +61,11 @@ class PackageInventoryDataTest { val inventory = parsePackageInventory(packages, users) assertFalse(inventory.complete) + assertTrue(inventory.partial) + assertFalse(inventory.isEmpty) assertEquals(setOf(10), inventory.failedUserIds) assertTrue(inventory.incompleteMessage().contains("10")) + assertTrue(inventory.packages.containsKey("com.example")) } @Test @@ -160,9 +167,104 @@ class PackageInventoryDataTest { assertEquals("shell stderr: $stderr", 0, process.waitFor()) val sections = parseRootShellSnapshot(stdout, recordMetric = { _, _ -> }) - val inventory = requireCompletePackageInventory(sections) + val inventory = requireNonEmptyPackageInventory(sections) assertTrue(inventory.complete) assertEquals(listOf(10), inventory.packages.getValue("com.work").userIds) assertFalse(shell.contains("--user all")) } + + @Test + fun `requireNonEmptyPackageInventory throws only when globally empty`() { + val usersWithFailedProfile = + """ + ${PM_USERS_STATUS_PREFIX}plain:0 + UserInfo{0:Owner:c13} + UserInfo{10:Work:1030} + """.trimIndent() + val packagesWithFailedProfile = + """ + $PM_USER_BEGIN_PREFIX${0} + package:/data/app/example/base.apk=com.example uid:10123 + $PM_USER_END_PREFIX${0}:0 + $PM_USER_BEGIN_PREFIX${10} + $PM_USER_END_PREFIX${10}:7 + """.trimIndent() + + // Partial (one failed profile, but user 0 still has a package): no throw. + val partialInventory = + requireNonEmptyPackageInventory( + mapOf("pm_packages" to packagesWithFailedProfile, "pm_users" to usersWithFailedProfile), + ) + assertTrue(partialInventory.partial) + + // Globally empty (nothing parsed from either section): throws. + var thrown: RootSnapshotException? = null + try { + requireNonEmptyPackageInventory(emptyMap()) + } catch (e: RootSnapshotException) { + thrown = e + } + assertTrue(thrown != null) + } + + @Test + fun `mergeUser0Backstop adds a user-0 package the root scan missed`() { + val backstop = BackstopPackage(packageName = "com.example", apkPath = "/data/app/a", uid = 10123) + + val merged = + mergeUser0Backstop( + packages = emptyMap(), + user0Packages = listOf(backstop), + currentUserId = 0, + ) + + assertEquals(listOf(0), merged.getValue("com.example").userIds) + assertEquals(listOf(10123), merged.getValue("com.example").uids) + assertEquals("/data/app/a", merged.getValue("com.example").apkPath) + } + + @Test + fun `mergeUser0Backstop does not override a package the root scan already found for that user`() { + val existing = + mapOf( + "com.example" to + PackageInventoryEntry( + apkPath = "/data/app/root-scanned", + uidsByUser = mapOf(0 to listOf(10123)), + ), + ) + val backstop = BackstopPackage(packageName = "com.example", apkPath = "/data/app/backstop", uid = 99999) + + val merged = + mergeUser0Backstop( + packages = existing, + user0Packages = listOf(backstop), + currentUserId = 0, + ) + + assertEquals("/data/app/root-scanned", merged.getValue("com.example").apkPath) + assertEquals(listOf(10123), merged.getValue("com.example").uids) + } + + @Test + fun `mergeUser0Backstop fills a missing apkPath but keeps the profile the root scan already has`() { + val existing = + mapOf( + "com.example" to + PackageInventoryEntry(apkPath = null, uidsByUser = mapOf(10 to listOf(1010123))), + ) + val backstop = BackstopPackage(packageName = "com.example", apkPath = "/data/app/backstop", uid = 10123) + + val merged = + mergeUser0Backstop( + packages = existing, + user0Packages = listOf(backstop), + currentUserId = 0, + ) + + val entry = merged.getValue("com.example") + assertEquals("/data/app/backstop", entry.apkPath) + assertEquals(setOf(0, 10), entry.uidsByUser.keys) + assertEquals(listOf(10123), entry.uidsByUser.getValue(0)) + } }