diff --git a/app/src/main/java/space/bdeshi/veil/VeilHook.kt b/app/src/main/java/space/bdeshi/veil/VeilHook.kt index 1b5f794..0939d83 100644 --- a/app/src/main/java/space/bdeshi/veil/VeilHook.kt +++ b/app/src/main/java/space/bdeshi/veil/VeilHook.kt @@ -4,7 +4,13 @@ import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.VersionedPackage +import android.content.ContentResolver +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri import android.os.Build +import android.os.Bundle +import android.os.CancellationSignal import de.robv.android.xposed.IXposedHookLoadPackage import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodReplacement @@ -44,6 +50,7 @@ class VeilHook : IXposedHookLoadPackage { hookAccessibilityManager(lpparam) hookSettingsClass("android.provider.Settings\$Secure", lpparam, SECURE_NULL_STRING_KEYS, SECURE_ZERO_VALUE_KEYS) hookSettingsClass("android.provider.Settings\$Global", lpparam, emptySet(), GLOBAL_ZERO_VALUE_KEYS) + hookSettingsProviderAccess(lpparam) hookPackageManager(lpparam, Features.HIDDEN_PACKAGES) spoofBuildTags() } @@ -138,6 +145,120 @@ class VeilHook : IXposedHookLoadPackage { } } + // The Settings classes above end up in the settings provider; an app can skip them and reach + // it directly, either through ContentResolver.call(content://settings/, "GET_
", + // key), whose Bundle carries the answer under "value", or through ContentResolver.query, + // whose cursor carries name/value rows. Mask both. The Settings classes' own cache goes + // through IContentProvider, not ContentResolver, so nothing is masked twice. + private fun hookSettingsProviderAccess(lpparam: LoadPackageParam) { + val resolverClass = XposedHelpers.findClassIfExists("android.content.ContentResolver", lpparam.classLoader) + ?: return + + val maskValue = object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val authority = when (val target = param.args.getOrNull(0)) { + is Uri -> target.authority + is String -> target + else -> return + } + if (authority != "settings") return + val method = param.args.getOrNull(1) as? String ?: return + val key = param.args.getOrNull(2) as? String ?: return + val result = param.result as? Bundle ?: return + when (method) { + "GET_secure" -> when (key) { + in SECURE_NULL_STRING_KEYS -> result.putString("value", null) + in SECURE_ZERO_VALUE_KEYS -> result.putString("value", "0") + } + "GET_global" -> if (key in GLOBAL_ZERO_VALUE_KEYS) result.putString("value", "0") + } + } + } + + // Every query() overload funnels into this one. + val maskCursor = object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val uri = param.args.getOrNull(0) as? Uri ?: return + if (uri.authority != "settings") return + val table = uri.pathSegments?.firstOrNull() ?: return + if (table != "secure" && table != "global") return + val cursor = param.result as? Cursor ?: return + val nameIndex = cursor.getColumnIndex("name") + val valueIndex = cursor.getColumnIndex("value") + if (valueIndex < 0) return + // A caller can project just "value". The key is then only in the URI path + // (settings/global/) or in the selection args (name=?), so take it from there. + val queriedName = if (nameIndex >= 0) null else { + uri.pathSegments.getOrNull(1) + ?: (param.args.getOrNull(2) as? Bundle) + ?.getStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS)?.singleOrNull() + } + if (nameIndex < 0 && queriedName == null) return + + fun masked(name: String?): Pair = when (table) { + "secure" -> when (name) { + in SECURE_NULL_STRING_KEYS -> true to null + in SECURE_ZERO_VALUE_KEYS -> true to "0" + else -> false to null + } + "global" -> if (name in GLOBAL_ZERO_VALUE_KEYS) true to "0" else false to null + else -> false to null + } + + val rows = mutableListOf>() + var changed = false + if (cursor.moveToFirst()) { + do { + val row = arrayOfNulls(cursor.columnCount) + for (i in row.indices) { + row[i] = when (cursor.getType(i)) { + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i) + Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(i) + Cursor.FIELD_TYPE_STRING -> cursor.getString(i) + Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(i) + else -> null + } + } + val (hide, value) = masked(if (nameIndex >= 0) row[nameIndex] as? String else queriedName) + if (hide) { + row[valueIndex] = value + changed = true + } + rows.add(row) + } while (cursor.moveToNext()) + } + if (!changed) { + cursor.moveToPosition(-1) + return + } + val replacement = MatrixCursor(cursor.columnNames, rows.size) + rows.forEach { replacement.addRow(it) } + cursor.close() + param.result = replacement + } + } + + val stringType = String::class.java + try { + XposedHelpers.findAndHookMethod( + resolverClass, "query", Uri::class.java, Array::class.java, + Bundle::class.java, CancellationSignal::class.java, maskCursor, + ) + } catch (t: Throwable) { + // Not present on this API level. + } + for (signature in listOf( + arrayOf>(Uri::class.java, stringType, stringType, Bundle::class.java), + arrayOf>(stringType, stringType, stringType, Bundle::class.java), + )) { + try { + XposedHelpers.findAndHookMethod(resolverClass, "call", *signature, maskValue) + } catch (t: Throwable) { + // Not present with this signature on this API level. + } + } + } + private fun hookPackageManager(lpparam: LoadPackageParam, hiddenPackages: Set) { val clazz = XposedHelpers.findClassIfExists( "android.app.ApplicationPackageManager",