diff --git a/app/src/main/java/se/ajpanton/navbuttons/ActionExecutor.kt b/app/src/main/java/se/ajpanton/navbuttons/ActionExecutor.kt new file mode 100644 index 0000000..5d874b3 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/ActionExecutor.kt @@ -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 + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/AospLauncherTaskbarHooks.kt b/app/src/main/java/se/ajpanton/navbuttons/AospLauncherTaskbarHooks.kt new file mode 100644 index 0000000..76b1f6c --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/AospLauncherTaskbarHooks.kt @@ -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() + private val controllers = WeakHashMap() + private val trackedContainers = Collections.newSetFromMap(WeakHashMap()) + private val buttons = WeakHashMap>() + private val sideArrows = WeakHashMap() + private val sideButtonTranslations = WeakHashMap() + 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>, + 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 + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/AospLauncherTaskbarSupport.kt b/app/src/main/java/se/ajpanton/navbuttons/AospLauncherTaskbarSupport.kt new file mode 100644 index 0000000..2484e15 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/AospLauncherTaskbarSupport.kt @@ -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, + sideButtonTranslations: WeakHashMap, +) { + 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, +) { + 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 +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/AospSystemUiHooks.kt b/app/src/main/java/se/ajpanton/navbuttons/AospSystemUiHooks.kt new file mode 100644 index 0000000..22ad10d --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/AospSystemUiHooks.kt @@ -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()) + private val sideArrowDirections = WeakHashMap() + 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::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(id) as? ViewGroup)?.removeAllViews() + } + } + } + + private fun trackInflatedSideArrows( + tokens: List, + 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 { + val result = mutableListOf() + 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>? { + 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>): 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 + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/AospTaskbarTouchController.kt b/app/src/main/java/se/ajpanton/navbuttons/AospTaskbarTouchController.kt new file mode 100644 index 0000000..0722ca6 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/AospTaskbarTouchController.kt @@ -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() + private val longPressTriggered = WeakHashMap() + + 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) + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/ForegroundAppSupport.kt b/app/src/main/java/se/ajpanton/navbuttons/ForegroundAppSupport.kt new file mode 100644 index 0000000..bea1ced --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/ForegroundAppSupport.kt @@ -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 + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/HomeStockLongPressGuard.kt b/app/src/main/java/se/ajpanton/navbuttons/HomeStockLongPressGuard.kt new file mode 100644 index 0000000..26f8707 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/HomeStockLongPressGuard.kt @@ -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() + private val canceled = WeakHashMap() + + 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 + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/InputInjectionSupport.kt b/app/src/main/java/se/ajpanton/navbuttons/InputInjectionSupport.kt new file mode 100644 index 0000000..9043992 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/InputInjectionSupport.kt @@ -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) + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtonConstants.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtonConstants.kt new file mode 100644 index 0000000..a6f9881 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtonConstants.kt @@ -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, + ) +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtonMappings.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtonMappings.kt new file mode 100644 index 0000000..d159ae5 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtonMappings.kt @@ -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 + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt index 79f6cdb..978aaa9 100644 --- a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt +++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt @@ -1,52 +1,17 @@ package se.ajpanton.navbuttons -import android.app.ActivityManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.ComponentName import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.SharedPreferences import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import android.graphics.Canvas -import android.graphics.Bitmap -import android.graphics.Color -import android.graphics.ColorFilter -import android.graphics.Paint -import android.graphics.Path -import android.graphics.PixelFormat -import android.graphics.Point -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable -import android.hardware.camera2.CameraAccessException -import android.hardware.camera2.CameraCharacteristics -import android.hardware.camera2.CameraManager -import android.os.Build import android.os.Handler import android.os.Looper -import android.os.Process import android.os.SystemClock -import android.provider.Settings import android.util.Log -import android.view.HapticFeedbackConstants -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.MotionEvent import android.view.View -import android.view.ViewConfiguration -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.Toast import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModuleInterface import org.lsposed.hiddenapibypass.HiddenApiBypass -import java.lang.reflect.Field import java.lang.reflect.Method -import java.util.Collections -import java.util.WeakHashMap class NavButtons() : XposedModule() { constructor( @@ -57,15 +22,6 @@ class NavButtons() : XposedModule() { onModuleLoaded(param) } - @Volatile - private var remotePreferences: SharedPreferences? = null - - @Volatile - private var remotePreferencesRetryAfterMs = 0L - - @Volatile - private var remotePreferencesLoading = false - private var context: Context? = null private var mainHandler: Handler? = null private var systemUiHookInstalled = false @@ -76,50 +32,127 @@ class NavButtons() : XposedModule() { private var loadedApkClassLoaderFallbackHookInstalled = false private var appBindFallbackHookInstalled = false private var lastHomeLongPressHandledAtMs = 0L - private var flashlightCameraId: String? = null - private var flashlightEnabled = false - private val systemUiLongPressTriggered = WeakHashMap() - private val systemUiLongPressRunnables = WeakHashMap() - private val systemUiHomeStockLongPressGuards = WeakHashMap() - private val systemUiHomeStockPressCanceled = WeakHashMap() - private val systemServerLongPressRunnables = mutableMapOf() - private val systemServerLongPressTriggered = mutableMapOf() - private val aospTaskbarButtonIds = WeakHashMap() - private val aospTaskbarControllers = WeakHashMap() - private val aospTaskbarLongPressRunnables = WeakHashMap() - private val aospTaskbarLongPressTriggered = WeakHashMap() - private val aospTaskbarBackLongPressTriggered = WeakHashMap() - private val trackedAospSystemUiInflaters = Collections.newSetFromMap(WeakHashMap()) - private val aospSystemUiSideArrowDirections = WeakHashMap() - private val trackedAospTaskbarContainers = Collections.newSetFromMap(WeakHashMap()) - private val aospTaskbarButtons = WeakHashMap>() - private val aospTaskbarSideArrows = WeakHashMap() - private val aospTaskbarSideButtonTranslations = WeakHashMap() - private val honeyspaceLongPressTriggered = WeakHashMap() - private val honeyspaceHomeStockLongPressGuards = WeakHashMap() - private val honeyspaceHomeStockPressCanceled = WeakHashMap() - private val sideArrowLongPressTriggered = WeakHashMap() - private val sideArrowLongPressRunnables = WeakHashMap() - private val sideArrowRepeatRunnables = WeakHashMap() private val targetHookRetryAttempts = mutableMapOf() - private val trackedHoneyspaceNavContainers = Collections.newSetFromMap(WeakHashMap()) - private val honeyspaceNativeSideArrowStates = WeakHashMap() - private val trackedSamsungNavigationBarViews = Collections.newSetFromMap(WeakHashMap()) - private val sideArrowLayoutKinds = WeakHashMap() - private var foldedSideArrowProviderHookInstalled = false - private var samsungNavigationBarViewHookInstalled = false - private var aospSystemUiSideArrowHookInstalled = false - private var aospTaskbarTintHookInstalled = false - private var aospSystemUiDarkIntensity = 0f - private var settingsChangedReceiverRegistered = false - private var privilegedActionReceiverRegistered = false - private var privilegedActionToken: String? = null - private val buttonConfigOverrides = mutableMapOf() - private var sideArrowsEnabledOverride: Boolean? = null - private var sideArrowLongPressActionOverride: SideArrowLongPressAction? = null - private var sideArrowLongPressDurationOverride: Int? = null - private var sideArrowRepeatSpeedOverride: Float? = null - + private val settings: SettingsRepository = SettingsRepository( + isSystemServerProcess = { systemServerProcess }, + contextProvider = { context }, + contextUpdater = ::updateContext, + currentApplicationProvider = ::getCurrentApplication, + remotePreferencesProvider = { getRemotePreferences(it) }, + log = { message -> log(message) }, + onChanged = { + samsungSystemUiHooks.refreshNavigationBars() + aospSystemUiHooks.refreshSideArrowLayouts() + samsungHoneyspaceHooks.refreshSideArrowLayouts("settings changed") + aospLauncherTaskbarHooks.refreshSideArrows() + }, + ) + private val actions: ActionExecutor = ActionExecutor( + contextProvider = { context }, + handlerProvider = ::mainHandler, + requestPrivilegedAction = ::requestPrivilegedAction, + log = ::log, + logError = { message, error -> log(message, error) }, + ) + private val privilegedActions: PrivilegedActionBridge = PrivilegedActionBridge( + contextProvider = { context }, + contextUpdater = ::updateContext, + currentApplicationProvider = ::getCurrentApplication, + settingsPreferencesProvider = ::getSettingsPreferences, + log = { message, error -> + if (error == null) { + log(message) + } else { + log(message, error) + } + }, + killForegroundApp = actions::killForegroundApp, + toggleFlashlight = actions::toggleFlashlight, + sendKeyPress = actions::sendKeyPress, + ) + private val sideArrowLongPresses: SideArrowLongPressController = SideArrowLongPressController( + handlerProvider = ::mainHandler, + actionProvider = { settings.sideArrowLongPressAction() }, + durationProvider = { settings.sideArrowLongPressDurationMs() }, + repeatIntervalProvider = { settings.sideArrowRepeatIntervalMs() }, + sendKeyPress = actions::sendSideArrowKeyPress, + ) + private val aospSystemUiHooks: AospSystemUiHooks = AospSystemUiHooks( + hookMethod = ::hookIntercepting, + deoptimizeMethod = ::deoptimizeMethodIfPresent, + sideArrowsEnabled = ::isSideArrowsEnabled, + registerSettingsReceiver = ::registerSettingsChangedReceiver, + contextUpdater = ::updateContext, + log = ::log, + ) + private val samsungSystemUiHooks: SamsungSystemUiHooks = SamsungSystemUiHooks( + hookMethod = ::hookIntercepting, + deoptimizeMethod = ::deoptimizeMethodIfPresent, + sideArrowsEnabled = ::isSideArrowsEnabled, + registerSettingsReceiver = ::registerSettingsChangedReceiver, + contextProvider = { context }, + contextUpdater = ::updateContext, + handlerProvider = { mainHandler }, + log = ::log, + ) + private val aospLauncherTaskbarHooks: AospLauncherTaskbarHooks = AospLauncherTaskbarHooks( + hookMethod = ::hookIntercepting, + deoptimizeMethod = ::deoptimizeMethodIfPresent, + configProvider = ::getButtonConfig, + sideArrowsEnabled = ::isSideArrowsEnabled, + sideArrowLongPresses = sideArrowLongPresses, + actions = actions, + registerSettingsReceiver = ::registerSettingsChangedReceiver, + contextUpdater = ::updateContext, + requestPrivilegedAction = ::requestPrivilegedAction, + wasHomeLongPressHandledRecently = ::wasHomeLongPressHandledRecently, + noteHomeLongPressHandled = ::noteHomeLongPressHandled, + log = ::log, + ) + private val systemServerHooks = SystemServerHooks( + hookMethod = ::hookIntercepting, + handlerProvider = ::mainHandler, + configProvider = ::getButtonConfig, + updateContextFromField = ::updateContextFromField, + registerSettingsReceiver = ::registerSettingsChangedReceiver, + registerPrivilegedReceiver = ::registerPrivilegedActionReceiver, + performAction = ::performConfiguredAction, + performHomeLongPressAction = ::performHomeLongPressAction, + contextUpdater = ::updateContext, + log = ::log, + ) + private val systemUiNavHooks: SystemUiNavHooks = SystemUiNavHooks( + hookMethod = ::hookIntercepting, + deoptimizeMethod = ::deoptimizeMethodIfPresent, + prepareLongPressHook = ::prepareLongPressHook, + configProvider = ::getButtonConfig, + sideArrowLongPresses = sideArrowLongPresses, + registerSettingsReceiver = ::registerSettingsChangedReceiver, + contextUpdater = ::updateContext, + performAction = ::performConfiguredAction, + performHomeLongPressAction = ::performHomeLongPressAction, + installSamsungSideArrows = samsungSystemUiHooks::installFoldedSideArrows, + trackSamsungNavigationBarView = samsungSystemUiHooks::trackNavigationBarView, + installAospSideArrows = aospSystemUiHooks::installSideArrows, + configureAospSideArrowButton = aospSystemUiHooks::configureSideArrowButton, + log = ::log, + logError = { message, error -> log(message, error) }, + ) + private val samsungHoneyspaceHooks: SamsungHoneyspaceHooks = SamsungHoneyspaceHooks( + hookMethod = ::hookIntercepting, + deoptimizeMethod = ::deoptimizeMethodIfPresent, + prepareLongPressHook = ::prepareLongPressHook, + configProvider = ::getButtonConfig, + sideArrowsEnabled = ::isSideArrowsEnabled, + sideArrowLongPresses = sideArrowLongPresses, + registerSettingsReceiver = ::registerSettingsChangedReceiver, + contextProvider = { context }, + contextUpdater = ::updateContext, + handlerProvider = { mainHandler }, + performAction = ::performConfiguredAction, + sendKeyPress = ::sendKeyPress, + log = ::log, + ) override fun onModuleLoaded(param: XposedModuleInterface.ModuleLoadedParam) { if (!HiddenApiBypass.addHiddenApiExemptions("")) { log("HiddenApiBypass could not be enabled.") @@ -157,7 +190,7 @@ class NavButtons() : XposedModule() { } systemServerProcess = true initContextAndHandler() - systemServerHookInstalled = hookSystemServerHomeLongPress(param.classLoader) + systemServerHookInstalled = systemServerHooks.install(param.classLoader) } private fun handleTargetPackage(packageName: String, classLoader: ClassLoader?) { @@ -166,7 +199,7 @@ class NavButtons() : XposedModule() { return } initContextAndHandler() - systemUiHookInstalled = hookSystemUiNavigationButtons(classLoader) + systemUiHookInstalled = systemUiNavHooks.install(classLoader) if (!systemUiHookInstalled) { scheduleTargetHookRetry(packageName) } @@ -178,7 +211,7 @@ class NavButtons() : XposedModule() { return } initContextAndHandler() - launcherHookInstalled = hookHoneyspaceTaskbar(classLoader) + launcherHookInstalled = samsungHoneyspaceHooks.install(classLoader) return } @@ -187,7 +220,7 @@ class NavButtons() : XposedModule() { return } initContextAndHandler() - aospTaskbarHookInstalled = hookAospTaskbar(classLoader) + aospTaskbarHookInstalled = aospLauncherTaskbarHooks.install(classLoader) if (!aospTaskbarHookInstalled) { scheduleTargetHookRetry(packageName) } @@ -313,60 +346,7 @@ class NavButtons() : XposedModule() { } } - private fun getSettingsPreferences(): SharedPreferences? { - remotePreferences?.let { return it } - if (systemServerProcess) { - return null - } - if (Looper.myLooper() == Looper.getMainLooper()) { - startSettingsPreferencesLoad() - return null - } - return loadSettingsPreferences() - } - - private fun startSettingsPreferencesLoad() { - if (remotePreferences != null || remotePreferencesLoading) { - return - } - if (context?.packageName == ANDROID_PACKAGE) { - return - } - val now = SystemClock.uptimeMillis() - if (now < remotePreferencesRetryAfterMs) { - return - } - remotePreferencesLoading = true - Thread { - try { - repeat(REMOTE_PREFERENCES_LOAD_ATTEMPTS) { - if (loadSettingsPreferences() != null) { - return@Thread - } - Thread.sleep(REMOTE_PREFERENCES_RETRY_DELAY_MS) - } - } finally { - remotePreferencesLoading = false - } - }.start() - } - - private fun loadSettingsPreferences(): SharedPreferences? { - remotePreferences?.let { return it } - val now = SystemClock.uptimeMillis() - if (now < remotePreferencesRetryAfterMs) { - return null - } - return runCatching { - getRemotePreferences(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 getSettingsPreferences() = settings.preferences() private fun getCurrentApplication(): Context? { return try { @@ -378,2983 +358,47 @@ class NavButtons() : XposedModule() { } } - private fun hookSystemUiNavigationButtons(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 - } - - deoptimizeMethodIfPresent(onTouchEventMethod) - prepareLongPressHook(runMethod) - sendEventMethods.forEach(::deoptimizeMethodIfPresent) - sendEventMethods.forEach(::hookSystemUiSendEvent) - hookFoldedSideArrows(classLoader) - hookAospSystemUiSideArrows(classLoader, keyButtonClass) - - hook(onTouchEventMethod).intercept { chain -> - val keyButtonView = chain.thisObject ?: return@intercept chain.proceed() - val motionEvent = chain.args[0] as? MotionEvent ?: return@intercept chain.proceed() - val keyCode = getIntField(keyButtonView, "mCode") - if (isSideArrowKeyCode(keyCode)) { - ensureContextFromView(keyButtonView) - registerSettingsChangedReceiver() - trackSamsungNavigationBarView(keyButtonView) - return@intercept handleNativeSideArrowTouch(keyButtonView, keyCode, motionEvent) { - chain.proceed() - } - } - val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() - - when (motionEvent.actionMasked) { - MotionEvent.ACTION_DOWN -> { - ensureContextFromView(keyButtonView) - registerSettingsChangedReceiver() - trackSamsungNavigationBarView(keyButtonView) - systemUiLongPressTriggered[keyButtonView] = false - cancelSystemUiHomeStockLongPressGuard(keyButtonView) - systemUiHomeStockPressCanceled.remove(keyButtonView) - val result = chain.proceed() - scheduleSystemUiLongPress(keyButtonView, buttonId) - scheduleSystemUiHomeStockLongPressGuard(keyButtonView, buttonId) - result - } - - MotionEvent.ACTION_UP -> { - if (systemUiLongPressTriggered[keyButtonView] == true) { - return@intercept proceedWithCanceledAction(motionEvent) { - chain.proceed() - }.also { - cancelSystemUiHomeStockLongPressGuard(keyButtonView) - cancelSystemUiLongPress(keyButtonView) - systemUiLongPressTriggered.remove(keyButtonView) - systemUiHomeStockPressCanceled.remove(keyButtonView) - } - } - - val buttonConfig = getButtonConfig(buttonId) - val pressAction = buttonConfig.pressAction - val stockHomePressCanceled = - buttonId == NavButtonId.HOME && systemUiHomeStockPressCanceled[keyButtonView] == true - val shouldOverridePress = - pressAction != NavButtonAction.STOCK && - ((keyButtonView as? View)?.isPressed == true || stockHomePressCanceled) && - getFieldValue(keyButtonView, "mLongClicked") != true - - if (shouldOverridePress) { - markSystemUiLongClicked(keyButtonView) - } - - val result = chain.proceed() - if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) { - dispatchSystemUiHomeTap(keyButtonView) - } - if (shouldOverridePress) { - updateContextFromView(keyButtonView) - performConfiguredAction(pressAction) - } - cancelSystemUiHomeStockLongPressGuard(keyButtonView) - cancelSystemUiLongPress(keyButtonView) - systemUiLongPressTriggered.remove(keyButtonView) - systemUiHomeStockPressCanceled.remove(keyButtonView) - result - } - - MotionEvent.ACTION_CANCEL -> { - cancelSystemUiHomeStockLongPressGuard(keyButtonView) - cancelSystemUiLongPress(keyButtonView) - systemUiLongPressTriggered.remove(keyButtonView) - systemUiHomeStockPressCanceled.remove(keyButtonView) - chain.proceed() - } - - else -> chain.proceed() - } - } - - hook(runMethod).intercept { chain -> - val keyButtonView = getFieldValue(chain.thisObject, "this\$0") - ?: return@intercept chain.proceed() - val keyCode = getIntField(keyButtonView, "mCode") - if (isSideArrowKeyCode(keyCode)) { - return@intercept null - } - val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() - val longPressAction = getButtonConfig(buttonId).longPressAction - if (longPressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - - updateContextFromView(keyButtonView) - systemUiLongPressTriggered[keyButtonView] = true - cancelSystemUiKeyPress(keyButtonView) - markSystemUiLongClicked(keyButtonView) - if (buttonId == NavButtonId.HOME) { - performHomeLongPressAction(longPressAction) - } else { - performConfiguredAction(longPressAction) - } - null - } - return true - } - - private fun hookSystemUiSendEvent(sendEventMethod: Method) { - hook(sendEventMethod).intercept { chain -> - val keyButtonView = chain.thisObject ?: return@intercept chain.proceed() - val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode")) - ?: return@intercept chain.proceed() - if (buttonId != NavButtonId.HOME) { - return@intercept chain.proceed() - } - - val action = chain.args.firstOrNull() as? Int ?: return@intercept chain.proceed() - val longPressAction = getButtonConfig(buttonId).longPressAction - if (longPressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - - if (action == KeyEvent.ACTION_DOWN && sendEventFlags(chain.args).hasFlag(KeyEvent.FLAG_LONG_PRESS)) { - return@intercept null - } - - chain.proceed() - } - } - - private fun hookSystemServerHomeLongPress(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 -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val result = chain.proceed() - context = chain.args.firstOrNull() as? Context - registerSettingsChangedReceiver() - registerPrivilegedActionReceiver() - result - } - installed = true - } - - keyDispatchMethods.forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent - ?: return@intercept chain.proceed() - val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@intercept chain.proceed() - val config = getButtonConfig(buttonId) - if (config.pressAction == NavButtonAction.STOCK && - config.longPressAction == NavButtonAction.STOCK - ) { - return@intercept chain.proceed() - } - - updateContextFromField(chain.thisObject, "mContext") - if (handleSystemServerKeyOverride(keyEvent, buttonId, config)) { - return@intercept -1L - } - chain.proceed() - } - installed = true - } - - keyQueueingMethods.forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent - ?: return@intercept chain.proceed() - val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@intercept chain.proceed() - val config = getButtonConfig(buttonId) - if (config.pressAction == NavButtonAction.STOCK && - config.longPressAction == NavButtonAction.STOCK - ) { - return@intercept chain.proceed() - } - - updateContextFromField(chain.thisObject, "mContext") - val consume = handleSystemServerKeyOverride(keyEvent, buttonId, config) - val result = chain.proceed() as? Int ?: 0 - if (consume) result and ACTION_PASS_TO_USER.inv() else result - } - installed = true - } - - if (longPressOnHomeWithEvent != null) { - deoptimizeMethodIfPresent(longPressOnHomeWithEvent) - hook(longPressOnHomeWithEvent).intercept { chain -> - val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction - if (longPressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - updateContextFromField(chain.thisObject, "mContext") - performHomeLongPressAction(longPressAction) - null - } - installed = true - } - - if (longPressOnHomeLegacy != null) { - deoptimizeMethodIfPresent(longPressOnHomeLegacy) - hook(longPressOnHomeLegacy).intercept { chain -> - val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction - if (longPressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - updateContextFromField(chain.thisObject, "mContext") - performHomeLongPressAction(longPressAction) - null - } - installed = true - } - - if (launchAssistActionMethod != null) { - deoptimizeMethodIfPresent(launchAssistActionMethod) - hook(launchAssistActionMethod).intercept { chain -> - val invocationType = chain.args[3] as? Int ?: ASSIST_INVOCATION_TYPE_UNKNOWN - if (invocationType != ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS) { - return@intercept chain.proceed() - } - - val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction - if (longPressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - - updateContextFromField(chain.thisObject, "mContext") - performHomeLongPressAction(longPressAction) - null - } - installed = true - } - return installed - } - - private fun hookAospSystemUiSideArrows(classLoader: ClassLoader?, keyButtonClass: Class<*>) { - if (aospSystemUiSideArrowHookInstalled || - Build.MANUFACTURER.equals("samsung", ignoreCase = true) - ) { - return - } - val inflaterClass = findFirstClassIfExists(AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES, classLoader) ?: return - aospSystemUiSideArrowHookInstalled = true - registerSettingsChangedReceiver() - hookAospSystemUiDarkIntensity(classLoader) - - findMethodIfExists(inflaterClass, "getDefaultLayout")?.let { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - chain.thisObject?.let(::trackAospSystemUiInflater) - val layout = chain.proceed() as? String ?: return@intercept null - val result = if (isSideArrowsEnabled()) { - addAospSystemUiSideArrows(layout) - } else { - removeAospSystemUiSideArrows(layout) - } - result - } - } - - findMethodIfExists(inflaterClass, "inflateLayout", String::class.java)?.let { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - chain.thisObject?.let(::trackAospSystemUiInflater) - val layout = chain.args.getOrNull(0) as? String - if (layout != null) { - chain.args[0] = if (isSideArrowsEnabled()) { - addAospSystemUiSideArrows(layout) - } else { - removeAospSystemUiSideArrows(layout) - } - } - chain.proceed() - } - } - - findMethodIfExists( - inflaterClass, - "inflateButtons", - Array::class.java, - ViewGroup::class.java, - Boolean::class.javaPrimitiveType!!, - Boolean::class.javaPrimitiveType!!, - )?.let { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val tokens = (chain.args.getOrNull(0) as? Array<*>) - ?.mapNotNull { it as? String } - ?: return@intercept chain.proceed() - val parent = chain.args.getOrNull(1) as? ViewGroup - ?: return@intercept chain.proceed() - val childCountBefore = parent.childCount - val result = chain.proceed() - trackInflatedAospSystemUiSideArrows(tokens, parent, childCountBefore) - result - } - } - - } - - private fun trackAospSystemUiInflater(inflater: Any) { - trackedAospSystemUiInflaters.add(inflater) - (inflater as? View)?.let { - ensureContextFromView(it) - registerSettingsChangedReceiver() - } - } - - private fun refreshTrackedAospSystemUiSideArrowLayouts() { - trackedAospSystemUiInflaters.toList().forEach { inflater -> - refreshAospSystemUiSideArrowLayout(inflater) - } - } - - private fun refreshAospSystemUiSideArrowLayout(inflater: Any) { - try { - clearAospSystemUiInflaterViews(inflater) - callMethod(inflater, "inflateLayout", arrayOf(String::class.java), null) - } catch (error: Throwable) { - log("AOSP SystemUI side arrows refresh failed: ${error.javaClass.simpleName}") - } - } - - private fun clearAospSystemUiInflaterViews(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(id) as? ViewGroup)?.removeAllViews() - } - } - } - - 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 splitAospSystemUiLayout(layout: String): List>? { - 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>): 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 - } - } - - private fun layoutButtonName(token: String): String { - return token.substringBefore("[") - } - - private fun trackInflatedAospSystemUiSideArrows( - tokens: List, - 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 -> findAospSystemUiKeyButtons(parent.getChildAt(index)) } - .toMutableList() - directions.forEach { direction -> - val keyCode = when (direction) { - SideArrowDirection.LEFT -> KeyEvent.KEYCODE_DPAD_LEFT - SideArrowDirection.RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT - } - val button = buttons.firstOrNull { getIntField(it, "mCode") == keyCode } ?: return@forEach - buttons.remove(button) - aospSystemUiSideArrowDirections[button] = direction - configureAospSystemUiSideArrowButton(button) - } - } - - private fun hookAospSystemUiDarkIntensity(classLoader: ClassLoader?) { - val transitionsClass = findFirstClassIfExists( - AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES, - classLoader, - ) ?: return - findMethodIfExists( - transitionsClass, - "applyDarkIntensity", - Float::class.javaPrimitiveType!!, - )?.let { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val result = chain.proceed() - aospSystemUiDarkIntensity = (chain.args.getOrNull(0) as? Float)?.coerceIn(0f, 1f) - ?: aospSystemUiDarkIntensity - updateAospSystemUiSideArrowDarkIntensity() - result - } - } - } - - private fun updateAospSystemUiSideArrowDarkIntensity() { - aospSystemUiSideArrowDirections.keys.toList().forEach { view -> - callFloatMethodIfExists(view, "setDarkIntensity", aospSystemUiDarkIntensity) - setFieldIfExists(view, "mDarkIntensity", aospSystemUiDarkIntensity) - } - } - - private fun findAospSystemUiKeyButtons(view: View): List { - val result = mutableListOf() - if (view is ImageView && getFieldValue(view, "mCode") != null) { - result.add(view) - } - if (view is ViewGroup) { - repeat(view.childCount) { index -> - result.addAll(findAospSystemUiKeyButtons(view.getChildAt(index))) - } - } - return result - } - - private fun configureAospSystemUiSideArrowButton(value: Any?) { - val view = value as? ImageView ?: return - val direction = aospSystemUiSideArrowDirections[view] ?: return - ensureContextFromView(view) - createAospSystemUiSideArrowDrawable(view, direction)?.let { - view.setImageDrawable(it) - callFloatMethodIfExists(view, "setDarkIntensity", aospSystemUiDarkIntensity) - } - } - - private fun createAospSystemUiSideArrowDrawable( - 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", aospSystemUiDarkIntensity) - } - } catch (error: Throwable) { - log("AOSP SystemUI side arrow drawable failed: ${error.javaClass.simpleName}") - null - } - } - - private fun hookAospTaskbar(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!!, - ) - - deoptimizeMethodIfPresent(clickMethod) - deoptimizeMethodIfPresent(longClickMethod) - deoptimizeMethodIfPresent(sendBackKeyEventMethod) - hookAospTaskbarButtonMapping(navButtonsViewControllerClass, controllerClass) - hookAospTaskbarTintUpdates() - registerSettingsChangedReceiver() - - hook(clickMethod).intercept { chain -> - val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int) - ?: return@intercept chain.proceed() - val pressAction = getButtonConfig(buttonId).pressAction - if (pressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - - updateContextFromTaskbar(chain.thisObject, chain.args.firstOrNull { it is View }) - performAospTaskbarAction(chain.thisObject, pressAction) - null - } - - hook(longClickMethod).intercept { chain -> - val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int) - ?: return@intercept chain.proceed() - val longPressAction = getButtonConfig(buttonId).longPressAction - if (longPressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - - val view = chain.args.firstOrNull { it is View } - if (view != null && aospTaskbarButtonIds.containsKey(view)) { - return@intercept true - } - - updateContextFromTaskbar(chain.thisObject, view) - if (buttonId == NavButtonId.HOME) { - performAospTaskbarHomeLongPressAction(chain.thisObject, longPressAction) - } else { - performAospTaskbarAction(chain.thisObject, longPressAction) - } - true - } - - if (sendBackKeyEventMethod != null) { - hook(sendBackKeyEventMethod).intercept { chain -> - val pressAction = getButtonConfig(NavButtonId.BACK).pressAction - val longPressAction = getButtonConfig(NavButtonId.BACK).longPressAction - val action = chain.args[0] as? Int ?: return@intercept chain.proceed() - val customLongPress = longPressAction != NavButtonAction.STOCK - - if (pressAction == NavButtonAction.STOCK && !customLongPress) { - return@intercept chain.proceed() - } - - updateContextFromField(chain.thisObject, "mContext") - - if (action == KeyEvent.ACTION_UP) { - aospTaskbarBackLongPressTriggered.remove(chain.thisObject) - } - - null - } - } - - return true - } - - private fun hookAospTaskbarButtonMapping( - 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 - deoptimizeMethodIfPresent(addButtonMethod) - hook(addButtonMethod).intercept { chain -> - val result = chain.proceed() - val view = result as? View ?: return@intercept result - trackAospTaskbarButton( - view, - chain.args.getOrNull(1) as? Int, - chain.args.getOrNull(3), - chain.args.getOrNull(2) as? ViewGroup, - ) - result - } - } - } - - private fun hookAospTaskbarTintUpdates() { - if (aospTaskbarTintHookInstalled) { - return - } - aospTaskbarTintHookInstalled = true - - findDeclaredMethodsNamed(ImageView::class.java, "setImageTintList").forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val result = chain.proceed() - refreshAospTaskbarSideArrowTint(chain.thisObject as? ImageView) - result - } - } - - findDeclaredMethodsNamed(ImageView::class.java, "setColorFilter").forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val result = chain.proceed() - refreshAospTaskbarSideArrowTint(chain.thisObject as? ImageView) - result - } - } - } - - private fun trackAospTaskbarButton( - view: View, - buttonCode: Int?, - controller: Any?, - parent: ViewGroup?, - ) { - val buttonId = buttonIdForAospTaskbarCode(buttonCode) ?: return - aospTaskbarButtonIds[view] = buttonId - if (controller != null) { - aospTaskbarControllers[view] = controller - } - view.setOnTouchListener { touchedView, event -> - handleAospTaskbarTouch(touchedView, event) - } - - ensureContextFromView(view) - registerSettingsChangedReceiver() - val container = (view.parent as? ViewGroup) ?: parent ?: return - aospTaskbarButtons.getOrPut(container) { mutableMapOf() }[buttonId] = view - trackAospTaskbarContainer(container) - refreshAospTaskbarSideArrows(container) - } - - private fun trackAospTaskbarContainer(container: ViewGroup) { - if (trackedAospTaskbarContainers.add(container)) { - container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> - refreshAospTaskbarSideArrows(view as ViewGroup) - } - } - } - - private fun refreshTrackedAospTaskbarSideArrows() { - trackedAospTaskbarContainers.toList().forEach { container -> - refreshAospTaskbarSideArrows(container) - } - } - - private fun refreshAospTaskbarSideArrows(container: ViewGroup) { - try { - val buttons = aospTaskbarButtons[container].orEmpty() - val home = buttons[NavButtonId.HOME] as? ImageView ?: return - if (home.parent != container) { - return - } - - if (!isSideArrowsEnabled()) { - removeAospSideArrows(container) - resetAospTaskbarSideButtonTranslations(container) - return - } - - val arrows = aospTaskbarSideArrows.getOrPut(container) { - AospSideArrowViews( - createAospSideArrowView(container, home, SideArrowDirection.LEFT), - createAospSideArrowView(container, home, SideArrowDirection.RIGHT), - ) - } - configureAospSideArrowView(arrows.left, home, SideArrowDirection.LEFT) - configureAospSideArrowView(arrows.right, home, SideArrowDirection.RIGHT) - placeAospSideArrows(container, home, arrows) - applyAospSideArrowTint(arrows, home) - } catch (error: Throwable) { - log("AOSP side arrows refresh failed: ${error.javaClass.simpleName}") - } - } - - private fun createAospSideArrowView( - 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 -> - handleAospSideArrowTouch( - view, - if (direction == SideArrowDirection.LEFT) { - KeyEvent.KEYCODE_DPAD_LEFT - } else { - KeyEvent.KEYCODE_DPAD_RIGHT - }, - event, - ) - } - configureAospSideArrowView(this, home, direction) - } - } - - private fun configureAospSideArrowView( - 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 placeAospSideArrows( - container: ViewGroup, - home: ImageView, - arrows: AospSideArrowViews, - ) { - val host = findAospTaskbarArrowHost(container) ?: return - val buttons = aospTaskbarButtons[container].orEmpty() - 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)) - translateAospTaskbarSideButton(rightButton, aospTaskbarSideButtonShift(arrows.right)) - placeAospTaskbarArrow(arrows.left, midpoint(centerInHost(leftButton, host), homeCenter)) - placeAospTaskbarArrow(arrows.right, midpoint(centerInHost(rightButton, host), homeCenter)) - } - - private fun translateAospTaskbarSideButton(button: View, shiftPx: Float) { - val originalTranslation = aospTaskbarSideButtonTranslations.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): android.widget.FrameLayout? { - var current = start.parent as? View - repeat(5) { - if (current is android.widget.FrameLayout) { - return current as android.widget.FrameLayout - } - current = current?.parent as? View - } - return null - } - - private fun addAospTaskbarArrowToHost( - host: android.widget.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, android.widget.FrameLayout.LayoutParams(width, height)) - } else { - arrow.layoutParams = (arrow.layoutParams as? android.widget.FrameLayout.LayoutParams) - ?.apply { - this.width = width - this.height = height - } - ?: android.widget.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 - } - - private fun removeAospSideArrows(container: ViewGroup) { - aospTaskbarSideArrows[container]?.let { arrows -> - removeFromParent(arrows.left) - removeFromParent(arrows.right) - } - } - - private fun resetAospTaskbarSideButtonTranslations(container: ViewGroup) { - aospTaskbarButtons[container].orEmpty().values.forEach { button -> - aospTaskbarSideButtonTranslations.remove(button)?.let { button.translationX = it } - } - } - - private fun removeFromParent(view: View) { - (view.parent as? ViewGroup)?.removeView(view) - } - - private fun applyAospSideArrowTint(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 refreshAospTaskbarSideArrowTint(source: ImageView?) { - val container = source?.parent as? ViewGroup ?: return - val buttons = aospTaskbarButtons[container] ?: return - val arrows = aospTaskbarSideArrows[container] ?: return - if (source !in buttons.values) { - return - } - val tintSource = (buttons[NavButtonId.HOME] ?: buttons.values.firstOrNull()) as? ImageView ?: return - applyAospSideArrowTint(arrows, tintSource) - } - - private fun handleAospSideArrowTouch(view: View, keyCode: Int, event: MotionEvent): Boolean { - updateContextFromView(view) - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - view.isPressed = true - sideArrowLongPressTriggered[view] = false - cancelSideArrowLongPress(view) - scheduleSideArrowLongPress(view, keyCode) - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL, - -> { - val triggered = sideArrowLongPressTriggered[view] == true - cancelSideArrowLongPress(view) - sideArrowLongPressTriggered.remove(view) - view.isPressed = false - if (event.actionMasked == MotionEvent.ACTION_UP && !triggered) { - view.performClick() - sendSideArrowKeyPress(keyCode) - } - } - } - return true - } - - private fun handleNativeSideArrowTouch( - view: Any, - keyCode: Int, - event: MotionEvent, - proceed: () -> Any?, - ): Any? { - return when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - sideArrowLongPressTriggered[view] = false - cancelSideArrowLongPress(view) - val result = proceed() - scheduleSideArrowLongPress(view, keyCode) - result - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL, - -> { - cancelSideArrowLongPress(view) - sideArrowLongPressTriggered.remove(view) - proceed() - } - - else -> proceed() - } - } - - private fun handleAospTaskbarTouch(view: View, event: MotionEvent): Boolean { - val buttonId = aospTaskbarButtonIds[view] ?: return false - val controller = aospTaskbarControllers[view] ?: return false - val config = getButtonConfig(buttonId) - if (config.pressAction == NavButtonAction.STOCK && - config.longPressAction == NavButtonAction.STOCK - ) { - return false - } - - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - aospTaskbarLongPressTriggered[view] = false - if (buttonId == NavButtonId.BACK) { - aospTaskbarBackLongPressTriggered[controller] = false - } - if (config.longPressAction != NavButtonAction.STOCK) { - scheduleAospTaskbarLongPress(view, controller, buttonId, config) - } - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL, - -> { - val triggered = aospTaskbarLongPressTriggered[view] == true - cancelAospTaskbarLongPress(view) - aospTaskbarLongPressTriggered.remove(view) - if (event.actionMasked == MotionEvent.ACTION_CANCEL && buttonId == NavButtonId.BACK) { - aospTaskbarBackLongPressTriggered.remove(controller) - } - if (triggered) { - return true - } - if (event.actionMasked == MotionEvent.ACTION_UP) { - when { - config.pressAction != NavButtonAction.STOCK -> { - performAospTaskbarAction(controller, config.pressAction) - return true - } - - config.longPressAction != NavButtonAction.STOCK && - event.eventTime - event.downTime >= ViewConfiguration.getLongPressTimeout() -> { - performAospTaskbarAction(controller, stockActionFor(buttonId)) - return true - } - - buttonId == NavButtonId.BACK -> { - performAospTaskbarAction(controller, NavButtonAction.BACK) - return true - } - } - } - } - } - - return false - } - - private fun scheduleAospTaskbarLongPress( - view: View, - controller: Any, - buttonId: NavButtonId, - config: NavButtonConfig, - ) { - cancelAospTaskbarLongPress(view) - val longPressRunnable = Runnable { - aospTaskbarLongPressTriggered[view] = true - if (buttonId == NavButtonId.BACK) { - aospTaskbarBackLongPressTriggered[controller] = true - } - updateContextFromTaskbar(controller, view) - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - if (buttonId == NavButtonId.HOME) { - performAospTaskbarHomeLongPressAction(controller, config.longPressAction) - } else { - performAospTaskbarAction(controller, config.longPressAction) - } - } - aospTaskbarLongPressRunnables[view] = longPressRunnable - view.postDelayed(longPressRunnable, config.longPressDurationMs.toLong()) - } - - private fun stockActionFor(buttonId: NavButtonId): NavButtonAction { - return when (buttonId) { - NavButtonId.BACK -> NavButtonAction.BACK - NavButtonId.HOME -> NavButtonAction.HOME - NavButtonId.RECENTS -> NavButtonAction.RECENTS - } - } - - private fun cancelAospTaskbarLongPress(view: View) { - aospTaskbarLongPressRunnables.remove(view)?.let(view::removeCallbacks) - } - - private fun hookHoneyspaceTaskbar(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 - } - - deoptimizeMethodIfPresent(onTouchEventMethod) - prepareLongPressHook(runMethod) - hookHoneyspaceSideArrowLayout(classLoader) - - hook(onTouchEventMethod).intercept { chain -> - val navButtonView = chain.thisObject ?: return@intercept chain.proceed() - val motionEvent = chain.args[0] as? MotionEvent ?: return@intercept chain.proceed() - val keyCode = callMethod(navButtonView, "getKeyCode") as? Int ?: Int.MIN_VALUE - if (isSideArrowKeyCode(keyCode)) { - ensureContextFromView(navButtonView) - registerSettingsChangedReceiver() - trackHoneyspaceNavContainerFrom(navButtonView as? View) - - return@intercept handleNativeSideArrowTouch(navButtonView, keyCode, motionEvent) { - chain.proceed() - } - } - val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() - ensureContextFromView(navButtonView) - registerSettingsChangedReceiver() - trackHoneyspaceNavContainerFrom(navButtonView as? View) - - when (motionEvent.actionMasked) { - MotionEvent.ACTION_DOWN -> { - honeyspaceLongPressTriggered[navButtonView] = false - cancelHoneyspaceHomeStockLongPressGuard(navButtonView) - honeyspaceHomeStockPressCanceled.remove(navButtonView) - val result = chain.proceed() - rescheduleHoneyspaceLongPress(navButtonView, runnableClass, buttonId) - scheduleHoneyspaceHomeStockLongPressGuard(navButtonView, buttonId) - result - } - - MotionEvent.ACTION_UP -> { - val longPressTriggered = honeyspaceLongPressTriggered[navButtonView] == true - if (longPressTriggered) { - return@intercept proceedWithCanceledAction(motionEvent) { - chain.proceed() - }.also { - cancelHoneyspaceHomeStockLongPressGuard(navButtonView) - honeyspaceLongPressTriggered.remove(navButtonView) - honeyspaceHomeStockPressCanceled.remove(navButtonView) - } - } - - val pressAction = getButtonConfig(buttonId).pressAction - val stockHomePressCanceled = - buttonId == NavButtonId.HOME && honeyspaceHomeStockPressCanceled[navButtonView] == true - val shouldOverridePress = - pressAction != NavButtonAction.STOCK && - ((navButtonView as? View)?.isPressed == true || stockHomePressCanceled) && - !longPressTriggered - - if (shouldOverridePress) { - markHoneyspaceLongClicked(navButtonView) - } - - val result = chain.proceed() - if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) { - dispatchHoneyspaceHomeTap(navButtonView) - } - if (shouldOverridePress) { - updateContextFromView(navButtonView) - performConfiguredAction(pressAction) - } - cancelHoneyspaceHomeStockLongPressGuard(navButtonView) - honeyspaceLongPressTriggered.remove(navButtonView) - honeyspaceHomeStockPressCanceled.remove(navButtonView) - result - } - - MotionEvent.ACTION_CANCEL -> { - cancelHoneyspaceHomeStockLongPressGuard(navButtonView) - honeyspaceLongPressTriggered.remove(navButtonView) - honeyspaceHomeStockPressCanceled.remove(navButtonView) - chain.proceed() - } - - else -> chain.proceed() - } - } - - hook(runMethod).intercept { chain -> - val caseId = getIntField(chain.thisObject, "b") - if (caseId != HONEYSPACE_LONG_PRESS_CASE) { - return@intercept chain.proceed() - } - - val target = getFieldValue(chain.thisObject, "c") - if (!navButtonClass.isInstance(target)) { - return@intercept chain.proceed() - } - - val navButtonView = target ?: return@intercept chain.proceed() - - val keyCode = callMethod(navButtonView, "getKeyCode") as Int - if (isSideArrowKeyCode(keyCode)) { - return@intercept null - } - val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() - val longPressAction = getButtonConfig(buttonId).longPressAction - honeyspaceLongPressTriggered[navButtonView] = true - if (longPressAction == NavButtonAction.STOCK) { - return@intercept chain.proceed() - } - - updateContextFromView(navButtonView) - markHoneyspaceLongClicked(navButtonView) - cancelHoneyspaceHomeStockLongPressGuard(navButtonView) - performConfiguredAction(longPressAction) - null - } - return true - } - - private fun hookHoneyspaceSideArrowLayout(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 -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val result = chain.proceed() - val container = chain.thisObject as? ViewGroup - ?: return@intercept result - ensureContextFromView(container) - registerSettingsChangedReceiver() - trackHoneyspaceNavContainer(container) - refreshHoneyspaceSideArrowLayout(container, "after ${method.name}") - scheduleDelayedHoneyspaceSideArrowRefresh(container, "after ${method.name}") - result - } - } - } - - hookHoneyspaceNativeSideArrowRefresh(containerClass) - } - - private fun hookHoneyspaceNativeSideArrowRefresh(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 - } - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val container = chain.thisObject as? ViewGroup - val result = chain.proceed() - if (container != null && isSideArrowsEnabled()) { - restoreHoneyspaceSideArrowDrawables(container) - } - result - } - } - } - } - - private fun trackHoneyspaceNavContainerFrom(view: View?) { - var current = view?.parent as? View - repeat(HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH) { - val target = current ?: return - if (isHoneyspaceNavButtonContainer(target)) { - trackHoneyspaceNavContainer(target) - val container = target as ViewGroup - refreshHoneyspaceSideArrowLayout(container, "from touch") - scheduleDelayedHoneyspaceSideArrowRefresh(container, "from touch") - return - } - current = target.parent as? View - } - } - - private fun trackHoneyspaceNavContainer(container: View) { - if (trackedHoneyspaceNavContainers.add(container)) { - container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> - refreshHoneyspaceSideArrowLayout(view as ViewGroup, "layout listener") - } - } - } - - private fun refreshTrackedHoneyspaceSideArrowLayouts(reason: String) { - val containers = trackedHoneyspaceNavContainers.toList() - containers.forEach { container -> - refreshHoneyspaceSideArrowLayout(container as? ViewGroup ?: return@forEach, reason) - scheduleDelayedHoneyspaceSideArrowRefresh(container, reason) - } - } - - private fun scheduleDelayedHoneyspaceSideArrowRefresh(container: ViewGroup, reason: String) { - listOf( - HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS, - HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS, - ).forEach { delayMs -> - mainHandler?.postDelayed( - { refreshHoneyspaceSideArrowLayout(container, "$reason delayed ${delayMs}ms") }, - delayMs, - ) - } - } - - private fun refreshHoneyspaceSideArrowLayout(container: ViewGroup, reason: String) { - try { - applyNativeHoneyspaceSideArrows(container, reason) - } catch (error: Throwable) { - log("Honeyspace side arrows refresh failed: ${error.javaClass.simpleName}") - } - } - - @Suppress("UNCHECKED_CAST") - private fun applyNativeHoneyspaceSideArrows(container: ViewGroup, reason: String) { - val buttonMap = getFieldValue(container, "i") as? MutableMap ?: run { - log("Honeyspace side arrows native apply skipped: button map missing reason=$reason") - return - } - val enabled = isSideArrowsEnabled() - val targetSlots = honeyspaceSideArrowSlots(container, enabled) - val currentSlots = getFieldValue(container, "u") as? BooleanArray - val alreadyApplied = honeyspaceNativeSideArrowStates[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) - } - - honeyspaceNativeSideArrowStates[container] = enabled - callBooleanMethodIfExists(container, "g", false) - restoreHoneyspaceSideArrowDrawables(container) - } - - private 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) - } - } - - private fun honeyspaceSideArrowDrawablesPresent(buttonMap: Map): Boolean { - return HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow -> - (buttonMap[arrow.type] as? ImageView)?.drawable != null - } - } - - private 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, - ) - } - } - - 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? android.graphics.drawable.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 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 createHoneyspaceSideArrowLayerDrawable(context: Context, direction: SideArrowDirection): Drawable { - return android.graphics.drawable.LayerDrawable( - arrayOf( - sideArrowBitmapDrawable(context, direction, Color.WHITE), - sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)), - ), - ) - } - - private fun scheduleSideArrowLongPress( - navButtonView: Any, - keyCode: Int, - ) { - val view = navButtonView as? View ?: return - val handler = mainHandler ?: Handler(Looper.getMainLooper()).also { mainHandler = it } - val longPressRunnable = Runnable { - sideArrowLongPressTriggered[navButtonView] = true - when (getSideArrowLongPressAction()) { - SideArrowLongPressAction.NOTHING -> Unit - SideArrowLongPressAction.SINGLE_STEP -> sendSideArrowKeyPress(keyCode) - SideArrowLongPressAction.MULTIPLE_STEPS -> { - sendSideArrowKeyPress(keyCode) - val repeatRunnable = object : Runnable { - override fun run() { - sendSideArrowKeyPress(keyCode) - handler.postDelayed(this, sideArrowRepeatIntervalMs()) - } - } - sideArrowRepeatRunnables[navButtonView] = repeatRunnable - handler.postDelayed(repeatRunnable, sideArrowRepeatIntervalMs()) - } - } - } - sideArrowLongPressRunnables[navButtonView] = longPressRunnable - view.postDelayed(longPressRunnable, getSideArrowLongPressDurationMs().toLong()) - } - - private fun sendSideArrowKeyPress(keyCode: Int) { - if (context?.packageName in AOSP_LAUNCHER_PACKAGES) { - requestPrivilegedAction(PRIVILEGED_ACTION_SEND_KEY_PRESS, keyCode) - } else { - sendKeyPress(keyCode) - } - } - - private fun cancelSideArrowLongPress(navButtonView: Any) { - val view = navButtonView as? View - sideArrowLongPressRunnables.remove(navButtonView)?.let { runnable -> - view?.removeCallbacks(runnable) ?: mainHandler?.removeCallbacks(runnable) - } - sideArrowRepeatRunnables.remove(navButtonView)?.let { runnable -> - mainHandler?.removeCallbacks(runnable) - } - } - - private fun isSideArrowKeyCode(keyCode: Int): Boolean { - return keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT - } - - private fun getSideArrowLongPressAction(): SideArrowLongPressAction { - sideArrowLongPressActionOverride?.let { return it } - return SideArrowLongPressAction.fromStorageValue( - getSettingsPreferences()?.getString(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION, null), - ) - } - - private fun getSideArrowLongPressDurationMs(): Int { - sideArrowLongPressDurationOverride?.let { return it } - return ( - getSettingsPreferences()?.getInt( - NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, - ViewConfiguration.getLongPressTimeout(), - ) ?: ViewConfiguration.getLongPressTimeout() - ).coerceLongPressDuration() - } - - private fun sideArrowRepeatIntervalMs(): Long { - val speed = sanitizeSideArrowRepeatSpeed( - sideArrowRepeatSpeedOverride - ?: getSettingsPreferences()?.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 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 - } - } - - private fun isHoneyspaceNavButtonContainer(view: View): Boolean { - return view.javaClass.name == HONEYSPACE_NAV_BUTTON_CONTAINER || - view.javaClass.simpleName == "NavigationBarButtonsLayout" || - viewIdName(view) == "navbar_button_container" - } - - private fun viewIdName(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 markSystemUiLongClicked(keyButtonView: Any) { - try { - findField(keyButtonView.javaClass, "mLongClicked").setBoolean(keyButtonView, true) - } catch (t: Throwable) { - log("Failed to set mLongClicked", t) - } - } - - private fun markHoneyspaceLongClicked(navButtonView: Any) { - try { - callMethod( - navButtonView, - "setLongClicked", - arrayOf(Boolean::class.javaPrimitiveType!!), - true, - ) - } catch (t: Throwable) { - log("setLongClicked failed: ${t.javaClass.simpleName}") - } - } - - private fun updateContextFromView(view: Any) { - val currentContext = (view as View).context - context = currentContext.applicationContext ?: currentContext - } - - private fun updateContextFromTaskbar(controller: Any?, view: Any?) { - if (view is View) { - updateContextFromView(view) - return - } - updateContextFromField(controller, "mContext") - } - private fun updateContextFromField(target: Any?, fieldName: String) { val currentContext = getFieldValue(target, fieldName) as? Context ?: return context = currentContext.applicationContext ?: currentContext } + private fun updateContext(newContext: Context?) { + if (newContext != null) { + context = newContext + } + } + private fun performConfiguredAction(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) - } - } - - private fun handleSystemServerKeyOverride( - 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) { - scheduleSystemServerLongPress(keyCode, buttonId, longPressAction) - } - pressAction != NavButtonAction.STOCK - } - - KeyEvent.ACTION_UP -> { - val longPressTriggered = systemServerLongPressTriggered[keyCode] == true - val longPressElapsed = keyEvent.eventTime - keyEvent.downTime >= config.longPressDurationMs - cancelSystemServerLongPress(keyCode) - if (longPressTriggered || - (longPressAction != NavButtonAction.STOCK && longPressElapsed) - ) { - if (!longPressTriggered) { - if (buttonId == NavButtonId.HOME) { - performHomeLongPressAction(longPressAction) - } else { - performConfiguredAction(longPressAction) - } - } - true - } else { - if (pressAction != NavButtonAction.STOCK && !keyEvent.isCanceled) { - performConfiguredAction(pressAction) - } - pressAction != NavButtonAction.STOCK - } - } - - else -> pressAction != NavButtonAction.STOCK - } - } - - private fun scheduleSystemServerLongPress( - keyCode: Int, - buttonId: NavButtonId, - action: NavButtonAction, - ) { - if (systemServerLongPressRunnables[keyCode] != null) { - return - } - val handler = mainHandler ?: Handler(Looper.getMainLooper()).also { mainHandler = it } - val runnable = Runnable { - systemServerLongPressRunnables.remove(keyCode) - systemServerLongPressTriggered[keyCode] = true - if (buttonId == NavButtonId.HOME) { - performHomeLongPressAction(action) - } else { - performConfiguredAction(action) - } - } - systemServerLongPressTriggered[keyCode] = false - systemServerLongPressRunnables[keyCode] = runnable - handler.postDelayed(runnable, getButtonConfig(buttonId).longPressDurationMs.toLong()) - } - - private fun cancelSystemServerLongPress(keyCode: Int) { - systemServerLongPressRunnables.remove(keyCode)?.let { runnable -> - mainHandler?.removeCallbacks(runnable) - } - } - - private fun sendConfiguredKeyPress(keyCode: Int) { - if (context?.packageName in AOSP_LAUNCHER_PACKAGES) { - requestPrivilegedAction(PRIVILEGED_ACTION_SEND_KEY_PRESS, keyCode) - } else { - sendKeyPress(keyCode) - } - } - - private fun performPrivilegedActionOrDirect(action: String) { - if (context?.packageName == ANDROID_PACKAGE) { - when (action) { - PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp() - PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight() - } - } else { - requestPrivilegedAction(action) - } - } - - private fun performAospTaskbarHomeLongPressAction(controller: Any?, action: NavButtonAction) { - if (wasHomeLongPressHandledRecently()) { - return - } - noteHomeLongPressHandled() - performAospTaskbarAction(controller, action) - } - - private fun performAospTaskbarAction(controller: Any?, action: NavButtonAction) { - when (action) { - NavButtonAction.STOCK, - NavButtonAction.NONE, - -> Unit - - NavButtonAction.BACK -> { - if (!callAospTaskbarMethod(controller, "executeBack", arrayOf(KeyEvent::class.java), null)) { - performConfiguredAction(action) - } - } - - NavButtonAction.HOME -> { - if (!callAospTaskbarMethod(controller, "navigateHome", emptyArray())) { - performConfiguredAction(action) - } - } - - NavButtonAction.ASSISTANT -> { - if (!callAospTaskbarMethod(controller, "onLongPressHome", emptyArray())) { - performConfiguredAction(action) - } - } - - NavButtonAction.RECENTS -> { - if (!callAospTaskbarMethod(controller, "navigateToOverview", emptyArray())) { - performConfiguredAction(action) - } - } - - NavButtonAction.KILL_FOREGROUND_APP -> requestPrivilegedAction(PRIVILEGED_ACTION_KILL_FOREGROUND_APP) - - NavButtonAction.TOGGLE_FLASHLIGHT -> requestPrivilegedAction(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT) - - NavButtonAction.TOGGLE_AUTO_ROTATE -> performConfiguredAction(action) - } - } - - private fun callAospTaskbarMethod( - controller: Any?, - methodName: String, - parameterTypes: Array>, - 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 - } + actions.perform(action) } private fun sendKeyPress(keyCode: Int) { - try { - val inputManager = getInputManagerForInjection() ?: 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}") - } + actions.sendKeyPress(keyCode) } - private fun getInputManagerForInjection(): 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 - } - } - } - - private fun hookFoldedSideArrows(classLoader: ClassLoader?) { - if (!Build.MANUFACTURER.equals("samsung", ignoreCase = true)) { - return - } - hookFoldedLayoutProvider(classLoader) - hookSamsungNavigationBarView(classLoader) - registerSettingsChangedReceiver() - } - - 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 -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - val result = chain.proceed() - val layout = result as? String ?: return@intercept result - if (!isSideArrowsEnabled()) { - return@intercept layout - } - - val sideArrowLayout = addSideArrows(layout, chain.thisObject) - 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(::isPointBooleanIntMethod) - .forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - sideArrowSize(chain.thisObject, chain.args, phoneRatio = PHONE_BUTTON_RATIO, tabletRatio = TABLET_BUTTON_RATIO) - ?: chain.proceed() - } - } - - findMethodsNamed(providerClass, "getButtonDistanceSize") - .filter(::isPointBooleanIntMethod) - .forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - sideArrowSize(chain.thisObject, chain.args, phoneRatio = SIDE_ARROW_GAP_RATIO, tabletRatio = SIDE_ARROW_GAP_RATIO) - ?: chain.proceed() - } - } - - findMethodsNamed(providerClass, "getSpaceWidth") - .filter { method -> - method.returnType == Int::class.javaPrimitiveType && - method.parameterTypes.firstOrNull() == Point::class.java - } - .forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - sideArrowSize(chain.thisObject, chain.args, phoneRatio = PHONE_SPACE_RATIO, tabletRatio = TABLET_SPACE_RATIO) - ?: chain.proceed() - } - } - - findMethodsNamed(providerClass, "getSpaceSidePadding") - .filter { method -> - method.returnType == Int::class.javaPrimitiveType && - method.parameterTypes.firstOrNull() == Point::class.java - } - .forEach { method -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - sideArrowSize(chain.thisObject, chain.args, phoneRatio = 0.0, tabletRatio = TABLET_SPACE_SIDE_PADDING_RATIO) - ?: chain.proceed() - } - } - } - - private fun isPointBooleanIntMethod(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 - } - - private fun sideArrowSize(provider: Any?, args: List, phoneRatio: Double, tabletRatio: Double): Int? { - if (!isSideArrowsEnabled()) { - return null - } - val displaySize = args.firstOrNull() as? Point ?: return null - val kind = provider?.let { sideArrowLayoutKinds[it] } ?: SideArrowLayoutKind.PHONE - val ratio = if (kind == SideArrowLayoutKind.TABLET) tabletRatio else phoneRatio - return (minOf(displaySize.x, displaySize.y) * ratio * SIDE_ARROW_REDUCE_RATIO).toInt() - } - - private fun addSideArrows(layout: String, provider: Any?): SideArrowLayout { - val providerContext = findContext(provider) ?: context - val leftKey = navCustomKeyToken(providerContext, KeyEvent.KEYCODE_DPAD_LEFT, 1) - val rightKey = navCustomKeyToken(providerContext, KeyEvent.KEYCODE_DPAD_RIGHT, 2) - return when (layout) { - NAV_LAYOUT_PHONE_DEFAULT -> - SideArrowLayout("left,recent;$leftKey,home,$rightKey;back,right", SideArrowLayoutKind.PHONE) - NAV_LAYOUT_PHONE_REVERSED -> - SideArrowLayout("left,back;$leftKey,home,$rightKey;recent,right", SideArrowLayoutKind.PHONE) - NAV_LAYOUT_TABLET_DEFAULT -> - SideArrowLayout("left;recent,$leftKey,home,$rightKey,back;right", SideArrowLayoutKind.TABLET) - NAV_LAYOUT_TABLET_REVERSED -> - SideArrowLayout("left;back,$leftKey,home,$rightKey,recent;right", SideArrowLayoutKind.TABLET) - NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT -> - SideArrowLayout("left;back,$leftKey,home,$rightKey,recent;r", SideArrowLayoutKind.TABLET) - NAV_LAYOUT_WINNER_DEFAULT -> - SideArrowLayout("left,back,$leftKey,home,$rightKey,recent;;right", SideArrowLayoutKind.TABLET) - NAV_LAYOUT_WINNER_REVERSED -> - SideArrowLayout("left,recent,$leftKey,home,$rightKey,back;;right", SideArrowLayoutKind.TABLET) - NAV_LAYOUT_UNFOLDED_DEFAULT -> - SideArrowLayout("left;;extrakey,back,$leftKey,home,$rightKey,recent,right", SideArrowLayoutKind.TABLET) - NAV_LAYOUT_UNFOLDED_REVERSED -> - SideArrowLayout("left;;extrakey,recent,$leftKey,home,$rightKey,back,right", SideArrowLayoutKind.TABLET) - else -> SideArrowLayout(layout, SideArrowLayoutKind.PHONE) - } - } - - private fun navCustomKeyToken(sourceContext: Context?, keyCode: Int, index: Int): String { - val customKeyId = navCustomKeyId(sourceContext, index) - if (customKeyId == 0) { - log("Folded side arrows custom key id not found: nav_custom_key_$index") - } - return "navkey($keyCode:$customKeyId)" - } - - private fun navCustomKeyId(sourceContext: Context?, index: Int): Int { - return sourceContext?.resources?.getIdentifier( - "nav_custom_key_$index", - "id", - sourceContext.packageName, - ) ?: 0 - } - - 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 - } - - private fun findButtonDispatcherProxy(root: Any): Any? { - findButtonDispatcherProxyFromMethods(root)?.let { return it } - - val visited = Collections.newSetFromMap(WeakHashMap()) - val queue = ArrayDeque>() - queue.add(root to 0) - - while (queue.isNotEmpty()) { - val (target, depth) = queue.removeFirst() - if (!visited.add(target)) { - continue - } - if (isButtonDispatcherProxy(target)) { - return target - } - findButtonDispatcherProxyFromMethods(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 (shouldSearchButtonDispatcherField(value)) { - queue.add(value to depth + 1) - } - } catch (_: Throwable) { - return@forEach - } - } - current = current.superclass - } - } - - return null - } - - private fun findButtonDispatcherProxyFromMethods(target: Any): Any? { - findMethodsNamed(target.javaClass, "getButtonDispatcherProxy") - .firstOrNull { it.parameterTypes.isEmpty() } - ?.let { method -> - runCatching { method.invoke(target) } - .getOrNull() - ?.takeIf(::isButtonDispatcherProxy) - ?.let { return it } - } - return null - } - - private fun isButtonDispatcherProxy(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 shouldSearchButtonDispatcherField(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 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 - } - - private 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 - } - - private 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 - } - - private 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 - } - - private fun hookSamsungNavigationBarView(classLoader: ClassLoader?) { - if (samsungNavigationBarViewHookInstalled) { - return - } - samsungNavigationBarViewHookInstalled = 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 -> - deoptimizeMethodIfPresent(method) - hook(method).intercept { chain -> - chain.thisObject?.let { navBarView -> - ensureContextFromView(navBarView) - registerSettingsChangedReceiver() - trackSamsungNavigationBar(navBarView) - } - val result = chain.proceed() - result - } - } - } + private fun mainHandler(): Handler { + return mainHandler ?: Handler(Looper.getMainLooper()).also { mainHandler = it } } private fun registerSettingsChangedReceiver() { - if (settingsChangedReceiverRegistered) { - return - } - val currentContext = getCurrentApplication()?.applicationContext - ?: context?.applicationContext - ?: context - ?: return - context = currentContext - - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action != NavButtonSettingsStore.ACTION_SETTINGS_CHANGED) { - return - } - updateSettingsOverrides(intent) - reInflateTrackedSamsungNavigationBars() - refreshTrackedAospSystemUiSideArrowLayouts() - refreshTrackedHoneyspaceSideArrowLayouts("settings changed") - refreshTrackedAospTaskbarSideArrows() - } - } - - try { - currentContext.registerReceiver( - receiver, - IntentFilter(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED), - Context.RECEIVER_EXPORTED, - ) - settingsChangedReceiverRegistered = true - } catch (error: Throwable) { - log("Folded side arrows settings receiver failed: ${error.javaClass.simpleName}") - } - } - - private fun updateSettingsOverrides(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, - ), - ) + settings.registerChangedReceiver() } private fun registerPrivilegedActionReceiver() { - if (privilegedActionReceiverRegistered) { - return - } - val currentContext = context ?: getCurrentApplication() ?: return - context = currentContext.applicationContext ?: currentContext - - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action != ACTION_PRIVILEGED_ACTION || - context == null || - !isAllowedPrivilegedActionSender( - context, - intent, - getSentFromPackageCompat(), - getSentFromUidCompat(), - ) - ) { - return - } - this@NavButtons.context = context.applicationContext ?: context - when (intent.getStringExtra(EXTRA_PRIVILEGED_ACTION)) { - PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp() - PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight() - PRIVILEGED_ACTION_SEND_KEY_PRESS -> { - val keyCode = intent.getIntExtra(EXTRA_PRIVILEGED_KEY_CODE, KeyEvent.KEYCODE_UNKNOWN) - if (keyCode != KeyEvent.KEYCODE_UNKNOWN) { - sendKeyPress(keyCode) - } - } - } - } - } - - try { - currentContext.registerReceiver( - receiver, - IntentFilter(ACTION_PRIVILEGED_ACTION), - Context.RECEIVER_EXPORTED, - ) - if (currentContext.packageName != ANDROID_PACKAGE) { - privilegedActionToken = readPrivilegedActionToken() - } - privilegedActionReceiverRegistered = true - } catch (error: Throwable) { - log("Privileged action receiver failed", error) - } - } - - private fun BroadcastReceiver.getSentFromUidCompat(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - sentFromUid - } else { - Process.INVALID_UID - } - } - - private fun BroadcastReceiver.getSentFromPackageCompat(): String? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - sentFromPackage - } else { - null - } - } - - private fun isAllowedPrivilegedActionSender( - 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 = privilegedActionToken ?: if (context.packageName != ANDROID_PACKAGE) { - readPrivilegedActionToken()?.also { - privilegedActionToken = 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 readPrivilegedActionToken(): String? { - return getSettingsPreferences()?.getString(NavButtonSettingsStore.KEY_PRIVILEGED_ACTION_TOKEN, null) + privilegedActions.registerReceiver() } private fun requestPrivilegedAction(action: String, keyCode: Int? = null) { - val currentContext = context - if (currentContext == null) { - when (action) { - PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp() - PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight() - PRIVILEGED_ACTION_SEND_KEY_PRESS -> keyCode?.let(::sendKeyPress) - } - return - } - val token = getSettingsPreferences()?.getString( - NavButtonSettingsStore.KEY_PRIVILEGED_ACTION_TOKEN, - null, - ) - val intent = Intent(ACTION_PRIVILEGED_ACTION) - .setPackage(ANDROID_PACKAGE) - .putExtra(EXTRA_PRIVILEGED_ACTION, action) - .putExtra(EXTRA_PRIVILEGED_CALLER, privilegedActionCaller(currentContext, action)) - if (keyCode != null) { - intent.putExtra(EXTRA_PRIVILEGED_KEY_CODE, keyCode) - } - if (token != null) { - intent.putExtra(EXTRA_PRIVILEGED_ACTION_TOKEN, token) - } - currentContext.sendBroadcast(intent) - } - - private fun privilegedActionCaller(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 getParcelableExtraCompat(intent: Intent, name: String, type: Class): T? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(name, type) - } else { - @Suppress("DEPRECATION") - type.cast(intent.getParcelableExtra(name)) - } - } - - private fun trackSamsungNavigationBarView(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) { - trackSamsungNavigationBar(target) - return - } - current = target.parent as? View - } - } - - private fun trackSamsungNavigationBar(navBarView: Any) { - trackedSamsungNavigationBarViews.add(navBarView) - configureFoldedSideArrowButtons(navBarView) - } - - private fun callMethodIfExists(target: Any, methodName: String): Any? { - return findMethodsNamed(target.javaClass, methodName) - .firstOrNull { method -> method.parameterTypes.isEmpty() } - ?.let { method -> - runCatching { method.invoke(target) }.getOrNull() - } - } - - private 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 - } - - private fun reInflateTrackedSamsungNavigationBars() { - val views = trackedSamsungNavigationBarViews.toList() - if (views.isEmpty()) { - return - } - - views.forEach { navBarView -> - try { - refreshSamsungNavigationBar(navBarView) - if (isSideArrowsEnabled()) { - mainHandler?.postDelayed( - { - refreshSamsungNavigationBar(navBarView) - }, - SIDE_ARROW_REFRESH_DELAY_MS, - ) - } - } catch (error: Throwable) { - log("Side arrows reinflate failed: ${error.javaClass.simpleName}") - } - } - } - - private fun refreshSamsungNavigationBar(navBarView: Any) { - try { - configureFoldedSideArrowButtons(navBarView) - callMethodIfExists(navBarView, "updateLayoutProviderView") - callMethod(navBarView, "reInflateNavBarLayout", emptyArray>()) - configureFoldedSideArrowButtons(navBarView) - } catch (error: Throwable) { - log("Side arrows nav refresh failed: ${error.javaClass.simpleName}") - } - } - - private fun configureFoldedSideArrowButtons(navBarView: Any) { - if (isSideArrowsEnabled()) { - configureSideArrowDispatcherButtons(navBarView, navBarView) - } - } - - private fun configureSideArrowDispatcherButtons( - source: Any, - darkIntensitySource: Any? = source, - ): Boolean { - val navContext = navigationBarContext(source) ?: return false - val dispatcherProxy = findButtonDispatcherProxy(source) - ?: return false - - configureSideArrowDispatcherButton( - dispatcherProxy, - darkIntensitySource, - navContext, - index = 1, - direction = SideArrowDirection.LEFT, - ) - configureSideArrowDispatcherButton( - dispatcherProxy, - darkIntensitySource, - navContext, - index = 2, - direction = SideArrowDirection.RIGHT, - ) - return true - } - - private fun navigationBarContext(source: Any): Context? { - return (callMethodIfExists(source, "getNavigationBarContext") as? Context) - ?.let { it.applicationContext ?: it } - ?: findContext(source) - ?: context - } - - private fun configureSideArrowDispatcherButton( - 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) { - setSideArrowDarkIntensity(darkIntensitySource, id) - } - } - - private fun setSideArrowDarkIntensity(navBarView: Any, id: Int) { - val dispatchers = getFieldValue(navBarView, "mButtonDispatchers") as? android.util.SparseArray<*> ?: return - val dispatcher = dispatchers.get(id) ?: return - val intensity = findReferenceDarkIntensity(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 findReferenceDarkIntensity(dispatchers: android.util.SparseArray<*>): Float { - listOf("home", "back", "recent").forEach { name -> - val currentContext = context ?: return@forEach - val id = currentContext.resources.getIdentifier(name, "id", currentContext.packageName) - val value = getFieldValue(dispatchers.get(id), "mDarkIntensity") as? Float - if (value != null) { - return value - } - } - return 0f - } - - private fun setFieldIfExists(target: Any, fieldName: String, value: Any?) { - runCatching { - findField(target.javaClass, fieldName).set(target, value) - } - } - - private fun navStarArrowIconSizePx(context: Context): Int { - return (context.resources.displayMetrics.density * SIDE_ARROW_ICON_DP + 0.5f).toInt() - } - - private 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) - } - - private fun ensureContextFromView(value: Any) { - if (context != null) { - return - } - val viewContext = (value as? View)?.context ?: return - context = viewContext.applicationContext ?: viewContext - if (mainHandler == null) { - mainHandler = Handler(Looper.getMainLooper()) - } + privilegedActions.request(action, keyCode) } private fun isSideArrowsEnabled(): Boolean { - return sideArrowsEnabledOverride - ?: (getSettingsPreferences()?.getBoolean(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, false) == true) - } - - private 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) - } - } - - private fun toggleAutoRotate() { - val currentContext = context ?: run { - log("Context unavailable, cannot toggle auto rotate.") - return - } - - try { - val resolver = currentContext.contentResolver - val currentlyEnabled = Settings.System.getInt( - resolver, - Settings.System.ACCELEROMETER_ROTATION, - 0, - ) == 1 - val enabled = !currentlyEnabled - Settings.System.putInt( - resolver, - Settings.System.ACCELEROMETER_ROTATION, - if (enabled) 1 else 0, - ) - 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 toggleFlashlight() { - val currentContext = context ?: run { - log("Context unavailable, cannot toggle flashlight.") - return - } - - try { - val cameraManager = currentContext.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 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 - } - } - - private fun killForegroundApp() { - if (mainHandler == null) { - initContextAndHandler() - } - - val handler = mainHandler - if (handler == null) { - log("Main handler unavailable.") - return - } - - handler.post { - try { - val currentContext = context - if (currentContext == null) { - log("Context unavailable, cannot kill app.") - return@post - } - - val activityManager = - currentContext.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - if (activityManager == null) { - log("ActivityManager unavailable.") - return@post - } - - val defaultLauncher = getDefaultLauncherPackageName() - val foregroundApp = getTopPackageName() - if (foregroundApp != null && !shouldSkipPackage(foregroundApp, defaultLauncher)) { - val appLabel = getApplicationLabel(foregroundApp, currentContext.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 (t: Throwable) { - Log.e(TAG, "Error in killForegroundApp", t) - log("Error in killForegroundApp", t) - } - } - } - - private fun getDefaultLauncherPackageName(): String? { - val currentContext = context ?: return null - val intent = Intent(Intent.ACTION_MAIN).apply { - addCategory(Intent.CATEGORY_HOME) - } - val resolveInfo = currentContext.packageManager.resolveActivity( - intent, - PackageManager.MATCH_DEFAULT_ONLY, - ) - return resolveInfo?.activityInfo?.packageName - } - - private fun getTopPackageName(): String? { - return getTopPackageFromActivityTaskManager() - ?: getTopPackageFromRunningTasks() - } - - private fun getTopPackageFromActivityTaskManager(): String? { - return try { - val service = callStaticMethod( - findClass("android.app.ActivityTaskManager", null), - "getService", - ) ?: return null - val taskInfo = callMethod(service, "getFocusedRootTaskInfo") - extractPackageFromTaskInfo(taskInfo) - } catch (t: Throwable) { - log("ActivityTaskManager.getFocusedRootTaskInfo failed: ${t.javaClass.simpleName}") - null - } - } - - private fun extractPackageFromTaskInfo(taskInfo: Any?): String? { - if (taskInfo == null) { - return null - } - - val pkg = getPackageNameFromComponent(getFieldValue(taskInfo, "topActivity")) - return pkg ?: getPackageNameFromActivityInfo(getFieldValue(taskInfo, "topActivityInfo")) - } - - @Suppress("DEPRECATION") - private fun getTopPackageFromRunningTasks(): String? { - val activityManager = context?.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - ?: return null - return try { - activityManager.getRunningTasks(1) - .firstOrNull() - ?.topActivity - ?.packageName - } catch (error: Throwable) { - log("ActivityManager.getRunningTasks failed: ${error.javaClass.simpleName}") - null - } - } - - private fun getFieldValue(target: Any?, fieldName: String): Any? { - if (target == null) { - return null - } - return try { - findField(target.javaClass, fieldName).get(target) - } catch (_: Throwable) { - null - } - } - - private fun getIntField(target: Any?, fieldName: String): Int { - val value = getFieldValue(target, fieldName) - return value as? Int ?: Int.MIN_VALUE + return settings.isSideArrowsEnabled() } private fun getButtonConfig(buttonId: NavButtonId): NavButtonConfig { - buttonConfigOverrides[buttonId]?.let { return it } - val preferences = getSettingsPreferences() - 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(), - ) - } - - private 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 - } - } - - private 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 - } - } - - private fun getPackageNameFromComponent(component: Any?): String? { - return (component as? ComponentName)?.packageName - } - - private fun getPackageNameFromActivityInfo(info: Any?): String? { - if (info == null) { - return null - } - return try { - findField(info.javaClass, "packageName").get(info)?.toString() - } catch (_: Throwable) { - null - } - } - - private fun shouldSkipPackage(packageName: String?, defaultLauncher: String?): Boolean { - return packageName == null || - packageName == SYSTEMUI_PACKAGE || - packageName == SAMSUNG_LAUNCHER_PACKAGE || - packageName in AOSP_LAUNCHER_PACKAGES || - packageName == defaultLauncher - } - - private fun getApplicationLabel(packageName: String, packageManager: PackageManager): String { - return try { - val appInfo: ApplicationInfo = packageManager.getApplicationInfo(packageName, 0) - packageManager.getApplicationLabel(appInfo).toString() - } catch (_: PackageManager.NameNotFoundException) { - packageName - } - } - - private fun showToast(message: String) { - val currentContext = context ?: return - try { - Toast.makeText(currentContext, message, Toast.LENGTH_SHORT).show() - } catch (t: Throwable) { - log("Toast failed: ${t.javaClass.simpleName}") - } + return settings.getButtonConfig(buttonId) } private fun log(message: String) { @@ -3371,17 +415,9 @@ class NavButtons() : XposedModule() { } } - 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 hookIntercepting(method: Method, hooker: XposedInterface.Hooker) { + deoptimizeMethodIfPresent(method) + hook(method).intercept(hooker) } private fun noteHomeLongPressHandled() { @@ -3392,13 +428,6 @@ class NavButtons() : XposedModule() { return SystemClock.uptimeMillis() - lastHomeLongPressHandledAtMs < HOME_LONG_PRESS_DEDUP_WINDOW_MS } - private fun Int.coerceLongPressDuration(): Int { - return coerceIn( - NavButtonSettingsStore.MIN_DURATION_MS, - NavButtonSettingsStore.MAX_DURATION_MS, - ) - } - private fun performHomeLongPressAction(action: NavButtonAction) { if (wasHomeLongPressHandledRecently()) { return @@ -3407,240 +436,6 @@ class NavButtons() : XposedModule() { performConfiguredAction(action) } - private fun cancelSystemUiKeyPress(keyButtonView: Any) { - if (buttonIdForKeyCode(getIntField(keyButtonView, "mCode")) == NavButtonId.HOME && - systemUiHomeStockPressCanceled[keyButtonView] == true - ) { - abortSystemUiKeyGesture(keyButtonView) - return - } - - abortSystemUiKeyGesture(keyButtonView) - sendSystemUiCanceledKeyUp(keyButtonView) - } - - private fun scheduleSystemUiHomeStockLongPressGuard(keyButtonView: Any, buttonId: NavButtonId) { - scheduleHomeStockLongPressGuard( - keyButtonView, - buttonId, - systemUiHomeStockLongPressGuards, - systemUiLongPressTriggered, - systemUiHomeStockPressCanceled, - ::sendSystemUiCanceledKeyUp, - ) - } - - private fun cancelSystemUiHomeStockLongPressGuard(keyButtonView: Any) { - cancelHomeStockLongPressGuard(keyButtonView, systemUiHomeStockLongPressGuards) - } - - private fun scheduleHoneyspaceHomeStockLongPressGuard(navButtonView: Any, buttonId: NavButtonId) { - scheduleHomeStockLongPressGuard( - navButtonView, - buttonId, - honeyspaceHomeStockLongPressGuards, - honeyspaceLongPressTriggered, - honeyspaceHomeStockPressCanceled, - ::sendHoneyspaceCanceledKeyUp, - ) - } - - private fun cancelHoneyspaceHomeStockLongPressGuard(navButtonView: Any) { - cancelHomeStockLongPressGuard(navButtonView, honeyspaceHomeStockLongPressGuards) - } - - private fun scheduleHomeStockLongPressGuard( - target: Any, - buttonId: NavButtonId, - guards: WeakHashMap, - longPressTriggered: WeakHashMap, - stockPressCanceled: WeakHashMap, - sendCanceledKeyUp: (Any) -> Unit, - ) { - if (buttonId != NavButtonId.HOME) { - return - } - if (getButtonConfig(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] == true) { - return@Runnable - } - stockPressCanceled[target] = true - sendCanceledKeyUp(target) - } - - guards[target] = guard - view.postDelayed(guard, delayMs.toLong()) - } - - private fun cancelHomeStockLongPressGuard(target: Any, guards: WeakHashMap) { - val guard = guards.remove(target) ?: return - (target as? View)?.removeCallbacks(guard) - } - - private fun abortSystemUiKeyGesture(keyButtonView: Any) { - try { - callMethod(keyButtonView, "abortCurrentGesture") - } catch (_: Throwable) { - // Older/newer SystemUI variants may not expose this helper. - } - } - - private fun sendSystemUiCanceledKeyUp(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 sendHoneyspaceCanceledKeyUp(navButtonView: Any) { - if (callIntIntMethodIfExists( - navButtonView, - listOf("sendEvent", "sendKeyEvent"), - KeyEvent.ACTION_UP, - KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS, - ) - ) { - return - } - - try { - val inputManager = getInputManagerForInjection() ?: 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 dispatchSystemUiHomeTap(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 fun dispatchHoneyspaceHomeTap(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 fun scheduleSystemUiLongPress(keyButtonView: Any, buttonId: NavButtonId) { - val view = keyButtonView as? View ?: return - val config = getButtonConfig(buttonId) - if (config.longPressAction == NavButtonAction.STOCK) { - return - } - (getFieldValue(keyButtonView, "mCheckLongPress") as? Runnable)?.let(view::removeCallbacks) - - val navButtonsRunnable = Runnable { - updateContextFromView(keyButtonView) - systemUiLongPressTriggered[keyButtonView] = true - cancelSystemUiKeyPress(keyButtonView) - markSystemUiLongClicked(keyButtonView) - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - if (buttonId == NavButtonId.HOME) { - performHomeLongPressAction(config.longPressAction) - } else { - performConfiguredAction(config.longPressAction) - } - } - systemUiLongPressRunnables[keyButtonView] = navButtonsRunnable - view.postDelayed(navButtonsRunnable, config.longPressDurationMs.toLong()) - } - - private fun cancelSystemUiLongPress(keyButtonView: Any) { - val view = keyButtonView as? View ?: return - systemUiLongPressRunnables.remove(keyButtonView)?.let(view::removeCallbacks) - } - - private fun rescheduleHoneyspaceLongPress( - navButtonView: Any, - runnableClass: Class<*>, - buttonId: NavButtonId, - ) { - val view = navButtonView as? View ?: return - val longPressRunnable = findHoneyspaceLongPressRunnable(navButtonView, runnableClass) ?: return - view.removeCallbacks(longPressRunnable) - view.postDelayed(longPressRunnable, getButtonConfig(buttonId).longPressDurationMs.toLong()) - } - - 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 prepareLongPressHook(runMethod: Method) { deoptimizeMethodIfPresent( findMethodIfExists(Handler::class.java, "dispatchMessage", android.os.Message::class.java), @@ -3651,372 +446,4 @@ class NavButtons() : XposedModule() { deoptimizeMethodIfPresent(runMethod) } - private fun findClass(className: String, classLoader: ClassLoader?): Class<*> { - return Class.forName(className, false, classLoader) - } - - private fun findClassIfExists(className: String, classLoader: ClassLoader?): Class<*>? { - return try { - findClass(className, classLoader) - } catch (_: Throwable) { - null - } - } - - private fun findFirstClass(classNames: List, classLoader: ClassLoader?): Class<*> { - classNames.forEach { className -> - findClassIfExists(className, classLoader)?.let { return it } - } - throw ClassNotFoundException(classNames.joinToString()) - } - - private fun findFirstClassIfExists( - classNames: List, - classLoader: ClassLoader?, - ): Class<*>? { - return classNames.firstNotNullOfOrNull { className -> - findClassIfExists(className, classLoader) - } - } - - private fun findMethodIfExists( - clazz: Class<*>, - methodName: String, - vararg parameterTypes: Class<*>, - ): Method? { - return try { - findMethod(clazz, methodName, *parameterTypes) - } catch (_: Throwable) { - null - } - } - - private fun findDeclaredMethodsNamed(clazz: Class<*>, methodName: String): List { - return clazz.declaredMethods - .filter { it.name == methodName } - .onEach { it.isAccessible = true } - } - - private fun findMethodsNamed(clazz: Class<*>, methodName: String): List { - val methodsBySignature = linkedMapOf() - 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() - } - - private fun sendEventFlags(args: Any?): Int { - return argsToList(args).getOrNull(1) as? Int ?: 0 - } - - private fun argsToList(args: Any?): List<*> { - return when (args) { - is Array<*> -> args.asList() - is Iterable<*> -> args.toList() - null -> emptyList() - else -> listOf(args) - } - } - - private fun Int.hasFlag(flag: Int): Boolean { - return this and flag == flag - } - - @Throws(NoSuchMethodException::class) - private 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) - private 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) - private fun callStaticMethod(clazz: Class<*>, methodName: String): Any? { - return findMethod(clazz, methodName).invoke(null) - } - - @Throws(ReflectiveOperationException::class) - private fun callMethod(target: Any, methodName: String): Any? { - return callMethod(target, methodName, emptyArray()) - } - - @Throws(ReflectiveOperationException::class) - private fun callMethod( - target: Any, - methodName: String, - parameterTypes: Array>, - vararg args: Any?, - ): Any? { - return findMethod(target.javaClass, methodName, *parameterTypes).invoke(target, *args) - } - - private fun callIntIntMethodIfExists( - target: Any, - methodNames: List, - 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 - } - - private enum class SideArrowDirection { - LEFT, - RIGHT, - } - - private enum class SideArrowLayoutKind { - PHONE, - TABLET, - } - - private data class SideArrowLayout( - val layout: String, - val kind: SideArrowLayoutKind, - ) - - private data class AospSideArrowViews( - val left: ImageView, - val right: ImageView, - ) - - private data class HoneyspaceSideArrowButton( - val type: Int, - val keyCode: Int, - val direction: SideArrowDirection, - ) - - 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 - } - } - - private companion object { - const val TAG = "NavButtons" - const val ASSIST_INVOCATION_TYPE_UNKNOWN = 0 - const val ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS = 5 - const val INPUT_MANAGER_CLASS = "android.hardware.input.InputManager" - const val INPUT_MANAGER_GLOBAL_CLASS = "android.hardware.input.InputManagerGlobal" - const val INPUT_EVENT_INJECTION_SYNC_NONE = 0 - const val ACTION_PASS_TO_USER = 1 - const val HOME_LONG_PRESS_DEDUP_WINDOW_MS = 1_000L - const val REMOTE_PREFERENCES_RETRY_DELAY_MS = 1_000L - const val REMOTE_PREFERENCES_LOAD_ATTEMPTS = 15 - const val STOCK_LONG_PRESS_GUARD_EARLY_MS = 75 - const val STOCK_LONG_PRESS_GUARD_MIN_DELAY_MS = 100 - const val SYSTEMUI_PACKAGE = "com.android.systemui" - const val ANDROID_PACKAGE = "android" - const val SYSTEM_SERVER_PHONE_WINDOW_MANAGER = "com.android.server.policy.PhoneWindowManager" - val SYSTEMUI_KEY_BUTTON_VIEW_CLASSES = listOf( - "com.android.systemui.navigationbar.views.buttons.KeyButtonView", - "com.android.systemui.navigationbar.buttons.KeyButtonView", - ) - const val SAMSUNG_PHONE_LAYOUT_PROVIDER = - "com.android.systemui.navigationbar.layout.LayoutProviderImpl" - const val SAMSUNG_TABLET_LAYOUT_PROVIDER = - "com.android.systemui.navigationbar.layout.TabletLayoutProviderImpl" - const val SAMSUNG_NAVIGATION_BAR_VIEW = - "com.android.systemui.navigationbar.views.SamsungNavigationBarView" - const val SAMSUNG_LAUNCHER_PACKAGE = "com.sec.android.app.launcher" - val AOSP_LAUNCHER_PACKAGES = setOf( - "com.android.launcher3", - "com.google.android.apps.nexuslauncher", - ) - val TARGET_PACKAGES = AOSP_LAUNCHER_PACKAGES + SYSTEMUI_PACKAGE - const val AOSP_TASKBAR_NAV_BUTTON_CONTROLLER = - "com.android.launcher3.taskbar.TaskbarNavButtonController" - const val AOSP_TASKBAR_BACK_BUTTON = 1 - const val AOSP_TASKBAR_HOME_BUTTON = 2 - const val AOSP_TASKBAR_RECENTS_BUTTON = 4 - const val AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER = - "com.android.launcher3.taskbar.NavbarButtonsViewController" - const val AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO = 0.45f - const val TARGET_HOOK_RETRY_ATTEMPTS = 120 - const val TARGET_HOOK_RETRY_DELAY_MS = 250L - val AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES = listOf( - "com.android.systemui.navigationbar.views.NavigationBarInflaterView", - "com.android.systemui.navigationbar.NavigationBarInflaterView", - ) - val AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES = listOf( - "com.android.systemui.navigationbar.NavigationBarTransitions", - "com.android.systemui.navigationbar.views.NavigationBarTransitions", - ) - val AOSP_KEY_BUTTON_DRAWABLE_CLASSES = listOf( - "com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable", - "com.android.systemui.navigationbar.buttons.KeyButtonDrawable", - ) - val AOSP_KEY_BUTTON_DRAWABLE_STATE_CLASSES = listOf( - "com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable\$ShadowDrawableState", - "com.android.systemui.navigationbar.buttons.KeyButtonDrawable\$ShadowDrawableState", - ) - const val AOSP_SYSUI_LEFT_ARROW_BUTTON = "key(21:)" - const val AOSP_SYSUI_RIGHT_ARROW_BUTTON = "key(22:)" - const val ACTION_PRIVILEGED_ACTION = - "se.ajpanton.navbuttons.action.PRIVILEGED_ACTION" - const val EXTRA_PRIVILEGED_ACTION = "privileged_action" - const val EXTRA_PRIVILEGED_CALLER = "privileged_caller" - const val EXTRA_PRIVILEGED_ACTION_TOKEN = "privileged_action_token" - const val EXTRA_PRIVILEGED_KEY_CODE = "privileged_key_code" - const val PRIVILEGED_ACTION_KILL_FOREGROUND_APP = "kill_foreground_app" - const val PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT = "toggle_flashlight" - const val PRIVILEGED_ACTION_SEND_KEY_PRESS = "send_key_press" - const val SIDE_ARROW_ICON_DP = 32f - const val SIDE_ARROW_GLYPH_SCALE = 0.8f - const val SIDE_ARROW_STROKE_WIDTH = 1.45f - const val HONEYSPACE_NAV_BUTTON_VIEW = - "com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView" - const val HONEYSPACE_NAV_BUTTON_CONTAINER = - "com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarButtonsLayout" - const val HONEYSPACE_LONG_PRESS_RUNNABLE = "f.a" - const val HONEYSPACE_LONG_PRESS_CASE = 8 - const val HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH = 8 - const val HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS = 150L - const val HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS = 600L - const val HONEYSPACE_LEFT_ARROW_BUTTON_TYPE = 64 - const val HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE = 128 - val HONEYSPACE_SIDE_ARROW_BUTTONS = listOf( - HoneyspaceSideArrowButton( - HONEYSPACE_LEFT_ARROW_BUTTON_TYPE, - KeyEvent.KEYCODE_DPAD_LEFT, - SideArrowDirection.LEFT, - ), - HoneyspaceSideArrowButton( - HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE, - KeyEvent.KEYCODE_DPAD_RIGHT, - SideArrowDirection.RIGHT, - ), - ) - val HONEYSPACE_EXTRA_BUTTON_TYPES = HONEYSPACE_SIDE_ARROW_BUTTONS.map { it.type } + listOf(256, 512, 1024) - const val NAV_LAYOUT_PHONE_DEFAULT = "left,recent;home;back,right" - const val NAV_LAYOUT_PHONE_REVERSED = "left,back;home;recent,right" - const val NAV_LAYOUT_TABLET_DEFAULT = "left;recent,gap,home,gap,back;right" - const val NAV_LAYOUT_TABLET_REVERSED = "left;back,gap,home,gap,recent;right" - const val NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT = "left;back,gap,home,gap,recent;r" - const val NAV_LAYOUT_WINNER_DEFAULT = "left,gap,back,gap,home,gap,recent;;right" - const val NAV_LAYOUT_WINNER_REVERSED = "left,gap,recent,gap,home,gap,back;;right" - const val NAV_LAYOUT_UNFOLDED_DEFAULT = "left;;back,gap,home,gap,recent,gap,right" - const val NAV_LAYOUT_UNFOLDED_REVERSED = "left;;recent,gap,home,gap,back,gap,right" - const val SAMSUNG_NAV_VIEW_PARENT_DEPTH = 8 - const val BUTTON_DISPATCHER_SEARCH_DEPTH = 4 - const val SIDE_ARROW_REFRESH_DELAY_MS = 100L - const val PHONE_BUTTON_RATIO = 0.22220000624656677 - const val TABLET_BUTTON_RATIO = 0.2070000022649765 - const val SIDE_ARROW_GAP_RATIO = 0.013000000268220901 - const val PHONE_SPACE_RATIO = 0.10999999940395355 - const val TABLET_SPACE_RATIO = 0.10999999940395355 - const val TABLET_SPACE_SIDE_PADDING_RATIO = 0.033250000327825546 - const val SIDE_ARROW_REDUCE_RATIO = 0.75 - } } diff --git a/app/src/main/java/se/ajpanton/navbuttons/PrivilegedActionBridge.kt b/app/src/main/java/se/ajpanton/navbuttons/PrivilegedActionBridge.kt new file mode 100644 index 0000000..025196f --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/PrivilegedActionBridge.kt @@ -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 getParcelableExtraCompat(intent: Intent, name: String, type: Class): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(name, type) + } else { + @Suppress("DEPRECATION") + type.cast(intent.getParcelableExtra(name)) + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/ReflectionUtils.kt b/app/src/main/java/se/ajpanton/navbuttons/ReflectionUtils.kt new file mode 100644 index 0000000..a92f0a1 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/ReflectionUtils.kt @@ -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, classLoader: ClassLoader?): Class<*> { + classNames.forEach { className -> + findClassIfExists(className, classLoader)?.let { return it } + } + throw ClassNotFoundException(classNames.joinToString()) +} + +internal fun findFirstClassIfExists( + classNames: List, + 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 { + return clazz.declaredMethods + .filter { it.name == methodName } + .onEach { it.isAccessible = true } +} + +internal fun findMethodsNamed(clazz: Class<*>, methodName: String): List { + val methodsBySignature = linkedMapOf() + 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>, + vararg args: Any?, +): Any? { + return findMethod(target.javaClass, methodName, *parameterTypes).invoke(target, *args) +} + +internal fun callIntIntMethodIfExists( + target: Any, + methodNames: List, + 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) + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SamsungHoneyspaceHooks.kt b/app/src/main/java/se/ajpanton/navbuttons/SamsungHoneyspaceHooks.kt new file mode 100644 index 0000000..b344048 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SamsungHoneyspaceHooks.kt @@ -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() + private val trackedNavContainers = Collections.newSetFromMap(WeakHashMap()) + private val nativeSideArrowStates = WeakHashMap() + 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 ?: 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 + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SamsungHoneyspaceSupport.kt b/app/src/main/java/se/ajpanton/navbuttons/SamsungHoneyspaceSupport.kt new file mode 100644 index 0000000..5aa18ba --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SamsungHoneyspaceSupport.kt @@ -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): 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)), + ), + ) +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SamsungSystemUiHooks.kt b/app/src/main/java/se/ajpanton/navbuttons/SamsungSystemUiHooks.kt new file mode 100644 index 0000000..e41587b --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SamsungSystemUiHooks.kt @@ -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()) + private val startupRefreshScheduled = Collections.newSetFromMap(WeakHashMap()) + private val sideArrowLayoutKinds = WeakHashMap() + 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>()) + 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 + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SamsungSystemUiSideArrowSupport.kt b/app/src/main/java/se/ajpanton/navbuttons/SamsungSystemUiSideArrowSupport.kt new file mode 100644 index 0000000..07ea0b0 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SamsungSystemUiSideArrowSupport.kt @@ -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, + layoutKinds: Map, + 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()) + val queue = ArrayDeque>() + 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)" +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SettingsRepository.kt b/app/src/main/java/se/ajpanton/navbuttons/SettingsRepository.kt new file mode 100644 index 0000000..ad898d1 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SettingsRepository.kt @@ -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() + 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 + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SideArrowLongPressController.kt b/app/src/main/java/se/ajpanton/navbuttons/SideArrowLongPressController.kt new file mode 100644 index 0000000..e53be4b --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SideArrowLongPressController.kt @@ -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() + private val longPressRunnables = WeakHashMap() + private val repeatRunnables = WeakHashMap() + + 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) + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SideArrowSupport.kt b/app/src/main/java/se/ajpanton/navbuttons/SideArrowSupport.kt new file mode 100644 index 0000000..1d57160 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SideArrowSupport.kt @@ -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 + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SystemServerButtonOverrideController.kt b/app/src/main/java/se/ajpanton/navbuttons/SystemServerButtonOverrideController.kt new file mode 100644 index 0000000..5b243fe --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SystemServerButtonOverrideController.kt @@ -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() + private val longPressTriggered = mutableMapOf() + + 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) + } + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SystemServerHooks.kt b/app/src/main/java/se/ajpanton/navbuttons/SystemServerHooks.kt new file mode 100644 index 0000000..94f406e --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SystemServerHooks.kt @@ -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 + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SystemUiLongPressController.kt b/app/src/main/java/se/ajpanton/navbuttons/SystemUiLongPressController.kt new file mode 100644 index 0000000..02d5158 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SystemUiLongPressController.kt @@ -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() + private val runnables = WeakHashMap() + + 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) + } +} diff --git a/app/src/main/java/se/ajpanton/navbuttons/SystemUiNavHooks.kt b/app/src/main/java/se/ajpanton/navbuttons/SystemUiNavHooks.kt new file mode 100644 index 0000000..4c306a7 --- /dev/null +++ b/app/src/main/java/se/ajpanton/navbuttons/SystemUiNavHooks.kt @@ -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 + } +}