434 lines
17 KiB
Kotlin
434 lines
17 KiB
Kotlin
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
|
|
}
|
|
}
|
|
}
|