Add package visibility hooks in system_server

Hides selected packages from selected caller UIDs at the PackageManagerService
Binder stub. Filters getInstalled{Packages,Applications}, queryIntent*,
resolve{Intent,Service}, get{Package,Application}Info, getPackageUid,
getPackagesForUid, getInstaller{PackageName,SourceInfo}. Hooks
IPackageManagerBase with PackageManagerService fallback.

Config via /data/system/vpnhide_hidden_pkgs.txt and
/data/system/vpnhide_observer_uids.txt with inotify live-reload. Callers
with UID < 10000 are exempt to avoid breaking installd / LauncherApps.
This commit is contained in:
okhsunrog 2026-04-15 14:55:26 +03:00
parent cffb52776b
commit 23e2ca292b
2 changed files with 313 additions and 0 deletions

View file

@ -50,6 +50,7 @@ class HookEntry : IXposedHookLoadPackage {
if (hookInstalled.compareAndSet(false, true)) {
XposedBridge.log("VpnHide: system_server detected, installing Binder hooks")
installSystemServerHooks()
tryHook("PackageVisibility") { PackageVisibilityHooks.install(lpparam.classLoader) }
writeHookStatusFile()
}
}

View file

@ -0,0 +1,312 @@
package dev.okhsunrog.vpnhide
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.ResolveInfo
import android.os.Binder
import android.os.FileObserver
import android.os.Process
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import java.io.File
/**
* Package-visibility policy hide packages from selected callers.
*
* Two flat groups:
* - hiddenPackages: package names to hide from PM responses
* - observerUids: caller UIDs that should not see hidden packages
*
* When the Binder caller in system_server is an observer, results of
* PackageManager queries are filtered to exclude hidden packages:
* - list queries (getInstalledPackages / queryIntentActivities / ...)
* have matching entries removed from the returned ParceledListSlice
* - single-package queries (getPackageInfo / getApplicationInfo /
* resolveService / ...) return null, which the caller side converts
* to NameNotFoundException
*
* Targets the PackageManagerService Binder stub via
* com.android.server.pm.IPackageManagerBase same file in AOSP 13/14/15.
* Filtering happens post-AppsFilter (AppsFilter runs inside ComputerEngine,
* before these methods return), so we subtract further.
*
* System callers (UID < 10000) are always exempt to avoid breaking
* installd, LauncherApps, StatusBar, etc.
*/
internal object PackageVisibilityHooks {
private const val HIDDEN_PKGS_FILE = "/data/system/vpnhide_hidden_pkgs.txt"
private const val OBSERVER_UIDS_FILE = "/data/system/vpnhide_observer_uids.txt"
private const val IPM_BASE = "com.android.server.pm.IPackageManagerBase"
private const val IPM_LEGACY = "com.android.server.pm.PackageManagerService"
private const val PARCELED_LIST_SLICE = "android.content.pm.ParceledListSlice"
@Volatile private var parceledListSliceClass: Class<*>? = null
@Volatile private var hiddenPackages: Set<String>? = null
@Volatile private var observerUids: Set<Int>? = null
@Volatile private var fileObserver: FileObserver? = null
private val lock = Any()
fun install(classLoader: ClassLoader) {
val ipmClass =
try {
classLoader.loadClass(IPM_BASE)
} catch (_: ClassNotFoundException) {
try {
classLoader.loadClass(IPM_LEGACY)
} catch (t: Throwable) {
XposedBridge.log("VpnHide/PV: neither $IPM_BASE nor $IPM_LEGACY found: ${t.message}")
return
}
}
XposedBridge.log("VpnHide/PV: hooking ${ipmClass.name}")
parceledListSliceClass =
try {
classLoader.loadClass(PARCELED_LIST_SLICE)
} catch (t: Throwable) {
XposedBridge.log("VpnHide/PV: ParceledListSlice not found: ${t.message}")
return
}
hook(ipmClass, "getInstalledPackages", listFilter<PackageInfo> { it.packageName })
hook(ipmClass, "getInstalledApplications", listFilter<ApplicationInfo> { it.packageName })
hook(ipmClass, "queryIntentActivities", resolveInfoListFilter())
hook(ipmClass, "queryIntentServices", resolveInfoListFilter())
hook(ipmClass, "queryIntentReceivers", resolveInfoListFilter())
hook(ipmClass, "queryIntentContentProviders", resolveInfoListFilter())
hook(ipmClass, "getPackageInfo", singleHideByFirstStringArg())
hook(ipmClass, "getApplicationInfo", singleHideByFirstStringArg())
hook(ipmClass, "getInstallerPackageName", singleHideByFirstStringArg())
hook(ipmClass, "getInstallSourceInfo", singleHideByFirstStringArg())
hook(ipmClass, "getPackageUid", packageUidHide())
hook(ipmClass, "resolveIntent", resolveInfoSingleHide())
hook(ipmClass, "resolveService", resolveInfoSingleHide())
hook(ipmClass, "getPackagesForUid", packagesForUidHide())
watchConfigFiles()
}
// ------------------------------------------------------------------
// Caller classification
// ------------------------------------------------------------------
private fun isObserverCaller(): Boolean {
val uid = Binder.getCallingUid()
// Exempt system callers: installd, shell, system_server itself,
// LauncherApps, StatusBar, etc. all run under UID < 10000.
if (uid < Process.FIRST_APPLICATION_UID) return false
if (uid == Process.myUid()) return false
return loadObserverUids().contains(uid)
}
private fun loadObserverUids(): Set<Int> {
observerUids?.let { return it }
synchronized(lock) {
observerUids?.let { return it }
val result = readUidFile(OBSERVER_UIDS_FILE)
observerUids = result
if (result.isNotEmpty()) {
XposedBridge.log("VpnHide/PV: loaded ${result.size} observer UIDs: $result")
}
return result
}
}
private fun loadHiddenPackages(): Set<String> {
hiddenPackages?.let { return it }
synchronized(lock) {
hiddenPackages?.let { return it }
val result = readLineFile(HIDDEN_PKGS_FILE)
hiddenPackages = result
if (result.isNotEmpty()) {
XposedBridge.log("VpnHide/PV: loaded ${result.size} hidden packages: $result")
}
return result
}
}
private fun readUidFile(path: String): Set<Int> =
try {
val f = File(path)
if (!f.exists()) {
emptySet()
} else {
f
.readLines()
.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() && !s.startsWith("#") }?.toIntOrNull() }
.toSet()
}
} catch (t: Throwable) {
XposedBridge.log("VpnHide/PV: failed to read $path: ${t.message}")
emptySet()
}
private fun readLineFile(path: String): Set<String> =
try {
val f = File(path)
if (!f.exists()) {
emptySet()
} else {
f
.readLines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.toSet()
}
} catch (t: Throwable) {
XposedBridge.log("VpnHide/PV: failed to read $path: ${t.message}")
emptySet()
}
private fun watchConfigFiles() {
val observer =
object : FileObserver(File("/data/system"), CREATE or CLOSE_WRITE or MOVED_TO or MODIFY) {
override fun onEvent(
event: Int,
path: String?,
) {
when (path) {
"vpnhide_hidden_pkgs.txt" -> {
XposedBridge.log("VpnHide/PV: hidden_pkgs changed, invalidating")
hiddenPackages = null
}
"vpnhide_observer_uids.txt" -> {
XposedBridge.log("VpnHide/PV: observer_uids changed, invalidating")
observerUids = null
}
}
}
}
fileObserver = observer
observer.startWatching()
XposedBridge.log("VpnHide/PV: watching /data/system for config changes")
}
// ------------------------------------------------------------------
// Hook installation
// ------------------------------------------------------------------
private fun hook(
clazz: Class<*>,
methodName: String,
handler: XC_MethodHook,
) {
try {
val hooked = XposedBridge.hookAllMethods(clazz, methodName, handler)
if (hooked.isEmpty()) {
XposedBridge.log("VpnHide/PV: no method '$methodName' on ${clazz.name}")
} else {
XposedBridge.log("VpnHide/PV: hooked $methodName (${hooked.size} overload(s))")
}
} catch (t: Throwable) {
XposedBridge.log("VpnHide/PV: hook $methodName failed: ${t::class.java.simpleName}: ${t.message}")
}
}
// ------------------------------------------------------------------
// Hook handlers
// ------------------------------------------------------------------
/**
* Generic list filter for ParceledListSlice<T>.
* Removes items whose packageName (extracted by [pkgOf]) is in hiddenPackages.
*/
private inline fun <reified T> listFilter(crossinline pkgOf: (T) -> String?): XC_MethodHook =
object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) {
if (param.hasThrowable()) return
if (!isObserverCaller()) return
val hidden = loadHiddenPackages()
if (hidden.isEmpty()) return
val result = param.result ?: return
val pls = parceledListSliceClass ?: return
if (!pls.isInstance(result)) return
@Suppress("UNCHECKED_CAST")
val original = XposedHelpers.callMethod(result, "getList") as? List<T> ?: return
val filtered =
original.filter {
val p = pkgOf(it)
p == null || p !in hidden
}
if (filtered.size != original.size) {
param.result = XposedHelpers.newInstance(pls, filtered)
}
}
}
/** ResolveInfo list: packageName is on the inner ComponentInfo (activityInfo / serviceInfo / providerInfo). */
private fun resolveInfoListFilter(): XC_MethodHook = listFilter<ResolveInfo> { resolveInfoPackageName(it) }
private fun resolveInfoPackageName(ri: ResolveInfo): String? =
ri.activityInfo?.packageName
?: ri.serviceInfo?.packageName
?: ri.providerInfo?.packageName
/**
* For getPackageInfo / getApplicationInfo / getInstallerPackageName / getInstallSourceInfo.
* Signature starts with `String packageName`. If that package is hidden and caller is an
* observer, set result=null. Caller-side API converts null to NameNotFoundException.
*/
private fun singleHideByFirstStringArg(): XC_MethodHook =
object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) {
if (param.hasThrowable()) return
if (param.result == null) return
val pkg = param.args.firstOrNull() as? String ?: return
if (!isObserverCaller()) return
if (pkg in loadHiddenPackages()) {
param.result = null
}
}
}
/** getPackageUid(String, long/int, int): returns -1 if hidden. */
private fun packageUidHide(): XC_MethodHook =
object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) {
if (param.hasThrowable()) return
val pkg = param.args.firstOrNull() as? String ?: return
if (!isObserverCaller()) return
if (pkg in loadHiddenPackages()) {
param.result = -1
}
}
}
/** resolveIntent / resolveService: ResolveInfo result. Null it out if it points to a hidden pkg. */
private fun resolveInfoSingleHide(): XC_MethodHook =
object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) {
if (param.hasThrowable()) return
val ri = param.result as? ResolveInfo ?: return
if (!isObserverCaller()) return
val pkg = resolveInfoPackageName(ri) ?: return
if (pkg in loadHiddenPackages()) {
param.result = null
}
}
}
/** getPackagesForUid(int): String[]. Filter out hidden entries. Return null if all filtered. */
private fun packagesForUidHide(): XC_MethodHook =
object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) {
if (param.hasThrowable()) return
val arr = param.result as? Array<*> ?: return
if (!isObserverCaller()) return
val hidden = loadHiddenPackages()
if (hidden.isEmpty()) return
val filtered = arr.filterIsInstance<String>().filter { it !in hidden }
if (filtered.size == arr.size) return
param.result = if (filtered.isEmpty()) null else filtered.toTypedArray()
}
}
}