From 6f308a823cc103a5dc8162ba508b7504573d3bba Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 30 Jun 2026 19:17:47 +0000 Subject: [PATCH] Clean up compatibility support code --- README.md | 63 +- app/build.gradle.kts | 6 +- app/proguard-rules.pro | 25 +- app/src/main/AndroidManifest.xml | 9 +- .../java/se/ajpanton/navbuttons/NavButtons.kt | 753 +++++++++++++----- .../ajpanton/navbuttons/SettingsActivity.kt | 48 +- app/src/main/res/values-v35/themes.xml | 8 + app/src/main/res/values/themes.xml | 1 - app/src/main/res/xml/backup_rules.xml | 13 - .../main/res/xml/data_extraction_rules.xml | 19 - .../resources/META-INF/xposed/module.prop | 2 +- .../main/resources/META-INF/xposed/scope.list | 1 - build.gradle.kts | 1 - gradle.properties | 18 - 14 files changed, 670 insertions(+), 297 deletions(-) create mode 100644 app/src/main/res/values-v35/themes.xml delete mode 100644 app/src/main/res/xml/backup_rules.xml delete mode 100644 app/src/main/res/xml/data_extraction_rules.xml diff --git a/README.md b/README.md index 609a672..f5eccbe 100644 --- a/README.md +++ b/README.md @@ -4,43 +4,68 @@ NavButtons is an Android LSPosed module for customizing the three-button navigat It lets you choose what should happen when Back, Home, or Recents is pressed or long-pressed, and lets you configure the long-press duration per button. +It also adds side arrow keys into the navigation bar for cursor movement. + +## AI Assistance + +This project was developed with help from AI-assisted coding tools. + ## Features - Configure press and long-press actions for Back, Home, and Recents. -- Keep the stock Android behavior with the `Stock` action. -- Disable a press with the `None` action. -- Reassign navigation actions: Back, Home, Assistant, and Recents. -- Kill the foreground app. -- Toggle auto-rotate. -- Toggle the flashlight. +- Individually configure custom long-press durations for all buttons. - Add optional side-arrow buttons for moving the text cursor left and right. -- Configure side-arrow long-press behavior, including repeated cursor movement. +- Long-press side arrows for repeated cursor movement with configurable speed. - Test long-press durations from the settings screen. +Supported actions: + +- `Stock`: keep the default behavior for that button. +- `None`: suppress the button. +- `Back`: perform the normal Back action. +- `Home`: perform the normal Home action. +- `Assistant`: open the assistant. +- `Recents`: open recent apps. +- `Kill foreground app`: force close the currently active app. +- `Toggle auto-rotate`: toggle the device's auto-rotate setting. +- `Toggle flashlight`: toggle the flashlight. + ## Requirements - Android device using three-button navigation. - LSPosed with Xposed API 101 or newer. -- Android 14 / API 34 or newer. -- The module enabled for the relevant navigation packages. +- Android (One UI, AOSP, Google APIs, LineageOS) version listed in the compatibility list. +- The module scope enabled for the relevant navigation packages. The module declares scope entries for: - `android` and `system`, used for privileged actions such as killing the foreground app. - `com.android.systemui`, used for phone navigation bars. - `com.android.launcher3` and `com.google.android.apps.nexuslauncher`, used for AOSP/Google taskbar navigation. -- `com.sec.android.app.launcher` and `com.samsung.systemui.navillera`, used for Samsung/One UI navigation. +- `com.sec.android.app.launcher`, used for Samsung/One UI folded navigation. -## Tested On +## Compatibility -- Samsung Galaxy Z Fold5 running One UI 8.0 / Android 16 with Magisk and LSPosed. -- AOSP emulator API 34 / Android 14 with Magisk and LSPosed. -- AOSP emulator API 35 / Android 15 with Magisk and LSPosed. -- AOSP emulator API 36 / Android 16 with Magisk and LSPosed. -- Android 16 Google APIs emulator with Magisk and LSPosed. +Supported targets are expected to support all features. -The emulator tests covered button press overrides, long-press overrides with custom duration, side-arrow visibility, side-arrow tinting, enabling/disabling side arrows, and side-arrow long-press repeat behavior where supported by the emulator navigation implementation. Actual device testing with others than the Galaxy Z Fold 5 has not been done. Compatibility may vary between Android versions and vendor System UI / launcher implementations. +Samsung, tested on real hardware: -## AI Assistance +- One UI 8 / Android 16 on a Galaxy Z Fold5. -This project was developed with help from AI-assisted coding tools. +AOSP emulator images: + +- Android 14 / API 34. +- Android 15 / API 35. +- Android 16 / API 36. + +Google APIs emulator images: + +- Android 15 / API 35. +- Android 16 / API 36. + +LineageOS emulator images: + +- LineageOS 22.2 / Android 15 / API 35. +- LineageOS 23.2 / Android 16 / API 36. + +Emulator validation used Magisk and LSPosed. Emulator results confirm compatibility with those images, not with every ROM derived from the same Android version. Vendor System UI and launcher implementations can differ enough to require separate support. On emulators, flashlight support is validated by confirming that the flashlight signal is sent; real hardware behavior depends on the device camera/flash implementation. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8e8cfaa..b9c1abd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,9 +86,5 @@ dependencies { implementation("androidx.annotation:annotation:1.7.1") implementation("io.github.libxposed:service:101.0.0") implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1") - compileOnly("io.github.libxposed:api:101.0.0") - - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + compileOnly("io.github.libxposed:api:102.0.0") } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4233291..ab7330c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,28 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - +-adaptresourcefilecontents META-INF/xposed/java_init.list -dontobfuscate --keep class se.ajpanton.navbuttons.NavButtons --keepclassmembers class se.ajpanton.navbuttons.NavButtons { +-keep,allowoptimization,allowobfuscation public class * extends io.github.libxposed.api.XposedModule { public (); } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6855b7a..0f3ea30 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,13 +6,16 @@ + + + + () + private val systemUiLongPressRunnables = WeakHashMap() private val systemUiHomeStockLongPressGuards = WeakHashMap() private val systemUiHomeStockPressCanceled = WeakHashMap() + private val systemServerLongPressRunnables = mutableMapOf() + private val systemServerLongPressTriggered = mutableMapOf() private val aospTaskbarButtonIds = WeakHashMap() private val aospTaskbarControllers = WeakHashMap() private val aospTaskbarLongPressRunnables = WeakHashMap() @@ -86,6 +101,7 @@ class NavButtons : XposedModule() { private val sideArrowLongPressTriggered = WeakHashMap() private val sideArrowLongPressRunnables = WeakHashMap() private val sideArrowRepeatRunnables = WeakHashMap() + private val targetHookRetryAttempts = mutableMapOf() private val trackedHoneyspaceNavContainers = Collections.newSetFromMap(WeakHashMap()) private val honeyspaceNativeSideArrowStates = WeakHashMap() private val trackedSamsungNavigationBarViews = Collections.newSetFromMap(WeakHashMap()) @@ -98,19 +114,40 @@ class NavButtons : XposedModule() { private var settingsChangedReceiverRegistered = false private var privilegedActionReceiverRegistered = false private var privilegedActionToken: String? = null + private val buttonConfigOverrides = mutableMapOf() private var sideArrowsEnabledOverride: Boolean? = null + private var sideArrowLongPressActionOverride: SideArrowLongPressAction? = null + private var sideArrowLongPressDurationOverride: Int? = null + private var sideArrowRepeatSpeedOverride: Float? = null override fun onModuleLoaded(param: XposedModuleInterface.ModuleLoadedParam) { if (!HiddenApiBypass.addHiddenApiExemptions("")) { log("HiddenApiBypass could not be enabled.") } + val classLoader = Thread.currentThread().contextClassLoader ?: return + if (param.isSystemServer) { + onSystemServerStarting(object : XposedModuleInterface.SystemServerStartingParam { + override fun getClassLoader(): ClassLoader = classLoader + }) + } else { + hookLoadedApkClassLoaderFallback(param.processName) + hookAppBindFallback(param.processName) + handleTargetPackage(param.processName, classLoader) + scheduleTargetHookRetry(param.processName) + } } override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) { + if (systemServerProcess) { + return + } handleTargetPackage(param.packageName, param.defaultClassLoader) } override fun onPackageReady(param: XposedModuleInterface.PackageReadyParam) { + if (systemServerProcess) { + return + } handleTargetPackage(param.packageName, param.classLoader) } @@ -118,6 +155,7 @@ class NavButtons : XposedModule() { if (systemServerHookInstalled) { return } + systemServerProcess = true initContextAndHandler() systemServerHookInstalled = hookSystemServerHomeLongPress(param.classLoader) } @@ -129,6 +167,9 @@ class NavButtons : XposedModule() { } initContextAndHandler() systemUiHookInstalled = hookSystemUiNavigationButtons(classLoader) + if (!systemUiHookInstalled) { + scheduleTargetHookRetry(packageName) + } return } @@ -147,10 +188,122 @@ class NavButtons : XposedModule() { } initContextAndHandler() aospTaskbarHookInstalled = hookAospTaskbar(classLoader) + if (!aospTaskbarHookInstalled) { + scheduleTargetHookRetry(packageName) + } return } } + private fun scheduleTargetHookRetry(packageName: String) { + if (packageName !in TARGET_PACKAGES || targetHookInstalled(packageName)) { + return + } + val attempts = targetHookRetryAttempts[packageName] ?: 0 + if (attempts >= TARGET_HOOK_RETRY_ATTEMPTS) { + return + } + targetHookRetryAttempts[packageName] = attempts + 1 + val handler = mainHandler ?: Handler(Looper.getMainLooper()).also { mainHandler = it } + handler.postDelayed({ + if (targetHookInstalled(packageName)) { + return@postDelayed + } + val application = getCurrentApplication() + if (application != null) { + context = application + handleTargetPackage(packageName, application.classLoader) + } + scheduleTargetHookRetry(packageName) + }, TARGET_HOOK_RETRY_DELAY_MS) + } + + private fun targetHookInstalled(packageName: String): Boolean { + return when (packageName) { + SYSTEMUI_PACKAGE -> systemUiHookInstalled + in AOSP_LAUNCHER_PACKAGES -> aospTaskbarHookInstalled + else -> true + } + } + + private fun hookAppBindFallback(processName: String) { + if (appBindFallbackHookInstalled || processName !in TARGET_PACKAGES) { + return + } + val activityThreadClass = findClassIfExists("android.app.ActivityThread", null) ?: return + val method = findMethodsNamed(activityThreadClass, "handleBindApplication").firstOrNull() ?: return + appBindFallbackHookInstalled = true + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + val result = chain.proceed() + getCurrentApplication()?.let { application -> + context = application + } + val classLoader = getAppBindClassLoader(chain.args.firstOrNull()) + ?: context?.classLoader + if (classLoader != null) { + handleTargetPackage(processName, classLoader) + } + result + } + } + + private fun hookLoadedApkClassLoaderFallback(processName: String) { + if (loadedApkClassLoaderFallbackHookInstalled || processName !in TARGET_PACKAGES) { + return + } + val loadedApkClass = findClassIfExists("android.app.LoadedApk", null) ?: return + val methods = findMethodsNamed(loadedApkClass, "createOrUpdateClassLoaderLocked") + if (methods.isEmpty()) { + log("LoadedApk classloader fallback method not found.") + return + } + + loadedApkClassLoaderFallbackHookInstalled = true + methods.forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + val result = chain.proceed() + val loadedApk = chain.thisObject ?: return@intercept result + val packageName = getLoadedApkPackageName(loadedApk) ?: return@intercept result + if (packageName in TARGET_PACKAGES) { + val classLoader = getFieldValue(loadedApk, "mClassLoader") as? ClassLoader + handleTargetPackage(packageName, classLoader) + } + result + } + } + } + + private fun getLoadedApkPackageName(loadedApk: Any): String? { + val packageName = getFieldValue(loadedApk, "mPackageName") as? String + if (packageName != null) { + return packageName + } + return (getFieldValue(loadedApk, "mApplicationInfo") as? ApplicationInfo)?.packageName + } + + private fun getAppBindClassLoader(appBindData: Any?): ClassLoader? { + val loadedApk = getFieldValue(appBindData, "info") ?: return null + return (getFieldValue(loadedApk, "mClassLoader") as? ClassLoader) + ?: callMethodIfExists(loadedApk, "getClassLoader") as? ClassLoader + } + + private fun attachFrameworkCompat(xposed: XposedInterface) { + var current = javaClass.superclass + while (current != null) { + try { + val method = current.getDeclaredMethod("attachFramework", XposedInterface::class.java) + method.isAccessible = true + method.invoke(this, xposed) + return + } catch (_: NoSuchMethodException) { + current = current.superclass + } + } + findField(javaClass, "mBase").set(this, xposed) + } + private fun initContextAndHandler() { if (context == null) { context = getCurrentApplication() @@ -158,11 +311,13 @@ class NavButtons : XposedModule() { if (mainHandler == null) { mainHandler = Handler(Looper.getMainLooper()) } - startSettingsPreferencesLoad() } private fun getSettingsPreferences(): SharedPreferences? { remotePreferences?.let { return it } + if (systemServerProcess) { + return null + } if (Looper.myLooper() == Looper.getMainLooper()) { startSettingsPreferencesLoad() return null @@ -174,6 +329,9 @@ class NavButtons : XposedModule() { if (remotePreferences != null || remotePreferencesLoading) { return } + if (context?.packageName == ANDROID_PACKAGE) { + return + } val now = SystemClock.uptimeMillis() if (now < remotePreferencesRetryAfterMs) { return @@ -266,24 +424,8 @@ class NavButtons : XposedModule() { ensureContextFromView(keyButtonView) registerSettingsChangedReceiver() trackSamsungNavigationBarView(keyButtonView) - return@intercept when (motionEvent.actionMasked) { - MotionEvent.ACTION_DOWN -> { - sideArrowLongPressTriggered[keyButtonView] = false - cancelSideArrowLongPress(keyButtonView) - val result = chain.proceed() - scheduleSideArrowLongPress(keyButtonView, keyCode) - result - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL, - -> { - cancelSideArrowLongPress(keyButtonView) - sideArrowLongPressTriggered.remove(keyButtonView) - chain.proceed() - } - - else -> chain.proceed() + return@intercept handleNativeSideArrowTouch(keyButtonView, keyCode, motionEvent) { + chain.proceed() } } val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() @@ -297,7 +439,7 @@ class NavButtons : XposedModule() { cancelSystemUiHomeStockLongPressGuard(keyButtonView) systemUiHomeStockPressCanceled.remove(keyButtonView) val result = chain.proceed() - rescheduleSystemUiLongPress(keyButtonView, buttonId) + scheduleSystemUiLongPress(keyButtonView, buttonId) scheduleSystemUiHomeStockLongPressGuard(keyButtonView, buttonId) result } @@ -308,6 +450,7 @@ class NavButtons : XposedModule() { chain.proceed() }.also { cancelSystemUiHomeStockLongPressGuard(keyButtonView) + cancelSystemUiLongPress(keyButtonView) systemUiLongPressTriggered.remove(keyButtonView) systemUiHomeStockPressCanceled.remove(keyButtonView) } @@ -335,12 +478,15 @@ class NavButtons : XposedModule() { performConfiguredAction(pressAction) } cancelSystemUiHomeStockLongPressGuard(keyButtonView) + cancelSystemUiLongPress(keyButtonView) + systemUiLongPressTriggered.remove(keyButtonView) systemUiHomeStockPressCanceled.remove(keyButtonView) result } MotionEvent.ACTION_CANCEL -> { cancelSystemUiHomeStockLongPressGuard(keyButtonView) + cancelSystemUiLongPress(keyButtonView) systemUiLongPressTriggered.remove(keyButtonView) systemUiHomeStockPressCanceled.remove(keyButtonView) chain.proceed() @@ -428,7 +574,8 @@ class NavButtons : XposedModule() { Long::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!, ) - val systemReadyMethod = findMethodIfExists(phoneWindowManagerClass, "systemReady") + val initMethods = findMethodsNamed(phoneWindowManagerClass, "init") + .filter { method -> method.parameterTypes.firstOrNull() == Context::class.java } val keyDispatchMethods = findMethodsNamed(phoneWindowManagerClass, "interceptKeyBeforeDispatching") .filter { method -> method.returnType == Long::class.javaPrimitiveType && @@ -440,11 +587,12 @@ class NavButtons : XposedModule() { method.parameterTypes.any { it == KeyEvent::class.java } } - if (systemReadyMethod != null) { - deoptimizeMethodIfPresent(systemReadyMethod) - hook(systemReadyMethod).intercept { chain -> + initMethods.forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> val result = chain.proceed() - updateContextFromField(chain.thisObject, "mContext") + context = chain.args.firstOrNull() as? Context + registerSettingsChangedReceiver() registerPrivilegedActionReceiver() result } @@ -457,16 +605,18 @@ class NavButtons : XposedModule() { val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent ?: return@intercept chain.proceed() val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@intercept chain.proceed() - val pressAction = getButtonConfig(buttonId).pressAction - if (pressAction == NavButtonAction.STOCK) { + val config = getButtonConfig(buttonId) + if (config.pressAction == NavButtonAction.STOCK && + config.longPressAction == NavButtonAction.STOCK + ) { return@intercept chain.proceed() } updateContextFromField(chain.thisObject, "mContext") - if (keyEvent.action == KeyEvent.ACTION_UP && !keyEvent.isCanceled) { - performConfiguredAction(pressAction) + if (handleSystemServerKeyOverride(keyEvent, buttonId, config)) { + return@intercept -1L } - -1L + chain.proceed() } installed = true } @@ -477,17 +627,17 @@ class NavButtons : XposedModule() { val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent ?: return@intercept chain.proceed() val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@intercept chain.proceed() - val pressAction = getButtonConfig(buttonId).pressAction - if (pressAction == NavButtonAction.STOCK) { + val config = getButtonConfig(buttonId) + if (config.pressAction == NavButtonAction.STOCK && + config.longPressAction == NavButtonAction.STOCK + ) { return@intercept chain.proceed() } updateContextFromField(chain.thisObject, "mContext") - if (keyEvent.action == KeyEvent.ACTION_UP && !keyEvent.isCanceled) { - performConfiguredAction(pressAction) - } + val consume = handleSystemServerKeyOverride(keyEvent, buttonId, config) val result = chain.proceed() as? Int ?: 0 - result and ACTION_PASS_TO_USER.inv() + if (consume) result and ACTION_PASS_TO_USER.inv() else result } installed = true } @@ -573,12 +723,11 @@ class NavButtons : XposedModule() { chain.thisObject?.let(::trackAospSystemUiInflater) val layout = chain.args.getOrNull(0) as? String if (layout != null) { - val updatedLayout = if (isSideArrowsEnabled()) { + chain.args[0] = if (isSideArrowsEnabled()) { addAospSystemUiSideArrows(layout) } else { removeAospSystemUiSideArrows(layout) } - runCatching { chain.args[0] = updatedLayout } } chain.proceed() } @@ -851,6 +1000,11 @@ class NavButtons : XposedModule() { "onButtonClick", Int::class.javaPrimitiveType!!, View::class.java, + ) ?: findMethodIfExists( + controllerClass, + "onButtonClick", + View::class.java, + Int::class.javaPrimitiveType!!, ) ?: run { log("AOSP taskbar onButtonClick() method not found.") return false @@ -860,6 +1014,11 @@ class NavButtons : XposedModule() { "onButtonLongClick", Int::class.javaPrimitiveType!!, View::class.java, + ) ?: findMethodIfExists( + controllerClass, + "onButtonLongClick", + View::class.java, + Int::class.javaPrimitiveType!!, ) ?: run { log("AOSP taskbar onButtonLongClick() method not found.") return false @@ -875,37 +1034,36 @@ class NavButtons : XposedModule() { deoptimizeMethodIfPresent(longClickMethod) deoptimizeMethodIfPresent(sendBackKeyEventMethod) hookAospTaskbarButtonMapping(navButtonsViewControllerClass, controllerClass) - hookAospTaskbarTouchEvents() hookAospTaskbarTintUpdates() registerSettingsChangedReceiver() hook(clickMethod).intercept { chain -> - val buttonId = buttonIdForAospTaskbarCode(chain.args[0] as? Int) + val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int) ?: return@intercept chain.proceed() val pressAction = getButtonConfig(buttonId).pressAction if (pressAction == NavButtonAction.STOCK) { return@intercept chain.proceed() } - updateContextFromTaskbar(chain.thisObject, chain.args.getOrNull(1)) + updateContextFromTaskbar(chain.thisObject, chain.args.firstOrNull { it is View }) performAospTaskbarAction(chain.thisObject, pressAction) null } hook(longClickMethod).intercept { chain -> - val buttonId = buttonIdForAospTaskbarCode(chain.args[0] as? Int) + val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int) ?: return@intercept chain.proceed() val longPressAction = getButtonConfig(buttonId).longPressAction if (longPressAction == NavButtonAction.STOCK) { return@intercept chain.proceed() } - val view = chain.args.getOrNull(1) + val view = chain.args.firstOrNull { it is View } if (view != null && aospTaskbarButtonIds.containsKey(view)) { return@intercept true } - updateContextFromTaskbar(chain.thisObject, chain.args.getOrNull(1)) + updateContextFromTaskbar(chain.thisObject, view) if (buttonId == NavButtonId.HOME) { performAospTaskbarHomeLongPressAction(chain.thisObject, longPressAction) } else { @@ -919,9 +1077,7 @@ class NavButtons : XposedModule() { val pressAction = getButtonConfig(NavButtonId.BACK).pressAction val longPressAction = getButtonConfig(NavButtonId.BACK).longPressAction val action = chain.args[0] as? Int ?: return@intercept chain.proceed() - val canceled = chain.args.getOrNull(1) as? Boolean ?: false val customLongPress = longPressAction != NavButtonAction.STOCK - val longPressTriggered = aospTaskbarBackLongPressTriggered[chain.thisObject] == true if (pressAction == NavButtonAction.STOCK && !customLongPress) { return@intercept chain.proceed() @@ -933,16 +1089,6 @@ class NavButtons : XposedModule() { aospTaskbarBackLongPressTriggered.remove(chain.thisObject) } - if (customLongPress && action == KeyEvent.ACTION_DOWN) { - return@intercept null - } - - if (action == KeyEvent.ACTION_UP && !canceled && !longPressTriggered) { - performAospTaskbarAction( - chain.thisObject, - if (pressAction == NavButtonAction.STOCK) NavButtonAction.BACK else pressAction, - ) - } null } } @@ -996,25 +1142,6 @@ class NavButtons : XposedModule() { } } - private fun hookAospTaskbarTouchEvents() { - val onTouchEventMethod = findMethodIfExists( - View::class.java, - "onTouchEvent", - MotionEvent::class.java, - ) ?: run { - log("AOSP taskbar View.onTouchEvent() method not found.") - return - } - - deoptimizeMethodIfPresent(onTouchEventMethod) - hook(onTouchEventMethod).intercept { chain -> - val view = chain.thisObject as? View ?: return@intercept chain.proceed() - val event = chain.args.getOrNull(0) as? MotionEvent ?: return@intercept chain.proceed() - val overrideResult = handleAospTaskbarTouch(view, event) - overrideResult ?: chain.proceed() - } - } - private fun hookAospTaskbarTintUpdates() { if (aospTaskbarTintHookInstalled) { return @@ -1051,6 +1178,9 @@ class NavButtons : XposedModule() { if (controller != null) { aospTaskbarControllers[view] = controller } + view.setOnTouchListener { touchedView, event -> + handleAospTaskbarTouch(touchedView, event) + } ensureContextFromView(view) registerSettingsChangedReceiver() @@ -1208,6 +1338,8 @@ class NavButtons : XposedModule() { } ?: android.widget.FrameLayout.LayoutParams(width, height) } + arrow.bringToFront() + arrow.translationZ = home.translationZ + 1f } private fun centerInHost(view: View, host: View): Point { @@ -1298,12 +1430,41 @@ class NavButtons : XposedModule() { return true } - private fun handleAospTaskbarTouch(view: View, event: MotionEvent): Boolean? { - val buttonId = aospTaskbarButtonIds[view] ?: return null - val controller = aospTaskbarControllers[view] ?: return null + private fun handleNativeSideArrowTouch( + view: Any, + keyCode: Int, + event: MotionEvent, + proceed: () -> Any?, + ): Any? { + return when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + sideArrowLongPressTriggered[view] = false + cancelSideArrowLongPress(view) + val result = proceed() + scheduleSideArrowLongPress(view, keyCode) + result + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + cancelSideArrowLongPress(view) + sideArrowLongPressTriggered.remove(view) + proceed() + } + + else -> proceed() + } + } + + private fun handleAospTaskbarTouch(view: View, event: MotionEvent): Boolean { + val buttonId = aospTaskbarButtonIds[view] ?: return false + val controller = aospTaskbarControllers[view] ?: return false val config = getButtonConfig(buttonId) - if (config.longPressAction == NavButtonAction.STOCK) { - return null + if (config.pressAction == NavButtonAction.STOCK && + config.longPressAction == NavButtonAction.STOCK + ) { + return false } when (event.actionMasked) { @@ -1312,7 +1473,9 @@ class NavButtons : XposedModule() { if (buttonId == NavButtonId.BACK) { aospTaskbarBackLongPressTriggered[controller] = false } - scheduleAospTaskbarLongPress(view, controller, buttonId, config) + if (config.longPressAction != NavButtonAction.STOCK) { + scheduleAospTaskbarLongPress(view, controller, buttonId, config) + } } MotionEvent.ACTION_UP, @@ -1327,10 +1490,29 @@ class NavButtons : XposedModule() { if (triggered) { return true } + if (event.actionMasked == MotionEvent.ACTION_UP) { + when { + config.pressAction != NavButtonAction.STOCK -> { + performAospTaskbarAction(controller, config.pressAction) + return true + } + + config.longPressAction != NavButtonAction.STOCK && + event.eventTime - event.downTime >= ViewConfiguration.getLongPressTimeout() -> { + performAospTaskbarAction(controller, stockActionFor(buttonId)) + return true + } + + buttonId == NavButtonId.BACK -> { + performAospTaskbarAction(controller, NavButtonAction.BACK) + return true + } + } + } } } - return null + return false } private fun scheduleAospTaskbarLongPress( @@ -1357,6 +1539,14 @@ class NavButtons : XposedModule() { view.postDelayed(longPressRunnable, config.longPressDurationMs.toLong()) } + private fun stockActionFor(buttonId: NavButtonId): NavButtonAction { + return when (buttonId) { + NavButtonId.BACK -> NavButtonAction.BACK + NavButtonId.HOME -> NavButtonAction.HOME + NavButtonId.RECENTS -> NavButtonAction.RECENTS + } + } + private fun cancelAospTaskbarLongPress(view: View) { aospTaskbarLongPressRunnables.remove(view)?.let(view::removeCallbacks) } @@ -1399,24 +1589,8 @@ class NavButtons : XposedModule() { registerSettingsChangedReceiver() trackHoneyspaceNavContainerFrom(navButtonView as? View) - return@intercept when (motionEvent.actionMasked) { - MotionEvent.ACTION_DOWN -> { - sideArrowLongPressTriggered[navButtonView] = false - cancelSideArrowLongPress(navButtonView) - val result = chain.proceed() - scheduleSideArrowLongPress(navButtonView, keyCode) - result - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL, - -> { - cancelSideArrowLongPress(navButtonView) - sideArrowLongPressTriggered.remove(navButtonView) - chain.proceed() - } - - else -> chain.proceed() + return@intercept handleNativeSideArrowTouch(navButtonView, keyCode, motionEvent) { + chain.proceed() } } val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() @@ -1534,7 +1708,7 @@ class NavButtons : XposedModule() { deoptimizeMethodIfPresent(method) hook(method).intercept { chain -> val result = chain.proceed() - val container = chain.thisObject as? android.view.ViewGroup + val container = chain.thisObject as? ViewGroup ?: return@intercept result ensureContextFromView(container) registerSettingsChangedReceiver() @@ -1562,7 +1736,7 @@ class NavButtons : XposedModule() { } deoptimizeMethodIfPresent(method) hook(method).intercept { chain -> - val container = chain.thisObject as? android.view.ViewGroup + val container = chain.thisObject as? ViewGroup val result = chain.proceed() if (container != null && isSideArrowsEnabled()) { restoreHoneyspaceSideArrowDrawables(container) @@ -1579,7 +1753,7 @@ class NavButtons : XposedModule() { val target = current ?: return if (isHoneyspaceNavButtonContainer(target)) { trackHoneyspaceNavContainer(target) - val container = target as android.view.ViewGroup + val container = target as ViewGroup refreshHoneyspaceSideArrowLayout(container, "from touch") scheduleDelayedHoneyspaceSideArrowRefresh(container, "from touch") return @@ -1591,7 +1765,7 @@ class NavButtons : XposedModule() { private fun trackHoneyspaceNavContainer(container: View) { if (trackedHoneyspaceNavContainers.add(container)) { container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> - refreshHoneyspaceSideArrowLayout(view as android.view.ViewGroup, "layout listener") + refreshHoneyspaceSideArrowLayout(view as ViewGroup, "layout listener") } } } @@ -1599,12 +1773,12 @@ class NavButtons : XposedModule() { private fun refreshTrackedHoneyspaceSideArrowLayouts(reason: String) { val containers = trackedHoneyspaceNavContainers.toList() containers.forEach { container -> - refreshHoneyspaceSideArrowLayout(container as? android.view.ViewGroup ?: return@forEach, reason) + refreshHoneyspaceSideArrowLayout(container as? ViewGroup ?: return@forEach, reason) scheduleDelayedHoneyspaceSideArrowRefresh(container, reason) } } - private fun scheduleDelayedHoneyspaceSideArrowRefresh(container: android.view.ViewGroup, reason: String) { + private fun scheduleDelayedHoneyspaceSideArrowRefresh(container: ViewGroup, reason: String) { listOf( HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS, HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS, @@ -1616,7 +1790,7 @@ class NavButtons : XposedModule() { } } - private fun refreshHoneyspaceSideArrowLayout(container: android.view.ViewGroup, reason: String) { + private fun refreshHoneyspaceSideArrowLayout(container: ViewGroup, reason: String) { try { applyNativeHoneyspaceSideArrows(container, reason) } catch (error: Throwable) { @@ -1625,7 +1799,7 @@ class NavButtons : XposedModule() { } @Suppress("UNCHECKED_CAST") - private fun applyNativeHoneyspaceSideArrows(container: android.view.ViewGroup, reason: String) { + private fun applyNativeHoneyspaceSideArrows(container: ViewGroup, reason: String) { val buttonMap = getFieldValue(container, "i") as? MutableMap ?: run { log("Honeyspace side arrows native apply skipped: button map missing reason=$reason") return @@ -1635,8 +1809,8 @@ class NavButtons : XposedModule() { val currentSlots = getFieldValue(container, "u") as? BooleanArray val alreadyApplied = honeyspaceNativeSideArrowStates[container] == enabled && if (enabled) { - buttonMap[64] != null && - buttonMap[128] != null && + buttonMap[HONEYSPACE_LEFT_ARROW_BUTTON_TYPE] != null && + buttonMap[HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE] != null && getIntField(container, "v") == 2 && currentSlots?.contentEquals(targetSlots) == true && honeyspaceSideArrowDrawablesPresent(buttonMap) @@ -1651,11 +1825,11 @@ class NavButtons : XposedModule() { HONEYSPACE_EXTRA_BUTTON_TYPES.forEach { buttonMap.remove(it) } if (enabled) { - createHoneyspaceExtraButton(container, 64, KeyEvent.KEYCODE_DPAD_LEFT, SideArrowDirection.LEFT) - ?.let { buttonMap[64] = it } - createHoneyspaceExtraButton(container, 128, KeyEvent.KEYCODE_DPAD_RIGHT, SideArrowDirection.RIGHT) - ?.let { buttonMap[128] = it } - if (buttonMap[64] == null || buttonMap[128] == null) { + val created = HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow -> + createHoneyspaceExtraButton(container, arrow.type, arrow.keyCode, arrow.direction) + ?.also { buttonMap[arrow.type] = it } != null + } + if (!created) { log("Honeyspace side arrows native apply skipped: extra button creation failed reason=$reason") return } @@ -1671,7 +1845,7 @@ class NavButtons : XposedModule() { restoreHoneyspaceSideArrowDrawables(container) } - private fun honeyspaceSideArrowSlots(container: android.view.ViewGroup, enabled: Boolean): BooleanArray { + private fun honeyspaceSideArrowSlots(container: ViewGroup, enabled: Boolean): BooleanArray { if (!enabled) { return BooleanArray(5) } @@ -1687,23 +1861,21 @@ class NavButtons : XposedModule() { } private fun honeyspaceSideArrowDrawablesPresent(buttonMap: Map): Boolean { - val left = buttonMap[64] as? ImageView - val right = buttonMap[128] as? ImageView - return left?.drawable != null && right?.drawable != null + return HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow -> + (buttonMap[arrow.type] as? ImageView)?.drawable != null + } } - private fun restoreHoneyspaceSideArrowDrawables(container: android.view.ViewGroup) { + private fun restoreHoneyspaceSideArrowDrawables(container: ViewGroup) { val buttonMap = getFieldValue(container, "i") as? Map<*, *> ?: return - restoreHoneyspaceSideArrowDrawable( - buttonMap[64] as? ImageView, - SideArrowDirection.LEFT, - getFieldValue(container, "w") as? Float ?: 0f, - ) - restoreHoneyspaceSideArrowDrawable( - buttonMap[128] as? ImageView, - SideArrowDirection.RIGHT, - getFieldValue(container, "w") as? Float ?: 0f, - ) + val darkIntensity = getFieldValue(container, "w") as? Float ?: 0f + HONEYSPACE_SIDE_ARROW_BUTTONS.forEach { arrow -> + restoreHoneyspaceSideArrowDrawable( + buttonMap[arrow.type] as? ImageView, + arrow.direction, + darkIntensity, + ) + } } private fun restoreHoneyspaceSideArrowDrawable( @@ -1733,7 +1905,7 @@ class NavButtons : XposedModule() { } private fun createHoneyspaceExtraButton( - container: android.view.ViewGroup, + container: ViewGroup, buttonType: Int, keyCode: Int, direction: SideArrowDirection, @@ -1783,7 +1955,10 @@ class NavButtons : XposedModule() { ) } - private fun scheduleSideArrowLongPress(navButtonView: Any, keyCode: Int) { + private fun scheduleSideArrowLongPress( + navButtonView: Any, + keyCode: Int, + ) { val view = navButtonView as? View ?: return val handler = mainHandler ?: Handler(Looper.getMainLooper()).also { mainHandler = it } val longPressRunnable = Runnable { @@ -1831,12 +2006,14 @@ class NavButtons : XposedModule() { } private fun getSideArrowLongPressAction(): SideArrowLongPressAction { + sideArrowLongPressActionOverride?.let { return it } return SideArrowLongPressAction.fromStorageValue( getSettingsPreferences()?.getString(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION, null), ) } private fun getSideArrowLongPressDurationMs(): Int { + sideArrowLongPressDurationOverride?.let { return it } return ( getSettingsPreferences()?.getInt( NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, @@ -1847,10 +2024,12 @@ class NavButtons : XposedModule() { private fun sideArrowRepeatIntervalMs(): Long { val speed = sanitizeSideArrowRepeatSpeed( - getSettingsPreferences()?.getFloat( - NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, - NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, - ) ?: NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + sideArrowRepeatSpeedOverride + ?: getSettingsPreferences()?.getFloat( + NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ) + ?: NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, ) return (1_000f / speed).toLong().coerceAtLeast(1L) } @@ -1925,10 +2104,10 @@ class NavButtons : XposedModule() { NavButtonAction.NONE, -> Unit - NavButtonAction.BACK -> sendKeyPress(KeyEvent.KEYCODE_BACK) - NavButtonAction.HOME -> sendKeyPress(KeyEvent.KEYCODE_HOME) - NavButtonAction.ASSISTANT -> sendKeyPress(KeyEvent.KEYCODE_ASSIST) - NavButtonAction.RECENTS -> sendKeyPress(KeyEvent.KEYCODE_APP_SWITCH) + NavButtonAction.BACK -> sendConfiguredKeyPress(KeyEvent.KEYCODE_BACK) + NavButtonAction.HOME -> sendConfiguredKeyPress(KeyEvent.KEYCODE_HOME) + NavButtonAction.ASSISTANT -> sendConfiguredKeyPress(KeyEvent.KEYCODE_ASSIST) + NavButtonAction.RECENTS -> sendConfiguredKeyPress(KeyEvent.KEYCODE_APP_SWITCH) NavButtonAction.KILL_FOREGROUND_APP -> performPrivilegedActionOrDirect(PRIVILEGED_ACTION_KILL_FOREGROUND_APP) @@ -1938,6 +2117,87 @@ class NavButtons : XposedModule() { } } + private fun handleSystemServerKeyOverride( + keyEvent: KeyEvent, + buttonId: NavButtonId, + config: NavButtonConfig, + ): Boolean { + val pressAction = config.pressAction + val longPressAction = config.longPressAction + val keyCode = keyEvent.keyCode + + return when (keyEvent.action) { + KeyEvent.ACTION_DOWN -> { + if (longPressAction != NavButtonAction.STOCK && keyEvent.repeatCount == 0) { + scheduleSystemServerLongPress(keyCode, buttonId, longPressAction) + } + pressAction != NavButtonAction.STOCK + } + + KeyEvent.ACTION_UP -> { + val longPressTriggered = systemServerLongPressTriggered[keyCode] == true + val longPressElapsed = keyEvent.eventTime - keyEvent.downTime >= config.longPressDurationMs + cancelSystemServerLongPress(keyCode) + if (longPressTriggered || + (longPressAction != NavButtonAction.STOCK && longPressElapsed) + ) { + if (!longPressTriggered) { + if (buttonId == NavButtonId.HOME) { + performHomeLongPressAction(longPressAction) + } else { + performConfiguredAction(longPressAction) + } + } + true + } else { + if (pressAction != NavButtonAction.STOCK && !keyEvent.isCanceled) { + performConfiguredAction(pressAction) + } + pressAction != NavButtonAction.STOCK + } + } + + else -> pressAction != NavButtonAction.STOCK + } + } + + private fun scheduleSystemServerLongPress( + keyCode: Int, + buttonId: NavButtonId, + action: NavButtonAction, + ) { + if (systemServerLongPressRunnables[keyCode] != null) { + return + } + val handler = mainHandler ?: Handler(Looper.getMainLooper()).also { mainHandler = it } + val runnable = Runnable { + systemServerLongPressRunnables.remove(keyCode) + systemServerLongPressTriggered[keyCode] = true + if (buttonId == NavButtonId.HOME) { + performHomeLongPressAction(action) + } else { + performConfiguredAction(action) + } + } + systemServerLongPressTriggered[keyCode] = false + systemServerLongPressRunnables[keyCode] = runnable + handler.postDelayed(runnable, getButtonConfig(buttonId).longPressDurationMs.toLong()) + } + + private fun cancelSystemServerLongPress(keyCode: Int) { + systemServerLongPressRunnables.remove(keyCode)?.let { runnable -> + mainHandler?.removeCallbacks(runnable) + } + } + + private fun sendConfiguredKeyPress(keyCode: Int) { + if (context?.packageName in AOSP_LAUNCHER_PACKAGES) { + requestPrivilegedAction(PRIVILEGED_ACTION_SEND_KEY_PRESS, keyCode) + } else { + sendKeyPress(keyCode) + } + } + private fun performPrivilegedActionOrDirect(action: String) { if (context?.packageName == ANDROID_PACKAGE) { when (action) { @@ -2015,10 +2275,7 @@ class NavButtons : XposedModule() { private fun sendKeyPress(keyCode: Int) { try { - val inputManager = callStaticMethod( - findClass(INPUT_MANAGER_CLASS, null), - "getInstance", - ) ?: run { + val inputManager = getInputManagerForInjection() ?: run { log("InputManager unavailable for keyCode=$keyCode") return } @@ -2030,6 +2287,20 @@ class NavButtons : XposedModule() { } } + private fun getInputManagerForInjection(): Any? { + context?.getSystemService(Context.INPUT_SERVICE)?.let { return it } + + return try { + callStaticMethod(findClass(INPUT_MANAGER_CLASS, null), "getInstance") + } catch (_: Throwable) { + try { + callStaticMethod(findClass(INPUT_MANAGER_GLOBAL_CLASS, null), "getInstance") + } catch (_: Throwable) { + null + } + } + } + private fun hookFoldedSideArrows(classLoader: ClassLoader?) { if (!Build.MANUFACTURER.equals("samsung", ignoreCase = true)) { return @@ -2373,20 +2644,18 @@ class NavButtons : XposedModule() { if (settingsChangedReceiverRegistered) { return } - val currentContext = context ?: return - if (currentContext.packageName == ANDROID_PACKAGE) { - return - } + val currentContext = getCurrentApplication()?.applicationContext + ?: context?.applicationContext + ?: context + ?: return + context = currentContext val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action != NavButtonSettingsStore.ACTION_SETTINGS_CHANGED) { return } - sideArrowsEnabledOverride = intent.getBooleanExtra( - NavButtonSettingsStore.EXTRA_SIDE_ARROWS_ENABLED, - getSettingsPreferences()?.getBoolean(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, false) == true, - ) + updateSettingsOverrides(intent) reInflateTrackedSamsungNavigationBars() refreshTrackedAospSystemUiSideArrowLayouts() refreshTrackedHoneyspaceSideArrowLayouts("settings changed") @@ -2406,6 +2675,41 @@ class NavButtons : XposedModule() { } } + private fun updateSettingsOverrides(intent: Intent) { + NavButtonId.entries.forEach { buttonId -> + val defaultDurationMs = ViewConfiguration.getLongPressTimeout().coerceLongPressDuration() + buttonConfigOverrides[buttonId] = NavButtonConfig( + pressAction = NavButtonAction.fromStorageValue( + intent.getStringExtra(NavButtonSettingsStore.pressActionKey(buttonId)), + ), + longPressAction = NavButtonAction.fromStorageValue( + intent.getStringExtra(NavButtonSettingsStore.longPressActionKey(buttonId)), + ), + longPressDurationMs = intent.getIntExtra( + NavButtonSettingsStore.longPressDurationKey(buttonId), + defaultDurationMs, + ).coerceLongPressDuration(), + ) + } + sideArrowsEnabledOverride = intent.getBooleanExtra( + NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, + sideArrowsEnabledOverride ?: false, + ) + sideArrowLongPressActionOverride = SideArrowLongPressAction.fromStorageValue( + intent.getStringExtra(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION), + ) + sideArrowLongPressDurationOverride = intent.getIntExtra( + NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, + ViewConfiguration.getLongPressTimeout(), + ).coerceLongPressDuration() + sideArrowRepeatSpeedOverride = sanitizeSideArrowRepeatSpeed( + intent.getFloatExtra( + NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ), + ) + } + private fun registerPrivilegedActionReceiver() { if (privilegedActionReceiverRegistered) { return @@ -2446,7 +2750,9 @@ class NavButtons : XposedModule() { IntentFilter(ACTION_PRIVILEGED_ACTION), Context.RECEIVER_EXPORTED, ) - privilegedActionToken = readPrivilegedActionToken() + if (currentContext.packageName != ANDROID_PACKAGE) { + privilegedActionToken = readPrivilegedActionToken() + } privilegedActionReceiverRegistered = true } catch (error: Throwable) { log("Privileged action receiver failed", error) @@ -2475,12 +2781,24 @@ class NavButtons : XposedModule() { senderPackage: String?, senderUid: Int, ): Boolean { - if (senderPackage in AOSP_LAUNCHER_PACKAGES) { + if (senderPackage == SYSTEMUI_PACKAGE || senderPackage in AOSP_LAUNCHER_PACKAGES) { return true } - val expectedToken = privilegedActionToken ?: readPrivilegedActionToken()?.also { - privilegedActionToken = it + val caller = getParcelableExtraCompat(intent, EXTRA_PRIVILEGED_CALLER, PendingIntent::class.java) + if (caller?.creatorPackage == SYSTEMUI_PACKAGE || caller?.creatorPackage in AOSP_LAUNCHER_PACKAGES) { + return true + } + if (caller?.creatorUid == Process.SYSTEM_UID) { + return true + } + + val expectedToken = privilegedActionToken ?: if (context.packageName != ANDROID_PACKAGE) { + readPrivilegedActionToken()?.also { + privilegedActionToken = it + } + } else { + null } if (expectedToken != null && expectedToken == intent.getStringExtra(EXTRA_PRIVILEGED_ACTION_TOKEN) @@ -2492,7 +2810,7 @@ class NavButtons : XposedModule() { return true } return context.packageManager.getPackagesForUid(senderUid) - ?.any { it in AOSP_LAUNCHER_PACKAGES } == true + ?.any { it == SYSTEMUI_PACKAGE || it in AOSP_LAUNCHER_PACKAGES } == true } private fun readPrivilegedActionToken(): String? { @@ -2516,6 +2834,7 @@ class NavButtons : XposedModule() { val intent = Intent(ACTION_PRIVILEGED_ACTION) .setPackage(ANDROID_PACKAGE) .putExtra(EXTRA_PRIVILEGED_ACTION, action) + .putExtra(EXTRA_PRIVILEGED_CALLER, privilegedActionCaller(currentContext, action)) if (keyCode != null) { intent.putExtra(EXTRA_PRIVILEGED_KEY_CODE, keyCode) } @@ -2525,6 +2844,21 @@ class NavButtons : XposedModule() { currentContext.sendBroadcast(intent) } + private fun privilegedActionCaller(context: Context, action: String): PendingIntent { + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + val intent = Intent("$ACTION_PRIVILEGED_ACTION.CALLER").setPackage(context.packageName) + return PendingIntent.getBroadcast(context, action.hashCode(), intent, flags) + } + + private fun getParcelableExtraCompat(intent: Intent, name: String, type: Class): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(name, type) + } else { + @Suppress("DEPRECATION") + type.cast(intent.getParcelableExtra(name)) + } + } + private fun trackSamsungNavigationBarView(startView: Any) { var current: View? = startView as? View ?: return repeat(SAMSUNG_NAV_VIEW_PARENT_DEPTH) { @@ -2542,14 +2876,6 @@ class NavButtons : XposedModule() { configureFoldedSideArrowButtons(navBarView) } - private fun callNoArgMethodIfExists(target: Any, methodName: String): Any? { - return findMethodsNamed(target.javaClass, methodName) - .firstOrNull { method -> method.parameterTypes.isEmpty() } - ?.let { method -> - runCatching { method.invoke(target) }.getOrNull() - } - } - private fun callMethodIfExists(target: Any, methodName: String): Any? { return findMethodsNamed(target.javaClass, methodName) .firstOrNull { method -> method.parameterTypes.isEmpty() } @@ -2635,7 +2961,7 @@ class NavButtons : XposedModule() { } private fun navigationBarContext(source: Any): Context? { - return (callNoArgMethodIfExists(source, "getNavigationBarContext") as? Context) + return (callMethodIfExists(source, "getNavigationBarContext") as? Context) ?.let { it.applicationContext ?: it } ?: findContext(source) ?: context @@ -2739,13 +3065,26 @@ class NavButtons : XposedModule() { } private fun injectKeyEvent(inputManager: Any, keyEvent: KeyEvent) { - callMethod( - inputManager, - "injectInputEvent", - arrayOf(InputEvent::class.java, Int::class.javaPrimitiveType!!), - keyEvent, - INPUT_EVENT_INJECTION_SYNC_NONE, - ) + val intType = Int::class.javaPrimitiveType!! + val methods = findMethodsNamed(inputManager.javaClass, "injectInputEvent") + val method = methods.firstOrNull { method -> + val types = method.parameterTypes + types.size == 2 && + types[0].isAssignableFrom(KeyEvent::class.java) && + types[1] == intType + } ?: methods.firstOrNull { method -> + val types = method.parameterTypes + types.size == 3 && + types[0].isAssignableFrom(KeyEvent::class.java) && + types[1] == intType && + types[2] == intType + } ?: throw NoSuchMethodException("injectInputEvent") + + if (method.parameterTypes.size == 2) { + method.invoke(inputManager, keyEvent, INPUT_EVENT_INJECTION_SYNC_NONE) + } else { + method.invoke(inputManager, keyEvent, INPUT_EVENT_INJECTION_SYNC_NONE, Process.INVALID_UID) + } } private fun toggleAutoRotate() { @@ -2793,6 +3132,7 @@ class NavButtons : XposedModule() { val enabled = !flashlightEnabled cameraManager.setTorchMode(cameraId, enabled) flashlightEnabled = enabled + log("Flashlight: ${if (enabled) "On" else "Off"}") showToast("Flashlight: ${if (enabled) "On" else "Off"}") } catch (error: CameraAccessException) { log("Toggle flashlight failed: ${error.javaClass.simpleName}") @@ -2939,6 +3279,7 @@ class NavButtons : XposedModule() { } private fun getButtonConfig(buttonId: NavButtonId): NavButtonConfig { + buttonConfigOverrides[buttonId]?.let { return it } val preferences = getSettingsPreferences() val defaultDurationMs = ViewConfiguration.getLongPressTimeout().coerceLongPressDuration() return NavButtonConfig( @@ -3177,10 +3518,7 @@ class NavButtons : XposedModule() { } try { - val inputManager = callStaticMethod( - findClass(INPUT_MANAGER_CLASS, null), - "getInstance", - ) ?: run { + val inputManager = getInputManagerForInjection() ?: run { log("InputManager unavailable for Honeyspace Home cancel.") return } @@ -3234,11 +3572,33 @@ class NavButtons : XposedModule() { sendKeyPress(KeyEvent.KEYCODE_HOME) } - private fun rescheduleSystemUiLongPress(keyButtonView: Any, buttonId: NavButtonId) { + private fun scheduleSystemUiLongPress(keyButtonView: Any, buttonId: NavButtonId) { val view = keyButtonView as? View ?: return - val longPressRunnable = getFieldValue(keyButtonView, "mCheckLongPress") as? Runnable ?: return - view.removeCallbacks(longPressRunnable) - view.postDelayed(longPressRunnable, getButtonConfig(buttonId).longPressDurationMs.toLong()) + val config = getButtonConfig(buttonId) + if (config.longPressAction == NavButtonAction.STOCK) { + return + } + (getFieldValue(keyButtonView, "mCheckLongPress") as? Runnable)?.let(view::removeCallbacks) + + val navButtonsRunnable = Runnable { + updateContextFromView(keyButtonView) + systemUiLongPressTriggered[keyButtonView] = true + cancelSystemUiKeyPress(keyButtonView) + markSystemUiLongClicked(keyButtonView) + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + if (buttonId == NavButtonId.HOME) { + performHomeLongPressAction(config.longPressAction) + } else { + performConfiguredAction(config.longPressAction) + } + } + systemUiLongPressRunnables[keyButtonView] = navButtonsRunnable + view.postDelayed(navButtonsRunnable, config.longPressDurationMs.toLong()) + } + + private fun cancelSystemUiLongPress(keyButtonView: Any) { + val view = keyButtonView as? View ?: return + systemUiLongPressRunnables.remove(keyButtonView)?.let(view::removeCallbacks) } private fun rescheduleHoneyspaceLongPress( @@ -3469,6 +3829,12 @@ class NavButtons : XposedModule() { val right: ImageView, ) + private data class HoneyspaceSideArrowButton( + val type: Int, + val keyCode: Int, + val direction: SideArrowDirection, + ) + private class SideArrowDrawable( private val direction: SideArrowDirection, color: Int, @@ -3542,6 +3908,7 @@ class NavButtons : XposedModule() { const val ASSIST_INVOCATION_TYPE_UNKNOWN = 0 const val ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS = 5 const val INPUT_MANAGER_CLASS = "android.hardware.input.InputManager" + const val INPUT_MANAGER_GLOBAL_CLASS = "android.hardware.input.InputManagerGlobal" const val INPUT_EVENT_INJECTION_SYNC_NONE = 0 const val ACTION_PASS_TO_USER = 1 const val HOME_LONG_PRESS_DEDUP_WINDOW_MS = 1_000L @@ -3567,6 +3934,7 @@ class NavButtons : XposedModule() { "com.android.launcher3", "com.google.android.apps.nexuslauncher", ) + val TARGET_PACKAGES = AOSP_LAUNCHER_PACKAGES + SYSTEMUI_PACKAGE const val AOSP_TASKBAR_NAV_BUTTON_CONTROLLER = "com.android.launcher3.taskbar.TaskbarNavButtonController" const val AOSP_TASKBAR_BACK_BUTTON = 1 @@ -3575,6 +3943,8 @@ class NavButtons : XposedModule() { const val AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER = "com.android.launcher3.taskbar.NavbarButtonsViewController" const val AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO = 0.45f + const val TARGET_HOOK_RETRY_ATTEMPTS = 120 + const val TARGET_HOOK_RETRY_DELAY_MS = 250L val AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES = listOf( "com.android.systemui.navigationbar.views.NavigationBarInflaterView", "com.android.systemui.navigationbar.NavigationBarInflaterView", @@ -3596,6 +3966,7 @@ class NavButtons : XposedModule() { const val ACTION_PRIVILEGED_ACTION = "se.ajpanton.navbuttons.action.PRIVILEGED_ACTION" const val EXTRA_PRIVILEGED_ACTION = "privileged_action" + const val EXTRA_PRIVILEGED_CALLER = "privileged_caller" const val EXTRA_PRIVILEGED_ACTION_TOKEN = "privileged_action_token" const val EXTRA_PRIVILEGED_KEY_CODE = "privileged_key_code" const val PRIVILEGED_ACTION_KILL_FOREGROUND_APP = "kill_foreground_app" @@ -3613,7 +3984,21 @@ class NavButtons : XposedModule() { const val HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH = 8 const val HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS = 150L const val HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS = 600L - val HONEYSPACE_EXTRA_BUTTON_TYPES = listOf(64, 128, 256, 512, 1024) + const val HONEYSPACE_LEFT_ARROW_BUTTON_TYPE = 64 + const val HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE = 128 + val HONEYSPACE_SIDE_ARROW_BUTTONS = listOf( + HoneyspaceSideArrowButton( + HONEYSPACE_LEFT_ARROW_BUTTON_TYPE, + KeyEvent.KEYCODE_DPAD_LEFT, + SideArrowDirection.LEFT, + ), + HoneyspaceSideArrowButton( + HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE, + KeyEvent.KEYCODE_DPAD_RIGHT, + SideArrowDirection.RIGHT, + ), + ) + val HONEYSPACE_EXTRA_BUTTON_TYPES = HONEYSPACE_SIDE_ARROW_BUTTONS.map { it.type } + listOf(256, 512, 1024) const val NAV_LAYOUT_PHONE_DEFAULT = "left,recent;home;back,right" const val NAV_LAYOUT_PHONE_REVERSED = "left,back;home;recent,right" const val NAV_LAYOUT_TABLET_DEFAULT = "left;recent,gap,home,gap,back;right" diff --git a/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt index 6624f97..895aa56 100644 --- a/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt +++ b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt @@ -81,6 +81,31 @@ class SettingsActivity : Activity() { applySectionTopMargin(sideArrowsSectionView) SideArrowsSectionController(sideArrowsSectionView).bind() sectionsContainer.addView(sideArrowsSectionView) + sendSettingsChangedBroadcast() + } + + private fun sendSettingsChangedBroadcast() { + val intent = Intent(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED) + NavButtonId.entries.forEach { buttonId -> + val config = settingsStore.getConfig(buttonId) + intent.putExtra(NavButtonSettingsStore.pressActionKey(buttonId), config.pressAction.storageValue) + intent.putExtra(NavButtonSettingsStore.longPressActionKey(buttonId), config.longPressAction.storageValue) + intent.putExtra(NavButtonSettingsStore.longPressDurationKey(buttonId), config.longPressDurationMs) + } + intent.putExtra(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, settingsStore.isSideArrowsEnabled()) + intent.putExtra( + NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION, + settingsStore.getSideArrowLongPressAction().storageValue, + ) + intent.putExtra( + NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, + settingsStore.getSideArrowLongPressDurationMs(), + ) + intent.putExtra( + NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + settingsStore.getSideArrowRepeatStepsPerSecond(), + ) + sendBroadcast(intent) } private fun applySectionTopMargin(sectionView: View) { @@ -246,7 +271,10 @@ class SettingsActivity : Activity() { NavButtonAction.entries, selectedAction, NavButtonAction::labelRes, - ) { action -> storeAction(buttonId, action) } + ) { action -> + storeAction(buttonId, action) + sendSettingsChangedBroadcast() + } } private fun bindDurationInput() { @@ -280,6 +308,7 @@ class SettingsActivity : Activity() { if (parsed in NavButtonSettingsStore.MIN_DURATION_MS..NavButtonSettingsStore.MAX_DURATION_MS) { lastValidDurationMs = parsed settingsStore.setLongPressDurationMs(buttonId, parsed) + sendSettingsChangedBroadcast() } } }) @@ -327,6 +356,7 @@ class SettingsActivity : Activity() { lastValidDurationMs = sanitized if (persist) { settingsStore.setLongPressDurationMs(buttonId, sanitized) + sendSettingsChangedBroadcast() } } } @@ -374,12 +404,7 @@ class SettingsActivity : Activity() { sideArrowsCheckbox.setOnCheckedChangeListener { _, isChecked -> settingsStore.setSideArrowsEnabled(isChecked) updateOptionsEnabled() - sendBroadcast( - Intent(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED).putExtra( - NavButtonSettingsStore.EXTRA_SIDE_ARROWS_ENABLED, - isChecked, - ), - ) + sendSettingsChangedBroadcast() } } @@ -390,8 +415,9 @@ class SettingsActivity : Activity() { settingsStore.getSideArrowLongPressAction(), SideArrowLongPressAction::labelRes, ) { action -> - settingsStore.setSideArrowLongPressAction(action) - updateRepeatSpeedVisibility(action) + settingsStore.setSideArrowLongPressAction(action) + updateRepeatSpeedVisibility(action) + sendSettingsChangedBroadcast() } updateRepeatSpeedVisibility(settingsStore.getSideArrowLongPressAction()) } @@ -429,6 +455,7 @@ class SettingsActivity : Activity() { ) { lastValidRepeatSpeed = parsed settingsStore.setSideArrowRepeatStepsPerSecond(parsed) + sendSettingsChangedBroadcast() } } }) @@ -474,6 +501,7 @@ class SettingsActivity : Activity() { if (parsed in NavButtonSettingsStore.MIN_DURATION_MS..NavButtonSettingsStore.MAX_DURATION_MS) { lastValidDurationMs = parsed settingsStore.setSideArrowLongPressDurationMs(parsed) + sendSettingsChangedBroadcast() } } }) @@ -536,6 +564,7 @@ class SettingsActivity : Activity() { lastValidRepeatSpeed = sanitized if (persist) { settingsStore.setSideArrowRepeatStepsPerSecond(sanitized) + sendSettingsChangedBroadcast() } } @@ -590,6 +619,7 @@ class SettingsActivity : Activity() { lastValidDurationMs = sanitized if (persist) { settingsStore.setSideArrowLongPressDurationMs(sanitized) + sendSettingsChangedBroadcast() } } diff --git a/app/src/main/res/values-v35/themes.xml b/app/src/main/res/values-v35/themes.xml new file mode 100644 index 0000000..f2dabf1 --- /dev/null +++ b/app/src/main/res/values-v35/themes.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f2dabf1..ecee997 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,6 +3,5 @@ false true @drawable/bg_settings_window - true diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml deleted file mode 100644 index fa0f996..0000000 --- a/app/src/main/res/xml/backup_rules.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml deleted file mode 100644 index 9ee9997..0000000 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/resources/META-INF/xposed/module.prop b/app/src/main/resources/META-INF/xposed/module.prop index c3975fe..afe7b97 100644 --- a/app/src/main/resources/META-INF/xposed/module.prop +++ b/app/src/main/resources/META-INF/xposed/module.prop @@ -1,3 +1,3 @@ minApiVersion=101 -targetApiVersion=101 +targetApiVersion=102 staticScope=true diff --git a/app/src/main/resources/META-INF/xposed/scope.list b/app/src/main/resources/META-INF/xposed/scope.list index 9c65593..0a33d13 100644 --- a/app/src/main/resources/META-INF/xposed/scope.list +++ b/app/src/main/resources/META-INF/xposed/scope.list @@ -4,4 +4,3 @@ com.android.systemui com.android.launcher3 com.google.android.apps.nexuslauncher com.sec.android.app.launcher -com.samsung.systemui.navillera diff --git a/build.gradle.kts b/build.gradle.kts index 5a3a86f..568d211 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id("com.android.application") version "9.1.1" apply false } diff --git a/gradle.properties b/gradle.properties index a03b354..2e11322 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,21 +1,3 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Enables namespacing of each library's R class so that its R class includes only the -# resources declared in the library itself and none from the library's dependencies, -# thereby reducing the size of the R class for that library android.nonTransitiveRClass=true