Add per-app configurable settings screen

Replaces "hide everything for every scoped app" with per-package, per-feature
toggles, backed by SharedPreferences the hook reads via XSharedPreferences.
A package with no explicit entry still gets every feature hidden, keeping
prior behavior as the default.

Adds a launcher activity so the settings screen is directly reachable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Claude Sonnet 5
2026-08-06 20:55:08 +06:00
parent cc5ceef51a
commit cf7ee62f46
4 changed files with 226 additions and 16 deletions
+10
View File
@@ -15,5 +15,15 @@
android:name="xposeddescription"
android:value="@string/module_description" />
<activity
android:name=".SettingsActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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<String> = setOf(ACCESSIBILITY, ADB, DEV_OPTIONS, MOCK_LOCATION)
val LABELS: LinkedHashMap<String, String> = 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
}
@@ -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<String> {
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<String>,
zeroValueKeys: Set<String>,
) {
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)
}
}
@@ -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()
}