Refactor NavButtons hook structure
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user