diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bfa34ab..a85ec14 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,5 +15,15 @@ android:name="xposeddescription" android:value="@string/module_description" /> + + + + + + + diff --git a/app/src/main/java/space/bdeshi/veil/Features.kt b/app/src/main/java/space/bdeshi/veil/Features.kt new file mode 100644 index 0000000..37ca591 --- /dev/null +++ b/app/src/main/java/space/bdeshi/veil/Features.kt @@ -0,0 +1,22 @@ +package space.bdeshi.veil + +object Features { + const val PREFS_NAME = "hidea11y_prefs" + const val KEY_PREFIX = "features_" + + const val ACCESSIBILITY = "accessibility" + const val ADB = "adb" + const val DEV_OPTIONS = "dev_options" + const val MOCK_LOCATION = "mock_location" + + val ALL: Set = setOf(ACCESSIBILITY, ADB, DEV_OPTIONS, MOCK_LOCATION) + + val LABELS: LinkedHashMap = linkedMapOf( + ACCESSIBILITY to "Accessibility services", + ADB to "USB / wireless debugging", + DEV_OPTIONS to "Developer options enabled", + MOCK_LOCATION to "Mock location (legacy flag only)", + ) + + fun keyFor(packageName: String): String = KEY_PREFIX + packageName +} diff --git a/app/src/main/java/space/bdeshi/veil/HideA11yHook.kt b/app/src/main/java/space/bdeshi/veil/HideA11yHook.kt index f6f626d..74e6821 100644 --- a/app/src/main/java/space/bdeshi/veil/HideA11yHook.kt +++ b/app/src/main/java/space/bdeshi/veil/HideA11yHook.kt @@ -3,23 +3,22 @@ package space.bdeshi.veil import de.robv.android.xposed.IXposedHookLoadPackage import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XSharedPreferences import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam private const val SELF_PACKAGE = "space.bdeshi.veil" -// Settings keys whose *string* reads should look absent to the hooked app. -private val NULL_STRING_KEYS = setOf( - "enabled_accessibility_services", +private val NULL_STRING_KEYS_BY_FEATURE = mapOf( + Features.ACCESSIBILITY to setOf("enabled_accessibility_services"), ) -// Settings keys whose reads (string "0" / int 0) should look disabled to the hooked app. -private val ZERO_VALUE_KEYS = setOf( - "accessibility_enabled", - "touch_exploration_enabled", - "adb_enabled", - "adb_wifi_enabled", +private val ZERO_VALUE_KEYS_BY_FEATURE = mapOf( + Features.ACCESSIBILITY to setOf("accessibility_enabled", "touch_exploration_enabled"), + Features.ADB to setOf("adb_enabled", "adb_wifi_enabled"), + Features.DEV_OPTIONS to setOf("development_settings_enabled"), + Features.MOCK_LOCATION to setOf("mock_location", "allow_mock_location"), ) class HideA11yHook : IXposedHookLoadPackage { @@ -27,9 +26,30 @@ class HideA11yHook : IXposedHookLoadPackage { override fun handleLoadPackage(lpparam: LoadPackageParam) { if (lpparam.packageName == SELF_PACKAGE) return - hookAccessibilityManager(lpparam) - hookSettingsClass("android.provider.Settings\$Secure", lpparam) - hookSettingsClass("android.provider.Settings\$Global", lpparam) + val enabled = readEnabledFeatures(lpparam.packageName) + if (enabled.isEmpty()) return + + if (Features.ACCESSIBILITY in enabled) { + hookAccessibilityManager(lpparam) + } + + val nullStringKeys = enabled.flatMap { NULL_STRING_KEYS_BY_FEATURE[it].orEmpty() }.toSet() + val zeroValueKeys = enabled.flatMap { ZERO_VALUE_KEYS_BY_FEATURE[it].orEmpty() }.toSet() + if (nullStringKeys.isNotEmpty() || zeroValueKeys.isNotEmpty()) { + hookSettingsClass("android.provider.Settings\$Secure", lpparam, nullStringKeys, zeroValueKeys) + hookSettingsClass("android.provider.Settings\$Global", lpparam, nullStringKeys, zeroValueKeys) + } + } + + private fun readEnabledFeatures(packageName: String): Set { + return try { + val prefs = XSharedPreferences(SELF_PACKAGE, Features.PREFS_NAME) + prefs.reload() + prefs.getStringSet(Features.keyFor(packageName), Features.ALL) ?: Features.ALL + } catch (t: Throwable) { + // Config unreadable (e.g. never configured, or permissions not yet fixed) — fail open to "hide everything". + Features.ALL + } } private fun hookAccessibilityManager(lpparam: LoadPackageParam) { @@ -60,7 +80,12 @@ class HideA11yHook : IXposedHookLoadPackage { hook("isTouchExplorationEnabled", result = false) } - private fun hookSettingsClass(className: String, lpparam: LoadPackageParam) { + private fun hookSettingsClass( + className: String, + lpparam: LoadPackageParam, + nullStringKeys: Set, + zeroValueKeys: Set, + ) { val clazz = XposedHelpers.findClassIfExists(className, lpparam.classLoader) ?: return fun callOriginal(param: XC_MethodHook.MethodHookParam): Any? = @@ -70,8 +95,8 @@ class HideA11yHook : IXposedHookLoadPackage { override fun replaceHookedMethod(param: XC_MethodHook.MethodHookParam): Any? { val name = param.args.getOrNull(1) as? String ?: return callOriginal(param) return when (name) { - in NULL_STRING_KEYS -> null - in ZERO_VALUE_KEYS -> "0" + in nullStringKeys -> null + in zeroValueKeys -> "0" else -> callOriginal(param) } } @@ -80,7 +105,7 @@ class HideA11yHook : IXposedHookLoadPackage { val intReplacement = object : XC_MethodReplacement() { override fun replaceHookedMethod(param: XC_MethodHook.MethodHookParam): Any? { val name = param.args.getOrNull(1) as? String ?: return callOriginal(param) - return if (name in ZERO_VALUE_KEYS) 0 else callOriginal(param) + return if (name in zeroValueKeys) 0 else callOriginal(param) } } diff --git a/app/src/main/java/space/bdeshi/veil/SettingsActivity.kt b/app/src/main/java/space/bdeshi/veil/SettingsActivity.kt new file mode 100644 index 0000000..310787c --- /dev/null +++ b/app/src/main/java/space/bdeshi/veil/SettingsActivity.kt @@ -0,0 +1,153 @@ +package space.bdeshi.veil + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Typeface +import android.os.Bundle +import android.text.InputType +import android.view.Gravity +import android.view.View +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import java.io.File + +class SettingsActivity : Activity() { + + private lateinit var prefs: SharedPreferences + private lateinit var listContainer: LinearLayout + private lateinit var packageInput: EditText + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + prefs = getSharedPreferences(Features.PREFS_NAME, Context.MODE_PRIVATE) + + val padding = dp(16) + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(padding, padding, padding, padding) + } + + root.addView(TextView(this).apply { + text = "Hide A11y" + textSize = 20f + setTypeface(typeface, Typeface.BOLD) + }) + root.addView(TextView(this).apply { + text = "Add the package name of an app you've scoped this module to in LSPosed, " + + "then choose which checks to hide from it. Apps you haven't added here get " + + "everything hidden by default (matches this module's original behavior)." + setPadding(0, dp(8), 0, dp(16)) + }) + + val addRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + packageInput = EditText(this).apply { + hint = "e.g. com.example.targetapp" + inputType = InputType.TYPE_CLASS_TEXT + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + val addButton = Button(this).apply { + text = "Add" + setOnClickListener { + val pkg = packageInput.text.toString().trim() + if (pkg.isNotEmpty() && pkg.contains(".")) { + prefs.edit().putStringSet(Features.keyFor(pkg), Features.ALL.toMutableSet()).apply() + fixPermissions() + packageInput.setText("") + refreshList() + } + } + } + addRow.addView(packageInput) + addRow.addView(addButton) + root.addView(addRow) + + listContainer = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, dp(16), 0, 0) + } + root.addView(listContainer) + + setContentView(ScrollView(this).apply { addView(root) }) + refreshList() + } + + private fun refreshList() { + listContainer.removeAllViews() + val packages = prefs.all.keys + .filter { it.startsWith(Features.KEY_PREFIX) } + .map { it.removePrefix(Features.KEY_PREFIX) } + .sorted() + + if (packages.isEmpty()) { + listContainer.addView(TextView(this).apply { + text = "No apps configured yet." + }) + return + } + + for (pkg in packages) { + listContainer.addView(buildPackageCard(pkg)) + } + } + + private fun buildPackageCard(pkg: String): View { + val enabled = prefs.getStringSet(Features.keyFor(pkg), Features.ALL) ?: Features.ALL + + val card = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, dp(12), 0, dp(12)) + } + card.addView(TextView(this).apply { + text = pkg + textSize = 16f + setTypeface(typeface, Typeface.BOLD) + }) + + for ((id, label) in Features.LABELS) { + card.addView(CheckBox(this).apply { + text = label + isChecked = id in enabled + setOnCheckedChangeListener { _, isChecked -> + val current = (prefs.getStringSet(Features.keyFor(pkg), Features.ALL) ?: Features.ALL).toMutableSet() + if (isChecked) current.add(id) else current.remove(id) + prefs.edit().putStringSet(Features.keyFor(pkg), current).apply() + fixPermissions() + } + }) + } + + card.addView(Button(this).apply { + text = "Remove (revert to default: hide everything)" + setOnClickListener { + prefs.edit().remove(Features.keyFor(pkg)).apply() + fixPermissions() + refreshList() + } + }) + + return card + } + + // LSPosed's own SELinux policy is what actually lets hooked processes read this file; + // this just ensures the DAC bits don't block it too. + private fun fixPermissions() { + try { + val prefsDir = File(applicationInfo.dataDir, "shared_prefs") + prefsDir.setReadable(true, false) + prefsDir.setExecutable(true, false) + prefsDir.listFiles()?.forEach { it.setReadable(true, false) } + } catch (t: Throwable) { + // Best-effort. + } + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() +}