diff --git a/README.md b/README.md index 6506b53..609a672 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,33 @@ It lets you choose what should happen when Back, Home, or Recents is pressed or - Kill the foreground app. - Toggle auto-rotate. - Toggle the flashlight. +- Add optional side-arrow buttons for moving the text cursor left and right. +- Configure side-arrow long-press behavior, including repeated cursor movement. - Test long-press durations from the settings screen. ## Requirements - Android device using three-button navigation. -- LSPosed. -- Xposed API 101 or newer. -- The module enabled for System UI. +- LSPosed with Xposed API 101 or newer. +- Android 14 / API 34 or newer. +- The module enabled for the relevant navigation packages. -This has only been tested on a Samsung Galaxy Z Fold5 running One UI 8.0 / Android 16. Compatibility may vary between Android versions and vendor System UI implementations. +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. + +## Tested On + +- 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. + +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. ## AI Assistance diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt index b4e7c77..bd51962 100644 --- a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt +++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt @@ -94,6 +94,7 @@ class NavButtons : XposedModule() { private var samsungNavigationBarViewHookInstalled = false private var aospSystemUiSideArrowHookInstalled = false private var aospTaskbarTintHookInstalled = false + private var aospSystemUiDarkIntensity = 0f private var settingsChangedReceiverRegistered = false private var privilegedActionReceiverRegistered = false private var privilegedActionToken: String? = null @@ -543,14 +544,14 @@ class NavButtons : XposedModule() { private fun hookAospSystemUiSideArrows(classLoader: ClassLoader?, keyButtonClass: Class<*>) { if (aospSystemUiSideArrowHookInstalled || - Build.MANUFACTURER.equals("samsung", ignoreCase = true) || - Build.VERSION.SDK_INT < 35 + Build.MANUFACTURER.equals("samsung", ignoreCase = true) ) { return } val inflaterClass = findFirstClassIfExists(AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES, classLoader) ?: return aospSystemUiSideArrowHookInstalled = true registerSettingsChangedReceiver() + hookAospSystemUiDarkIntensity(classLoader) findMethodIfExists(inflaterClass, "getDefaultLayout")?.let { method -> deoptimizeMethodIfPresent(method) @@ -572,14 +573,14 @@ class NavButtons : XposedModule() { chain.thisObject?.let(::trackAospSystemUiInflater) val layout = chain.args.getOrNull(0) as? String if (layout != null) { - chain.args[0] = if (isSideArrowsEnabled()) { + val updatedLayout = if (isSideArrowsEnabled()) { addAospSystemUiSideArrows(layout) } else { removeAospSystemUiSideArrows(layout) } + runCatching { chain.args[0] = updatedLayout } } - val result = chain.proceed() - result + chain.proceed() } } @@ -605,17 +606,6 @@ class NavButtons : XposedModule() { } } - findMethodIfExists(keyButtonClass, "setImageDrawable", Drawable::class.java)?.let { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - aospSystemUiSideArrowDirections[chain.thisObject]?.let { direction -> - createAospSystemUiSideArrowDrawable(chain.thisObject, direction)?.let { drawable -> - chain.args[0] = drawable - } - } - chain.proceed() - } - } } private fun trackAospSystemUiInflater(inflater: Any) { @@ -762,6 +752,34 @@ class NavButtons : XposedModule() { } } + private fun hookAospSystemUiDarkIntensity(classLoader: ClassLoader?) { + val transitionsClass = findFirstClassIfExists( + AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES, + classLoader, + ) ?: return + findMethodIfExists( + transitionsClass, + "applyDarkIntensity", + Float::class.javaPrimitiveType!!, + )?.let { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + val result = chain.proceed() + aospSystemUiDarkIntensity = (chain.args.getOrNull(0) as? Float)?.coerceIn(0f, 1f) + ?: aospSystemUiDarkIntensity + updateAospSystemUiSideArrowDarkIntensity() + result + } + } + } + + private fun updateAospSystemUiSideArrowDarkIntensity() { + aospSystemUiSideArrowDirections.keys.toList().forEach { view -> + callFloatMethodIfExists(view, "setDarkIntensity", aospSystemUiDarkIntensity) + setFieldIfExists(view, "mDarkIntensity", aospSystemUiDarkIntensity) + } + } + private fun findAospSystemUiKeyButtons(view: View): List { val result = mutableListOf() if (view is ImageView && getFieldValue(view, "mCode") != null) { @@ -779,7 +797,10 @@ class NavButtons : XposedModule() { val view = value as? ImageView ?: return val direction = aospSystemUiSideArrowDirections[view] ?: return ensureContextFromView(view) - createAospSystemUiSideArrowDrawable(view, direction)?.let(view::setImageDrawable) + createAospSystemUiSideArrowDrawable(view, direction)?.let { + view.setImageDrawable(it) + callFloatMethodIfExists(view, "setDarkIntensity", aospSystemUiDarkIntensity) + } } private fun createAospSystemUiSideArrowDrawable( @@ -798,17 +819,18 @@ class NavButtons : XposedModule() { Boolean::class.javaPrimitiveType, Boolean::class.javaPrimitiveType, ) + .apply { isAccessible = true } .newInstance(Color.WHITE, Color.rgb(55, 58, 62), false, false) - keyButtonDrawableClass + val drawable = keyButtonDrawableClass .getDeclaredConstructor(Drawable::class.java, stateClass) + .apply { isAccessible = true } .newInstance( - sideArrowBitmapDrawable( - view.context, - direction, - Color.WHITE, - ), + sideArrowBitmapDrawable(view.context, direction, Color.WHITE), state, ) as? Drawable + drawable?.also { + callFloatMethodIfExists(it, "setDarkIntensity", aospSystemUiDarkIntensity) + } } catch (error: Throwable) { log("AOSP SystemUI side arrow drawable failed: ${error.javaClass.simpleName}") null @@ -1113,7 +1135,7 @@ class NavButtons : XposedModule() { ) { arrow.minimumWidth = home.minimumWidth arrow.minimumHeight = home.minimumHeight - arrow.setPadding(0, home.paddingTop, 0, home.paddingBottom) + arrow.setPadding(0, 0, 0, 0) arrow.scaleType = ImageView.ScaleType.CENTER arrow.setImageDrawable(sideArrowBitmapDrawable(arrow.context, direction, Color.WHITE)) } @@ -1204,8 +1226,14 @@ class NavButtons : XposedModule() { } private fun placeAospTaskbarArrow(arrow: View, center: Point) { - arrow.x = center.x - arrow.width / 2f - arrow.y = center.y - arrow.height / 2f + val width = arrow.width.takeIf { it > 0 } + ?: arrow.layoutParams?.width?.takeIf { it > 0 } + ?: 0 + val height = arrow.height.takeIf { it > 0 } + ?: arrow.layoutParams?.height?.takeIf { it > 0 } + ?: 0 + arrow.x = center.x - width / 2f + arrow.y = center.y - height / 2f } private fun removeAospSideArrows(container: ViewGroup) { @@ -1901,9 +1929,23 @@ class NavButtons : XposedModule() { NavButtonAction.HOME -> sendKeyPress(KeyEvent.KEYCODE_HOME) NavButtonAction.ASSISTANT -> sendKeyPress(KeyEvent.KEYCODE_ASSIST) NavButtonAction.RECENTS -> sendKeyPress(KeyEvent.KEYCODE_APP_SWITCH) - NavButtonAction.KILL_FOREGROUND_APP -> killForegroundApp() + NavButtonAction.KILL_FOREGROUND_APP -> + performPrivilegedActionOrDirect(PRIVILEGED_ACTION_KILL_FOREGROUND_APP) + NavButtonAction.TOGGLE_AUTO_ROTATE -> toggleAutoRotate() - NavButtonAction.TOGGLE_FLASHLIGHT -> toggleFlashlight() + NavButtonAction.TOGGLE_FLASHLIGHT -> + performPrivilegedActionOrDirect(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT) + } + } + + private fun performPrivilegedActionOrDirect(action: String) { + if (context?.packageName == ANDROID_PACKAGE) { + when (action) { + PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp() + PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight() + } + } else { + requestPrivilegedAction(action) } } @@ -2837,7 +2879,10 @@ class NavButtons : XposedModule() { return resolveInfo?.activityInfo?.packageName } - private fun getTopPackageName(): String? = getTopPackageFromActivityTaskManager() + private fun getTopPackageName(): String? { + return getTopPackageFromActivityTaskManager() + ?: getTopPackageFromRunningTasks() + } private fun getTopPackageFromActivityTaskManager(): String? { return try { @@ -2862,6 +2907,21 @@ class NavButtons : XposedModule() { return pkg ?: getPackageNameFromActivityInfo(getFieldValue(taskInfo, "topActivityInfo")) } + @Suppress("DEPRECATION") + private fun getTopPackageFromRunningTasks(): String? { + val activityManager = context?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + ?: return null + return try { + activityManager.getRunningTasks(1) + .firstOrNull() + ?.topActivity + ?.packageName + } catch (error: Throwable) { + log("ActivityManager.getRunningTasks failed: ${error.javaClass.simpleName}") + null + } + } + private fun getFieldValue(target: Any?, fieldName: String): Any? { if (target == null) { return null @@ -3519,6 +3579,10 @@ class NavButtons : XposedModule() { "com.android.systemui.navigationbar.views.NavigationBarInflaterView", "com.android.systemui.navigationbar.NavigationBarInflaterView", ) + val AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES = listOf( + "com.android.systemui.navigationbar.NavigationBarTransitions", + "com.android.systemui.navigationbar.views.NavigationBarTransitions", + ) val AOSP_KEY_BUTTON_DRAWABLE_CLASSES = listOf( "com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable", "com.android.systemui.navigationbar.buttons.KeyButtonDrawable", @@ -3527,8 +3591,8 @@ class NavButtons : XposedModule() { "com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable\$ShadowDrawableState", "com.android.systemui.navigationbar.buttons.KeyButtonDrawable\$ShadowDrawableState", ) - const val AOSP_SYSUI_LEFT_ARROW_BUTTON = "key(21)" - const val AOSP_SYSUI_RIGHT_ARROW_BUTTON = "key(22)" + const val AOSP_SYSUI_LEFT_ARROW_BUTTON = "key(21:)" + const val AOSP_SYSUI_RIGHT_ARROW_BUTTON = "key(22:)" const val ACTION_PRIVILEGED_ACTION = "se.ajpanton.navbuttons.action.PRIVILEGED_ACTION" const val EXTRA_PRIVILEGED_ACTION = "privileged_action"