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.
This commit is contained in:
okhsunrog 2026-08-20 17:12:13 +03:00
parent df8f4ed22f
commit cccfeefd7b
15 changed files with 417 additions and 26 deletions

View file

@ -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.
## Русский
Пикер приложений теперь показывает то, что удалось прочитать, и помечает непрочитанные профили, вместо блокировки всего списка; настройки непрочитанных профилей сохраняются.

View file

@ -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

View file

@ -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:

View file

@ -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<Int>,
)
/**
* 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<List<AppSummary>>(
private val _userNames = MutableStateFlow<Map<Int, String>>(emptyMap())
val userNames: StateFlow<Map<Int, String>> = _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<PackageScanWarning?>(null)
val scanWarning: StateFlow<PackageScanWarning?> = _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<List<AppSummary>>(
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<List<AppSummary>>(
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<List<AppSummary>>(
}
}
/**
* 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<Int, String>,
) {
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<BackstopPackage> {
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<String> {
val intent = Intent(VpnService.SERVICE_INTERFACE)
val resolveInfos =

View file

@ -136,6 +136,7 @@ internal fun buildCanonicalConfigForAppPickerSave(
selections: Collection<AppRoleSelection>,
snapshot: TargetsSnapshot?,
autoHideSignals: Collection<AppAutoHideSignal> = 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<AppAutoHideSignal>,
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,

View file

@ -386,6 +386,7 @@ private suspend fun persistUnifiedSelection(
selections = selections,
snapshot = TargetsCache.snapshot.value,
autoHideSignals = autoHideSignals,
partial = ctx.partial,
)
return CanonicalConfigRepository.commit(
canonical,

View file

@ -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

View file

@ -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<Int, String>): 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<String, String>): 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<String, String>): 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<String, PackageInventoryEntry>,
user0Packages: List<BackstopPackage>,
currentUserId: Int,
): Map<String, PackageInventoryEntry> {
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
}
/**

View file

@ -103,11 +103,16 @@ internal data class MergeResult<T : TargetEntry>(
/**
* 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 <T : TargetEntry> 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 <T : TargetEntry> 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 <T : TargetEntry> 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 <T : TargetEntry> 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)

View file

@ -110,7 +110,7 @@ internal object TargetsCache : StateCache<TargetsSnapshot>(
@Suppress("UNUSED_PARAMETER") force: Boolean,
): TargetsSnapshot {
val rootSnapshot = RootSnapshotCache.getOrLoad()
requireCompletePackageInventory(rootSnapshot.sections)
requireNonEmptyPackageInventory(rootSnapshot.sections)
return parseTargetsSnapshot(rootSnapshot)
}
}

View file

@ -26,9 +26,10 @@
<string name="profile_kind_private">Приватное пространство</string>
<string name="profile_kind_secondary">Пользователь %d</string>
<string name="profile_kind_unknown">Профиль %d</string>
<string name="profile_scan_failed_title">Не удалос прочитать все профили Android</string>
<string name="profile_scan_failed_message">VPN Hide не получил полный список приложений из одного или нескольких профилей. Неполный список не загружен. При необходимости разблокируйте или запустите профиль, затем нажмите «Повторить».</string>
<string name="profile_scan_failed_title">Не удалось прочитать список приложений</string>
<string name="profile_scan_failed_message">VPN Hide не получил ни одного списка приложений. Настройки не удалялись. Нажмите «Повторить».</string>
<string name="profile_scan_stale_message">Не удалось обновить все профили Android. Показан прежний полный список; настройки не удалялись.</string>
<string name="profile_scan_partial_message">Не удалось прочитать: %1$s. Показаны все приложения, которые удалось найти; настройки для %1$s сохранены.</string>
<string name="apps_help_title">Как это работает</string>
<string name="java_hooks_title">Java-хуки</string>

View file

@ -27,9 +27,10 @@
<string name="profile_kind_private">隐私空间</string>
<string name="profile_kind_secondary">用户 %d</string>
<string name="profile_kind_unknown">资料 %d</string>
<string name="profile_scan_failed_title">未能扫描全部 Android 用户资料</string>
<string name="profile_scan_failed_message">VPN Hide 没有从一个或多个用户资料拿到完整的应用列表,也没有加载不完整的列表。如有需要,请先解锁或启动对应资料,再点按“重试”。</string>
<string name="profile_scan_failed_title">未能读取任何已安装应用</string>
<string name="profile_scan_failed_message">VPN Hide 没有拿到任何应用列表。没有删除任何设置。请点按“重试”。</string>
<string name="profile_scan_stale_message">未能刷新全部 Android 用户资料。正在显示上一次的完整应用列表;没有删除任何设置。</string>
<string name="profile_scan_partial_message">未能扫描:%1$s。已显示 VPN Hide 能读取到的全部应用;%1$s 的设置予以保留。</string>
<string name="apps_help_title">工作原理</string>
<string name="java_hooks_title">Java 钩子</string>
<string name="native_hooks_title">原生钩子</string>

View file

@ -26,9 +26,10 @@
<string name="profile_kind_private">Private space</string>
<string name="profile_kind_secondary">User %d</string>
<string name="profile_kind_unknown">Profile %d</string>
<string name="profile_scan_failed_title">Couldnt scan every Android profile</string>
<string name="profile_scan_failed_message">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.</string>
<string name="profile_scan_failed_title">Couldnt read any installed apps</string>
<string name="profile_scan_failed_message">VPN Hide did not receive any app list. No settings were removed. Tap Retry.</string>
<string name="profile_scan_stale_message">Couldnt refresh every Android profile. Showing the previous complete app list; no settings were removed.</string>
<string name="profile_scan_partial_message">Couldnt scan %1$s. Showing every app VPN Hide could read; settings for %1$s are kept.</string>
<string name="apps_help_title">How it works</string>
<string name="chip_java" translatable="false">J</string>
<string name="chip_native" translatable="false">N</string>

View file

@ -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<String>(), cfg.settings.autoHiddenPackages)
}
private fun snapshotWithCanonical(
vararg apps: Pair<String, CanonicalApp>,
settings: CanonicalSettings = CanonicalSettings(),

View file

@ -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))
}
}