21 Commits
Author SHA1 Message Date
ajp_anton 9fd4586899 Release 2.4 2026-07-16 00:08:35 +00:00
ajp_anton dd444f9fba Remove unused LoadedApk fallback hook 2026-07-15 23:56:59 +00:00
ajp_anton f537f724cd Remove unused settings import 2026-07-15 19:09:30 +00:00
ajp_anton d0313df2b3 Simplify support fallbacks 2026-07-15 19:08:50 +00:00
ajp_anton c260125f6e Deduplicate settings sanitizers 2026-07-15 19:08:07 +00:00
ajp_anton e46cdefe67 Share native side arrow touch handling 2026-07-15 19:06:13 +00:00
ajp_anton 91cdd1a665 Reduce duplicated cleanup code 2026-07-15 18:58:16 +00:00
ajp_anton d2e5af35ed Release 2.3 2026-07-11 19:14:35 +00:00
ajp_anton 09bab5db90 Fix Samsung unfolded long presses 2026-07-11 19:13:12 +00:00
ajp_anton 963dbd7b5f Release 2.2 2026-07-01 11:16:34 +00:00
ajp_anton 4ba27bd9cf Refactor NavButtons hook structure 2026-07-01 02:51:42 +00:00
ajp_anton aaa471ec33 Clarify navigation scope documentation 2026-06-30 19:29:32 +00:00
ajp_anton 6f308a823c Clean up compatibility support code 2026-06-30 19:17:47 +00:00
ajp_anton 6b11e76504 Release 2.1 2026-06-23 13:19:55 +00:00
ajp_anton 60769980cd Improve AOSP side arrow support 2026-06-23 13:04:42 +00:00
ajp_anton c2ee5a5581 Support AOSP API 34 and Google APIs 2026-06-22 22:05:36 +00:00
ajp_anton 465dec356d Add AOSP side arrow support 2026-06-22 18:26:12 +00:00
ajp_anton 1fc63aef8d Add initial AOSP button support 2026-06-22 11:31:13 +00:00
ajp_anton e9686f6cb3 Release 2.0 2026-06-22 01:25:11 +00:00
ajp_anton d9b55c6811 Clean up navigation module code 2026-06-22 01:11:33 +00:00
ajp_anton 0956bbc4d7 Add side arrow navigation buttons 2026-06-22 00:42:17 +00:00
43 changed files with 5167 additions and 1503 deletions
+1
View File
@@ -4,3 +4,4 @@ build/
app/build/ app/build/
local/ local/
local.properties local.properties
*.log
+55 -12
View File
@@ -4,26 +4,69 @@ 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 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. The design
decisions, physical device testing, and final behaviour are still manually reviewed.
## Features ## Features
- Configure press and long-press actions for Back, Home, and Recents. - Configure press and long-press actions for Back, Home, and Recents.
- Keep the stock Android behavior with the `Stock` action. - Individually configure custom long-press durations for all buttons.
- Disable a press with the `None` action. - Add optional side-arrow buttons for moving the text cursor left and right.
- Reassign navigation actions: Back, Home, Assistant, and Recents. - Long-press side arrows for repeated cursor movement with configurable speed.
- Kill the foreground app.
- Toggle auto-rotate.
- Toggle the flashlight.
- Test long-press durations from the settings screen. - 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 ## Requirements
- Android device using three-button navigation. - Android device using three-button navigation.
- LSPosed. - LSPosed with Xposed API 101 or newer.
- Xposed API 101 or newer. - Android (One UI, AOSP, Google APIs, LineageOS) version listed in the compatibility list.
- The module enabled for System UI. - The module scope 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:
## AI Assistance - `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 APIs, and LineageOS taskbar navigation.
- `com.sec.android.app.launcher`, used for Samsung/One UI unfolded taskbar navigation.
This project was developed with help from AI-assisted coding tools. ## Compatibility
Supported targets are expected to support all features.
Samsung, tested on real hardware:
- One UI 8 / Android 16 on a Galaxy Z Fold5.
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.
+2 -6
View File
@@ -54,7 +54,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "se.ajpanton.navbuttons" applicationId = "se.ajpanton.navbuttons"
minSdk = 35 minSdk = 34
targetSdk = 35 targetSdk = 35
versionCode = project.findProperty("nbVersionCode")?.toString()?.toIntOrNull() ?: 1777070659 versionCode = project.findProperty("nbVersionCode")?.toString()?.toIntOrNull() ?: 1777070659
versionName = project.findProperty("nbVersionName")?.toString() ?: "0.3" versionName = project.findProperty("nbVersionName")?.toString() ?: "0.3"
@@ -86,9 +86,5 @@ dependencies {
implementation("androidx.annotation:annotation:1.7.1") implementation("androidx.annotation:annotation:1.7.1")
implementation("io.github.libxposed:service:101.0.0") implementation("io.github.libxposed:service:101.0.0")
implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1") implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1")
compileOnly("io.github.libxposed:api:101.0.0") compileOnly("io.github.libxposed:api:102.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")
} }
+2 -23
View File
@@ -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 -keepattributes SourceFile,LineNumberTable
-adaptresourcefilecontents META-INF/xposed/java_init.list
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-dontobfuscate -dontobfuscate
-keep class se.ajpanton.navbuttons.NavButtons -keep,allowoptimization,allowobfuscation public class * extends io.github.libxposed.api.XposedModule {
-keepclassmembers class se.ajpanton.navbuttons.NavButtons {
public <init>(); public <init>();
} }
+11 -3
View File
@@ -3,11 +3,19 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" /> <uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" />
<queries>
<package android:name="org.lsposed.manager" />
<package android:name="com.android.systemui" />
<package android:name="com.android.launcher3" />
<package android:name="com.google.android.apps.nexuslauncher" />
<package android:name="com.sec.android.app.launcher" />
</queries>
<application <application
android:allowBackup="true" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:description="@string/xposed_desc" android:description="@string/xposed_desc"
android:fullBackupContent="@xml/backup_rules" android:forceQueryable="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -0,0 +1,192 @@
package se.ajpanton.navbuttons
import android.app.ActivityManager
import android.content.Context
import android.hardware.camera2.CameraAccessException
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.os.Handler
import android.provider.Settings
import android.view.KeyEvent
import android.widget.Toast
internal class ActionExecutor(
private val contextProvider: () -> Context?,
private val handlerProvider: () -> Handler,
private val requestPrivilegedAction: (String, Int?) -> Unit,
private val log: (String) -> Unit,
private val logError: (String, Throwable) -> Unit,
) {
private var flashlightCameraId: String? = null
private var flashlightEnabled = false
fun perform(action: NavButtonAction) {
when (action) {
NavButtonAction.STOCK,
NavButtonAction.NONE,
-> Unit
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)
NavButtonAction.TOGGLE_AUTO_ROTATE -> performPrivilegedActionOrDirect(PRIVILEGED_ACTION_TOGGLE_AUTO_ROTATE)
NavButtonAction.TOGGLE_FLASHLIGHT -> performPrivilegedActionOrDirect(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT)
}
}
fun sendSideArrowKeyPress(keyCode: Int) {
sendConfiguredKeyPress(keyCode)
}
fun sendKeyPress(keyCode: Int) {
sendInjectedKeyPress(contextProvider(), keyCode, log)
}
fun killForegroundApp() {
handlerProvider().post {
try {
val context = contextProvider() ?: run {
log("Context unavailable, cannot kill app.")
return@post
}
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
if (activityManager == null) {
log("ActivityManager unavailable.")
return@post
}
val defaultLauncher = defaultLauncherPackageName(context)
val foregroundApp = topPackageName(context, log)
if (foregroundApp != null && !shouldSkipForegroundPackage(foregroundApp, defaultLauncher)) {
val appLabel = applicationLabel(foregroundApp, context.packageManager)
log("Killing: $foregroundApp ($appLabel)")
callMethod(activityManager, "forceStopPackage", arrayOf(String::class.java), foregroundApp)
showToast("Killed: $appLabel")
} else {
log("Nothing to kill.")
showToast("Nothing to kill.")
}
} catch (error: Throwable) {
logError("Error in killForegroundApp", error)
}
}
}
fun toggleFlashlight() {
val context = contextProvider() ?: run {
log("Context unavailable, cannot toggle flashlight.")
return
}
try {
val cameraManager = context.getSystemService(CameraManager::class.java)
val cameraId = flashlightCameraId ?: findFlashlightCameraId(cameraManager)
if (cameraId == null) {
log("No flashlight-capable camera found.")
showToast("No flashlight found.")
return
}
flashlightCameraId = cameraId
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}")
showToast("Flashlight toggle failed.")
} catch (error: SecurityException) {
log("Toggle flashlight permission denied.")
showToast("Flashlight permission denied.")
} catch (error: Throwable) {
log("Toggle flashlight failed: ${error.javaClass.simpleName}")
showToast("Flashlight toggle failed.")
}
}
private fun sendConfiguredKeyPress(keyCode: Int) {
if (contextProvider()?.packageName in AOSP_LAUNCHER_PACKAGES) {
requestPrivilegedAction(PRIVILEGED_ACTION_SEND_KEY_PRESS, keyCode)
} else {
sendKeyPress(keyCode)
}
}
private fun performPrivilegedActionOrDirect(action: String) {
if (contextProvider()?.packageName == ANDROID_PACKAGE) {
when (action) {
PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp()
PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight()
PRIVILEGED_ACTION_TOGGLE_AUTO_ROTATE -> toggleAutoRotate()
}
} else {
requestPrivilegedAction(action, null)
}
}
fun toggleAutoRotate() {
val context = contextProvider() ?: run {
log("Context unavailable, cannot toggle auto rotate.")
return
}
try {
val enabled = toggleAutoRotateViaRotationPolicy(context)
?: toggleAutoRotateSetting(context)
showToast("Auto rotate: ${if (enabled) "On" else "Off"}")
} catch (error: Throwable) {
log("Toggle auto rotate failed: ${error.javaClass.simpleName}")
showToast("Auto rotate toggle failed.")
}
}
private fun toggleAutoRotateViaRotationPolicy(context: Context): Boolean? {
return runCatching {
val rotationPolicy = findClass("com.android.internal.view.RotationPolicy", null)
val isLocked = findMethod(
rotationPolicy,
"isRotationLocked",
Context::class.java,
).invoke(null, context) as Boolean
val enabled = isLocked
findMethod(
rotationPolicy,
"setRotationLock",
Context::class.java,
Boolean::class.javaPrimitiveType!!,
).invoke(null, context, !enabled)
enabled
}.getOrNull()
}
private fun toggleAutoRotateSetting(context: Context): Boolean {
val resolver = context.contentResolver
val enabled = Settings.System.getInt(resolver, Settings.System.ACCELEROMETER_ROTATION, 0) != 1
Settings.System.putInt(resolver, Settings.System.ACCELEROMETER_ROTATION, if (enabled) 1 else 0)
return enabled
}
private fun showToast(message: String) {
val context = contextProvider() ?: return
try {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} catch (error: Throwable) {
log("Toast failed: ${error.javaClass.simpleName}")
}
}
private fun findFlashlightCameraId(cameraManager: CameraManager): String? {
return cameraManager.cameraIdList.firstOrNull { cameraId ->
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING)
hasFlash && lensFacing == CameraCharacteristics.LENS_FACING_BACK
} ?: cameraManager.cameraIdList.firstOrNull { cameraId ->
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
}
}
}
@@ -0,0 +1,451 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.graphics.Color
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
internal class AospLauncherTaskbarHooks(
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
private val deoptimizeMethod: (Method?) -> Unit,
private val configProvider: (NavButtonId) -> NavButtonConfig,
private val sideArrowsEnabled: () -> Boolean,
private val sideArrowLongPresses: SideArrowLongPressController,
private val actions: ActionExecutor,
private val registerSettingsReceiver: () -> Unit,
private val contextUpdater: (Context) -> Unit,
private val requestPrivilegedAction: (String, Int?) -> Unit,
private val wasHomeLongPressHandledRecently: () -> Boolean,
private val noteHomeLongPressHandled: () -> Unit,
private val log: (String) -> Unit,
) {
private val buttonIds = WeakHashMap<Any, NavButtonId>()
private val controllers = WeakHashMap<Any, Any>()
private val trackedContainers = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
private val buttons = WeakHashMap<ViewGroup, MutableMap<NavButtonId, View>>()
private val sideArrows = WeakHashMap<ViewGroup, AospSideArrowViews>()
private val sideButtonTranslations = WeakHashMap<View, Float>()
private var tintHookInstalled = false
private val touchController = AospTaskbarTouchController(
buttonIdProvider = { buttonIds[it] },
controllerProvider = { controllers[it] },
configProvider = configProvider,
updateContext = ::updateContextFromTaskbar,
performAction = ::performAction,
performHomeLongPressAction = ::performHomeLongPressAction,
)
fun install(classLoader: ClassLoader?): Boolean {
val controllerClass = findClassIfExists(AOSP_TASKBAR_NAV_BUTTON_CONTROLLER, classLoader) ?: run {
log("AOSP taskbar controller class not found: $AOSP_TASKBAR_NAV_BUTTON_CONTROLLER")
return false
}
val navButtonsViewControllerClass = findClassIfExists(
AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER,
classLoader,
)
val clickMethod = findMethodIfExists(
controllerClass,
"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
}
val longClickMethod = findMethodIfExists(
controllerClass,
"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
}
val sendBackKeyEventMethod = findMethodIfExists(
controllerClass,
"sendBackKeyEvent",
Int::class.javaPrimitiveType!!,
Boolean::class.javaPrimitiveType!!,
)
deoptimizeMethod(clickMethod)
deoptimizeMethod(longClickMethod)
deoptimizeMethod(sendBackKeyEventMethod)
hookButtonMapping(navButtonsViewControllerClass, controllerClass)
hookTintUpdates()
registerSettingsReceiver()
hookMethod(clickMethod) { chain ->
val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int)
?: return@hookMethod chain.proceed()
val pressAction = configProvider(buttonId).pressAction
if (pressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
updateContextFromTaskbar(chain.thisObject, chain.args.firstOrNull { it is View })
performAction(chain.thisObject, pressAction)
null
}
hookMethod(longClickMethod) { chain ->
val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int)
?: return@hookMethod chain.proceed()
val longPressAction = configProvider(buttonId).longPressAction
if (longPressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
val view = chain.args.firstOrNull { it is View }
if (view != null && buttonIds.containsKey(view)) {
return@hookMethod true
}
updateContextFromTaskbar(chain.thisObject, view)
if (buttonId == NavButtonId.HOME) {
performHomeLongPressAction(chain.thisObject, longPressAction)
} else {
performAction(chain.thisObject, longPressAction)
}
true
}
if (sendBackKeyEventMethod != null) {
hookMethod(sendBackKeyEventMethod) { chain ->
val pressAction = configProvider(NavButtonId.BACK).pressAction
val longPressAction = configProvider(NavButtonId.BACK).longPressAction
if (pressAction == NavButtonAction.STOCK && longPressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
updateContextFromField(chain.thisObject, "mContext")
null
}
}
return true
}
fun refreshSideArrows() {
trackedContainers.toList().forEach(::refreshSideArrows)
}
private fun hookButtonMapping(
navButtonsViewControllerClass: Class<*>?,
controllerClass: Class<*>,
) {
if (navButtonsViewControllerClass == null) {
log("AOSP navbar buttons controller class not found: $AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER")
return
}
listOf(
arrayOf(
Int::class.javaPrimitiveType!!,
Int::class.javaPrimitiveType!!,
ViewGroup::class.java,
controllerClass,
Int::class.javaPrimitiveType!!,
),
arrayOf(
Int::class.javaPrimitiveType!!,
Int::class.javaPrimitiveType!!,
ViewGroup::class.java,
controllerClass,
Int::class.javaPrimitiveType!!,
Int::class.javaPrimitiveType!!,
),
).forEach { parameterTypes ->
val addButtonMethod = findMethodIfExists(
navButtonsViewControllerClass,
"addButton",
*parameterTypes,
) ?: return@forEach
deoptimizeMethod(addButtonMethod)
hookMethod(addButtonMethod) { chain ->
val result = chain.proceed()
val view = result as? View ?: return@hookMethod result
trackButton(
view,
chain.args.getOrNull(1) as? Int,
chain.args.getOrNull(3),
chain.args.getOrNull(2) as? ViewGroup,
)
result
}
}
}
private fun hookTintUpdates() {
if (tintHookInstalled) {
return
}
tintHookInstalled = true
findDeclaredMethodsNamed(ImageView::class.java, "setImageTintList").forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
val result = chain.proceed()
refreshSideArrowTint(chain.thisObject as? ImageView)
result
}
}
findDeclaredMethodsNamed(ImageView::class.java, "setColorFilter").forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
val result = chain.proceed()
refreshSideArrowTint(chain.thisObject as? ImageView)
result
}
}
}
private fun trackButton(
view: View,
buttonCode: Int?,
controller: Any?,
parent: ViewGroup?,
) {
val buttonId = buttonIdForAospTaskbarCode(buttonCode) ?: return
buttonIds[view] = buttonId
if (controller != null) {
controllers[view] = controller
}
view.setOnTouchListener { touchedView, event -> touchController.handle(touchedView, event) }
updateContextFromView(view)
registerSettingsReceiver()
val container = (view.parent as? ViewGroup) ?: parent ?: return
buttons.getOrPut(container) { mutableMapOf() }[buttonId] = view
trackContainer(container)
refreshSideArrows(container)
}
private fun trackContainer(container: ViewGroup) {
if (trackedContainers.add(container)) {
container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
refreshSideArrows(view as ViewGroup)
}
}
}
private fun refreshSideArrows(container: ViewGroup) {
try {
val containerButtons = buttons[container].orEmpty()
val home = containerButtons[NavButtonId.HOME] as? ImageView ?: return
if (home.parent != container) {
return
}
if (!sideArrowsEnabled()) {
removeSideArrows(container)
resetSideButtonTranslations(container)
return
}
val arrows = sideArrows.getOrPut(container) {
AospSideArrowViews(
createSideArrowView(container, home, SideArrowDirection.LEFT),
createSideArrowView(container, home, SideArrowDirection.RIGHT),
)
}
configureSideArrowView(arrows.left, home, SideArrowDirection.LEFT)
configureSideArrowView(arrows.right, home, SideArrowDirection.RIGHT)
placeAospTaskbarSideArrows(
container,
home,
arrows,
containerButtons,
sideButtonTranslations,
)
applySideArrowTint(arrows, home)
} catch (error: Throwable) {
log("AOSP side arrows refresh failed: ${error.javaClass.simpleName}")
}
}
private fun createSideArrowView(
container: ViewGroup,
home: ImageView,
direction: SideArrowDirection,
): ImageView {
return ImageView(container.context).apply {
id = View.generateViewId()
isClickable = true
isFocusable = true
contentDescription = direction.name.lowercase()
setOnTouchListener { view, event ->
handleSideArrowTouch(view, direction.keyCode, event)
}
configureSideArrowView(this, home, direction)
}
}
private fun configureSideArrowView(
arrow: ImageView,
home: ImageView,
direction: SideArrowDirection,
) {
arrow.minimumWidth = home.minimumWidth
arrow.minimumHeight = home.minimumHeight
arrow.setPadding(0, 0, 0, 0)
arrow.scaleType = ImageView.ScaleType.CENTER
arrow.setImageDrawable(sideArrowBitmapDrawable(arrow.context, direction, Color.WHITE))
}
private fun removeSideArrows(container: ViewGroup) {
sideArrows[container]?.let { arrows ->
removeFromParent(arrows.left)
removeFromParent(arrows.right)
}
}
private fun resetSideButtonTranslations(container: ViewGroup) {
buttons[container].orEmpty().values.forEach { button ->
sideButtonTranslations.remove(button)?.let { button.translationX = it }
}
}
private fun applySideArrowTint(arrows: AospSideArrowViews, source: ImageView) {
listOf(arrows.left, arrows.right).forEach { arrow ->
arrow.imageTintList = source.imageTintList
arrow.colorFilter = source.colorFilter
arrow.imageAlpha = source.imageAlpha
}
}
private fun refreshSideArrowTint(source: ImageView?) {
val container = source?.parent as? ViewGroup ?: return
val containerButtons = buttons[container] ?: return
val arrows = sideArrows[container] ?: return
if (source !in containerButtons.values) {
return
}
val tintSource = (containerButtons[NavButtonId.HOME] ?: containerButtons.values.firstOrNull()) as? ImageView
?: return
applySideArrowTint(arrows, tintSource)
}
private fun handleSideArrowTouch(view: View, keyCode: Int, event: MotionEvent): Boolean {
updateContextFromView(view)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
view.isPressed = true
sideArrowLongPresses.start(view, keyCode)
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
val triggered = sideArrowLongPresses.finish(view)
view.isPressed = false
if (event.actionMasked == MotionEvent.ACTION_UP && !triggered) {
view.performClick()
actions.sendSideArrowKeyPress(keyCode)
}
}
}
return true
}
private fun updateContextFromTaskbar(controller: Any?, view: Any?) {
if (view is View) {
updateContextFromView(view)
} else {
updateContextFromField(controller, "mContext")
}
}
private fun updateContextFromView(view: View) {
contextUpdater(view.context.applicationContext ?: view.context)
}
private fun updateContextFromField(target: Any?, fieldName: String) {
val currentContext = getFieldValue(target, fieldName) as? Context ?: return
contextUpdater(currentContext.applicationContext ?: currentContext)
}
private fun performHomeLongPressAction(controller: Any?, action: NavButtonAction) {
if (wasHomeLongPressHandledRecently()) {
return
}
noteHomeLongPressHandled()
performAction(controller, action)
}
private fun performAction(controller: Any?, action: NavButtonAction) {
val handled = when (action) {
NavButtonAction.STOCK,
NavButtonAction.NONE,
-> true
NavButtonAction.BACK ->
callTaskbarMethod(controller, "executeBack", arrayOf(KeyEvent::class.java), null)
NavButtonAction.HOME ->
callTaskbarMethod(controller, "navigateHome", emptyArray())
NavButtonAction.ASSISTANT ->
callTaskbarMethod(controller, "onLongPressHome", emptyArray())
NavButtonAction.RECENTS ->
callTaskbarMethod(controller, "navigateToOverview", emptyArray())
NavButtonAction.KILL_FOREGROUND_APP -> {
requestPrivilegedAction(PRIVILEGED_ACTION_KILL_FOREGROUND_APP, null)
true
}
NavButtonAction.TOGGLE_FLASHLIGHT -> {
requestPrivilegedAction(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT, null)
true
}
NavButtonAction.TOGGLE_AUTO_ROTATE -> {
actions.perform(action)
true
}
}
if (!handled) {
actions.perform(action)
}
}
private fun callTaskbarMethod(
controller: Any?,
methodName: String,
parameterTypes: Array<Class<*>>,
vararg args: Any?,
): Boolean {
if (controller == null) {
return false
}
return try {
callMethod(controller, methodName, parameterTypes, *args)
true
} catch (error: Throwable) {
log("AOSP taskbar $methodName failed: ${error.javaClass.simpleName}")
false
}
}
}
@@ -0,0 +1,123 @@
package se.ajpanton.navbuttons
import android.graphics.Point
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import java.util.WeakHashMap
internal fun placeAospTaskbarSideArrows(
container: ViewGroup,
home: ImageView,
arrows: AospSideArrowViews,
buttons: Map<NavButtonId, View>,
sideButtonTranslations: WeakHashMap<View, Float>,
) {
val host = findAospTaskbarArrowHost(container) ?: return
val sideButtons = listOfNotNull(buttons[NavButtonId.BACK], buttons[NavButtonId.RECENTS])
.filter { it.width > 0 && it.height > 0 }
if (home.width <= 0 || home.height <= 0 || sideButtons.size < 2) {
return
}
addAospTaskbarArrowToHost(host, arrows.left, home)
addAospTaskbarArrowToHost(host, arrows.right, home)
val homeCenter = centerInHost(home, host)
val leftButton = sideButtons.filter { centerInHost(it, host).x < homeCenter.x }
.maxByOrNull { centerInHost(it, host).x }
?: return
val rightButton = sideButtons.filter { centerInHost(it, host).x > homeCenter.x }
.minByOrNull { centerInHost(it, host).x }
?: return
translateAospTaskbarSideButton(
leftButton,
-aospTaskbarSideButtonShift(arrows.left),
sideButtonTranslations,
)
translateAospTaskbarSideButton(
rightButton,
aospTaskbarSideButtonShift(arrows.right),
sideButtonTranslations,
)
placeAospTaskbarArrow(arrows.left, midpoint(centerInHost(leftButton, host), homeCenter))
placeAospTaskbarArrow(arrows.right, midpoint(centerInHost(rightButton, host), homeCenter))
}
internal fun removeFromParent(view: View) {
(view.parent as? ViewGroup)?.removeView(view)
}
private fun translateAospTaskbarSideButton(
button: View,
shiftPx: Float,
sideButtonTranslations: WeakHashMap<View, Float>,
) {
val originalTranslation = sideButtonTranslations.getOrPut(button) { button.translationX }
button.translationX = originalTranslation + shiftPx
}
private fun aospTaskbarSideButtonShift(arrow: View): Float {
return navStarArrowIconSizePx(arrow.context) * AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO
}
private fun findAospTaskbarArrowHost(start: View): FrameLayout? {
var current = start.parent as? View
repeat(5) {
if (current is FrameLayout) {
return current as FrameLayout
}
current = current?.parent as? View
}
return null
}
private fun addAospTaskbarArrowToHost(
host: FrameLayout,
arrow: ImageView,
home: ImageView,
) {
val width = navStarArrowIconSizePx(arrow.context)
val height = home.height.coerceAtLeast(width)
if (arrow.parent != host) {
removeFromParent(arrow)
host.addView(arrow, FrameLayout.LayoutParams(width, height))
} else {
arrow.layoutParams = (arrow.layoutParams as? FrameLayout.LayoutParams)
?.apply {
this.width = width
this.height = height
}
?: FrameLayout.LayoutParams(width, height)
}
arrow.bringToFront()
arrow.translationZ = home.translationZ + 1f
}
private fun centerInHost(view: View, host: View): Point {
val viewLocation = IntArray(2)
val hostLocation = IntArray(2)
view.getLocationOnScreen(viewLocation)
host.getLocationOnScreen(hostLocation)
return Point(
viewLocation[0] - hostLocation[0] + view.width / 2,
viewLocation[1] - hostLocation[1] + view.height / 2,
)
}
private fun midpoint(first: Point, second: Point): Point {
return Point((first.x + second.x) / 2, (first.y + second.y) / 2)
}
private fun placeAospTaskbarArrow(arrow: View, center: Point) {
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
}
@@ -0,0 +1,318 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
internal class AospSystemUiHooks(
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
private val deoptimizeMethod: (Method?) -> Unit,
private val sideArrowsEnabled: () -> Boolean,
private val registerSettingsReceiver: () -> Unit,
private val contextUpdater: (Context) -> Unit,
private val log: (String) -> Unit,
) {
private val trackedInflaters = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private val sideArrowDirections = WeakHashMap<Any, SideArrowDirection>()
private var sideArrowHookInstalled = false
private var darkIntensity = 0f
fun installSideArrows(classLoader: ClassLoader?) {
if (sideArrowHookInstalled || Build.MANUFACTURER.equals("samsung", ignoreCase = true)) {
return
}
val inflaterClass = findFirstClassIfExists(AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES, classLoader)
?: return
sideArrowHookInstalled = true
registerSettingsReceiver()
hookDarkIntensity(classLoader)
findMethodIfExists(inflaterClass, "getDefaultLayout")?.let { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
chain.thisObject?.let(::trackInflater)
val layout = chain.proceed() as? String ?: return@hookMethod null
if (sideArrowsEnabled()) {
addAospSystemUiSideArrows(layout)
} else {
removeAospSystemUiSideArrows(layout)
}
}
}
findMethodIfExists(inflaterClass, "inflateLayout", String::class.java)?.let { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
chain.thisObject?.let(::trackInflater)
val layout = chain.args.getOrNull(0) as? String
if (layout != null) {
chain.args[0] = if (sideArrowsEnabled()) {
addAospSystemUiSideArrows(layout)
} else {
removeAospSystemUiSideArrows(layout)
}
}
chain.proceed()
}
}
findMethodIfExists(
inflaterClass,
"inflateButtons",
Array<String>::class.java,
ViewGroup::class.java,
Boolean::class.javaPrimitiveType!!,
Boolean::class.javaPrimitiveType!!,
)?.let { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
val tokens = (chain.args.getOrNull(0) as? Array<*>)
?.mapNotNull { it as? String }
?: return@hookMethod chain.proceed()
val parent = chain.args.getOrNull(1) as? ViewGroup
?: return@hookMethod chain.proceed()
val childCountBefore = parent.childCount
val result = chain.proceed()
trackInflatedSideArrows(tokens, parent, childCountBefore)
result
}
}
}
fun refreshSideArrowLayouts() {
trackedInflaters.toList().forEach(::refreshSideArrowLayout)
}
fun configureSideArrowButton(value: Any?) {
val view = value as? ImageView ?: return
val direction = sideArrowDirections[view] ?: return
updateContextFromView(view)
createSideArrowDrawable(view, direction)?.let {
view.setImageDrawable(it)
callFloatMethodIfExists(view, "setDarkIntensity", darkIntensity)
}
}
private fun trackInflater(inflater: Any) {
trackedInflaters.add(inflater)
(inflater as? View)?.let {
updateContextFromView(it)
registerSettingsReceiver()
}
}
private fun refreshSideArrowLayout(inflater: Any) {
try {
clearInflaterViews(inflater)
callMethod(inflater, "inflateLayout", arrayOf(String::class.java), null)
} catch (error: Throwable) {
log("AOSP SystemUI side arrows refresh failed: ${error.javaClass.simpleName}")
}
}
private fun clearInflaterViews(inflater: Any) {
callMethodIfExists(inflater, "clearDispatcherViews")
val view = inflater as? View ?: return
val ids = listOf("ends_group", "center_group").mapNotNull { name ->
view.resources.getIdentifier(name, "id", view.context.packageName).takeIf { it != 0 }
}
listOf("mHorizontal", "mVertical").forEach { fieldName ->
val root = getFieldValue(inflater, fieldName) as? ViewGroup ?: return@forEach
ids.forEach { id ->
(root.findViewById<View>(id) as? ViewGroup)?.removeAllViews()
}
}
}
private fun trackInflatedSideArrows(
tokens: List<String>,
parent: ViewGroup,
childCountBefore: Int,
) {
val directions = tokens.mapNotNull { token ->
when (layoutButtonName(token)) {
AOSP_SYSUI_LEFT_ARROW_BUTTON -> SideArrowDirection.LEFT
AOSP_SYSUI_RIGHT_ARROW_BUTTON -> SideArrowDirection.RIGHT
else -> null
}
}
if (directions.isEmpty()) {
return
}
val buttons = (childCountBefore until parent.childCount)
.flatMap { index -> findKeyButtons(parent.getChildAt(index)) }
.toMutableList()
directions.forEach { direction ->
val button = buttons.firstOrNull {
getIntField(it, "mCode") == direction.keyCode
} ?: return@forEach
buttons.remove(button)
sideArrowDirections[button] = direction
configureSideArrowButton(button)
}
}
private fun hookDarkIntensity(classLoader: ClassLoader?) {
val transitionsClass = findFirstClassIfExists(
AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES,
classLoader,
) ?: return
findMethodIfExists(
transitionsClass,
"applyDarkIntensity",
Float::class.javaPrimitiveType!!,
)?.let { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
val result = chain.proceed()
darkIntensity = (chain.args.getOrNull(0) as? Float)?.coerceIn(0f, 1f)
?: darkIntensity
updateSideArrowDarkIntensity()
result
}
}
}
private fun updateSideArrowDarkIntensity() {
sideArrowDirections.keys.toList().forEach { view ->
callFloatMethodIfExists(view, "setDarkIntensity", darkIntensity)
setFieldIfExists(view, "mDarkIntensity", darkIntensity)
}
}
private fun findKeyButtons(view: View): List<ImageView> {
val result = mutableListOf<ImageView>()
if (view is ImageView && getFieldValue(view, "mCode") != null) {
result.add(view)
}
if (view is ViewGroup) {
repeat(view.childCount) { index ->
result.addAll(findKeyButtons(view.getChildAt(index)))
}
}
return result
}
private fun createSideArrowDrawable(
value: Any?,
direction: SideArrowDirection,
): Drawable? {
val view = value as? View ?: return null
val classLoader = view.javaClass.classLoader ?: return null
return try {
val keyButtonDrawableClass = findFirstClass(AOSP_KEY_BUTTON_DRAWABLE_CLASSES, classLoader)
val stateClass = findFirstClass(AOSP_KEY_BUTTON_DRAWABLE_STATE_CLASSES, classLoader)
val state = stateClass
.getDeclaredConstructor(
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType,
)
.apply { isAccessible = true }
.newInstance(Color.WHITE, Color.rgb(55, 58, 62), false, false)
val drawable = keyButtonDrawableClass
.getDeclaredConstructor(Drawable::class.java, stateClass)
.apply { isAccessible = true }
.newInstance(
sideArrowBitmapDrawable(view.context, direction, Color.WHITE),
state,
) as? Drawable
drawable?.also {
callFloatMethodIfExists(it, "setDarkIntensity", darkIntensity)
}
} catch (error: Throwable) {
log("AOSP SystemUI side arrow drawable failed: ${error.javaClass.simpleName}")
null
}
}
private fun updateContextFromView(view: View) {
contextUpdater(view.context.applicationContext ?: view.context)
}
private fun addAospSystemUiSideArrows(layout: String): String {
val parts = splitAospSystemUiLayout(layout) ?: return layout
val withoutArrows = parts.map { group ->
group.filterNot(::isAospSystemUiSideArrowToken).toMutableList()
}
val center = withoutArrows[1]
val homeIndex = center.indexOfFirst { layoutButtonName(it) == "home" }
if (homeIndex < 0) {
return layout
}
if (center.any { layoutButtonName(it) == "back" } &&
center.any { layoutButtonName(it) == "recent" }
) {
center.add(homeIndex, AOSP_SYSUI_LEFT_ARROW_BUTTON)
center.add(homeIndex + 2, AOSP_SYSUI_RIGHT_ARROW_BUTTON)
} else {
val leftButton = withoutArrows[0].firstOrNull(::isAospSystemUiPrimaryButton)
?: return layout
val rightButton = withoutArrows[2].firstOrNull(::isAospSystemUiPrimaryButton)
?: return layout
val homeButton = center[homeIndex]
withoutArrows[0].removeAll(::isAospSystemUiPrimaryButton)
withoutArrows[2].removeAll(::isAospSystemUiPrimaryButton)
center.clear()
center.addAll(
listOf(
layoutButtonName(leftButton),
AOSP_SYSUI_LEFT_ARROW_BUTTON,
layoutButtonName(homeButton),
AOSP_SYSUI_RIGHT_ARROW_BUTTON,
layoutButtonName(rightButton),
),
)
}
return joinAospSystemUiLayout(withoutArrows)
}
private fun removeAospSystemUiSideArrows(layout: String): String {
val parts = splitAospSystemUiLayout(layout) ?: return layout
return joinAospSystemUiLayout(
parts.map { group -> group.filterNot(::isAospSystemUiSideArrowToken) },
)
}
private fun layoutButtonName(token: String): String {
return token.substringBefore("[")
}
private fun splitAospSystemUiLayout(layout: String): List<List<String>>? {
val parts = layout.split(";", limit = 3)
if (parts.size != 3) {
return null
}
return parts.map { group -> group.split(",").filter { it.isNotBlank() } }
}
private fun joinAospSystemUiLayout(parts: List<List<String>>): String {
return parts.joinToString(";") { group -> group.joinToString(",") }
}
private fun isAospSystemUiSideArrowToken(token: String): Boolean {
val button = layoutButtonName(token)
return button == AOSP_SYSUI_LEFT_ARROW_BUTTON || button == AOSP_SYSUI_RIGHT_ARROW_BUTTON
}
private fun isAospSystemUiPrimaryButton(token: String): Boolean {
return when (layoutButtonName(token)) {
"back",
"recent",
-> true
else -> false
}
}
}
@@ -0,0 +1,96 @@
package se.ajpanton.navbuttons
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import java.util.WeakHashMap
internal class AospTaskbarTouchController(
private val buttonIdProvider: (View) -> NavButtonId?,
private val controllerProvider: (View) -> Any?,
private val configProvider: (NavButtonId) -> NavButtonConfig,
private val updateContext: (Any?, View?) -> Unit,
private val performAction: (Any?, NavButtonAction) -> Unit,
private val performHomeLongPressAction: (Any?, NavButtonAction) -> Unit,
) {
private val longPressRunnables = WeakHashMap<Any, Runnable>()
private val longPressTriggered = WeakHashMap<Any, Boolean>()
fun handle(view: View, event: MotionEvent): Boolean {
val buttonId = buttonIdProvider(view) ?: return false
val controller = controllerProvider(view) ?: return false
val config = configProvider(buttonId)
if (config.pressAction == NavButtonAction.STOCK &&
config.longPressAction == NavButtonAction.STOCK
) {
return false
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
longPressTriggered[view] = false
if (config.longPressAction != NavButtonAction.STOCK) {
scheduleLongPress(view, controller, buttonId, config)
}
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
val triggered = longPressTriggered[view] == true
cancelLongPress(view)
longPressTriggered.remove(view)
if (triggered) {
return true
}
if (event.actionMasked == MotionEvent.ACTION_UP) {
when {
config.pressAction != NavButtonAction.STOCK -> {
performAction(controller, config.pressAction)
return true
}
config.longPressAction != NavButtonAction.STOCK &&
event.eventTime - event.downTime >= ViewConfiguration.getLongPressTimeout() -> {
performAction(controller, stockActionFor(buttonId))
return true
}
buttonId == NavButtonId.BACK -> {
performAction(controller, NavButtonAction.BACK)
return true
}
}
}
}
}
return false
}
private fun scheduleLongPress(
view: View,
controller: Any,
buttonId: NavButtonId,
config: NavButtonConfig,
) {
cancelLongPress(view)
val longPressRunnable = Runnable {
longPressTriggered[view] = true
updateContext(controller, view)
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
if (buttonId == NavButtonId.HOME) {
performHomeLongPressAction(controller, config.longPressAction)
} else {
performAction(controller, config.longPressAction)
}
}
longPressRunnables[view] = longPressRunnable
view.postDelayed(longPressRunnable, config.longPressDurationMs.toLong())
}
private fun cancelLongPress(view: View) {
longPressRunnables.remove(view)?.let(view::removeCallbacks)
}
}
@@ -0,0 +1,87 @@
package se.ajpanton.navbuttons
import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
internal fun defaultLauncherPackageName(context: Context): String? {
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
}
val resolveInfo = context.packageManager.resolveActivity(
intent,
PackageManager.MATCH_DEFAULT_ONLY,
)
return resolveInfo?.activityInfo?.packageName
}
internal fun topPackageName(context: Context, log: (String) -> Unit): String? {
return topPackageFromActivityTaskManager(log)
?: topPackageFromRunningTasks(context, log)
}
private fun topPackageFromActivityTaskManager(log: (String) -> Unit): String? {
return try {
val service = callStaticMethod(
findClass("android.app.ActivityTaskManager", null),
"getService",
) ?: return null
val taskInfo = callMethod(service, "getFocusedRootTaskInfo")
extractPackageFromTaskInfo(taskInfo)
} catch (error: Throwable) {
log("ActivityTaskManager.getFocusedRootTaskInfo failed: ${error.javaClass.simpleName}")
null
}
}
private fun extractPackageFromTaskInfo(taskInfo: Any?): String? {
if (taskInfo == null) {
return null
}
val pkg = packageNameFromComponent(getFieldValue(taskInfo, "topActivity"))
return pkg ?: packageNameFromActivityInfo(getFieldValue(taskInfo, "topActivityInfo"))
}
@Suppress("DEPRECATION")
private fun topPackageFromRunningTasks(context: Context, log: (String) -> Unit): 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 packageNameFromComponent(component: Any?): String? {
return (component as? ComponentName)?.packageName
}
private fun packageNameFromActivityInfo(info: Any?): String? {
return getFieldValue(info, "packageName")?.toString()
}
internal fun shouldSkipForegroundPackage(packageName: String?, defaultLauncher: String?): Boolean {
return packageName == null ||
packageName == SYSTEMUI_PACKAGE ||
packageName == SAMSUNG_LAUNCHER_PACKAGE ||
packageName in AOSP_LAUNCHER_PACKAGES ||
packageName == defaultLauncher
}
internal fun applicationLabel(packageName: String, packageManager: PackageManager): String {
return try {
val appInfo: ApplicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(appInfo).toString()
} catch (_: PackageManager.NameNotFoundException) {
packageName
}
}
@@ -0,0 +1,51 @@
package se.ajpanton.navbuttons
import android.view.View
import android.view.ViewConfiguration
import java.util.WeakHashMap
internal class HomeStockLongPressGuard(
private val configProvider: (NavButtonId) -> NavButtonConfig,
private val longPressTriggered: (Any) -> Boolean,
private val sendCanceledKeyUp: (Any) -> Unit,
) {
private val guards = WeakHashMap<Any, Runnable>()
private val canceled = WeakHashMap<Any, Boolean>()
fun schedule(target: Any, buttonId: NavButtonId) {
if (buttonId != NavButtonId.HOME) {
return
}
if (configProvider(buttonId).longPressAction == NavButtonAction.STOCK) {
return
}
val view = target as? View ?: return
val delayMs = (ViewConfiguration.getLongPressTimeout() - STOCK_LONG_PRESS_GUARD_EARLY_MS)
.coerceAtLeast(STOCK_LONG_PRESS_GUARD_MIN_DELAY_MS)
val guard = Runnable {
if (!view.isPressed || longPressTriggered(target)) {
return@Runnable
}
canceled[target] = true
sendCanceledKeyUp(target)
}
guards[target] = guard
view.postDelayed(guard, delayMs.toLong())
}
fun cancel(target: Any) {
guards.remove(target)?.let { guard ->
(target as? View)?.removeCallbacks(guard)
}
}
fun clear(target: Any) {
canceled.remove(target)
}
fun isCanceled(target: Any): Boolean {
return canceled[target] == true
}
}
@@ -0,0 +1,50 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.os.Process
import android.os.SystemClock
import android.view.KeyEvent
internal fun sendInjectedKeyPress(context: Context?, keyCode: Int, log: (String) -> Unit) {
try {
val inputManager = inputManagerForInjection(context) ?: run {
log("InputManager unavailable for keyCode=$keyCode")
return
}
val downTime = SystemClock.uptimeMillis()
injectKeyEvent(inputManager, KeyEvent(downTime, downTime, KeyEvent.ACTION_DOWN, keyCode, 0))
injectKeyEvent(inputManager, KeyEvent(downTime, SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode, 0))
} catch (error: Throwable) {
log("Key injection failed for keyCode=$keyCode: ${error.javaClass.simpleName}")
}
}
internal fun inputManagerForInjection(context: Context?): Any? {
context?.getSystemService(Context.INPUT_SERVICE)?.let { return it }
return listOf(INPUT_MANAGER_CLASS, INPUT_MANAGER_GLOBAL_CLASS).firstNotNullOfOrNull { className ->
runCatching { callStaticMethod(findClass(className, null), "getInstance") }.getOrNull()
}
}
internal fun injectKeyEvent(inputManager: Any, keyEvent: KeyEvent) {
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)
}
}
@@ -0,0 +1,112 @@
package se.ajpanton.navbuttons
internal const val TAG = "NavButtons"
internal const val ASSIST_INVOCATION_TYPE_UNKNOWN = 0
internal const val ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS = 5
internal const val INPUT_MANAGER_CLASS = "android.hardware.input.InputManager"
internal const val INPUT_MANAGER_GLOBAL_CLASS = "android.hardware.input.InputManagerGlobal"
internal const val INPUT_EVENT_INJECTION_SYNC_NONE = 0
internal const val ACTION_PASS_TO_USER = 1
internal const val HOME_LONG_PRESS_DEDUP_WINDOW_MS = 1_000L
internal const val REMOTE_PREFERENCES_RETRY_DELAY_MS = 1_000L
internal const val REMOTE_PREFERENCES_LOAD_ATTEMPTS = 15
internal const val STOCK_LONG_PRESS_GUARD_EARLY_MS = 75
internal const val STOCK_LONG_PRESS_GUARD_MIN_DELAY_MS = 100
internal const val SYSTEMUI_PACKAGE = "com.android.systemui"
internal const val ANDROID_PACKAGE = "android"
internal const val SYSTEM_SERVER_PHONE_WINDOW_MANAGER = "com.android.server.policy.PhoneWindowManager"
internal val SYSTEMUI_KEY_BUTTON_VIEW_CLASSES = listOf(
"com.android.systemui.navigationbar.views.buttons.KeyButtonView",
"com.android.systemui.navigationbar.buttons.KeyButtonView",
)
internal const val SAMSUNG_PHONE_LAYOUT_PROVIDER =
"com.android.systemui.navigationbar.layout.LayoutProviderImpl"
internal const val SAMSUNG_TABLET_LAYOUT_PROVIDER =
"com.android.systemui.navigationbar.layout.TabletLayoutProviderImpl"
internal const val SAMSUNG_NAVIGATION_BAR_VIEW =
"com.android.systemui.navigationbar.views.SamsungNavigationBarView"
internal const val SAMSUNG_LAUNCHER_PACKAGE = "com.sec.android.app.launcher"
internal const val SAMSUNG_NAV_VIEW_PARENT_DEPTH = 8
internal val AOSP_LAUNCHER_PACKAGES = setOf(
"com.android.launcher3",
"com.google.android.apps.nexuslauncher",
)
internal val TARGET_PACKAGES = AOSP_LAUNCHER_PACKAGES + SYSTEMUI_PACKAGE
internal const val AOSP_TASKBAR_NAV_BUTTON_CONTROLLER =
"com.android.launcher3.taskbar.TaskbarNavButtonController"
internal const val AOSP_TASKBAR_BACK_BUTTON = 1
internal const val AOSP_TASKBAR_HOME_BUTTON = 2
internal const val AOSP_TASKBAR_RECENTS_BUTTON = 4
internal const val AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER =
"com.android.launcher3.taskbar.NavbarButtonsViewController"
internal const val AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO = 0.45f
internal const val TARGET_HOOK_RETRY_ATTEMPTS = 120
internal const val TARGET_HOOK_RETRY_DELAY_MS = 250L
internal val AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES = listOf(
"com.android.systemui.navigationbar.views.NavigationBarInflaterView",
"com.android.systemui.navigationbar.NavigationBarInflaterView",
)
internal val AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES = listOf(
"com.android.systemui.navigationbar.NavigationBarTransitions",
"com.android.systemui.navigationbar.views.NavigationBarTransitions",
)
internal val AOSP_KEY_BUTTON_DRAWABLE_CLASSES = listOf(
"com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable",
"com.android.systemui.navigationbar.buttons.KeyButtonDrawable",
)
internal val AOSP_KEY_BUTTON_DRAWABLE_STATE_CLASSES = listOf(
"com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable\$ShadowDrawableState",
"com.android.systemui.navigationbar.buttons.KeyButtonDrawable\$ShadowDrawableState",
)
internal const val AOSP_SYSUI_LEFT_ARROW_BUTTON = "key(21:)"
internal const val AOSP_SYSUI_RIGHT_ARROW_BUTTON = "key(22:)"
internal const val ACTION_PRIVILEGED_ACTION =
"se.ajpanton.navbuttons.action.PRIVILEGED_ACTION"
internal const val EXTRA_PRIVILEGED_ACTION = "privileged_action"
internal const val EXTRA_PRIVILEGED_CALLER = "privileged_caller"
internal const val EXTRA_PRIVILEGED_ACTION_TOKEN = "privileged_action_token"
internal const val EXTRA_PRIVILEGED_KEY_CODE = "privileged_key_code"
internal const val PRIVILEGED_ACTION_KILL_FOREGROUND_APP = "kill_foreground_app"
internal const val PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT = "toggle_flashlight"
internal const val PRIVILEGED_ACTION_TOGGLE_AUTO_ROTATE = "toggle_auto_rotate"
internal const val PRIVILEGED_ACTION_SEND_KEY_PRESS = "send_key_press"
internal const val HONEYSPACE_NAV_BUTTON_VIEW =
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView"
internal const val HONEYSPACE_NAV_BUTTON_CONTAINER =
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarButtonsLayout"
internal const val HONEYSPACE_LONG_PRESS_RUNNABLE = "f.a"
internal const val HONEYSPACE_LONG_PRESS_CASE = 8
internal const val HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH = 8
internal const val HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS = 150L
internal const val HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS = 600L
internal const val NAV_LAYOUT_PHONE_DEFAULT = "left,recent;home;back,right"
internal const val NAV_LAYOUT_PHONE_REVERSED = "left,back;home;recent,right"
internal const val NAV_LAYOUT_TABLET_DEFAULT = "left;recent,gap,home,gap,back;right"
internal const val NAV_LAYOUT_TABLET_REVERSED = "left;back,gap,home,gap,recent;right"
internal const val NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT = "left;back,gap,home,gap,recent;r"
internal const val NAV_LAYOUT_WINNER_DEFAULT = "left,gap,back,gap,home,gap,recent;;right"
internal const val NAV_LAYOUT_WINNER_REVERSED = "left,gap,recent,gap,home,gap,back;;right"
internal const val NAV_LAYOUT_UNFOLDED_DEFAULT = "left;;back,gap,home,gap,recent,gap,right"
internal const val NAV_LAYOUT_UNFOLDED_REVERSED = "left;;recent,gap,home,gap,back,gap,right"
internal const val BUTTON_DISPATCHER_SEARCH_DEPTH = 4
internal const val SIDE_ARROW_REFRESH_DELAY_MS = 100L
internal const val PHONE_BUTTON_RATIO = 0.22220000624656677
internal const val TABLET_BUTTON_RATIO = 0.2070000022649765
internal const val SIDE_ARROW_GAP_RATIO = 0.013000000268220901
internal const val PHONE_SPACE_RATIO = 0.10999999940395355
internal const val TABLET_SPACE_RATIO = 0.10999999940395355
internal const val TABLET_SPACE_SIDE_PADDING_RATIO = 0.033250000327825546
internal const val SIDE_ARROW_REDUCE_RATIO = 0.75
internal fun Int.coerceLongPressDuration(): Int {
return coerceIn(
NavButtonSettingsStore.MIN_DURATION_MS,
NavButtonSettingsStore.MAX_DURATION_MS,
)
}
@@ -0,0 +1,33 @@
package se.ajpanton.navbuttons
import android.view.KeyEvent
internal fun stockActionFor(buttonId: NavButtonId): NavButtonAction {
return when (buttonId) {
NavButtonId.BACK -> NavButtonAction.BACK
NavButtonId.HOME -> NavButtonAction.HOME
NavButtonId.RECENTS -> NavButtonAction.RECENTS
}
}
internal fun isSideArrowKeyCode(keyCode: Int): Boolean {
return keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
}
internal fun buttonIdForKeyCode(keyCode: Int): NavButtonId? {
return when (keyCode) {
KeyEvent.KEYCODE_BACK -> NavButtonId.BACK
KeyEvent.KEYCODE_HOME -> NavButtonId.HOME
KeyEvent.KEYCODE_APP_SWITCH -> NavButtonId.RECENTS
else -> null
}
}
internal fun buttonIdForAospTaskbarCode(buttonCode: Int?): NavButtonId? {
return when (buttonCode) {
AOSP_TASKBAR_BACK_BUTTON -> NavButtonId.BACK
AOSP_TASKBAR_HOME_BUTTON -> NavButtonId.HOME
AOSP_TASKBAR_RECENTS_BUTTON -> NavButtonId.RECENTS
else -> null
}
}
@@ -7,6 +7,7 @@ import android.view.ViewConfiguration
import androidx.annotation.StringRes import androidx.annotation.StringRes
import io.github.libxposed.service.XposedService import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper import io.github.libxposed.service.XposedServiceHelper
import java.util.UUID
enum class NavButtonId( enum class NavButtonId(
val preferencePrefix: String, val preferencePrefix: String,
@@ -39,12 +40,39 @@ enum class NavButtonAction(
} }
} }
enum class SideArrowLongPressAction(
val storageValue: String,
@get:StringRes val labelRes: Int,
) {
NOTHING("nothing", R.string.side_arrow_long_press_nothing),
SINGLE_STEP("single_step", R.string.side_arrow_long_press_single_step),
MULTIPLE_STEPS("multiple_steps", R.string.side_arrow_long_press_multiple_steps),
;
companion object {
fun fromStorageValue(value: String?): SideArrowLongPressAction {
return entries.firstOrNull { it.storageValue == value } ?: MULTIPLE_STEPS
}
}
}
data class NavButtonConfig( data class NavButtonConfig(
val pressAction: NavButtonAction, val pressAction: NavButtonAction,
val longPressAction: NavButtonAction, val longPressAction: NavButtonAction,
val longPressDurationMs: Int, val longPressDurationMs: Int,
) )
internal fun sanitizeSideArrowRepeatSpeed(stepsPerSecond: Float): Float {
return if (stepsPerSecond.isFinite()) {
stepsPerSecond.coerceIn(
NavButtonSettingsStore.MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
NavButtonSettingsStore.MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
)
} else {
NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND
}
}
class NavButtonSettingsStore(context: Context) { class NavButtonSettingsStore(context: Context) {
private val localPreferences = context.applicationContext.getSharedPreferences( private val localPreferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME, PREFERENCES_NAME,
@@ -52,6 +80,7 @@ class NavButtonSettingsStore(context: Context) {
) )
init { init {
ensurePrivilegedActionToken()
registerRemotePreferencesListener(localPreferences) registerRemotePreferencesListener(localPreferences)
} }
@@ -64,12 +93,10 @@ class NavButtonSettingsStore(context: Context) {
longPressAction = NavButtonAction.fromStorageValue( longPressAction = NavButtonAction.fromStorageValue(
preferences.getString(longPressActionKey(buttonId), null), preferences.getString(longPressActionKey(buttonId), null),
), ),
longPressDurationMs = sanitizeDuration( longPressDurationMs = preferences.getInt(
preferences.getInt(
longPressDurationKey(buttonId), longPressDurationKey(buttonId),
defaultLongPressDurationMs(), defaultLongPressDurationMs(),
), ).coerceLongPressDuration(),
),
) )
} }
@@ -87,16 +114,65 @@ class NavButtonSettingsStore(context: Context) {
fun setLongPressDurationMs(buttonId: NavButtonId, durationMs: Int) { fun setLongPressDurationMs(buttonId: NavButtonId, durationMs: Int) {
persistChanges { persistChanges {
putInt(longPressDurationKey(buttonId), sanitizeDuration(durationMs)) putInt(longPressDurationKey(buttonId), durationMs.coerceLongPressDuration())
}
}
fun isSideArrowsEnabled(): Boolean {
return currentPreferences().getBoolean(KEY_SIDE_ARROWS_ENABLED, false)
}
fun setSideArrowsEnabled(enabled: Boolean) {
persistChanges {
putBoolean(KEY_SIDE_ARROWS_ENABLED, enabled)
}
}
fun getSideArrowLongPressAction(): SideArrowLongPressAction {
return SideArrowLongPressAction.fromStorageValue(
currentPreferences().getString(KEY_SIDE_ARROW_LONG_PRESS_ACTION, null),
)
}
fun setSideArrowLongPressAction(action: SideArrowLongPressAction) {
persistChanges {
putString(KEY_SIDE_ARROW_LONG_PRESS_ACTION, action.storageValue)
}
}
fun getSideArrowRepeatStepsPerSecond(): Float {
return sanitizeSideArrowRepeatSpeed(
currentPreferences().getFloat(
KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
),
)
}
fun setSideArrowRepeatStepsPerSecond(stepsPerSecond: Float) {
persistChanges {
putFloat(
KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
sanitizeSideArrowRepeatSpeed(stepsPerSecond),
)
}
}
fun getSideArrowLongPressDurationMs(): Int {
return currentPreferences().getInt(
KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS,
defaultLongPressDurationMs(),
).coerceLongPressDuration()
}
fun setSideArrowLongPressDurationMs(durationMs: Int) {
persistChanges {
putInt(KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, durationMs.coerceLongPressDuration())
} }
} }
fun defaultLongPressDurationMs(): Int { fun defaultLongPressDurationMs(): Int {
return sanitizeDuration(ViewConfiguration.getLongPressTimeout()) return ViewConfiguration.getLongPressTimeout().coerceLongPressDuration()
}
fun sanitizeDuration(durationMs: Int): Int {
return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS)
} }
private fun currentPreferences(): SharedPreferences { private fun currentPreferences(): SharedPreferences {
@@ -115,6 +191,15 @@ class NavButtonSettingsStore(context: Context) {
} }
} }
private fun ensurePrivilegedActionToken() {
if (localPreferences.contains(KEY_PRIVILEGED_ACTION_TOKEN)) {
return
}
writeToPreferences(localPreferences, System.currentTimeMillis()) {
putString(KEY_PRIVILEGED_ACTION_TOKEN, UUID.randomUUID().toString())
}
}
private fun writeToPreferences( private fun writeToPreferences(
preferences: SharedPreferences, preferences: SharedPreferences,
updatedAtMs: Long, updatedAtMs: Long,
@@ -129,18 +214,26 @@ class NavButtonSettingsStore(context: Context) {
companion object { companion object {
private const val TAG = "NavButtons" private const val TAG = "NavButtons"
const val PREFERENCES_NAME = "nav_button_settings" const val PREFERENCES_NAME = "nav_button_settings"
const val ACTION_SETTINGS_CHANGED = "se.ajpanton.navbuttons.SETTINGS_CHANGED"
const val KEY_PRESS_ACTION = "press_action" const val KEY_PRESS_ACTION = "press_action"
const val KEY_LONG_PRESS_ACTION = "long_press_action" const val KEY_LONG_PRESS_ACTION = "long_press_action"
const val KEY_LONG_PRESS_DURATION_MS = "long_press_duration_ms" const val KEY_LONG_PRESS_DURATION_MS = "long_press_duration_ms"
const val KEY_SIDE_ARROWS_ENABLED = "side_arrows_enabled"
const val KEY_SIDE_ARROW_LONG_PRESS_ACTION = "side_arrow_long_press_action"
const val KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = "side_arrow_repeat_steps_per_second"
const val KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS = "side_arrow_long_press_duration_ms"
const val KEY_PRIVILEGED_ACTION_TOKEN = "privileged_action_token"
private const val KEY_LAST_UPDATED_AT_MS = "last_updated_at_ms" private const val KEY_LAST_UPDATED_AT_MS = "last_updated_at_ms"
const val MIN_LONG_PRESS_DURATION_MS = 100
const val MAX_LONG_PRESS_DURATION_MS = 10_000
const val MIN_DURATION_MS = 100 const val MIN_DURATION_MS = 100
const val MAX_DURATION_MS = 10_000 const val MAX_DURATION_MS = 10_000
const val DURATION_STEP_MS = 100 const val DURATION_STEP_MS = 100
const val HOLD_REPEAT_START_DELAY_MS = 700L const val HOLD_REPEAT_START_DELAY_MS = 700L
const val HOLD_REPEAT_INTERVAL_MS = 100L const val HOLD_REPEAT_INTERVAL_MS = 100L
const val DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = 20f
const val MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = 0.1f
const val MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = 100f
const val SIDE_ARROW_REPEAT_SPEED_STEP = 1f
fun pressActionKey(buttonId: NavButtonId): String = key(buttonId, KEY_PRESS_ACTION) fun pressActionKey(buttonId: NavButtonId): String = key(buttonId, KEY_PRESS_ACTION)
@@ -161,13 +254,15 @@ class NavButtonSettingsStore(context: Context) {
pressActionKey(buttonId), pressActionKey(buttonId),
longPressActionKey(buttonId), longPressActionKey(buttonId),
) )
} } + KEY_SIDE_ARROW_LONG_PRESS_ACTION
} }
private val durationKeys by lazy(LazyThreadSafetyMode.NONE) { private val durationKeys by lazy(LazyThreadSafetyMode.NONE) {
NavButtonId.entries.map(::longPressDurationKey) NavButtonId.entries.map(::longPressDurationKey) + KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS
} }
private val floatKeys = listOf(KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND)
private fun key(buttonId: NavButtonId, suffix: String): String { private fun key(buttonId: NavButtonId, suffix: String): String {
return "${buttonId.preferencePrefix}_$suffix" return "${buttonId.preferencePrefix}_$suffix"
} }
@@ -220,7 +315,10 @@ class NavButtonSettingsStore(context: Context) {
} }
private fun hasStoredConfig(preferences: SharedPreferences): Boolean { private fun hasStoredConfig(preferences: SharedPreferences): Boolean {
return actionKeys.any(preferences::contains) || durationKeys.any(preferences::contains) return actionKeys.any(preferences::contains) ||
durationKeys.any(preferences::contains) ||
floatKeys.any(preferences::contains) ||
preferences.contains(KEY_SIDE_ARROWS_ENABLED)
} }
private fun copyPreferences(source: SharedPreferences, target: SharedPreferences) { private fun copyPreferences(source: SharedPreferences, target: SharedPreferences) {
@@ -236,12 +334,45 @@ class NavButtonSettingsStore(context: Context) {
for (key in durationKeys) { for (key in durationKeys) {
if (source.contains(key)) { if (source.contains(key)) {
editor.putInt(key, source.getInt(key, sanitizeDuration(ViewConfiguration.getLongPressTimeout()))) editor.putInt(
key,
source.getInt(
key,
ViewConfiguration.getLongPressTimeout().coerceLongPressDuration(),
).coerceLongPressDuration(),
)
} else { } else {
editor.remove(key) editor.remove(key)
} }
} }
for (key in floatKeys) {
if (source.contains(key)) {
editor.putFloat(
key,
source.getFloat(key, DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND)
.coerceIn(
MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
),
)
} else {
editor.remove(key)
}
}
if (source.contains(KEY_SIDE_ARROWS_ENABLED)) {
editor.putBoolean(KEY_SIDE_ARROWS_ENABLED, source.getBoolean(KEY_SIDE_ARROWS_ENABLED, false))
} else {
editor.remove(KEY_SIDE_ARROWS_ENABLED)
}
if (source.contains(KEY_PRIVILEGED_ACTION_TOKEN)) {
editor.putString(KEY_PRIVILEGED_ACTION_TOKEN, source.getString(KEY_PRIVILEGED_ACTION_TOKEN, null))
} else {
editor.remove(KEY_PRIVILEGED_ACTION_TOKEN)
}
if (source.contains(KEY_LAST_UPDATED_AT_MS)) { if (source.contains(KEY_LAST_UPDATED_AT_MS)) {
editor.putLong(KEY_LAST_UPDATED_AT_MS, source.getLong(KEY_LAST_UPDATED_AT_MS, 0L)) editor.putLong(KEY_LAST_UPDATED_AT_MS, source.getLong(KEY_LAST_UPDATED_AT_MS, 0L))
} else { } else {
@@ -250,9 +381,5 @@ class NavButtonSettingsStore(context: Context) {
editor.apply() editor.apply()
} }
private fun sanitizeDuration(durationMs: Int): Int {
return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS)
}
} }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
package se.ajpanton.navbuttons
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Process
import android.view.KeyEvent
internal class PrivilegedActionBridge(
private val contextProvider: () -> Context?,
private val contextUpdater: (Context) -> Unit,
private val currentApplicationProvider: () -> Context?,
private val settingsPreferencesProvider: () -> android.content.SharedPreferences?,
private val log: (String, Throwable?) -> Unit,
private val killForegroundApp: () -> Unit,
private val toggleFlashlight: () -> Unit,
private val toggleAutoRotate: () -> Unit,
private val sendKeyPress: (Int) -> Unit,
) {
private var receiverRegistered = false
private var token: String? = null
fun registerReceiver() {
if (receiverRegistered) {
return
}
val currentContext = contextProvider() ?: currentApplicationProvider() ?: return
contextUpdater(currentContext.applicationContext ?: currentContext)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_PRIVILEGED_ACTION ||
context == null ||
!isAllowedSender(context, intent, sentFromPackageCompat(), sentFromUidCompat())
) {
return
}
contextUpdater(context.applicationContext ?: context)
perform(intent.getStringExtra(EXTRA_PRIVILEGED_ACTION), intent.getIntExtra(EXTRA_PRIVILEGED_KEY_CODE, KeyEvent.KEYCODE_UNKNOWN))
}
}
try {
currentContext.registerReceiver(
receiver,
IntentFilter(ACTION_PRIVILEGED_ACTION),
Context.RECEIVER_EXPORTED,
)
if (currentContext.packageName != ANDROID_PACKAGE) {
token = readToken()
}
receiverRegistered = true
} catch (error: Throwable) {
log("Privileged action receiver failed", error)
}
}
fun request(action: String, keyCode: Int? = null) {
val currentContext = contextProvider()
if (currentContext == null) {
perform(action, keyCode ?: KeyEvent.KEYCODE_UNKNOWN)
return
}
val intent = Intent(ACTION_PRIVILEGED_ACTION)
.setPackage(ANDROID_PACKAGE)
.putExtra(EXTRA_PRIVILEGED_ACTION, action)
.putExtra(EXTRA_PRIVILEGED_CALLER, caller(currentContext, action))
if (keyCode != null) {
intent.putExtra(EXTRA_PRIVILEGED_KEY_CODE, keyCode)
}
readToken()?.let { intent.putExtra(EXTRA_PRIVILEGED_ACTION_TOKEN, it) }
currentContext.sendBroadcast(intent)
}
private fun perform(action: String?, keyCode: Int) {
when (action) {
PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp()
PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight()
PRIVILEGED_ACTION_TOGGLE_AUTO_ROTATE -> toggleAutoRotate()
PRIVILEGED_ACTION_SEND_KEY_PRESS -> {
if (keyCode != KeyEvent.KEYCODE_UNKNOWN) {
sendKeyPress(keyCode)
}
}
}
}
private fun isAllowedSender(
context: Context,
intent: Intent,
senderPackage: String?,
senderUid: Int,
): Boolean {
if (isTrustedUiPackage(senderPackage)) {
return true
}
val caller = getParcelableExtraCompat(intent, EXTRA_PRIVILEGED_CALLER, PendingIntent::class.java)
if (isTrustedUiPackage(caller?.creatorPackage)) {
return true
}
if (caller?.creatorUid == Process.SYSTEM_UID) {
return true
}
val expectedToken = token ?: if (context.packageName != ANDROID_PACKAGE) {
readToken()?.also { token = it }
} else {
null
}
if (expectedToken != null && expectedToken == intent.getStringExtra(EXTRA_PRIVILEGED_ACTION_TOKEN)) {
return true
}
if (senderUid == Process.SYSTEM_UID) {
return true
}
return context.packageManager.getPackagesForUid(senderUid)
?.any(::isTrustedUiPackage) == true
}
private fun isTrustedUiPackage(packageName: String?): Boolean {
return packageName == SYSTEMUI_PACKAGE ||
packageName == SAMSUNG_LAUNCHER_PACKAGE ||
packageName in AOSP_LAUNCHER_PACKAGES
}
private fun readToken(): String? {
return settingsPreferencesProvider()?.getString(NavButtonSettingsStore.KEY_PRIVILEGED_ACTION_TOKEN, null)
}
private fun caller(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 BroadcastReceiver.sentFromUidCompat(): Int {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
sentFromUid
} else {
Process.INVALID_UID
}
}
private fun BroadcastReceiver.sentFromPackageCompat(): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
sentFromPackage
} else {
null
}
}
private fun <T> getParcelableExtraCompat(intent: Intent, name: String, type: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(name, type)
} else {
@Suppress("DEPRECATION")
type.cast(intent.getParcelableExtra(name))
}
}
}
@@ -0,0 +1,223 @@
package se.ajpanton.navbuttons
import android.graphics.drawable.Drawable
import java.lang.reflect.Field
import java.lang.reflect.Method
internal fun findClass(className: String, classLoader: ClassLoader?): Class<*> {
return Class.forName(className, false, classLoader)
}
internal fun findClassIfExists(className: String, classLoader: ClassLoader?): Class<*>? {
return try {
findClass(className, classLoader)
} catch (_: Throwable) {
null
}
}
internal fun findFirstClass(classNames: List<String>, classLoader: ClassLoader?): Class<*> {
classNames.forEach { className ->
findClassIfExists(className, classLoader)?.let { return it }
}
throw ClassNotFoundException(classNames.joinToString())
}
internal fun findFirstClassIfExists(
classNames: List<String>,
classLoader: ClassLoader?,
): Class<*>? {
return classNames.firstNotNullOfOrNull { className ->
findClassIfExists(className, classLoader)
}
}
internal fun findMethodIfExists(
clazz: Class<*>,
methodName: String,
vararg parameterTypes: Class<*>,
): Method? {
return try {
findMethod(clazz, methodName, *parameterTypes)
} catch (_: Throwable) {
null
}
}
internal fun findDeclaredMethodsNamed(clazz: Class<*>, methodName: String): List<Method> {
return clazz.declaredMethods
.filter { it.name == methodName }
.onEach { it.isAccessible = true }
}
internal fun findMethodsNamed(clazz: Class<*>, methodName: String): List<Method> {
val methodsBySignature = linkedMapOf<String, Method>()
var current: Class<*>? = clazz
while (current != null) {
current.declaredMethods
.filter { it.name == methodName }
.forEach { method ->
method.isAccessible = true
methodsBySignature.putIfAbsent(method.toGenericString(), method)
}
current = current.superclass
}
return methodsBySignature.values.toList()
}
@Throws(NoSuchMethodException::class)
internal fun findMethod(
clazz: Class<*>,
methodName: String,
vararg parameterTypes: Class<*>,
): Method {
var current: Class<*>? = clazz
while (current != null) {
try {
return current.getDeclaredMethod(methodName, *parameterTypes).apply {
isAccessible = true
}
} catch (_: NoSuchMethodException) {
current = current.superclass
}
}
throw NoSuchMethodException("${clazz.name}#$methodName")
}
@Throws(NoSuchFieldException::class)
internal fun findField(clazz: Class<*>, fieldName: String): Field {
var current: Class<*>? = clazz
while (current != null) {
try {
return current.getDeclaredField(fieldName).apply {
isAccessible = true
}
} catch (_: NoSuchFieldException) {
current = current.superclass
}
}
throw NoSuchFieldException("${clazz.name}#$fieldName")
}
@Throws(ReflectiveOperationException::class)
internal fun callStaticMethod(clazz: Class<*>, methodName: String): Any? {
return findMethod(clazz, methodName).invoke(null)
}
@Throws(ReflectiveOperationException::class)
internal fun callMethod(target: Any, methodName: String): Any? {
return callMethod(target, methodName, emptyArray())
}
@Throws(ReflectiveOperationException::class)
internal fun callMethod(
target: Any,
methodName: String,
parameterTypes: Array<Class<*>>,
vararg args: Any?,
): Any? {
return findMethod(target.javaClass, methodName, *parameterTypes).invoke(target, *args)
}
internal fun callIntIntMethodIfExists(
target: Any,
methodNames: List<String>,
first: Int,
second: Int,
): Boolean {
methodNames.forEach { methodName ->
optionalMethod(target, methodName) { types ->
types.size >= 2 && types[0] == Int::class.javaPrimitiveType && types[1] == Int::class.javaPrimitiveType
}?.let { method -> return invokeOptional(method, target, first, second) }
}
return false
}
internal fun callIntMethodIfExists(target: Any, methodName: String, value: Int): Boolean {
return invokeOptional(target, methodName, value) { types ->
types.size == 1 && types[0] == Int::class.javaPrimitiveType
}
}
internal fun callIntIntMethodIfExists(target: Any, methodName: String, first: Int, second: Int): Boolean {
return invokeOptional(target, methodName, first, second) { types ->
types.size == 2 && types[0] == Int::class.javaPrimitiveType && types[1] == Int::class.javaPrimitiveType
}
}
internal fun callFloatMethodIfExists(target: Any, methodName: String, value: Float): Boolean {
return invokeOptional(target, methodName, value) { types ->
types.size == 1 && types[0] == Float::class.javaPrimitiveType
}
}
internal fun callSetButtonImageIfExists(
target: Any,
id: Int,
lightDrawable: Drawable,
darkDrawable: Drawable,
): Boolean {
return invokeOptional(target, "setButtonImage", id, lightDrawable, darkDrawable) { types ->
types.size == 3 &&
types[0] == Int::class.javaPrimitiveType &&
Drawable::class.java.isAssignableFrom(types[1]) &&
Drawable::class.java.isAssignableFrom(types[2])
}
}
internal fun callMethodIfExists(target: Any, methodName: String): Any? {
return optionalMethod(target, methodName) { it.isEmpty() }
?.let { method ->
runCatching { method.invoke(target) }.getOrNull()
}
}
internal fun callBooleanMethodIfExists(target: Any, methodName: String, value: Boolean): Boolean {
return invokeOptional(target, methodName, value) { types ->
types.size == 1 && types[0] == Boolean::class.javaPrimitiveType
}
}
private fun optionalMethod(
target: Any,
methodName: String,
accepts: (Array<Class<*>>) -> Boolean,
): Method? {
return findMethodsNamed(target.javaClass, methodName).firstOrNull { accepts(it.parameterTypes) }
}
private fun invokeOptional(
target: Any,
methodName: String,
vararg args: Any?,
accepts: (Array<Class<*>>) -> Boolean,
): Boolean {
val method = optionalMethod(target, methodName, accepts) ?: return false
return invokeOptional(method, target, *args)
}
private fun invokeOptional(method: Method, target: Any, vararg args: Any?): Boolean {
return runCatching { method.invoke(target, *args) }.isSuccess
}
internal fun getFieldValue(target: Any?, fieldName: String): Any? {
if (target == null) {
return null
}
return try {
findField(target.javaClass, fieldName).get(target)
} catch (_: Throwable) {
null
}
}
internal fun getIntField(target: Any?, fieldName: String): Int {
val value = getFieldValue(target, fieldName)
return value as? Int ?: Int.MIN_VALUE
}
internal fun setFieldIfExists(target: Any, fieldName: String, value: Any?) {
runCatching {
findField(target.javaClass, fieldName).set(target, value)
}
}
@@ -0,0 +1,444 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.os.Handler
import android.os.SystemClock
import android.view.HapticFeedbackConstants
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
internal class SamsungHoneyspaceHooks(
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
private val deoptimizeMethod: (Method?) -> Unit,
private val prepareLongPressHook: (Method) -> Unit,
private val configProvider: (NavButtonId) -> NavButtonConfig,
private val sideArrowsEnabled: () -> Boolean,
private val sideArrowLongPresses: SideArrowLongPressController,
private val registerSettingsReceiver: () -> Unit,
private val contextProvider: () -> Context?,
private val contextUpdater: (Context) -> Unit,
private val handlerProvider: () -> Handler?,
private val performAction: (NavButtonAction) -> Unit,
private val sendKeyPress: (Int) -> Unit,
private val log: (String) -> Unit,
) {
private val longPressTriggered = WeakHashMap<Any, Boolean>()
private val customLongPressRunnables = WeakHashMap<Any, Runnable>()
private val trackedNavContainers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private val nativeSideArrowStates = WeakHashMap<Any, Boolean>()
private val homeStockLongPressGuard = HomeStockLongPressGuard(
configProvider = configProvider,
longPressTriggered = { longPressTriggered[it] == true },
sendCanceledKeyUp = ::sendCanceledKeyUp,
)
fun install(classLoader: ClassLoader?): Boolean {
val navButtonClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_VIEW, classLoader) ?: run {
log("Launcher class not found: $HONEYSPACE_NAV_BUTTON_VIEW")
return false
}
val onTouchEventMethod = findMethodIfExists(
navButtonClass,
"onTouchEvent",
MotionEvent::class.java,
) ?: run {
log("Launcher onTouchEvent() method not found.")
return false
}
val runnableClass = findClassIfExists(HONEYSPACE_LONG_PRESS_RUNNABLE, classLoader) ?: run {
log("Launcher long-press runnable not found: $HONEYSPACE_LONG_PRESS_RUNNABLE")
return false
}
val runMethod = findMethodIfExists(runnableClass, "run") ?: run {
log("Launcher run() method not found.")
return false
}
deoptimizeMethod(onTouchEventMethod)
prepareLongPressHook(runMethod)
hookSideArrowLayout(classLoader)
hookMethod(onTouchEventMethod) { chain ->
val navButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
val motionEvent = chain.args[0] as? MotionEvent ?: return@hookMethod chain.proceed()
val keyCode = callMethod(navButtonView, "getKeyCode") as? Int ?: Int.MIN_VALUE
if (isSideArrowKeyCode(keyCode)) {
updateContextFromView(navButtonView)
registerSettingsReceiver()
trackNavContainerFrom(navButtonView as? View)
return@hookMethod sideArrowLongPresses.handleNativeTouch(navButtonView, keyCode, motionEvent) {
chain.proceed()
}
}
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
updateContextFromView(navButtonView)
registerSettingsReceiver()
trackNavContainerFrom(navButtonView as? View)
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> {
longPressTriggered[navButtonView] = false
cancelCustomLongPress(navButtonView)
homeStockLongPressGuard.cancel(navButtonView)
homeStockLongPressGuard.clear(navButtonView)
val result = chain.proceed()
val config = configProvider(buttonId)
if (config.longPressAction != NavButtonAction.STOCK) {
cancelHoneyspaceLongPress(navButtonView, runnableClass)
scheduleCustomLongPress(
navButtonView,
config.longPressAction,
config.longPressDurationMs,
)
}
homeStockLongPressGuard.schedule(navButtonView, buttonId)
result
}
MotionEvent.ACTION_UP -> {
val triggered = longPressTriggered[navButtonView] == true
cancelCustomLongPress(navButtonView)
if (triggered) {
return@hookMethod proceedWithCanceledAction(motionEvent) {
chain.proceed()
}.also {
homeStockLongPressGuard.cancel(navButtonView)
longPressTriggered.remove(navButtonView)
homeStockLongPressGuard.clear(navButtonView)
}
}
val pressAction = configProvider(buttonId).pressAction
val stockHomePressCanceled =
buttonId == NavButtonId.HOME && homeStockLongPressGuard.isCanceled(navButtonView)
val shouldOverridePress =
pressAction != NavButtonAction.STOCK &&
((navButtonView as? View)?.isPressed == true || stockHomePressCanceled) &&
!triggered
if (shouldOverridePress) {
markLongClicked(navButtonView)
}
val result = chain.proceed()
if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) {
dispatchHomeTap(navButtonView)
}
if (shouldOverridePress) {
updateContextFromView(navButtonView)
performAction(pressAction)
}
homeStockLongPressGuard.cancel(navButtonView)
longPressTriggered.remove(navButtonView)
homeStockLongPressGuard.clear(navButtonView)
result
}
MotionEvent.ACTION_CANCEL -> {
cancelCustomLongPress(navButtonView)
homeStockLongPressGuard.cancel(navButtonView)
longPressTriggered.remove(navButtonView)
homeStockLongPressGuard.clear(navButtonView)
chain.proceed()
}
else -> chain.proceed()
}
}
hookMethod(runMethod) { chain ->
val caseId = getIntField(chain.thisObject, "b")
if (caseId != HONEYSPACE_LONG_PRESS_CASE) {
return@hookMethod chain.proceed()
}
val target = getFieldValue(chain.thisObject, "c")
if (!navButtonClass.isInstance(target)) {
return@hookMethod chain.proceed()
}
val navButtonView = target ?: return@hookMethod chain.proceed()
val keyCode = callMethod(navButtonView, "getKeyCode") as Int
if (isSideArrowKeyCode(keyCode)) {
return@hookMethod null
}
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
val longPressAction = configProvider(buttonId).longPressAction
longPressTriggered[navButtonView] = true
if (longPressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
updateContextFromView(navButtonView)
markLongClicked(navButtonView)
homeStockLongPressGuard.cancel(navButtonView)
performAction(longPressAction)
null
}
return true
}
private fun scheduleCustomLongPress(
navButtonView: Any,
action: NavButtonAction,
durationMs: Int,
) {
val view = navButtonView as? View ?: return
val runnable = Runnable {
updateContextFromView(navButtonView)
longPressTriggered[navButtonView] = true
markLongClicked(navButtonView)
homeStockLongPressGuard.cancel(navButtonView)
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
performAction(action)
}
customLongPressRunnables[navButtonView] = runnable
view.postDelayed(runnable, durationMs.toLong())
}
private fun cancelCustomLongPress(navButtonView: Any) {
customLongPressRunnables.remove(navButtonView)?.let { runnable ->
(navButtonView as? View)?.removeCallbacks(runnable)
}
}
fun refreshSideArrowLayouts(reason: String) {
val containers = trackedNavContainers.toList()
containers.forEach { container ->
refreshSideArrowLayout(container as? ViewGroup ?: return@forEach, reason)
scheduleDelayedSideArrowRefresh(container, reason)
}
}
private fun hookSideArrowLayout(classLoader: ClassLoader?) {
val containerClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_CONTAINER, classLoader) ?: run {
log("Honeyspace side arrows container class not found: $HONEYSPACE_NAV_BUTTON_CONTAINER")
return
}
listOf(
"onAttachedToWindow",
"onLayout",
"onVisibilityChanged",
"onWindowVisibilityChanged",
"drawableStateChanged",
).forEach { methodName ->
findDeclaredMethodsNamed(containerClass, methodName).forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
val result = chain.proceed()
val container = chain.thisObject as? ViewGroup
?: return@hookMethod result
updateContextFromView(container)
registerSettingsReceiver()
trackNavContainer(container)
refreshSideArrowLayout(container, "after ${method.name}")
scheduleDelayedSideArrowRefresh(container, "after ${method.name}")
result
}
}
}
hookNativeSideArrowRefresh(containerClass)
}
private fun hookNativeSideArrowRefresh(containerClass: Class<*>) {
listOf("g", "e", "d").forEach { methodName ->
findMethodsNamed(containerClass, methodName).forEach { method ->
val parameterTypes = method.parameterTypes
val supported =
(methodName == "g" && parameterTypes.size == 1 && parameterTypes[0] == Boolean::class.javaPrimitiveType) ||
(methodName == "e" && parameterTypes.isEmpty()) ||
(methodName == "d" && parameterTypes.size == 1 && parameterTypes[0] == Float::class.javaPrimitiveType)
if (!supported) {
return@forEach
}
deoptimizeMethod(method)
hookMethod(method) { chain ->
val container = chain.thisObject as? ViewGroup
val result = chain.proceed()
if (container != null && sideArrowsEnabled()) {
restoreHoneyspaceSideArrowDrawables(container)
}
result
}
}
}
}
private fun trackNavContainerFrom(view: View?) {
var current = view?.parent as? View
repeat(HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH) {
val target = current ?: return
if (isHoneyspaceNavButtonContainer(target)) {
trackNavContainer(target)
val container = target as ViewGroup
refreshSideArrowLayout(container, "from touch")
scheduleDelayedSideArrowRefresh(container, "from touch")
return
}
current = target.parent as? View
}
}
private fun trackNavContainer(container: View) {
if (trackedNavContainers.add(container)) {
container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
refreshSideArrowLayout(view as ViewGroup, "layout listener")
}
}
}
private fun scheduleDelayedSideArrowRefresh(container: ViewGroup, reason: String) {
listOf(
HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS,
HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS,
).forEach { delayMs ->
handlerProvider()?.postDelayed(
{ refreshSideArrowLayout(container, "$reason delayed ${delayMs}ms") },
delayMs,
)
}
}
private fun refreshSideArrowLayout(container: ViewGroup, reason: String) {
try {
applyNativeSideArrows(container, reason)
} catch (error: Throwable) {
log("Honeyspace side arrows refresh failed: ${error.javaClass.simpleName}")
}
}
@Suppress("UNCHECKED_CAST")
private fun applyNativeSideArrows(container: ViewGroup, reason: String) {
val buttonMap = getFieldValue(container, "i") as? MutableMap<Any, Any> ?: run {
log("Honeyspace side arrows native apply skipped: button map missing reason=$reason")
return
}
val enabled = sideArrowsEnabled()
val targetSlots = honeyspaceSideArrowSlots(container, enabled)
val currentSlots = getFieldValue(container, "u") as? BooleanArray
val alreadyApplied = nativeSideArrowStates[container] == enabled &&
if (enabled) {
buttonMap[HONEYSPACE_LEFT_ARROW_BUTTON_TYPE] != null &&
buttonMap[HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE] != null &&
getIntField(container, "v") == 2 &&
currentSlots?.contentEquals(targetSlots) == true &&
honeyspaceSideArrowDrawablesPresent(buttonMap)
} else {
getIntField(container, "v") == 0 &&
currentSlots?.contentEquals(targetSlots) == true
}
if (alreadyApplied) {
return
}
HONEYSPACE_EXTRA_BUTTON_TYPES.forEach { buttonMap.remove(it) }
if (enabled) {
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
}
setFieldIfExists(container, "u", targetSlots)
setFieldIfExists(container, "v", 2)
} else {
setFieldIfExists(container, "u", targetSlots)
setFieldIfExists(container, "v", 0)
}
nativeSideArrowStates[container] = enabled
callBooleanMethodIfExists(container, "g", false)
restoreHoneyspaceSideArrowDrawables(container)
}
private fun markLongClicked(navButtonView: Any) {
try {
callMethod(
navButtonView,
"setLongClicked",
arrayOf(Boolean::class.javaPrimitiveType!!),
true,
)
} catch (error: Throwable) {
log("setLongClicked failed: ${error.javaClass.simpleName}")
}
}
private fun updateContextFromView(value: Any) {
val currentContext = (value as? View)?.context ?: return
contextUpdater(currentContext.applicationContext ?: currentContext)
}
private fun sendCanceledKeyUp(navButtonView: Any) {
if (callIntIntMethodIfExists(
navButtonView,
listOf("sendEvent", "sendKeyEvent"),
KeyEvent.ACTION_UP,
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
)
) {
return
}
try {
val inputManager = inputManagerForInjection(contextProvider()) ?: run {
log("InputManager unavailable for Honeyspace Home cancel.")
return
}
val now = SystemClock.uptimeMillis()
injectKeyEvent(
inputManager,
KeyEvent(
now,
now,
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_HOME,
0,
0,
KeyEvent.KEYCODE_UNKNOWN,
0,
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
),
)
} catch (error: Throwable) {
log("Honeyspace Home cancel failed: ${error.javaClass.simpleName}")
}
}
private fun dispatchHomeTap(navButtonView: Any) {
if (callIntIntMethodIfExists(navButtonView, listOf("sendEvent", "sendKeyEvent"), KeyEvent.ACTION_DOWN, 0) &&
callIntIntMethodIfExists(navButtonView, listOf("sendEvent", "sendKeyEvent"), KeyEvent.ACTION_UP, 0)
) {
return
}
sendKeyPress(KeyEvent.KEYCODE_HOME)
}
private inline fun proceedWithCanceledAction(
motionEvent: MotionEvent,
proceed: () -> Any?,
): Any? {
val originalAction = motionEvent.action
motionEvent.action = MotionEvent.ACTION_CANCEL
return try {
proceed()
} finally {
motionEvent.action = originalAction
}
}
}
@@ -0,0 +1,169 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
internal fun honeyspaceSideArrowSlots(container: ViewGroup, enabled: Boolean): BooleanArray {
if (!enabled) {
return BooleanArray(5)
}
val defaultOrder = getFieldValue(container, "k") as? Boolean ?: true
return if (defaultOrder) {
// Stock order consumes recent, home, back(+back_alt); insert arrows around home.
booleanArrayOf(false, true, false, true, false, false)
} else {
// Reversed order consumes back_alt, back(+back_alt), home, recent.
booleanArrayOf(false, false, true, false, true, false)
}
}
internal fun honeyspaceSideArrowDrawablesPresent(buttonMap: Map<Any, Any>): Boolean {
return HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow ->
(buttonMap[arrow.type] as? ImageView)?.drawable != null
}
}
internal fun restoreHoneyspaceSideArrowDrawables(container: ViewGroup) {
val buttonMap = getFieldValue(container, "i") as? Map<*, *> ?: return
val darkIntensity = getFieldValue(container, "w") as? Float ?: 0f
HONEYSPACE_SIDE_ARROW_BUTTONS.forEach { arrow ->
restoreHoneyspaceSideArrowDrawable(
buttonMap[arrow.type] as? ImageView,
arrow.direction,
darkIntensity,
)
}
}
internal fun isHoneyspaceNavButtonContainer(view: View): Boolean {
return view.javaClass.name == HONEYSPACE_NAV_BUTTON_CONTAINER ||
view.javaClass.simpleName == "NavigationBarButtonsLayout" ||
honeyspaceViewIdName(view) == "navbar_button_container"
}
internal fun cancelHoneyspaceLongPress(navButtonView: Any, runnableClass: Class<*>) {
val view = navButtonView as? View ?: return
val longPressRunnable = findHoneyspaceLongPressRunnable(navButtonView, runnableClass) ?: return
view.removeCallbacks(longPressRunnable)
}
internal fun createHoneyspaceExtraButton(
container: ViewGroup,
buttonType: Int,
keyCode: Int,
direction: SideArrowDirection,
): View? {
val layoutId = container.context.resources.getIdentifier(
"layout_taskbar_nav_button",
"layout",
container.context.packageName,
).takeIf { it != 0 } ?: return null
val button = LayoutInflater.from(container.context)
.inflate(layoutId, container, false) as? View ?: return null
val drawable = createHoneyspaceSideArrowLayerDrawable(container.context, direction)
val setupMethod = findMethodsNamed(button.javaClass, "c").firstOrNull { method ->
val types = method.parameterTypes
types.size == 9 &&
types[0] == Int::class.javaPrimitiveType &&
Drawable::class.java.isAssignableFrom(types[1]) &&
types[2] == Int::class.javaPrimitiveType &&
types[3] == Int::class.javaPrimitiveType
} ?: return null
val args = arrayOf(
buttonType,
drawable,
keyCode,
0,
getFieldValue(container, "c"),
getFieldValue(container, "d"),
getFieldValue(container, "e"),
getFieldValue(container, "f"),
getFieldValue(container, "h"),
)
return if (runCatching { setupMethod.invoke(button, *args) }.isSuccess) {
button
} else {
null
}
}
private fun findHoneyspaceLongPressRunnable(
navButtonView: Any,
runnableClass: Class<*>,
): Runnable? {
var current: Class<*>? = navButtonView.javaClass
while (current != null) {
current.declaredFields.forEach { field ->
try {
field.isAccessible = true
val value = field.get(navButtonView) ?: return@forEach
if (!runnableClass.isInstance(value)) {
return@forEach
}
if (getIntField(value, "b") != HONEYSPACE_LONG_PRESS_CASE) {
return@forEach
}
if (getFieldValue(value, "c") !== navButtonView) {
return@forEach
}
return value as Runnable
} catch (_: Throwable) {
return@forEach
}
}
current = current.superclass
}
return null
}
private fun honeyspaceViewIdName(view: View): String {
val id = view.id
if (id == View.NO_ID) {
return "no_id"
}
return runCatching { view.resources.getResourceEntryName(id) }.getOrElse { id.toString() }
}
private fun restoreHoneyspaceSideArrowDrawable(
image: ImageView?,
direction: SideArrowDirection,
darkIntensity: Float,
) {
image ?: return
if (image.drawable != null) {
return
}
val drawable = createHoneyspaceSideArrowLayerDrawable(image.context, direction)
applyHoneyspaceDarkIntensity(drawable, darkIntensity)
image.setImageDrawable(drawable)
}
private fun applyHoneyspaceDarkIntensity(drawable: Drawable, darkIntensity: Float) {
val layerDrawable = drawable as? LayerDrawable ?: return
if (layerDrawable.numberOfLayers < 2) {
return
}
val clamped = darkIntensity.coerceIn(0f, 1f)
layerDrawable.getDrawable(0).alpha = ((1f - clamped) * 255f).toInt()
layerDrawable.getDrawable(1).alpha = (clamped * 255f).toInt()
layerDrawable.invalidateSelf()
}
private fun createHoneyspaceSideArrowLayerDrawable(context: Context, direction: SideArrowDirection): Drawable {
return LayerDrawable(
arrayOf(
sideArrowBitmapDrawable(context, direction, Color.WHITE),
sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)),
),
)
}
@@ -0,0 +1,272 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.os.Build
import android.os.Handler
import android.view.View
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
internal class SamsungSystemUiHooks(
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
private val deoptimizeMethod: (Method?) -> Unit,
private val sideArrowsEnabled: () -> Boolean,
private val registerSettingsReceiver: () -> Unit,
private val contextProvider: () -> Context?,
private val contextUpdater: (Context) -> Unit,
private val handlerProvider: () -> Handler?,
private val log: (String) -> Unit,
) {
private val trackedNavigationBarViews = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private val startupRefreshScheduled = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
private val sideArrowLayoutKinds = WeakHashMap<Any, SamsungSideArrowLayoutKind>()
private var foldedSideArrowProviderHookInstalled = false
private var navigationBarViewHookInstalled = false
fun installFoldedSideArrows(classLoader: ClassLoader?) {
if (!Build.MANUFACTURER.equals("samsung", ignoreCase = true)) {
return
}
hookFoldedLayoutProvider(classLoader)
hookNavigationBarView(classLoader)
registerSettingsReceiver()
}
fun trackNavigationBarView(startView: Any) {
var current: View? = startView as? View ?: return
repeat(SAMSUNG_NAV_VIEW_PARENT_DEPTH) {
val target = current ?: return
if (target.javaClass.name == SAMSUNG_NAVIGATION_BAR_VIEW) {
trackNavigationBar(target)
return
}
current = target.parent as? View
}
}
fun refreshNavigationBars() {
val views = trackedNavigationBarViews.toList()
if (views.isEmpty()) {
return
}
views.forEach { navBarView ->
try {
refreshNavigationBar(navBarView)
if (sideArrowsEnabled()) {
handlerProvider()?.postDelayed(
{ refreshNavigationBar(navBarView) },
SIDE_ARROW_REFRESH_DELAY_MS,
)
}
} catch (error: Throwable) {
log("Side arrows reinflate failed: ${error.javaClass.simpleName}")
}
}
}
private fun hookFoldedLayoutProvider(classLoader: ClassLoader?) {
if (foldedSideArrowProviderHookInstalled) {
return
}
foldedSideArrowProviderHookInstalled = true
listOf(SAMSUNG_PHONE_LAYOUT_PROVIDER, SAMSUNG_TABLET_LAYOUT_PROVIDER)
.forEach { className ->
val providerClass = findClassIfExists(className, classLoader) ?: run {
log("Side arrows provider class not found: $className")
return@forEach
}
findMethodsNamed(providerClass, "getLayout")
.filter { method -> method.returnType == String::class.java }
.forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
val result = chain.proceed()
val layout = result as? String ?: return@hookMethod result
if (!sideArrowsEnabled()) {
return@hookMethod layout
}
val sideArrowLayout = addSamsungSideArrows(
layout,
findContext(chain.thisObject) ?: contextProvider(),
) { index ->
log("Folded side arrows custom key id not found: nav_custom_key_$index")
}
if (sideArrowLayout.layout != layout) {
chain.thisObject?.let { provider ->
sideArrowLayoutKinds[provider] = sideArrowLayout.kind
}
}
sideArrowLayout.layout
}
}
hookFoldedLayoutProviderSizing(providerClass)
}
}
private fun hookFoldedLayoutProviderSizing(providerClass: Class<*>) {
findMethodsNamed(providerClass, "getButtonWidth")
.filter(::isSamsungPointBooleanIntMethod)
.forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
samsungSideArrowSize(
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
PHONE_BUTTON_RATIO, TABLET_BUTTON_RATIO,
) ?: chain.proceed()
}
}
findMethodsNamed(providerClass, "getButtonDistanceSize")
.filter(::isSamsungPointBooleanIntMethod)
.forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
samsungSideArrowSize(
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
SIDE_ARROW_GAP_RATIO, SIDE_ARROW_GAP_RATIO,
) ?: chain.proceed()
}
}
findMethodsNamed(providerClass, "getSpaceWidth")
.filter { method ->
method.returnType == Int::class.javaPrimitiveType &&
method.parameterTypes.firstOrNull() == android.graphics.Point::class.java
}
.forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
samsungSideArrowSize(
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
PHONE_SPACE_RATIO, TABLET_SPACE_RATIO,
) ?: chain.proceed()
}
}
findMethodsNamed(providerClass, "getSpaceSidePadding")
.filter { method ->
method.returnType == Int::class.javaPrimitiveType &&
method.parameterTypes.firstOrNull() == android.graphics.Point::class.java
}
.forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
samsungSideArrowSize(
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
0.0, TABLET_SPACE_SIDE_PADDING_RATIO,
) ?: chain.proceed()
}
}
}
private fun hookNavigationBarView(classLoader: ClassLoader?) {
if (navigationBarViewHookInstalled) {
return
}
navigationBarViewHookInstalled = true
val navBarViewClass = findClassIfExists(SAMSUNG_NAVIGATION_BAR_VIEW, classLoader) ?: run {
log("Folded side arrows nav view class not found: $SAMSUNG_NAVIGATION_BAR_VIEW")
return
}
listOf(
"onAttachedToWindow",
"onFinishInflate",
"reInflateNavBarLayout",
"updateLayoutProviderView",
).forEach { methodName ->
findDeclaredMethodsNamed(navBarViewClass, methodName).forEach { method ->
deoptimizeMethod(method)
hookMethod(method) { chain ->
val navBarView = chain.thisObject
navBarView?.let { prepareNavigationBar(it) }
val result = chain.proceed()
navBarView?.let { prepareNavigationBar(it) }
result
}
}
}
}
private fun prepareNavigationBar(navBarView: Any) {
updateContextFromView(navBarView)
registerSettingsReceiver()
trackNavigationBar(navBarView)
if (sideArrowsEnabled()) {
handlerProvider()?.postDelayed(
{ configureFoldedSideArrowButtons(navBarView) },
SIDE_ARROW_REFRESH_DELAY_MS,
)
scheduleStartupRefresh(navBarView)
}
}
private fun trackNavigationBar(navBarView: Any) {
trackedNavigationBarViews.add(navBarView)
configureFoldedSideArrowButtons(navBarView)
}
private fun scheduleStartupRefresh(navBarView: Any) {
if (!startupRefreshScheduled.add(navBarView)) {
return
}
handlerProvider()?.postDelayed(
{ refreshNavigationBar(navBarView) },
SIDE_ARROW_REFRESH_DELAY_MS * 4,
)
}
private fun refreshNavigationBar(navBarView: Any) {
try {
configureFoldedSideArrowButtons(navBarView)
callMethodIfExists(navBarView, "updateLayoutProviderView")
callMethod(navBarView, "reInflateNavBarLayout", emptyArray<Class<*>>())
configureFoldedSideArrowButtons(navBarView)
} catch (error: Throwable) {
log("Side arrows nav refresh failed: ${error.javaClass.simpleName}")
}
}
private fun configureFoldedSideArrowButtons(navBarView: Any) {
if (sideArrowsEnabled()) {
configureSamsungSideArrowDispatcherButtons(
navBarView,
navBarView,
contextProvider(),
::findContext,
)
}
}
private fun updateContextFromView(value: Any) {
val viewContext = (value as? View)?.context ?: return
contextUpdater(viewContext.applicationContext ?: viewContext)
}
private fun findContext(target: Any?): Context? {
var current: Class<*>? = target?.javaClass ?: return null
while (current != null) {
current.declaredFields.forEach { field ->
try {
field.isAccessible = true
val value = field.get(target)
if (value is Context) {
return value.applicationContext ?: value
}
} catch (_: Throwable) {
return@forEach
}
}
current = current.superclass
}
return null
}
}
@@ -0,0 +1,302 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.graphics.Color
import android.graphics.Point
import android.graphics.drawable.Drawable
import android.util.SparseArray
import android.view.View
import java.lang.reflect.Method
import java.util.Collections
import java.util.WeakHashMap
internal enum class SamsungSideArrowLayoutKind {
PHONE,
TABLET,
}
internal data class SamsungSideArrowLayout(
val layout: String,
val kind: SamsungSideArrowLayoutKind,
)
internal fun addSamsungSideArrows(
layout: String,
sourceContext: Context?,
onMissingCustomKeyId: (Int) -> Unit,
): SamsungSideArrowLayout {
val leftKey = navCustomKeyToken(sourceContext, SideArrowDirection.LEFT.keyCode, 1, onMissingCustomKeyId)
val rightKey = navCustomKeyToken(sourceContext, SideArrowDirection.RIGHT.keyCode, 2, onMissingCustomKeyId)
return when (layout) {
NAV_LAYOUT_PHONE_DEFAULT ->
SamsungSideArrowLayout("left,recent;$leftKey,home,$rightKey;back,right", SamsungSideArrowLayoutKind.PHONE)
NAV_LAYOUT_PHONE_REVERSED ->
SamsungSideArrowLayout("left,back;$leftKey,home,$rightKey;recent,right", SamsungSideArrowLayoutKind.PHONE)
NAV_LAYOUT_TABLET_DEFAULT ->
SamsungSideArrowLayout("left;recent,$leftKey,home,$rightKey,back;right", SamsungSideArrowLayoutKind.TABLET)
NAV_LAYOUT_TABLET_REVERSED ->
SamsungSideArrowLayout("left;back,$leftKey,home,$rightKey,recent;right", SamsungSideArrowLayoutKind.TABLET)
NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT ->
SamsungSideArrowLayout("left;back,$leftKey,home,$rightKey,recent;r", SamsungSideArrowLayoutKind.TABLET)
NAV_LAYOUT_WINNER_DEFAULT ->
SamsungSideArrowLayout("left,back,$leftKey,home,$rightKey,recent;;right", SamsungSideArrowLayoutKind.TABLET)
NAV_LAYOUT_WINNER_REVERSED ->
SamsungSideArrowLayout("left,recent,$leftKey,home,$rightKey,back;;right", SamsungSideArrowLayoutKind.TABLET)
NAV_LAYOUT_UNFOLDED_DEFAULT ->
SamsungSideArrowLayout("left;;extrakey,back,$leftKey,home,$rightKey,recent,right", SamsungSideArrowLayoutKind.TABLET)
NAV_LAYOUT_UNFOLDED_REVERSED ->
SamsungSideArrowLayout("left;;extrakey,recent,$leftKey,home,$rightKey,back,right", SamsungSideArrowLayoutKind.TABLET)
else -> addSamsungSideArrowsAroundHome(layout, leftKey, rightKey)
}
}
private fun addSamsungSideArrowsAroundHome(
layout: String,
leftKey: String,
rightKey: String,
): SamsungSideArrowLayout {
if (layout.contains("navkey(${SideArrowDirection.LEFT.keyCode}:") ||
layout.contains("navkey(${SideArrowDirection.RIGHT.keyCode}:")
) {
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
}
val groups = layout.split(";").toMutableList()
val homeGroupIndex = groups.indexOfFirst { group ->
group.split(",").any { it == "home" }
}
if (homeGroupIndex < 0) {
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
}
val tokens = groups[homeGroupIndex]
.split(",")
.filter { it.isNotBlank() && it != "gap" }
.toMutableList()
val homeIndex = tokens.indexOf("home")
if (homeIndex < 0) {
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
}
tokens.add(homeIndex, leftKey)
tokens.add(homeIndex + 2, rightKey)
groups[homeGroupIndex] = tokens.joinToString(",")
val kind = if (groups.size == 3 && groups[1] == "$leftKey,home,$rightKey") {
SamsungSideArrowLayoutKind.PHONE
} else {
SamsungSideArrowLayoutKind.TABLET
}
return SamsungSideArrowLayout(groups.joinToString(";"), kind)
}
internal fun navCustomKeyId(sourceContext: Context?, index: Int): Int {
return sourceContext?.resources?.getIdentifier(
"nav_custom_key_$index",
"id",
sourceContext.packageName,
) ?: 0
}
internal fun isSamsungPointBooleanIntMethod(method: Method): Boolean {
val parameterTypes = method.parameterTypes
return method.returnType == Int::class.javaPrimitiveType &&
parameterTypes.size >= 2 &&
parameterTypes[0] == Point::class.java &&
parameterTypes[1] == Boolean::class.javaPrimitiveType
}
internal fun samsungSideArrowSize(
provider: Any?,
args: List<Any?>,
layoutKinds: Map<Any, SamsungSideArrowLayoutKind>,
enabled: Boolean,
phoneRatio: Double,
tabletRatio: Double,
): Int? {
if (!enabled) {
return null
}
val displaySize = args.firstOrNull() as? Point ?: return null
val kind = provider?.let { layoutKinds[it] } ?: SamsungSideArrowLayoutKind.PHONE
val ratio = if (kind == SamsungSideArrowLayoutKind.TABLET) tabletRatio else phoneRatio
return (minOf(displaySize.x, displaySize.y) * ratio * SIDE_ARROW_REDUCE_RATIO).toInt()
}
internal fun configureSamsungSideArrowDispatcherButtons(
source: Any,
darkIntensitySource: Any?,
fallbackContext: Context?,
contextFromObject: (Any) -> Context?,
): Boolean {
val navContext = samsungNavigationBarContext(source, fallbackContext, contextFromObject) ?: return false
val dispatcherProxy = findSamsungButtonDispatcherProxy(source)
if (dispatcherProxy != null) {
configureSamsungSideArrowDispatcherButton(
dispatcherProxy,
darkIntensitySource,
navContext,
index = 1,
direction = SideArrowDirection.LEFT,
)
configureSamsungSideArrowDispatcherButton(
dispatcherProxy,
darkIntensitySource,
navContext,
index = 2,
direction = SideArrowDirection.RIGHT,
)
}
return dispatcherProxy != null
}
private fun samsungNavigationBarContext(
source: Any,
fallbackContext: Context?,
contextFromObject: (Any) -> Context?,
): Context? {
return (callMethodIfExists(source, "getNavigationBarContext") as? Context)
?.let { it.applicationContext ?: it }
?: contextFromObject(source)
?: fallbackContext
}
private fun configureSamsungSideArrowDispatcherButton(
dispatcherProxy: Any,
darkIntensitySource: Any?,
context: Context,
index: Int,
direction: SideArrowDirection,
) {
val id = navCustomKeyId(context, index)
if (id == 0) {
return
}
callIntMethodIfExists(dispatcherProxy, "addButton", id)
callSetButtonImageIfExists(
dispatcherProxy,
id,
sideArrowBitmapDrawable(context, direction, Color.WHITE),
sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)),
)
callIntIntMethodIfExists(dispatcherProxy, "setButtonVisibility", id, View.VISIBLE)
if (darkIntensitySource != null) {
setSamsungSideArrowDarkIntensity(darkIntensitySource, id, context)
}
}
private fun findSamsungButtonDispatcherProxy(root: Any): Any? {
findSamsungButtonDispatcherProxyFromMethods(root)?.let { return it }
val visited = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
val queue = ArrayDeque<Pair<Any, Int>>()
queue.add(root to 0)
while (queue.isNotEmpty()) {
val (target, depth) = queue.removeFirst()
if (!visited.add(target)) {
continue
}
if (isSamsungButtonDispatcherProxy(target)) {
return target
}
findSamsungButtonDispatcherProxyFromMethods(target)?.let { return it }
if (depth >= BUTTON_DISPATCHER_SEARCH_DEPTH) {
continue
}
var current: Class<*>? = target.javaClass
while (current != null) {
current.declaredFields.forEach { field ->
try {
field.isAccessible = true
val value = field.get(target) ?: return@forEach
if (shouldSearchSamsungButtonDispatcherField(value)) {
queue.add(value to depth + 1)
}
} catch (_: Throwable) {
return@forEach
}
}
current = current.superclass
}
}
return null
}
private fun findSamsungButtonDispatcherProxyFromMethods(target: Any): Any? {
findMethodsNamed(target.javaClass, "getButtonDispatcherProxy")
.firstOrNull { it.parameterTypes.isEmpty() }
?.let { method ->
runCatching { method.invoke(target) }
.getOrNull()
?.takeIf(::isSamsungButtonDispatcherProxy)
?.let { return it }
}
return null
}
private fun isSamsungButtonDispatcherProxy(target: Any): Boolean {
return findMethodsNamed(target.javaClass, "setButtonImage").any { method ->
val types = method.parameterTypes
types.size == 3 &&
types[0] == Int::class.javaPrimitiveType &&
Drawable::class.java.isAssignableFrom(types[1]) &&
Drawable::class.java.isAssignableFrom(types[2])
}
}
private fun shouldSearchSamsungButtonDispatcherField(value: Any): Boolean {
val className = value.javaClass.name
return className.startsWith("com.android.systemui") ||
className.startsWith("com.samsung.systemui") ||
className.startsWith("java.util") ||
className.startsWith("android.util")
}
private fun setSamsungSideArrowDarkIntensity(navBarView: Any, id: Int, context: Context) {
val dispatchers = getFieldValue(navBarView, "mButtonDispatchers") as? SparseArray<*> ?: return
val dispatcher = dispatchers.get(id) ?: return
val intensity = findSamsungReferenceDarkIntensity(context, dispatchers)
callFloatMethodIfExists(dispatcher, "setDarkIntensity", intensity)
setFieldIfExists(dispatcher, "mDarkIntensity", intensity)
getFieldValue(dispatcher, "mImageDrawable")?.let { drawable ->
callFloatMethodIfExists(drawable, "setDarkIntensity", intensity)
}
getFieldValue(dispatcher, "mViews")
.let { it as? Iterable<*> }
?.forEach { view ->
if (view != null) {
callFloatMethodIfExists(view, "setDarkIntensity", intensity)
setFieldIfExists(view, "mDarkIntensity", intensity)
}
}
}
private fun findSamsungReferenceDarkIntensity(context: Context, dispatchers: SparseArray<*>): Float {
listOf("home", "back", "recent").forEach { name ->
val id = context.resources.getIdentifier(name, "id", context.packageName)
val value = getFieldValue(dispatchers.get(id), "mDarkIntensity") as? Float
if (value != null) {
return value
}
}
return 0f
}
private fun navCustomKeyToken(
sourceContext: Context?,
keyCode: Int,
index: Int,
onMissingCustomKeyId: (Int) -> Unit,
): String {
val customKeyId = navCustomKeyId(sourceContext, index)
if (customKeyId == 0) {
onMissingCustomKeyId(index)
}
return "navkey($keyCode:$customKeyId)"
}
@@ -1,6 +1,7 @@
package se.ajpanton.navbuttons package se.ajpanton.navbuttons
import android.app.Activity import android.app.Activity
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -13,10 +14,13 @@ import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Spinner import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import java.text.DecimalFormatSymbols
import java.util.Locale
class SettingsActivity : Activity() { class SettingsActivity : Activity() {
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@@ -61,6 +65,49 @@ class SettingsActivity : Activity() {
false, false,
) )
if (index > 0) { if (index > 0) {
applySectionTopMargin(sectionView)
}
NavButtonSectionController(sectionView, buttonId).bind()
sectionsContainer.addView(sectionView)
}
val sideArrowsSectionView = inflater.inflate(
R.layout.item_side_arrows_section,
sectionsContainer,
false,
)
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) {
val marginTop = resources.getDimensionPixelSize(R.dimen.section_spacing) val marginTop = resources.getDimensionPixelSize(R.dimen.section_spacing)
val layoutParams = sectionView.layoutParams as? ViewGroup.MarginLayoutParams val layoutParams = sectionView.layoutParams as? ViewGroup.MarginLayoutParams
if (layoutParams != null) { if (layoutParams != null) {
@@ -75,11 +122,6 @@ class SettingsActivity : Activity() {
} }
} }
NavButtonSectionController(sectionView, buttonId).bind()
sectionsContainer.addView(sectionView)
}
}
private fun startTestPreview(durationMs: Int) { private fun startTestPreview(durationMs: Int) {
stopTestPreview() stopTestPreview()
previewScheduled = true previewScheduled = true
@@ -96,138 +138,6 @@ class SettingsActivity : Activity() {
previewOverlay.visibility = View.GONE previewOverlay.visibility = View.GONE
} }
private inner class NavButtonSectionController(
sectionView: View,
private val buttonId: NavButtonId,
) {
private val titleView: TextView = sectionView.findViewById(R.id.section_title)
private val pressActionSpinner: Spinner = sectionView.findViewById(R.id.press_action_spinner)
private val longPressActionSpinner: Spinner =
sectionView.findViewById(R.id.long_press_action_spinner)
private val durationInput: EditText = sectionView.findViewById(R.id.long_press_duration_input)
private val minusButton: Button = sectionView.findViewById(R.id.minus_button)
private val plusButton: Button = sectionView.findViewById(R.id.plus_button)
private val testButton: Button = sectionView.findViewById(R.id.test_button)
private var lastValidDurationMs = settingsStore.defaultLongPressDurationMs()
private var updatingDurationText = false
fun bind() {
val config = settingsStore.getConfig(buttonId)
titleView.setText(buttonId.titleRes)
bindActionSpinner(
pressActionSpinner,
config.pressAction,
settingsStore::setPressAction,
)
bindActionSpinner(
longPressActionSpinner,
config.longPressAction,
settingsStore::setLongPressAction,
)
applyDuration(config.longPressDurationMs, persist = false)
bindDurationInput()
bindStepButtons()
bindTestButton()
}
private fun bindActionSpinner(
spinner: Spinner,
selectedAction: NavButtonAction,
storeAction: (NavButtonId, NavButtonAction) -> Unit,
) {
val labels = NavButtonAction.entries.map { getString(it.labelRes) }
val adapter = ArrayAdapter(
this@SettingsActivity,
R.layout.item_spinner_selected,
labels,
)
adapter.setDropDownViewResource(R.layout.item_spinner_dropdown)
spinner.adapter = adapter
spinner.setSelection(NavButtonAction.entries.indexOf(selectedAction), false)
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long,
) {
storeAction(buttonId, NavButtonAction.entries[position])
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
private fun bindDurationInput() {
durationInput.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
normalizeTypedDuration()
}
}
durationInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int,
) = Unit
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) = Unit
override fun afterTextChanged(editable: Editable?) {
if (updatingDurationText) {
return
}
val parsed = editable?.toString()?.toIntOrNull() ?: return
if (parsed in NavButtonSettingsStore.MIN_DURATION_MS..NavButtonSettingsStore.MAX_DURATION_MS) {
lastValidDurationMs = parsed
settingsStore.setLongPressDurationMs(buttonId, parsed)
}
}
})
}
private fun bindStepButtons() {
attachRepeatButton(minusButton) {
adjustDuration(-NavButtonSettingsStore.DURATION_STEP_MS)
}
attachRepeatButton(plusButton) {
adjustDuration(NavButtonSettingsStore.DURATION_STEP_MS)
}
}
private fun bindTestButton() {
testButton.setOnTouchListener { view, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
view.isPressed = true
startTestPreview(resolveDurationForUse())
true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
view.isPressed = false
stopTestPreview()
true
}
else -> false
}
}
}
private fun attachRepeatButton(button: Button, action: () -> Unit) { private fun attachRepeatButton(button: Button, action: () -> Unit) {
var repeated = false var repeated = false
val repeatRunnable = object : Runnable { val repeatRunnable = object : Runnable {
@@ -244,7 +154,7 @@ class SettingsActivity : Activity() {
} }
} }
button.setOnTouchListener { view, event -> button.setOnTouchListener { _, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
repeated = false repeated = false
@@ -267,40 +177,306 @@ class SettingsActivity : Activity() {
} }
} }
private fun adjustDuration(deltaMs: Int) { private fun attachTestButton(button: Button, durationProvider: () -> Int) {
val current = resolveDurationForUse() button.setOnTouchListener { view, event ->
val updated = (current + deltaMs).coerceIn( when (event.actionMasked) {
NavButtonSettingsStore.MIN_DURATION_MS, MotionEvent.ACTION_DOWN -> {
NavButtonSettingsStore.MAX_DURATION_MS, view.isPressed = true
) startTestPreview(durationProvider())
applyDuration(updated, persist = true) true
} }
private fun normalizeTypedDuration(): Int { MotionEvent.ACTION_UP,
val parsed = durationInput.text.toString().toIntOrNull() ?: lastValidDurationMs MotionEvent.ACTION_CANCEL,
val normalized = settingsStore.sanitizeDuration(parsed) -> {
applyDuration(normalized, persist = true) view.isPressed = false
stopTestPreview()
true
}
else -> false
}
}
}
private fun <T> bindSpinner(
spinner: Spinner,
items: List<T>,
selectedItem: T,
labelRes: (T) -> Int,
onSelected: (T) -> Unit,
) {
val adapter = ArrayAdapter(
this,
R.layout.item_spinner_selected,
items.map { getString(labelRes(it)) },
)
adapter.setDropDownViewResource(R.layout.item_spinner_dropdown)
spinner.adapter = adapter
spinner.setSelection(items.indexOf(selectedItem).coerceAtLeast(0), false)
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
onSelected(items[position])
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
}
}
private fun EditText.afterTextChanged(onChanged: (String) -> Unit) {
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(editable: Editable?) = onChanged(editable?.toString().orEmpty())
})
}
private inner class NumericField(
private val input: EditText,
private val minusButton: Button,
private val plusButton: Button,
private val step: Float,
private val parse: (String) -> Float?,
private val sanitize: (Float) -> Float,
private val format: (Float) -> String,
private val persist: (Float) -> Unit,
) {
private var lastValid = 0f
private var updatingText = false
fun bind(initialValue: Float) {
apply(initialValue, persist = false)
input.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
normalize()
}
}
input.afterTextChanged { text ->
if (updatingText) {
return@afterTextChanged
}
val parsed = parse(text) ?: return@afterTextChanged
if (parsed == sanitize(parsed)) {
lastValid = parsed
persist(parsed)
sendSettingsChangedBroadcast()
}
}
attachRepeatButton(minusButton) { adjust(-step) }
attachRepeatButton(plusButton) { adjust(step) }
}
fun normalize(): Float {
val normalized = sanitize(parse(input.text.toString()) ?: lastValid)
apply(normalized, persist = true)
return normalized return normalized
} }
private fun resolveDurationForUse(): Int { private fun adjust(delta: Float) {
return normalizeTypedDuration() apply(normalize() + delta, persist = true)
} }
private fun applyDuration(durationMs: Int, persist: Boolean) { private fun apply(value: Float, persist: Boolean) {
val sanitized = settingsStore.sanitizeDuration(durationMs) val sanitized = sanitize(value)
val text = sanitized.toString() val text = format(sanitized)
updatingDurationText = true updatingText = true
if (durationInput.text.toString() != text) { if (input.text.toString() != text) {
durationInput.setText(text) input.setText(text)
durationInput.setSelection(durationInput.text.length) input.setSelection(input.text.length)
} }
updatingDurationText = false updatingText = false
lastValidDurationMs = sanitized lastValid = sanitized
if (persist) { if (persist) {
settingsStore.setLongPressDurationMs(buttonId, sanitized) persist(sanitized)
sendSettingsChangedBroadcast()
} }
} }
} }
private inner class NavButtonSectionController(
sectionView: View,
private val buttonId: NavButtonId,
) {
private val titleView: TextView = sectionView.findViewById(R.id.section_title)
private val pressActionSpinner: Spinner = sectionView.findViewById(R.id.press_action_spinner)
private val longPressActionSpinner: Spinner =
sectionView.findViewById(R.id.long_press_action_spinner)
private val durationInput: EditText = sectionView.findViewById(R.id.long_press_duration_input)
private val minusButton: Button = sectionView.findViewById(R.id.minus_button)
private val plusButton: Button = sectionView.findViewById(R.id.plus_button)
private val testButton: Button = sectionView.findViewById(R.id.test_button)
private lateinit var durationField: NumericField
fun bind() {
val config = settingsStore.getConfig(buttonId)
titleView.setText(buttonId.titleRes)
bindActionSpinner(
pressActionSpinner,
config.pressAction,
settingsStore::setPressAction,
)
bindActionSpinner(
longPressActionSpinner,
config.longPressAction,
settingsStore::setLongPressAction,
)
durationField = durationField(
durationInput,
minusButton,
plusButton,
persist = { settingsStore.setLongPressDurationMs(buttonId, it) },
)
durationField.bind(config.longPressDurationMs.toFloat())
attachTestButton(testButton) { durationField.normalize().toInt() }
}
private fun bindActionSpinner(
spinner: Spinner,
selectedAction: NavButtonAction,
storeAction: (NavButtonId, NavButtonAction) -> Unit,
) {
bindSpinner(
spinner,
NavButtonAction.entries,
selectedAction,
NavButtonAction::labelRes,
) { action ->
storeAction(buttonId, action)
sendSettingsChangedBroadcast()
}
}
}
private inner class SideArrowsSectionController(sectionView: View) {
private val sideArrowsCheckbox: CheckBox = sectionView.findViewById(R.id.side_arrows_checkbox)
private val optionsContainer: LinearLayout =
sectionView.findViewById(R.id.side_arrows_options_container)
private val longPressActionSpinner: Spinner =
sectionView.findViewById(R.id.side_arrow_long_press_action_spinner)
private val repeatSpeedContainer: LinearLayout =
sectionView.findViewById(R.id.side_arrow_repeat_speed_container)
private val repeatSpeedInput: EditText =
sectionView.findViewById(R.id.side_arrow_repeat_speed_input)
private val speedMinusButton: Button =
sectionView.findViewById(R.id.side_arrow_speed_minus_button)
private val speedPlusButton: Button =
sectionView.findViewById(R.id.side_arrow_speed_plus_button)
private val durationInput: EditText =
sectionView.findViewById(R.id.side_arrow_long_press_duration_input)
private val durationMinusButton: Button =
sectionView.findViewById(R.id.side_arrow_duration_minus_button)
private val durationPlusButton: Button =
sectionView.findViewById(R.id.side_arrow_duration_plus_button)
private val testButton: Button = sectionView.findViewById(R.id.side_arrow_test_button)
private val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator
private lateinit var durationField: NumericField
private lateinit var repeatSpeedField: NumericField
fun bind() {
sideArrowsCheckbox.isChecked = settingsStore.isSideArrowsEnabled()
bindLongPressActionSpinner()
repeatSpeedField = NumericField(
repeatSpeedInput,
speedMinusButton,
speedPlusButton,
NavButtonSettingsStore.SIDE_ARROW_REPEAT_SPEED_STEP,
parse = ::parseRepeatSpeed,
sanitize = ::sanitizeSideArrowRepeatSpeed,
format = ::formatRepeatSpeed,
persist = settingsStore::setSideArrowRepeatStepsPerSecond,
)
repeatSpeedField.bind(settingsStore.getSideArrowRepeatStepsPerSecond())
durationField = durationField(
durationInput,
durationMinusButton,
durationPlusButton,
persist = settingsStore::setSideArrowLongPressDurationMs,
)
durationField.bind(settingsStore.getSideArrowLongPressDurationMs().toFloat())
attachTestButton(testButton) { durationField.normalize().toInt() }
updateOptionsEnabled()
sideArrowsCheckbox.setOnCheckedChangeListener { _, isChecked ->
settingsStore.setSideArrowsEnabled(isChecked)
updateOptionsEnabled()
sendSettingsChangedBroadcast()
}
}
private fun bindLongPressActionSpinner() {
bindSpinner(
longPressActionSpinner,
SideArrowLongPressAction.entries,
settingsStore.getSideArrowLongPressAction(),
SideArrowLongPressAction::labelRes,
) { action ->
settingsStore.setSideArrowLongPressAction(action)
updateRepeatSpeedVisibility(action)
sendSettingsChangedBroadcast()
}
updateRepeatSpeedVisibility(settingsStore.getSideArrowLongPressAction())
}
private fun updateOptionsEnabled() {
val enabled = sideArrowsCheckbox.isChecked
optionsContainer.alpha = if (enabled) 1f else 0.45f
setEnabledRecursively(optionsContainer, enabled)
}
private fun setEnabledRecursively(view: View, enabled: Boolean) {
view.isEnabled = enabled
if (view is ViewGroup) {
repeat(view.childCount) { index ->
setEnabledRecursively(view.getChildAt(index), enabled)
}
}
}
private fun updateRepeatSpeedVisibility(action: SideArrowLongPressAction) {
repeatSpeedContainer.visibility =
if (action == SideArrowLongPressAction.MULTIPLE_STEPS) View.VISIBLE else View.GONE
}
private fun parseRepeatSpeed(text: String?): Float? {
return text
?.trim()
?.replace(',', '.')
?.toFloatOrNull()
}
private fun formatRepeatSpeed(stepsPerSecond: Float): String {
val raw = if (stepsPerSecond == stepsPerSecond.toInt().toFloat()) {
stepsPerSecond.toInt().toString()
} else {
String.format(Locale.US, "%.2f", stepsPerSecond)
.trimEnd('0')
.trimEnd('.')
}
return raw.replace('.', decimalSeparator)
}
}
private fun durationField(
input: EditText,
minusButton: Button,
plusButton: Button,
persist: (Int) -> Unit,
): NumericField {
return NumericField(
input,
minusButton,
plusButton,
NavButtonSettingsStore.DURATION_STEP_MS.toFloat(),
parse = { it.toIntOrNull()?.toFloat() },
sanitize = { it.toInt().coerceLongPressDuration().toFloat() },
format = { it.toInt().toString() },
persist = { persist(it.toInt()) },
)
}
} }
@@ -0,0 +1,214 @@
package se.ajpanton.navbuttons
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.os.Looper
import android.os.SystemClock
import android.view.ViewConfiguration
internal class SettingsRepository(
private val isSystemServerProcess: () -> Boolean,
private val contextProvider: () -> Context?,
private val contextUpdater: (Context) -> Unit,
private val currentApplicationProvider: () -> Context?,
private val remotePreferencesProvider: (String) -> SharedPreferences,
private val log: (String) -> Unit,
private val onChanged: () -> Unit,
) {
@Volatile
private var remotePreferences: SharedPreferences? = null
@Volatile
private var remotePreferencesRetryAfterMs = 0L
@Volatile
private var remotePreferencesLoading = false
private var settingsChangedReceiverRegistered = false
private val buttonConfigOverrides = mutableMapOf<NavButtonId, NavButtonConfig>()
private var sideArrowsEnabledOverride: Boolean? = null
private var sideArrowLongPressActionOverride: SideArrowLongPressAction? = null
private var sideArrowLongPressDurationOverride: Int? = null
private var sideArrowRepeatSpeedOverride: Float? = null
fun preferences(): SharedPreferences? {
remotePreferences?.let { return it }
if (isSystemServerProcess()) {
return null
}
if (Looper.myLooper() == Looper.getMainLooper()) {
startPreferencesLoad()
return null
}
return loadPreferences()
}
fun registerChangedReceiver() {
if (settingsChangedReceiverRegistered) {
return
}
val currentContext = currentApplicationProvider()?.applicationContext
?: contextProvider()?.applicationContext
?: contextProvider()
?: return
contextUpdater(currentContext)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != NavButtonSettingsStore.ACTION_SETTINGS_CHANGED) {
return
}
updateOverrides(intent)
onChanged()
}
}
try {
currentContext.registerReceiver(
receiver,
IntentFilter(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED),
Context.RECEIVER_EXPORTED,
)
settingsChangedReceiverRegistered = true
} catch (error: Throwable) {
log("Settings receiver failed: ${error.javaClass.simpleName}")
}
}
fun getButtonConfig(buttonId: NavButtonId): NavButtonConfig {
buttonConfigOverrides[buttonId]?.let { return it }
val preferences = preferences()
val defaultDurationMs = ViewConfiguration.getLongPressTimeout().coerceLongPressDuration()
return NavButtonConfig(
pressAction = NavButtonAction.fromStorageValue(
preferences?.getString(NavButtonSettingsStore.pressActionKey(buttonId), null),
),
longPressAction = NavButtonAction.fromStorageValue(
preferences?.getString(NavButtonSettingsStore.longPressActionKey(buttonId), null),
),
longPressDurationMs = (
preferences?.getInt(
NavButtonSettingsStore.longPressDurationKey(buttonId),
defaultDurationMs,
) ?: defaultDurationMs
).coerceLongPressDuration(),
)
}
fun isSideArrowsEnabled(): Boolean {
return sideArrowsEnabledOverride
?: (preferences()?.getBoolean(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, false) ?: false)
}
fun sideArrowLongPressAction(): SideArrowLongPressAction {
sideArrowLongPressActionOverride?.let { return it }
return SideArrowLongPressAction.fromStorageValue(
preferences()?.getString(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION, null),
)
}
fun sideArrowLongPressDurationMs(): Int {
sideArrowLongPressDurationOverride?.let { return it }
val defaultDurationMs = ViewConfiguration.getLongPressTimeout()
return (
preferences()?.getInt(
NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS,
defaultDurationMs,
) ?: defaultDurationMs
).coerceLongPressDuration()
}
fun sideArrowRepeatIntervalMs(): Long {
val speed = sanitizeSideArrowRepeatSpeed(
sideArrowRepeatSpeedOverride
?: preferences()?.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)
}
private fun startPreferencesLoad() {
if (remotePreferences != null || remotePreferencesLoading) {
return
}
if (contextProvider()?.packageName == ANDROID_PACKAGE) {
return
}
val now = SystemClock.uptimeMillis()
if (now < remotePreferencesRetryAfterMs) {
return
}
remotePreferencesLoading = true
Thread {
try {
repeat(REMOTE_PREFERENCES_LOAD_ATTEMPTS) {
if (loadPreferences() != null) {
return@Thread
}
Thread.sleep(REMOTE_PREFERENCES_RETRY_DELAY_MS)
}
} finally {
remotePreferencesLoading = false
}
}.start()
}
private fun loadPreferences(): SharedPreferences? {
remotePreferences?.let { return it }
val now = SystemClock.uptimeMillis()
if (now < remotePreferencesRetryAfterMs) {
return null
}
return runCatching {
remotePreferencesProvider(NavButtonSettingsStore.PREFERENCES_NAME)
}.getOrElse { error ->
remotePreferencesRetryAfterMs = now + REMOTE_PREFERENCES_RETRY_DELAY_MS
log("Remote preferences unavailable: ${error.javaClass.simpleName}")
null
}?.also {
remotePreferences = it
}
}
private fun updateOverrides(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,
),
)
}
}
@@ -0,0 +1,96 @@
package se.ajpanton.navbuttons
import android.os.Handler
import android.view.MotionEvent
import android.view.View
import java.util.WeakHashMap
internal class SideArrowLongPressController(
private val handlerProvider: () -> Handler,
private val actionProvider: () -> SideArrowLongPressAction,
private val durationProvider: () -> Int,
private val repeatIntervalProvider: () -> Long,
private val sendKeyPress: (Int) -> Unit,
) {
private val triggered = WeakHashMap<Any, Boolean>()
private val longPressRunnables = WeakHashMap<Any, Runnable>()
private val repeatRunnables = WeakHashMap<Any, Runnable>()
fun start(target: Any, keyCode: Int) {
reset(target)
schedule(target, keyCode)
}
fun startAfter(target: Any, keyCode: Int, proceed: () -> Any?): Any? {
reset(target)
val result = proceed()
schedule(target, keyCode)
return result
}
fun handleNativeTouch(
target: Any,
keyCode: Int,
event: MotionEvent,
proceed: () -> Any?,
): Any? {
return when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> startAfter(target, keyCode, proceed)
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
finish(target)
proceed()
}
else -> proceed()
}
}
fun finish(target: Any): Boolean {
val wasTriggered = triggered[target] == true
cancel(target)
triggered.remove(target)
return wasTriggered
}
private fun reset(target: Any) {
triggered[target] = false
cancel(target)
}
private fun schedule(target: Any, keyCode: Int) {
val view = target as? View ?: return
val handler = handlerProvider()
val longPressRunnable = Runnable {
triggered[target] = true
when (actionProvider()) {
SideArrowLongPressAction.NOTHING -> Unit
SideArrowLongPressAction.SINGLE_STEP -> sendKeyPress(keyCode)
SideArrowLongPressAction.MULTIPLE_STEPS -> {
sendKeyPress(keyCode)
val repeatRunnable = object : Runnable {
override fun run() {
sendKeyPress(keyCode)
handler.postDelayed(this, repeatIntervalProvider())
}
}
repeatRunnables[target] = repeatRunnable
handler.postDelayed(repeatRunnable, repeatIntervalProvider())
}
}
}
longPressRunnables[target] = longPressRunnable
view.postDelayed(longPressRunnable, durationProvider().toLong())
}
private fun cancel(target: Any) {
val view = target as? View
longPressRunnables.remove(target)?.let { runnable ->
view?.removeCallbacks(runnable) ?: handlerProvider().removeCallbacks(runnable)
}
repeatRunnables.remove(target)?.let { runnable ->
handlerProvider().removeCallbacks(runnable)
}
}
}
@@ -0,0 +1,138 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.view.KeyEvent
import android.widget.ImageView
internal enum class SideArrowDirection(val keyCode: Int) {
LEFT(KeyEvent.KEYCODE_DPAD_LEFT),
RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT),
}
internal data class AospSideArrowViews(
val left: ImageView,
val right: ImageView,
)
internal data class HoneyspaceSideArrowButton(
val type: Int,
val keyCode: Int,
val direction: SideArrowDirection,
)
internal fun navStarArrowIconSizePx(context: Context): Int {
return (context.resources.displayMetrics.density * SIDE_ARROW_ICON_DP + 0.5f).toInt()
}
internal fun sideArrowBitmapDrawable(
context: Context,
direction: SideArrowDirection,
color: Int,
): Drawable {
val size = navStarArrowIconSizePx(context).coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
SideArrowDrawable(direction, color, size).apply {
setBounds(0, 0, size, size)
draw(canvas)
}
return BitmapDrawable(context.resources, bitmap)
}
internal val HONEYSPACE_SIDE_ARROW_BUTTONS = listOf(
HoneyspaceSideArrowButton(
HONEYSPACE_LEFT_ARROW_BUTTON_TYPE,
SideArrowDirection.LEFT.keyCode,
SideArrowDirection.LEFT,
),
HoneyspaceSideArrowButton(
HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE,
SideArrowDirection.RIGHT.keyCode,
SideArrowDirection.RIGHT,
),
)
internal const val HONEYSPACE_LEFT_ARROW_BUTTON_TYPE = 64
internal const val HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE = 128
internal val HONEYSPACE_EXTRA_BUTTON_TYPES =
HONEYSPACE_SIDE_ARROW_BUTTONS.map { it.type } + listOf(256, 512, 1024)
private const val SIDE_ARROW_ICON_DP = 32f
private const val SIDE_ARROW_GLYPH_SCALE = 0.8f
private const val SIDE_ARROW_STROKE_WIDTH = 1.45f
private class SideArrowDrawable(
private val direction: SideArrowDirection,
color: Int,
private val iconSizePx: Int,
private val colorProvider: (() -> Int)? = null,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.color = color
style = Paint.Style.STROKE
strokeCap = Paint.Cap.SQUARE
strokeJoin = Paint.Join.MITER
}
private val path = Path()
override fun draw(canvas: Canvas) {
val bounds = bounds
val width = bounds.width().toFloat()
val height = bounds.height().toFloat()
if (width <= 0f || height <= 0f) {
return
}
val size = minOf(width, height, iconSizePx.toFloat()) * SIDE_ARROW_GLYPH_SCALE
val left = bounds.left + (width - size) / 2f
val top = bounds.top + (height - size) / 2f
val scale = size / 24f
colorProvider?.let { provider ->
val alpha = paint.alpha
paint.color = provider()
paint.alpha = alpha
}
path.reset()
if (direction == SideArrowDirection.LEFT) {
path.moveTo(left + 15f * scale, top + 7f * scale)
path.lineTo(left + 10f * scale, top + 12f * scale)
path.lineTo(left + 15f * scale, top + 17f * scale)
} else {
path.moveTo(left + 9f * scale, top + 7f * scale)
path.lineTo(left + 14f * scale, top + 12f * scale)
path.lineTo(left + 9f * scale, top + 17f * scale)
}
paint.strokeWidth = SIDE_ARROW_STROKE_WIDTH * scale
canvas.drawPath(path, paint)
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
override fun getIntrinsicWidth(): Int {
return iconSizePx
}
override fun getIntrinsicHeight(): Int {
return iconSizePx
}
@Deprecated("Deprecated in Android framework")
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
}
@@ -0,0 +1,72 @@
package se.ajpanton.navbuttons
import android.os.Handler
import android.view.KeyEvent
internal class SystemServerButtonOverrideController(
private val handlerProvider: () -> Handler,
private val performPressAction: (NavButtonAction) -> Unit,
private val performLongPressAction: (NavButtonId, NavButtonAction) -> Unit,
) {
private val longPressRunnables = mutableMapOf<Int, Runnable>()
private val longPressTriggered = mutableMapOf<Int, Boolean>()
fun handle(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) {
schedule(keyCode, buttonId, longPressAction, config.longPressDurationMs)
}
pressAction != NavButtonAction.STOCK
}
KeyEvent.ACTION_UP -> {
val triggered = longPressTriggered[keyCode] == true
val elapsed = keyEvent.eventTime - keyEvent.downTime >= config.longPressDurationMs
cancel(keyCode)
if (triggered || (longPressAction != NavButtonAction.STOCK && elapsed)) {
if (!triggered) {
performLongPressAction(buttonId, longPressAction)
}
true
} else {
if (pressAction != NavButtonAction.STOCK && !keyEvent.isCanceled) {
performPressAction(pressAction)
}
pressAction != NavButtonAction.STOCK
}
}
else -> pressAction != NavButtonAction.STOCK
}
}
private fun schedule(
keyCode: Int,
buttonId: NavButtonId,
action: NavButtonAction,
durationMs: Int,
) {
if (longPressRunnables[keyCode] != null) {
return
}
val runnable = Runnable {
longPressRunnables.remove(keyCode)
longPressTriggered[keyCode] = true
performLongPressAction(buttonId, action)
}
longPressTriggered[keyCode] = false
longPressRunnables[keyCode] = runnable
handlerProvider().postDelayed(runnable, durationMs.toLong())
}
private fun cancel(keyCode: Int) {
longPressRunnables.remove(keyCode)?.let { runnable ->
handlerProvider().removeCallbacks(runnable)
}
}
}
@@ -0,0 +1,158 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.view.KeyEvent
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
internal class SystemServerHooks(
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
private val handlerProvider: () -> android.os.Handler,
private val configProvider: (NavButtonId) -> NavButtonConfig,
private val updateContextFromField: (Any?, String) -> Unit,
private val registerSettingsReceiver: () -> Unit,
private val registerPrivilegedReceiver: () -> Unit,
private val performAction: (NavButtonAction) -> Unit,
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
private val contextUpdater: (Context?) -> Unit,
private val log: (String) -> Unit,
) {
private val buttons = SystemServerButtonOverrideController(
handlerProvider = handlerProvider,
performPressAction = performAction,
performLongPressAction = { buttonId, action ->
if (buttonId == NavButtonId.HOME) {
performHomeLongPressAction(action)
} else {
performAction(action)
}
},
)
fun install(classLoader: ClassLoader?): Boolean {
val phoneWindowManagerClass = findClassIfExists(SYSTEM_SERVER_PHONE_WINDOW_MANAGER, classLoader)
?: run {
log("PhoneWindowManager class not found: $SYSTEM_SERVER_PHONE_WINDOW_MANAGER")
return false
}
var installed = false
val longPressOnHomeWithEvent = findMethodIfExists(
phoneWindowManagerClass,
"handleLongPressOnHome",
KeyEvent::class.java,
)
val longPressOnHomeLegacy = findMethodIfExists(
phoneWindowManagerClass,
"handleLongPressOnHome",
Int::class.javaPrimitiveType!!,
Long::class.javaPrimitiveType!!,
)
val launchAssistActionMethod = findMethodIfExists(
phoneWindowManagerClass,
"launchAssistAction",
String::class.java,
Int::class.javaPrimitiveType!!,
Long::class.javaPrimitiveType!!,
Int::class.javaPrimitiveType!!,
)
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 &&
method.parameterTypes.any { it == KeyEvent::class.java }
}
val keyQueueingMethods = findMethodsNamed(phoneWindowManagerClass, "interceptKeyBeforeQueueing")
.filter { method ->
method.returnType == Int::class.javaPrimitiveType &&
method.parameterTypes.any { it == KeyEvent::class.java }
}
initMethods.forEach { method ->
hookMethod(method) { chain ->
val result = chain.proceed()
contextUpdater(chain.args.firstOrNull() as? Context)
registerSettingsReceiver()
registerPrivilegedReceiver()
result
}
installed = true
}
keyDispatchMethods.forEach { method ->
hookMethod(method) { chain ->
val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent
?: return@hookMethod chain.proceed()
val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@hookMethod chain.proceed()
val config = configProvider(buttonId)
if (config.pressAction == NavButtonAction.STOCK &&
config.longPressAction == NavButtonAction.STOCK
) {
return@hookMethod chain.proceed()
}
updateContextFromField(chain.thisObject, "mContext")
if (buttons.handle(keyEvent, buttonId, config)) {
return@hookMethod -1L
}
chain.proceed()
}
installed = true
}
keyQueueingMethods.forEach { method ->
hookMethod(method) { chain ->
val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent
?: return@hookMethod chain.proceed()
val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@hookMethod chain.proceed()
val config = configProvider(buttonId)
if (config.pressAction == NavButtonAction.STOCK &&
config.longPressAction == NavButtonAction.STOCK
) {
return@hookMethod chain.proceed()
}
updateContextFromField(chain.thisObject, "mContext")
val consume = buttons.handle(keyEvent, buttonId, config)
val result = chain.proceed() as? Int ?: 0
if (consume) result and ACTION_PASS_TO_USER.inv() else result
}
installed = true
}
listOfNotNull(longPressOnHomeWithEvent, longPressOnHomeLegacy).forEach { method ->
hookMethod(method) { chain ->
val longPressAction = configProvider(NavButtonId.HOME).longPressAction
if (longPressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
updateContextFromField(chain.thisObject, "mContext")
performHomeLongPressAction(longPressAction)
null
}
installed = true
}
if (launchAssistActionMethod != null) {
hookMethod(launchAssistActionMethod) { chain ->
val invocationType = chain.args[3] as? Int ?: ASSIST_INVOCATION_TYPE_UNKNOWN
if (invocationType != ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS) {
return@hookMethod chain.proceed()
}
val longPressAction = configProvider(NavButtonId.HOME).longPressAction
if (longPressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
updateContextFromField(chain.thisObject, "mContext")
performHomeLongPressAction(longPressAction)
null
}
installed = true
}
return installed
}
}
@@ -0,0 +1,64 @@
package se.ajpanton.navbuttons
import android.view.HapticFeedbackConstants
import android.view.View
import java.util.WeakHashMap
internal class SystemUiLongPressController(
private val configProvider: (NavButtonId) -> NavButtonConfig,
private val updateContext: (Any) -> Unit,
private val cancelKeyPress: (Any) -> Unit,
private val markLongClicked: (Any) -> Unit,
private val performAction: (NavButtonAction) -> Unit,
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
) {
private val triggered = WeakHashMap<Any, Boolean>()
private val runnables = WeakHashMap<Any, Runnable>()
fun reset(target: Any) {
triggered[target] = false
cancel(target)
}
fun schedule(target: Any, buttonId: NavButtonId) {
val view = target as? View ?: return
val config = configProvider(buttonId)
if (config.longPressAction == NavButtonAction.STOCK) {
return
}
(getFieldValue(target, "mCheckLongPress") as? Runnable)?.let(view::removeCallbacks)
val runnable = Runnable {
updateContext(target)
triggered[target] = true
cancelKeyPress(target)
markLongClicked(target)
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
if (buttonId == NavButtonId.HOME) {
performHomeLongPressAction(config.longPressAction)
} else {
performAction(config.longPressAction)
}
}
runnables[target] = runnable
view.postDelayed(runnable, config.longPressDurationMs.toLong())
}
fun isTriggered(target: Any): Boolean {
return triggered[target] == true
}
fun markTriggered(target: Any) {
triggered[target] = true
}
fun clear(target: Any) {
cancel(target)
triggered.remove(target)
}
private fun cancel(target: Any) {
val view = target as? View ?: return
runnables.remove(target)?.let(view::removeCallbacks)
}
}
@@ -0,0 +1,296 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.os.Handler
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import io.github.libxposed.api.XposedInterface
import java.lang.reflect.Method
internal class SystemUiNavHooks(
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
private val deoptimizeMethod: (Method?) -> Unit,
private val prepareLongPressHook: (Method) -> Unit,
private val configProvider: (NavButtonId) -> NavButtonConfig,
private val sideArrowLongPresses: SideArrowLongPressController,
private val registerSettingsReceiver: () -> Unit,
private val contextUpdater: (Context) -> Unit,
private val performAction: (NavButtonAction) -> Unit,
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
private val installSamsungSideArrows: (ClassLoader?) -> Unit,
private val trackSamsungNavigationBarView: (Any) -> Unit,
private val installAospSideArrows: (ClassLoader?) -> Unit,
private val configureAospSideArrowButton: (Any?) -> Unit,
private val log: (String) -> Unit,
private val logError: (String, Throwable) -> Unit,
) {
private val longPresses = SystemUiLongPressController(
configProvider = configProvider,
updateContext = ::updateContextFromView,
cancelKeyPress = ::cancelKeyPress,
markLongClicked = ::markLongClicked,
performAction = performAction,
performHomeLongPressAction = performHomeLongPressAction,
)
private val homeStockLongPressGuard = HomeStockLongPressGuard(
configProvider = configProvider,
longPressTriggered = longPresses::isTriggered,
sendCanceledKeyUp = ::sendCanceledKeyUp,
)
fun install(classLoader: ClassLoader?): Boolean {
val keyButtonClass = findFirstClassIfExists(SYSTEMUI_KEY_BUTTON_VIEW_CLASSES, classLoader) ?: run {
log("SystemUI key button class not found: ${SYSTEMUI_KEY_BUTTON_VIEW_CLASSES.joinToString()}")
return false
}
val onTouchEventMethod = findMethodIfExists(
keyButtonClass,
"onTouchEvent",
MotionEvent::class.java,
) ?: run {
log("SystemUI onTouchEvent() method not found.")
return false
}
val runnableClass = findClassIfExists(
"${keyButtonClass.name}\$1",
classLoader,
) ?: run {
log("SystemUI long-press runnable not found.")
return false
}
val runMethod = findMethodIfExists(runnableClass, "run") ?: run {
log("SystemUI run() method not found.")
return false
}
val sendEventMethods = findMethodsNamed(keyButtonClass, "sendEvent").filter { method ->
val parameterTypes = method.parameterTypes
parameterTypes.isNotEmpty() && parameterTypes[0] == Int::class.javaPrimitiveType
}
deoptimizeMethod(onTouchEventMethod)
prepareLongPressHook(runMethod)
sendEventMethods.forEach(deoptimizeMethod)
sendEventMethods.forEach(::hookSendEvent)
installSamsungSideArrows(classLoader)
installAospSideArrows(classLoader)
hookMethod(onTouchEventMethod) { chain ->
val keyButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
val motionEvent = chain.args[0] as? MotionEvent ?: return@hookMethod chain.proceed()
val keyCode = getIntField(keyButtonView, "mCode")
if (isSideArrowKeyCode(keyCode)) {
updateContextFromView(keyButtonView)
registerSettingsReceiver()
trackSamsungNavigationBarView(keyButtonView)
configureAospSideArrowButton(keyButtonView)
return@hookMethod sideArrowLongPresses.handleNativeTouch(keyButtonView, keyCode, motionEvent) {
chain.proceed()
}
}
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
when (motionEvent.actionMasked) {
MotionEvent.ACTION_DOWN -> {
updateContextFromView(keyButtonView)
registerSettingsReceiver()
trackSamsungNavigationBarView(keyButtonView)
longPresses.reset(keyButtonView)
homeStockLongPressGuard.cancel(keyButtonView)
homeStockLongPressGuard.clear(keyButtonView)
val result = chain.proceed()
longPresses.schedule(keyButtonView, buttonId)
homeStockLongPressGuard.schedule(keyButtonView, buttonId)
result
}
MotionEvent.ACTION_UP -> {
if (longPresses.isTriggered(keyButtonView)) {
return@hookMethod proceedWithCanceledAction(motionEvent) {
chain.proceed()
}.also {
homeStockLongPressGuard.cancel(keyButtonView)
longPresses.clear(keyButtonView)
homeStockLongPressGuard.clear(keyButtonView)
}
}
val buttonConfig = configProvider(buttonId)
val pressAction = buttonConfig.pressAction
val stockHomePressCanceled =
buttonId == NavButtonId.HOME && homeStockLongPressGuard.isCanceled(keyButtonView)
val shouldOverridePress =
pressAction != NavButtonAction.STOCK &&
((keyButtonView as? View)?.isPressed == true || stockHomePressCanceled) &&
getFieldValue(keyButtonView, "mLongClicked") != true
if (shouldOverridePress) {
markLongClicked(keyButtonView)
}
val result = chain.proceed()
if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) {
dispatchHomeTap(keyButtonView)
}
if (shouldOverridePress) {
updateContextFromView(keyButtonView)
performAction(pressAction)
}
homeStockLongPressGuard.cancel(keyButtonView)
longPresses.clear(keyButtonView)
homeStockLongPressGuard.clear(keyButtonView)
result
}
MotionEvent.ACTION_CANCEL -> {
homeStockLongPressGuard.cancel(keyButtonView)
longPresses.clear(keyButtonView)
homeStockLongPressGuard.clear(keyButtonView)
chain.proceed()
}
else -> chain.proceed()
}
}
hookMethod(runMethod) { chain ->
val keyButtonView = getFieldValue(chain.thisObject, "this\$0")
?: return@hookMethod chain.proceed()
val keyCode = getIntField(keyButtonView, "mCode")
if (isSideArrowKeyCode(keyCode)) {
return@hookMethod null
}
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
val longPressAction = configProvider(buttonId).longPressAction
if (longPressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
updateContextFromView(keyButtonView)
longPresses.markTriggered(keyButtonView)
cancelKeyPress(keyButtonView)
markLongClicked(keyButtonView)
if (buttonId == NavButtonId.HOME) {
performHomeLongPressAction(longPressAction)
} else {
performAction(longPressAction)
}
null
}
return true
}
private fun hookSendEvent(sendEventMethod: Method) {
hookMethod(sendEventMethod) { chain ->
val keyButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode"))
?: return@hookMethod chain.proceed()
if (buttonId != NavButtonId.HOME) {
return@hookMethod chain.proceed()
}
val action = chain.args.firstOrNull() as? Int ?: return@hookMethod chain.proceed()
val longPressAction = configProvider(buttonId).longPressAction
if (longPressAction == NavButtonAction.STOCK) {
return@hookMethod chain.proceed()
}
if (action == KeyEvent.ACTION_DOWN && sendEventFlags(chain.args).hasFlag(KeyEvent.FLAG_LONG_PRESS)) {
return@hookMethod null
}
chain.proceed()
}
}
private fun markLongClicked(keyButtonView: Any) {
try {
findField(keyButtonView.javaClass, "mLongClicked").setBoolean(keyButtonView, true)
} catch (error: Throwable) {
logError("Failed to set mLongClicked", error)
}
}
private fun updateContextFromView(value: Any) {
val viewContext = (value as? View)?.context ?: return
contextUpdater(viewContext.applicationContext ?: viewContext)
}
private fun cancelKeyPress(keyButtonView: Any) {
if (buttonIdForKeyCode(getIntField(keyButtonView, "mCode")) == NavButtonId.HOME &&
homeStockLongPressGuard.isCanceled(keyButtonView)
) {
abortKeyGesture(keyButtonView)
return
}
abortKeyGesture(keyButtonView)
sendCanceledKeyUp(keyButtonView)
}
private fun abortKeyGesture(keyButtonView: Any) {
try {
callMethod(keyButtonView, "abortCurrentGesture")
} catch (_: Throwable) {
// Older/newer SystemUI variants may not expose this helper.
}
}
private fun sendCanceledKeyUp(keyButtonView: Any) {
try {
callMethod(
keyButtonView,
"sendEvent",
arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
KeyEvent.ACTION_UP,
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
)
} catch (error: Throwable) {
log("sendEvent cancel failed: ${error.javaClass.simpleName}")
}
}
private fun dispatchHomeTap(keyButtonView: Any) {
try {
callMethod(
keyButtonView,
"sendEvent",
arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
KeyEvent.ACTION_DOWN,
0,
)
callMethod(
keyButtonView,
"sendEvent",
arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
KeyEvent.ACTION_UP,
0,
)
} catch (error: Throwable) {
log("Guarded HOME tap failed: ${error.javaClass.simpleName}")
}
}
private inline fun proceedWithCanceledAction(
motionEvent: MotionEvent,
proceed: () -> Any?,
): Any? {
val originalAction = motionEvent.action
motionEvent.action = MotionEvent.ACTION_CANCEL
return try {
proceed()
} finally {
motionEvent.action = originalAction
}
}
private fun sendEventFlags(args: List<*>): Int {
return args.getOrNull(1) as? Int ?: 0
}
private fun Int.hasFlag(flag: Int): Boolean {
return this and flag == flag
}
}
@@ -29,15 +29,6 @@
android:textSize="30sp" android:textSize="30sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:lineSpacingExtra="3dp"
android:text="@string/settings_summary"
android:textColor="@color/settings_text_secondary"
android:textSize="15sp" />
<LinearLayout <LinearLayout
android:id="@+id/button_sections_container" android:id="@+id/button_sections_container"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_button_section"
android:elevation="2dp"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/side_arrows_section_title"
android:textColor="@color/settings_text_primary"
android:textSize="22sp"
android:textStyle="bold" />
<CheckBox
android:id="@+id/side_arrows_checkbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:buttonTint="@color/settings_label_text"
android:saveEnabled="false"
android:text="@string/side_arrows_enable_label"
android:textColor="@color/settings_text_primary"
android:textSize="16sp" />
<LinearLayout
android:id="@+id/side_arrows_options_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/long_press_action_label"
android:textColor="@color/settings_label_text"
android:textSize="13sp"
android:textStyle="bold" />
<Spinner
android:id="@+id/side_arrow_long_press_action_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:background="@drawable/bg_spinner_field"
android:dropDownWidth="match_parent"
android:paddingStart="12dp"
android:paddingTop="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp"
android:popupBackground="@drawable/bg_spinner_popup"
android:saveEnabled="false" />
<LinearLayout
android:id="@+id/side_arrow_repeat_speed_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/side_arrow_repeat_speed_label"
android:textColor="@color/settings_label_text"
android:textSize="13sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/side_arrow_speed_minus_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:minWidth="48dp"
android:text="@string/minus_button_label"
android:textAllCaps="false"
android:textSize="22sp" />
<EditText
android:id="@+id/side_arrow_repeat_speed_input"
android:layout_width="88dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:gravity="center"
android:importantForAutofill="no"
android:inputType="numberDecimal"
android:maxLength="6"
android:saveEnabled="false"
android:singleLine="true"
android:textColor="@color/settings_input_text"
android:textSize="18sp" />
<Button
android:id="@+id/side_arrow_speed_plus_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:minWidth="48dp"
android:text="@string/plus_button_label"
android:textAllCaps="false"
android:textSize="20sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/long_press_duration_label"
android:textColor="@color/settings_label_text"
android:textSize="13sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/side_arrow_duration_minus_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:minWidth="48dp"
android:text="@string/minus_button_label"
android:textAllCaps="false"
android:textSize="22sp" />
<EditText
android:id="@+id/side_arrow_long_press_duration_input"
android:layout_width="88dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:gravity="center"
android:importantForAutofill="no"
android:inputType="number"
android:maxLength="5"
android:saveEnabled="false"
android:singleLine="true"
android:textColor="@color/settings_input_text"
android:textSize="18sp" />
<Button
android:id="@+id/side_arrow_duration_plus_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
android:minWidth="48dp"
android:text="@string/plus_button_label"
android:textAllCaps="false"
android:textSize="20sp" />
</LinearLayout>
<Button
android:id="@+id/side_arrow_test_button"
android:layout_width="wrap_content"
android:layout_height="54dp"
android:layout_marginTop="14dp"
android:minWidth="160dp"
android:text="@string/test_button_label"
android:textAllCaps="false"
android:textSize="16sp" />
</LinearLayout>
</LinearLayout>
+8
View File
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.NavButtons" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">@drawable/bg_settings_window</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
</resources>
+6 -1
View File
@@ -2,10 +2,15 @@
<string name="app_name">NavButtons</string> <string name="app_name">NavButtons</string>
<string name="xposed_desc">Customize navigation button press and long-press actions.</string> <string name="xposed_desc">Customize navigation button press and long-press actions.</string>
<string name="settings_title">Nav Button Settings</string> <string name="settings_title">Nav Button Settings</string>
<string name="settings_summary">Choose what each navigation button should do on press and long press, then tune how long a long press should feel.</string>
<string name="button_back">Back Button</string> <string name="button_back">Back Button</string>
<string name="button_home">Home Button</string> <string name="button_home">Home Button</string>
<string name="button_recents">Recents Button</string> <string name="button_recents">Recents Button</string>
<string name="side_arrows_section_title">Side arrows</string>
<string name="side_arrows_enable_label">Enable side arrows</string>
<string name="side_arrow_long_press_nothing">Nothing</string>
<string name="side_arrow_long_press_single_step">Single step</string>
<string name="side_arrow_long_press_multiple_steps">Multiple steps</string>
<string name="side_arrow_repeat_speed_label">Repeat speed (steps/sec)</string>
<string name="press_action_label">When pressed</string> <string name="press_action_label">When pressed</string>
<string name="long_press_action_label">When long-pressed</string> <string name="long_press_action_label">When long-pressed</string>
<string name="long_press_duration_label">Long-press duration (ms)</string> <string name="long_press_duration_label">Long-press duration (ms)</string>
-1
View File
@@ -3,6 +3,5 @@
<item name="android:windowActionBar">false</item> <item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item> <item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">@drawable/bg_settings_window</item> <item name="android:windowBackground">@drawable/bg_settings_window</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style> </style>
</resources> </resources>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -1,3 +1,3 @@
minApiVersion=101 minApiVersion=101
targetApiVersion=101 targetApiVersion=102
staticScope=true staticScope=true
@@ -1,3 +1,6 @@
android android
system
com.android.systemui com.android.systemui
com.android.launcher3
com.google.android.apps.nexuslauncher
com.sec.android.app.launcher com.sec.android.app.launcher
-1
View File
@@ -1,4 +1,3 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id("com.android.application") version "9.1.1" apply false id("com.android.application") version "9.1.1" apply false
} }
-18
View File
@@ -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 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 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 android.nonTransitiveRClass=true
+1 -1
View File
@@ -1 +1 @@
version=1.0 version=2.4