mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-08-21 06:23:44 +00:00
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:
parent
df8f4ed22f
commit
cccfeefd7b
15 changed files with 417 additions and 26 deletions
9
changelog.d/fixed-the-app-picker-now-shows-the-3f7b.md
Normal file
9
changelog.d/fixed-the-app-picker-now-shows-the-3f7b.md
Normal 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.
|
||||
|
||||
## Русский
|
||||
|
||||
Пикер приложений теперь показывает то, что удалось прочитать, и помечает непрочитанные профили, вместо блокировки всего списка; настройки непрочитанных профилей сохраняются.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -386,6 +386,7 @@ private suspend fun persistUnifiedSelection(
|
|||
selections = selections,
|
||||
snapshot = TargetsCache.snapshot.value,
|
||||
autoHideSignals = autoHideSignals,
|
||||
partial = ctx.partial,
|
||||
)
|
||||
return CanonicalConfigRepository.commit(
|
||||
canonical,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">Couldn’t 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">Couldn’t 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">Couldn’t refresh every Android profile. Showing the previous complete app list; no settings were removed.</string>
|
||||
<string name="profile_scan_partial_message">Couldn’t 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>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue