Refactor NavButtons hook structure

This commit is contained in:
ajp_anton
2026-07-01 02:51:42 +00:00
parent aaa471ec33
commit 4ba27bd9cf
24 changed files with 4301 additions and 3716 deletions
@@ -0,0 +1,191 @@
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 -> toggleAutoRotate()
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()
}
} else {
requestPrivilegedAction(action, null)
}
}
private 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,455 @@
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) {
when (action) {
NavButtonAction.STOCK,
NavButtonAction.NONE,
-> Unit
NavButtonAction.BACK -> {
if (!callTaskbarMethod(controller, "executeBack", arrayOf(KeyEvent::class.java), null)) {
actions.perform(action)
}
}
NavButtonAction.HOME -> {
if (!callTaskbarMethod(controller, "navigateHome", emptyArray())) {
actions.perform(action)
}
}
NavButtonAction.ASSISTANT -> {
if (!callTaskbarMethod(controller, "onLongPressHome", emptyArray())) {
actions.perform(action)
}
}
NavButtonAction.RECENTS -> {
if (!callTaskbarMethod(controller, "navigateToOverview", emptyArray())) {
actions.perform(action)
}
}
NavButtonAction.KILL_FOREGROUND_APP -> {
requestPrivilegedAction(PRIVILEGED_ACTION_KILL_FOREGROUND_APP, null)
}
NavButtonAction.TOGGLE_FLASHLIGHT -> {
requestPrivilegedAction(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT, null)
}
NavButtonAction.TOGGLE_AUTO_ROTATE -> 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,94 @@
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? {
if (info == null) {
return null
}
return try {
findField(info.javaClass, "packageName").get(info)?.toString()
} catch (_: Throwable) {
null
}
}
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,57 @@
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 try {
callStaticMethod(findClass(INPUT_MANAGER_CLASS, null), "getInstance")
} catch (_: Throwable) {
try {
callStaticMethod(findClass(INPUT_MANAGER_GLOBAL_CLASS, null), "getInstance")
} catch (_: Throwable) {
null
}
}
}
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,111 @@
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_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
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,157 @@
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 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_SEND_KEY_PRESS -> {
if (keyCode != KeyEvent.KEYCODE_UNKNOWN) {
sendKeyPress(keyCode)
}
}
}
}
private fun isAllowedSender(
context: Context,
intent: Intent,
senderPackage: String?,
senderUid: Int,
): Boolean {
if (senderPackage == SYSTEMUI_PACKAGE || senderPackage in AOSP_LAUNCHER_PACKAGES) {
return true
}
val caller = getParcelableExtraCompat(intent, EXTRA_PRIVILEGED_CALLER, PendingIntent::class.java)
if (caller?.creatorPackage == SYSTEMUI_PACKAGE || caller?.creatorPackage in AOSP_LAUNCHER_PACKAGES) {
return true
}
if (caller?.creatorUid == Process.SYSTEM_UID) {
return true
}
val expectedToken = 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 { it == SYSTEMUI_PACKAGE || it in AOSP_LAUNCHER_PACKAGES } == true
}
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,241 @@
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 ->
findMethodsNamed(target.javaClass, methodName)
.firstOrNull { method ->
val types = method.parameterTypes
types.size >= 2 &&
types[0] == Int::class.javaPrimitiveType &&
types[1] == Int::class.javaPrimitiveType
}
?.let { method ->
return try {
method.invoke(target, first, second)
true
} catch (_: Throwable) {
false
}
}
}
return false
}
internal fun callIntMethodIfExists(target: Any, methodName: String, value: Int): Boolean {
return findMethodsNamed(target.javaClass, methodName)
.firstOrNull { method ->
val types = method.parameterTypes
types.size == 1 && types[0] == Int::class.javaPrimitiveType
}
?.let { method ->
runCatching { method.invoke(target, value) }.isSuccess
} == true
}
internal fun callIntIntMethodIfExists(target: Any, methodName: String, first: Int, second: Int): Boolean {
return findMethodsNamed(target.javaClass, methodName)
.firstOrNull { method ->
val types = method.parameterTypes
types.size == 2 &&
types[0] == Int::class.javaPrimitiveType &&
types[1] == Int::class.javaPrimitiveType
}
?.let { method ->
runCatching { method.invoke(target, first, second) }.isSuccess
} == true
}
internal fun callFloatMethodIfExists(target: Any, methodName: String, value: Float): Boolean {
return findMethodsNamed(target.javaClass, methodName)
.firstOrNull { method ->
val types = method.parameterTypes
types.size == 1 && types[0] == Float::class.javaPrimitiveType
}
?.let { method ->
runCatching { method.invoke(target, value) }.isSuccess
} == true
}
internal fun callSetButtonImageIfExists(
target: Any,
id: Int,
lightDrawable: Drawable,
darkDrawable: Drawable,
): Boolean {
return findMethodsNamed(target.javaClass, "setButtonImage")
.firstOrNull { 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])
}
?.let { method ->
runCatching { method.invoke(target, id, lightDrawable, darkDrawable) }.isSuccess
} == true
}
internal fun callMethodIfExists(target: Any, methodName: String): Any? {
return findMethodsNamed(target.javaClass, methodName)
.firstOrNull { method -> method.parameterTypes.isEmpty() }
?.let { method ->
runCatching { method.invoke(target) }.getOrNull()
}
}
internal fun callBooleanMethodIfExists(target: Any, methodName: String, value: Boolean): Boolean {
return findMethodsNamed(target.javaClass, methodName)
.firstOrNull { method ->
val types = method.parameterTypes
types.size == 1 && types[0] == Boolean::class.javaPrimitiveType
}
?.let { method ->
runCatching { method.invoke(target, value) }.isSuccess
} ?: false
}
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,433 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.os.Handler
import android.os.SystemClock
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 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 handleNativeSideArrowTouch(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
homeStockLongPressGuard.cancel(navButtonView)
homeStockLongPressGuard.clear(navButtonView)
val result = chain.proceed()
rescheduleHoneyspaceLongPress(
navButtonView,
runnableClass,
configProvider(buttonId).longPressDurationMs,
)
homeStockLongPressGuard.schedule(navButtonView, buttonId)
result
}
MotionEvent.ACTION_UP -> {
val triggered = longPressTriggered[navButtonView] == true
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 -> {
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
}
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 handleNativeSideArrowTouch(
view: Any,
keyCode: Int,
event: MotionEvent,
proceed: () -> Any?,
): Any? {
return when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
sideArrowLongPresses.startAfter(view, keyCode, proceed)
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
sideArrowLongPresses.finish(view)
proceed()
}
else -> proceed()
}
}
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,174 @@
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 rescheduleHoneyspaceLongPress(
navButtonView: Any,
runnableClass: Class<*>,
durationMs: Int,
) {
val view = navButtonView as? View ?: return
val longPressRunnable = findHoneyspaceLongPressRunnable(navButtonView, runnableClass) ?: return
view.removeCallbacks(longPressRunnable)
view.postDelayed(longPressRunnable, durationMs.toLong())
}
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)"
}
@@ -0,0 +1,224 @@
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,
),
)
}
private 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
}
}
}
@@ -0,0 +1,76 @@
package se.ajpanton.navbuttons
import android.os.Handler
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 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,318 @@
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 handleNativeSideArrowTouch(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 handleNativeSideArrowTouch(
view: Any,
keyCode: Int,
event: MotionEvent,
proceed: () -> Any?,
): Any? {
return when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
sideArrowLongPresses.startAfter(view, keyCode, proceed)
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL,
-> {
sideArrowLongPresses.finish(view)
proceed()
}
else -> 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
}
}