Refactor NavButtons hook structure
This commit is contained in:
@@ -0,0 +1,191 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.hardware.camera2.CameraAccessException
|
||||||
|
import android.hardware.camera2.CameraCharacteristics
|
||||||
|
import android.hardware.camera2.CameraManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.widget.Toast
|
||||||
|
|
||||||
|
internal class ActionExecutor(
|
||||||
|
private val contextProvider: () -> Context?,
|
||||||
|
private val handlerProvider: () -> Handler,
|
||||||
|
private val requestPrivilegedAction: (String, Int?) -> Unit,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
private val logError: (String, Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
private var flashlightCameraId: String? = null
|
||||||
|
private var flashlightEnabled = false
|
||||||
|
|
||||||
|
fun perform(action: NavButtonAction) {
|
||||||
|
when (action) {
|
||||||
|
NavButtonAction.STOCK,
|
||||||
|
NavButtonAction.NONE,
|
||||||
|
-> Unit
|
||||||
|
|
||||||
|
NavButtonAction.BACK -> sendConfiguredKeyPress(KeyEvent.KEYCODE_BACK)
|
||||||
|
NavButtonAction.HOME -> sendConfiguredKeyPress(KeyEvent.KEYCODE_HOME)
|
||||||
|
NavButtonAction.ASSISTANT -> sendConfiguredKeyPress(KeyEvent.KEYCODE_ASSIST)
|
||||||
|
NavButtonAction.RECENTS -> sendConfiguredKeyPress(KeyEvent.KEYCODE_APP_SWITCH)
|
||||||
|
NavButtonAction.KILL_FOREGROUND_APP -> performPrivilegedActionOrDirect(PRIVILEGED_ACTION_KILL_FOREGROUND_APP)
|
||||||
|
NavButtonAction.TOGGLE_AUTO_ROTATE -> toggleAutoRotate()
|
||||||
|
NavButtonAction.TOGGLE_FLASHLIGHT -> performPrivilegedActionOrDirect(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendSideArrowKeyPress(keyCode: Int) {
|
||||||
|
sendConfiguredKeyPress(keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendKeyPress(keyCode: Int) {
|
||||||
|
sendInjectedKeyPress(contextProvider(), keyCode, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun killForegroundApp() {
|
||||||
|
handlerProvider().post {
|
||||||
|
try {
|
||||||
|
val context = contextProvider() ?: run {
|
||||||
|
log("Context unavailable, cannot kill app.")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||||
|
if (activityManager == null) {
|
||||||
|
log("ActivityManager unavailable.")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultLauncher = defaultLauncherPackageName(context)
|
||||||
|
val foregroundApp = topPackageName(context, log)
|
||||||
|
if (foregroundApp != null && !shouldSkipForegroundPackage(foregroundApp, defaultLauncher)) {
|
||||||
|
val appLabel = applicationLabel(foregroundApp, context.packageManager)
|
||||||
|
log("Killing: $foregroundApp ($appLabel)")
|
||||||
|
callMethod(activityManager, "forceStopPackage", arrayOf(String::class.java), foregroundApp)
|
||||||
|
showToast("Killed: $appLabel")
|
||||||
|
} else {
|
||||||
|
log("Nothing to kill.")
|
||||||
|
showToast("Nothing to kill.")
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
logError("Error in killForegroundApp", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFlashlight() {
|
||||||
|
val context = contextProvider() ?: run {
|
||||||
|
log("Context unavailable, cannot toggle flashlight.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val cameraManager = context.getSystemService(CameraManager::class.java)
|
||||||
|
val cameraId = flashlightCameraId ?: findFlashlightCameraId(cameraManager)
|
||||||
|
if (cameraId == null) {
|
||||||
|
log("No flashlight-capable camera found.")
|
||||||
|
showToast("No flashlight found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flashlightCameraId = cameraId
|
||||||
|
val enabled = !flashlightEnabled
|
||||||
|
cameraManager.setTorchMode(cameraId, enabled)
|
||||||
|
flashlightEnabled = enabled
|
||||||
|
log("Flashlight: ${if (enabled) "On" else "Off"}")
|
||||||
|
showToast("Flashlight: ${if (enabled) "On" else "Off"}")
|
||||||
|
} catch (error: CameraAccessException) {
|
||||||
|
log("Toggle flashlight failed: ${error.javaClass.simpleName}")
|
||||||
|
showToast("Flashlight toggle failed.")
|
||||||
|
} catch (error: SecurityException) {
|
||||||
|
log("Toggle flashlight permission denied.")
|
||||||
|
showToast("Flashlight permission denied.")
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Toggle flashlight failed: ${error.javaClass.simpleName}")
|
||||||
|
showToast("Flashlight toggle failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendConfiguredKeyPress(keyCode: Int) {
|
||||||
|
if (contextProvider()?.packageName in AOSP_LAUNCHER_PACKAGES) {
|
||||||
|
requestPrivilegedAction(PRIVILEGED_ACTION_SEND_KEY_PRESS, keyCode)
|
||||||
|
} else {
|
||||||
|
sendKeyPress(keyCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performPrivilegedActionOrDirect(action: String) {
|
||||||
|
if (contextProvider()?.packageName == ANDROID_PACKAGE) {
|
||||||
|
when (action) {
|
||||||
|
PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp()
|
||||||
|
PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requestPrivilegedAction(action, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleAutoRotate() {
|
||||||
|
val context = contextProvider() ?: run {
|
||||||
|
log("Context unavailable, cannot toggle auto rotate.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val enabled = toggleAutoRotateViaRotationPolicy(context)
|
||||||
|
?: toggleAutoRotateSetting(context)
|
||||||
|
showToast("Auto rotate: ${if (enabled) "On" else "Off"}")
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Toggle auto rotate failed: ${error.javaClass.simpleName}")
|
||||||
|
showToast("Auto rotate toggle failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleAutoRotateViaRotationPolicy(context: Context): Boolean? {
|
||||||
|
return runCatching {
|
||||||
|
val rotationPolicy = findClass("com.android.internal.view.RotationPolicy", null)
|
||||||
|
val isLocked = findMethod(
|
||||||
|
rotationPolicy,
|
||||||
|
"isRotationLocked",
|
||||||
|
Context::class.java,
|
||||||
|
).invoke(null, context) as Boolean
|
||||||
|
val enabled = isLocked
|
||||||
|
findMethod(
|
||||||
|
rotationPolicy,
|
||||||
|
"setRotationLock",
|
||||||
|
Context::class.java,
|
||||||
|
Boolean::class.javaPrimitiveType!!,
|
||||||
|
).invoke(null, context, !enabled)
|
||||||
|
enabled
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleAutoRotateSetting(context: Context): Boolean {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val enabled = Settings.System.getInt(resolver, Settings.System.ACCELEROMETER_ROTATION, 0) != 1
|
||||||
|
Settings.System.putInt(resolver, Settings.System.ACCELEROMETER_ROTATION, if (enabled) 1 else 0)
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showToast(message: String) {
|
||||||
|
val context = contextProvider() ?: return
|
||||||
|
try {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Toast failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findFlashlightCameraId(cameraManager: CameraManager): String? {
|
||||||
|
return cameraManager.cameraIdList.firstOrNull { cameraId ->
|
||||||
|
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
||||||
|
val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
|
||||||
|
val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING)
|
||||||
|
hasFlash && lensFacing == CameraCharacteristics.LENS_FACING_BACK
|
||||||
|
} ?: cameraManager.cameraIdList.firstOrNull { cameraId ->
|
||||||
|
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
||||||
|
characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class AospLauncherTaskbarHooks(
|
||||||
|
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||||
|
private val deoptimizeMethod: (Method?) -> Unit,
|
||||||
|
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||||
|
private val sideArrowsEnabled: () -> Boolean,
|
||||||
|
private val sideArrowLongPresses: SideArrowLongPressController,
|
||||||
|
private val actions: ActionExecutor,
|
||||||
|
private val registerSettingsReceiver: () -> Unit,
|
||||||
|
private val contextUpdater: (Context) -> Unit,
|
||||||
|
private val requestPrivilegedAction: (String, Int?) -> Unit,
|
||||||
|
private val wasHomeLongPressHandledRecently: () -> Boolean,
|
||||||
|
private val noteHomeLongPressHandled: () -> Unit,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val buttonIds = WeakHashMap<Any, NavButtonId>()
|
||||||
|
private val controllers = WeakHashMap<Any, Any>()
|
||||||
|
private val trackedContainers = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
|
||||||
|
private val buttons = WeakHashMap<ViewGroup, MutableMap<NavButtonId, View>>()
|
||||||
|
private val sideArrows = WeakHashMap<ViewGroup, AospSideArrowViews>()
|
||||||
|
private val sideButtonTranslations = WeakHashMap<View, Float>()
|
||||||
|
private var tintHookInstalled = false
|
||||||
|
|
||||||
|
private val touchController = AospTaskbarTouchController(
|
||||||
|
buttonIdProvider = { buttonIds[it] },
|
||||||
|
controllerProvider = { controllers[it] },
|
||||||
|
configProvider = configProvider,
|
||||||
|
updateContext = ::updateContextFromTaskbar,
|
||||||
|
performAction = ::performAction,
|
||||||
|
performHomeLongPressAction = ::performHomeLongPressAction,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun install(classLoader: ClassLoader?): Boolean {
|
||||||
|
val controllerClass = findClassIfExists(AOSP_TASKBAR_NAV_BUTTON_CONTROLLER, classLoader) ?: run {
|
||||||
|
log("AOSP taskbar controller class not found: $AOSP_TASKBAR_NAV_BUTTON_CONTROLLER")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val navButtonsViewControllerClass = findClassIfExists(
|
||||||
|
AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER,
|
||||||
|
classLoader,
|
||||||
|
)
|
||||||
|
val clickMethod = findMethodIfExists(
|
||||||
|
controllerClass,
|
||||||
|
"onButtonClick",
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
View::class.java,
|
||||||
|
) ?: findMethodIfExists(
|
||||||
|
controllerClass,
|
||||||
|
"onButtonClick",
|
||||||
|
View::class.java,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
) ?: run {
|
||||||
|
log("AOSP taskbar onButtonClick() method not found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val longClickMethod = findMethodIfExists(
|
||||||
|
controllerClass,
|
||||||
|
"onButtonLongClick",
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
View::class.java,
|
||||||
|
) ?: findMethodIfExists(
|
||||||
|
controllerClass,
|
||||||
|
"onButtonLongClick",
|
||||||
|
View::class.java,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
) ?: run {
|
||||||
|
log("AOSP taskbar onButtonLongClick() method not found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val sendBackKeyEventMethod = findMethodIfExists(
|
||||||
|
controllerClass,
|
||||||
|
"sendBackKeyEvent",
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
Boolean::class.javaPrimitiveType!!,
|
||||||
|
)
|
||||||
|
|
||||||
|
deoptimizeMethod(clickMethod)
|
||||||
|
deoptimizeMethod(longClickMethod)
|
||||||
|
deoptimizeMethod(sendBackKeyEventMethod)
|
||||||
|
hookButtonMapping(navButtonsViewControllerClass, controllerClass)
|
||||||
|
hookTintUpdates()
|
||||||
|
registerSettingsReceiver()
|
||||||
|
|
||||||
|
hookMethod(clickMethod) { chain ->
|
||||||
|
val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int)
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
val pressAction = configProvider(buttonId).pressAction
|
||||||
|
if (pressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromTaskbar(chain.thisObject, chain.args.firstOrNull { it is View })
|
||||||
|
performAction(chain.thisObject, pressAction)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
hookMethod(longClickMethod) { chain ->
|
||||||
|
val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int)
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
val longPressAction = configProvider(buttonId).longPressAction
|
||||||
|
if (longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
val view = chain.args.firstOrNull { it is View }
|
||||||
|
if (view != null && buttonIds.containsKey(view)) {
|
||||||
|
return@hookMethod true
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromTaskbar(chain.thisObject, view)
|
||||||
|
if (buttonId == NavButtonId.HOME) {
|
||||||
|
performHomeLongPressAction(chain.thisObject, longPressAction)
|
||||||
|
} else {
|
||||||
|
performAction(chain.thisObject, longPressAction)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sendBackKeyEventMethod != null) {
|
||||||
|
hookMethod(sendBackKeyEventMethod) { chain ->
|
||||||
|
val pressAction = configProvider(NavButtonId.BACK).pressAction
|
||||||
|
val longPressAction = configProvider(NavButtonId.BACK).longPressAction
|
||||||
|
if (pressAction == NavButtonAction.STOCK && longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromField(chain.thisObject, "mContext")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSideArrows() {
|
||||||
|
trackedContainers.toList().forEach(::refreshSideArrows)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookButtonMapping(
|
||||||
|
navButtonsViewControllerClass: Class<*>?,
|
||||||
|
controllerClass: Class<*>,
|
||||||
|
) {
|
||||||
|
if (navButtonsViewControllerClass == null) {
|
||||||
|
log("AOSP navbar buttons controller class not found: $AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
arrayOf(
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
ViewGroup::class.java,
|
||||||
|
controllerClass,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
),
|
||||||
|
arrayOf(
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
ViewGroup::class.java,
|
||||||
|
controllerClass,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
),
|
||||||
|
).forEach { parameterTypes ->
|
||||||
|
val addButtonMethod = findMethodIfExists(
|
||||||
|
navButtonsViewControllerClass,
|
||||||
|
"addButton",
|
||||||
|
*parameterTypes,
|
||||||
|
) ?: return@forEach
|
||||||
|
deoptimizeMethod(addButtonMethod)
|
||||||
|
hookMethod(addButtonMethod) { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
val view = result as? View ?: return@hookMethod result
|
||||||
|
trackButton(
|
||||||
|
view,
|
||||||
|
chain.args.getOrNull(1) as? Int,
|
||||||
|
chain.args.getOrNull(3),
|
||||||
|
chain.args.getOrNull(2) as? ViewGroup,
|
||||||
|
)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookTintUpdates() {
|
||||||
|
if (tintHookInstalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tintHookInstalled = true
|
||||||
|
|
||||||
|
findDeclaredMethodsNamed(ImageView::class.java, "setImageTintList").forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
refreshSideArrowTint(chain.thisObject as? ImageView)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findDeclaredMethodsNamed(ImageView::class.java, "setColorFilter").forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
refreshSideArrowTint(chain.thisObject as? ImageView)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackButton(
|
||||||
|
view: View,
|
||||||
|
buttonCode: Int?,
|
||||||
|
controller: Any?,
|
||||||
|
parent: ViewGroup?,
|
||||||
|
) {
|
||||||
|
val buttonId = buttonIdForAospTaskbarCode(buttonCode) ?: return
|
||||||
|
buttonIds[view] = buttonId
|
||||||
|
if (controller != null) {
|
||||||
|
controllers[view] = controller
|
||||||
|
}
|
||||||
|
view.setOnTouchListener { touchedView, event -> touchController.handle(touchedView, event) }
|
||||||
|
|
||||||
|
updateContextFromView(view)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
val container = (view.parent as? ViewGroup) ?: parent ?: return
|
||||||
|
buttons.getOrPut(container) { mutableMapOf() }[buttonId] = view
|
||||||
|
trackContainer(container)
|
||||||
|
refreshSideArrows(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackContainer(container: ViewGroup) {
|
||||||
|
if (trackedContainers.add(container)) {
|
||||||
|
container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
|
||||||
|
refreshSideArrows(view as ViewGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSideArrows(container: ViewGroup) {
|
||||||
|
try {
|
||||||
|
val containerButtons = buttons[container].orEmpty()
|
||||||
|
val home = containerButtons[NavButtonId.HOME] as? ImageView ?: return
|
||||||
|
if (home.parent != container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sideArrowsEnabled()) {
|
||||||
|
removeSideArrows(container)
|
||||||
|
resetSideButtonTranslations(container)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val arrows = sideArrows.getOrPut(container) {
|
||||||
|
AospSideArrowViews(
|
||||||
|
createSideArrowView(container, home, SideArrowDirection.LEFT),
|
||||||
|
createSideArrowView(container, home, SideArrowDirection.RIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
configureSideArrowView(arrows.left, home, SideArrowDirection.LEFT)
|
||||||
|
configureSideArrowView(arrows.right, home, SideArrowDirection.RIGHT)
|
||||||
|
placeAospTaskbarSideArrows(
|
||||||
|
container,
|
||||||
|
home,
|
||||||
|
arrows,
|
||||||
|
containerButtons,
|
||||||
|
sideButtonTranslations,
|
||||||
|
)
|
||||||
|
applySideArrowTint(arrows, home)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("AOSP side arrows refresh failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSideArrowView(
|
||||||
|
container: ViewGroup,
|
||||||
|
home: ImageView,
|
||||||
|
direction: SideArrowDirection,
|
||||||
|
): ImageView {
|
||||||
|
return ImageView(container.context).apply {
|
||||||
|
id = View.generateViewId()
|
||||||
|
isClickable = true
|
||||||
|
isFocusable = true
|
||||||
|
contentDescription = direction.name.lowercase()
|
||||||
|
setOnTouchListener { view, event ->
|
||||||
|
handleSideArrowTouch(view, direction.keyCode, event)
|
||||||
|
}
|
||||||
|
configureSideArrowView(this, home, direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureSideArrowView(
|
||||||
|
arrow: ImageView,
|
||||||
|
home: ImageView,
|
||||||
|
direction: SideArrowDirection,
|
||||||
|
) {
|
||||||
|
arrow.minimumWidth = home.minimumWidth
|
||||||
|
arrow.minimumHeight = home.minimumHeight
|
||||||
|
arrow.setPadding(0, 0, 0, 0)
|
||||||
|
arrow.scaleType = ImageView.ScaleType.CENTER
|
||||||
|
arrow.setImageDrawable(sideArrowBitmapDrawable(arrow.context, direction, Color.WHITE))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeSideArrows(container: ViewGroup) {
|
||||||
|
sideArrows[container]?.let { arrows ->
|
||||||
|
removeFromParent(arrows.left)
|
||||||
|
removeFromParent(arrows.right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetSideButtonTranslations(container: ViewGroup) {
|
||||||
|
buttons[container].orEmpty().values.forEach { button ->
|
||||||
|
sideButtonTranslations.remove(button)?.let { button.translationX = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySideArrowTint(arrows: AospSideArrowViews, source: ImageView) {
|
||||||
|
listOf(arrows.left, arrows.right).forEach { arrow ->
|
||||||
|
arrow.imageTintList = source.imageTintList
|
||||||
|
arrow.colorFilter = source.colorFilter
|
||||||
|
arrow.imageAlpha = source.imageAlpha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSideArrowTint(source: ImageView?) {
|
||||||
|
val container = source?.parent as? ViewGroup ?: return
|
||||||
|
val containerButtons = buttons[container] ?: return
|
||||||
|
val arrows = sideArrows[container] ?: return
|
||||||
|
if (source !in containerButtons.values) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val tintSource = (containerButtons[NavButtonId.HOME] ?: containerButtons.values.firstOrNull()) as? ImageView
|
||||||
|
?: return
|
||||||
|
applySideArrowTint(arrows, tintSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSideArrowTouch(view: View, keyCode: Int, event: MotionEvent): Boolean {
|
||||||
|
updateContextFromView(view)
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
view.isPressed = true
|
||||||
|
sideArrowLongPresses.start(view, keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP,
|
||||||
|
MotionEvent.ACTION_CANCEL,
|
||||||
|
-> {
|
||||||
|
val triggered = sideArrowLongPresses.finish(view)
|
||||||
|
view.isPressed = false
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_UP && !triggered) {
|
||||||
|
view.performClick()
|
||||||
|
actions.sendSideArrowKeyPress(keyCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContextFromTaskbar(controller: Any?, view: Any?) {
|
||||||
|
if (view is View) {
|
||||||
|
updateContextFromView(view)
|
||||||
|
} else {
|
||||||
|
updateContextFromField(controller, "mContext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContextFromView(view: View) {
|
||||||
|
contextUpdater(view.context.applicationContext ?: view.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContextFromField(target: Any?, fieldName: String) {
|
||||||
|
val currentContext = getFieldValue(target, fieldName) as? Context ?: return
|
||||||
|
contextUpdater(currentContext.applicationContext ?: currentContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performHomeLongPressAction(controller: Any?, action: NavButtonAction) {
|
||||||
|
if (wasHomeLongPressHandledRecently()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noteHomeLongPressHandled()
|
||||||
|
performAction(controller, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performAction(controller: Any?, action: NavButtonAction) {
|
||||||
|
when (action) {
|
||||||
|
NavButtonAction.STOCK,
|
||||||
|
NavButtonAction.NONE,
|
||||||
|
-> Unit
|
||||||
|
|
||||||
|
NavButtonAction.BACK -> {
|
||||||
|
if (!callTaskbarMethod(controller, "executeBack", arrayOf(KeyEvent::class.java), null)) {
|
||||||
|
actions.perform(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NavButtonAction.HOME -> {
|
||||||
|
if (!callTaskbarMethod(controller, "navigateHome", emptyArray())) {
|
||||||
|
actions.perform(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NavButtonAction.ASSISTANT -> {
|
||||||
|
if (!callTaskbarMethod(controller, "onLongPressHome", emptyArray())) {
|
||||||
|
actions.perform(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NavButtonAction.RECENTS -> {
|
||||||
|
if (!callTaskbarMethod(controller, "navigateToOverview", emptyArray())) {
|
||||||
|
actions.perform(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NavButtonAction.KILL_FOREGROUND_APP -> {
|
||||||
|
requestPrivilegedAction(PRIVILEGED_ACTION_KILL_FOREGROUND_APP, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
NavButtonAction.TOGGLE_FLASHLIGHT -> {
|
||||||
|
requestPrivilegedAction(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
NavButtonAction.TOGGLE_AUTO_ROTATE -> actions.perform(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callTaskbarMethod(
|
||||||
|
controller: Any?,
|
||||||
|
methodName: String,
|
||||||
|
parameterTypes: Array<Class<*>>,
|
||||||
|
vararg args: Any?,
|
||||||
|
): Boolean {
|
||||||
|
if (controller == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
callMethod(controller, methodName, parameterTypes, *args)
|
||||||
|
true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("AOSP taskbar $methodName failed: ${error.javaClass.simpleName}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.graphics.Point
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal fun placeAospTaskbarSideArrows(
|
||||||
|
container: ViewGroup,
|
||||||
|
home: ImageView,
|
||||||
|
arrows: AospSideArrowViews,
|
||||||
|
buttons: Map<NavButtonId, View>,
|
||||||
|
sideButtonTranslations: WeakHashMap<View, Float>,
|
||||||
|
) {
|
||||||
|
val host = findAospTaskbarArrowHost(container) ?: return
|
||||||
|
val sideButtons = listOfNotNull(buttons[NavButtonId.BACK], buttons[NavButtonId.RECENTS])
|
||||||
|
.filter { it.width > 0 && it.height > 0 }
|
||||||
|
if (home.width <= 0 || home.height <= 0 || sideButtons.size < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addAospTaskbarArrowToHost(host, arrows.left, home)
|
||||||
|
addAospTaskbarArrowToHost(host, arrows.right, home)
|
||||||
|
|
||||||
|
val homeCenter = centerInHost(home, host)
|
||||||
|
val leftButton = sideButtons.filter { centerInHost(it, host).x < homeCenter.x }
|
||||||
|
.maxByOrNull { centerInHost(it, host).x }
|
||||||
|
?: return
|
||||||
|
val rightButton = sideButtons.filter { centerInHost(it, host).x > homeCenter.x }
|
||||||
|
.minByOrNull { centerInHost(it, host).x }
|
||||||
|
?: return
|
||||||
|
|
||||||
|
translateAospTaskbarSideButton(
|
||||||
|
leftButton,
|
||||||
|
-aospTaskbarSideButtonShift(arrows.left),
|
||||||
|
sideButtonTranslations,
|
||||||
|
)
|
||||||
|
translateAospTaskbarSideButton(
|
||||||
|
rightButton,
|
||||||
|
aospTaskbarSideButtonShift(arrows.right),
|
||||||
|
sideButtonTranslations,
|
||||||
|
)
|
||||||
|
placeAospTaskbarArrow(arrows.left, midpoint(centerInHost(leftButton, host), homeCenter))
|
||||||
|
placeAospTaskbarArrow(arrows.right, midpoint(centerInHost(rightButton, host), homeCenter))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun removeFromParent(view: View) {
|
||||||
|
(view.parent as? ViewGroup)?.removeView(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translateAospTaskbarSideButton(
|
||||||
|
button: View,
|
||||||
|
shiftPx: Float,
|
||||||
|
sideButtonTranslations: WeakHashMap<View, Float>,
|
||||||
|
) {
|
||||||
|
val originalTranslation = sideButtonTranslations.getOrPut(button) { button.translationX }
|
||||||
|
button.translationX = originalTranslation + shiftPx
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aospTaskbarSideButtonShift(arrow: View): Float {
|
||||||
|
return navStarArrowIconSizePx(arrow.context) * AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findAospTaskbarArrowHost(start: View): FrameLayout? {
|
||||||
|
var current = start.parent as? View
|
||||||
|
repeat(5) {
|
||||||
|
if (current is FrameLayout) {
|
||||||
|
return current as FrameLayout
|
||||||
|
}
|
||||||
|
current = current?.parent as? View
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addAospTaskbarArrowToHost(
|
||||||
|
host: FrameLayout,
|
||||||
|
arrow: ImageView,
|
||||||
|
home: ImageView,
|
||||||
|
) {
|
||||||
|
val width = navStarArrowIconSizePx(arrow.context)
|
||||||
|
val height = home.height.coerceAtLeast(width)
|
||||||
|
if (arrow.parent != host) {
|
||||||
|
removeFromParent(arrow)
|
||||||
|
host.addView(arrow, FrameLayout.LayoutParams(width, height))
|
||||||
|
} else {
|
||||||
|
arrow.layoutParams = (arrow.layoutParams as? FrameLayout.LayoutParams)
|
||||||
|
?.apply {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
}
|
||||||
|
?: FrameLayout.LayoutParams(width, height)
|
||||||
|
}
|
||||||
|
arrow.bringToFront()
|
||||||
|
arrow.translationZ = home.translationZ + 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun centerInHost(view: View, host: View): Point {
|
||||||
|
val viewLocation = IntArray(2)
|
||||||
|
val hostLocation = IntArray(2)
|
||||||
|
view.getLocationOnScreen(viewLocation)
|
||||||
|
host.getLocationOnScreen(hostLocation)
|
||||||
|
return Point(
|
||||||
|
viewLocation[0] - hostLocation[0] + view.width / 2,
|
||||||
|
viewLocation[1] - hostLocation[1] + view.height / 2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun midpoint(first: Point, second: Point): Point {
|
||||||
|
return Point((first.x + second.x) / 2, (first.y + second.y) / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placeAospTaskbarArrow(arrow: View, center: Point) {
|
||||||
|
val width = arrow.width.takeIf { it > 0 }
|
||||||
|
?: arrow.layoutParams?.width?.takeIf { it > 0 }
|
||||||
|
?: 0
|
||||||
|
val height = arrow.height.takeIf { it > 0 }
|
||||||
|
?: arrow.layoutParams?.height?.takeIf { it > 0 }
|
||||||
|
?: 0
|
||||||
|
arrow.x = center.x - width / 2f
|
||||||
|
arrow.y = center.y - height / 2f
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class AospSystemUiHooks(
|
||||||
|
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||||
|
private val deoptimizeMethod: (Method?) -> Unit,
|
||||||
|
private val sideArrowsEnabled: () -> Boolean,
|
||||||
|
private val registerSettingsReceiver: () -> Unit,
|
||||||
|
private val contextUpdater: (Context) -> Unit,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val trackedInflaters = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||||
|
private val sideArrowDirections = WeakHashMap<Any, SideArrowDirection>()
|
||||||
|
private var sideArrowHookInstalled = false
|
||||||
|
private var darkIntensity = 0f
|
||||||
|
|
||||||
|
fun installSideArrows(classLoader: ClassLoader?) {
|
||||||
|
if (sideArrowHookInstalled || Build.MANUFACTURER.equals("samsung", ignoreCase = true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val inflaterClass = findFirstClassIfExists(AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES, classLoader)
|
||||||
|
?: return
|
||||||
|
sideArrowHookInstalled = true
|
||||||
|
registerSettingsReceiver()
|
||||||
|
hookDarkIntensity(classLoader)
|
||||||
|
|
||||||
|
findMethodIfExists(inflaterClass, "getDefaultLayout")?.let { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
chain.thisObject?.let(::trackInflater)
|
||||||
|
val layout = chain.proceed() as? String ?: return@hookMethod null
|
||||||
|
if (sideArrowsEnabled()) {
|
||||||
|
addAospSystemUiSideArrows(layout)
|
||||||
|
} else {
|
||||||
|
removeAospSystemUiSideArrows(layout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findMethodIfExists(inflaterClass, "inflateLayout", String::class.java)?.let { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
chain.thisObject?.let(::trackInflater)
|
||||||
|
val layout = chain.args.getOrNull(0) as? String
|
||||||
|
if (layout != null) {
|
||||||
|
chain.args[0] = if (sideArrowsEnabled()) {
|
||||||
|
addAospSystemUiSideArrows(layout)
|
||||||
|
} else {
|
||||||
|
removeAospSystemUiSideArrows(layout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findMethodIfExists(
|
||||||
|
inflaterClass,
|
||||||
|
"inflateButtons",
|
||||||
|
Array<String>::class.java,
|
||||||
|
ViewGroup::class.java,
|
||||||
|
Boolean::class.javaPrimitiveType!!,
|
||||||
|
Boolean::class.javaPrimitiveType!!,
|
||||||
|
)?.let { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val tokens = (chain.args.getOrNull(0) as? Array<*>)
|
||||||
|
?.mapNotNull { it as? String }
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
val parent = chain.args.getOrNull(1) as? ViewGroup
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
val childCountBefore = parent.childCount
|
||||||
|
val result = chain.proceed()
|
||||||
|
trackInflatedSideArrows(tokens, parent, childCountBefore)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSideArrowLayouts() {
|
||||||
|
trackedInflaters.toList().forEach(::refreshSideArrowLayout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun configureSideArrowButton(value: Any?) {
|
||||||
|
val view = value as? ImageView ?: return
|
||||||
|
val direction = sideArrowDirections[view] ?: return
|
||||||
|
updateContextFromView(view)
|
||||||
|
createSideArrowDrawable(view, direction)?.let {
|
||||||
|
view.setImageDrawable(it)
|
||||||
|
callFloatMethodIfExists(view, "setDarkIntensity", darkIntensity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackInflater(inflater: Any) {
|
||||||
|
trackedInflaters.add(inflater)
|
||||||
|
(inflater as? View)?.let {
|
||||||
|
updateContextFromView(it)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSideArrowLayout(inflater: Any) {
|
||||||
|
try {
|
||||||
|
clearInflaterViews(inflater)
|
||||||
|
callMethod(inflater, "inflateLayout", arrayOf(String::class.java), null)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("AOSP SystemUI side arrows refresh failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearInflaterViews(inflater: Any) {
|
||||||
|
callMethodIfExists(inflater, "clearDispatcherViews")
|
||||||
|
val view = inflater as? View ?: return
|
||||||
|
val ids = listOf("ends_group", "center_group").mapNotNull { name ->
|
||||||
|
view.resources.getIdentifier(name, "id", view.context.packageName).takeIf { it != 0 }
|
||||||
|
}
|
||||||
|
listOf("mHorizontal", "mVertical").forEach { fieldName ->
|
||||||
|
val root = getFieldValue(inflater, fieldName) as? ViewGroup ?: return@forEach
|
||||||
|
ids.forEach { id ->
|
||||||
|
(root.findViewById<View>(id) as? ViewGroup)?.removeAllViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackInflatedSideArrows(
|
||||||
|
tokens: List<String>,
|
||||||
|
parent: ViewGroup,
|
||||||
|
childCountBefore: Int,
|
||||||
|
) {
|
||||||
|
val directions = tokens.mapNotNull { token ->
|
||||||
|
when (layoutButtonName(token)) {
|
||||||
|
AOSP_SYSUI_LEFT_ARROW_BUTTON -> SideArrowDirection.LEFT
|
||||||
|
AOSP_SYSUI_RIGHT_ARROW_BUTTON -> SideArrowDirection.RIGHT
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (directions.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttons = (childCountBefore until parent.childCount)
|
||||||
|
.flatMap { index -> findKeyButtons(parent.getChildAt(index)) }
|
||||||
|
.toMutableList()
|
||||||
|
directions.forEach { direction ->
|
||||||
|
val button = buttons.firstOrNull {
|
||||||
|
getIntField(it, "mCode") == direction.keyCode
|
||||||
|
} ?: return@forEach
|
||||||
|
buttons.remove(button)
|
||||||
|
sideArrowDirections[button] = direction
|
||||||
|
configureSideArrowButton(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookDarkIntensity(classLoader: ClassLoader?) {
|
||||||
|
val transitionsClass = findFirstClassIfExists(
|
||||||
|
AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES,
|
||||||
|
classLoader,
|
||||||
|
) ?: return
|
||||||
|
findMethodIfExists(
|
||||||
|
transitionsClass,
|
||||||
|
"applyDarkIntensity",
|
||||||
|
Float::class.javaPrimitiveType!!,
|
||||||
|
)?.let { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
darkIntensity = (chain.args.getOrNull(0) as? Float)?.coerceIn(0f, 1f)
|
||||||
|
?: darkIntensity
|
||||||
|
updateSideArrowDarkIntensity()
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSideArrowDarkIntensity() {
|
||||||
|
sideArrowDirections.keys.toList().forEach { view ->
|
||||||
|
callFloatMethodIfExists(view, "setDarkIntensity", darkIntensity)
|
||||||
|
setFieldIfExists(view, "mDarkIntensity", darkIntensity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findKeyButtons(view: View): List<ImageView> {
|
||||||
|
val result = mutableListOf<ImageView>()
|
||||||
|
if (view is ImageView && getFieldValue(view, "mCode") != null) {
|
||||||
|
result.add(view)
|
||||||
|
}
|
||||||
|
if (view is ViewGroup) {
|
||||||
|
repeat(view.childCount) { index ->
|
||||||
|
result.addAll(findKeyButtons(view.getChildAt(index)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSideArrowDrawable(
|
||||||
|
value: Any?,
|
||||||
|
direction: SideArrowDirection,
|
||||||
|
): Drawable? {
|
||||||
|
val view = value as? View ?: return null
|
||||||
|
val classLoader = view.javaClass.classLoader ?: return null
|
||||||
|
return try {
|
||||||
|
val keyButtonDrawableClass = findFirstClass(AOSP_KEY_BUTTON_DRAWABLE_CLASSES, classLoader)
|
||||||
|
val stateClass = findFirstClass(AOSP_KEY_BUTTON_DRAWABLE_STATE_CLASSES, classLoader)
|
||||||
|
val state = stateClass
|
||||||
|
.getDeclaredConstructor(
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Int::class.javaPrimitiveType,
|
||||||
|
Boolean::class.javaPrimitiveType,
|
||||||
|
Boolean::class.javaPrimitiveType,
|
||||||
|
)
|
||||||
|
.apply { isAccessible = true }
|
||||||
|
.newInstance(Color.WHITE, Color.rgb(55, 58, 62), false, false)
|
||||||
|
val drawable = keyButtonDrawableClass
|
||||||
|
.getDeclaredConstructor(Drawable::class.java, stateClass)
|
||||||
|
.apply { isAccessible = true }
|
||||||
|
.newInstance(
|
||||||
|
sideArrowBitmapDrawable(view.context, direction, Color.WHITE),
|
||||||
|
state,
|
||||||
|
) as? Drawable
|
||||||
|
drawable?.also {
|
||||||
|
callFloatMethodIfExists(it, "setDarkIntensity", darkIntensity)
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("AOSP SystemUI side arrow drawable failed: ${error.javaClass.simpleName}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContextFromView(view: View) {
|
||||||
|
contextUpdater(view.context.applicationContext ?: view.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addAospSystemUiSideArrows(layout: String): String {
|
||||||
|
val parts = splitAospSystemUiLayout(layout) ?: return layout
|
||||||
|
val withoutArrows = parts.map { group ->
|
||||||
|
group.filterNot(::isAospSystemUiSideArrowToken).toMutableList()
|
||||||
|
}
|
||||||
|
val center = withoutArrows[1]
|
||||||
|
val homeIndex = center.indexOfFirst { layoutButtonName(it) == "home" }
|
||||||
|
if (homeIndex < 0) {
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
if (center.any { layoutButtonName(it) == "back" } &&
|
||||||
|
center.any { layoutButtonName(it) == "recent" }
|
||||||
|
) {
|
||||||
|
center.add(homeIndex, AOSP_SYSUI_LEFT_ARROW_BUTTON)
|
||||||
|
center.add(homeIndex + 2, AOSP_SYSUI_RIGHT_ARROW_BUTTON)
|
||||||
|
} else {
|
||||||
|
val leftButton = withoutArrows[0].firstOrNull(::isAospSystemUiPrimaryButton)
|
||||||
|
?: return layout
|
||||||
|
val rightButton = withoutArrows[2].firstOrNull(::isAospSystemUiPrimaryButton)
|
||||||
|
?: return layout
|
||||||
|
val homeButton = center[homeIndex]
|
||||||
|
withoutArrows[0].removeAll(::isAospSystemUiPrimaryButton)
|
||||||
|
withoutArrows[2].removeAll(::isAospSystemUiPrimaryButton)
|
||||||
|
center.clear()
|
||||||
|
center.addAll(
|
||||||
|
listOf(
|
||||||
|
layoutButtonName(leftButton),
|
||||||
|
AOSP_SYSUI_LEFT_ARROW_BUTTON,
|
||||||
|
layoutButtonName(homeButton),
|
||||||
|
AOSP_SYSUI_RIGHT_ARROW_BUTTON,
|
||||||
|
layoutButtonName(rightButton),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinAospSystemUiLayout(withoutArrows)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeAospSystemUiSideArrows(layout: String): String {
|
||||||
|
val parts = splitAospSystemUiLayout(layout) ?: return layout
|
||||||
|
return joinAospSystemUiLayout(
|
||||||
|
parts.map { group -> group.filterNot(::isAospSystemUiSideArrowToken) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun layoutButtonName(token: String): String {
|
||||||
|
return token.substringBefore("[")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitAospSystemUiLayout(layout: String): List<List<String>>? {
|
||||||
|
val parts = layout.split(";", limit = 3)
|
||||||
|
if (parts.size != 3) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return parts.map { group -> group.split(",").filter { it.isNotBlank() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun joinAospSystemUiLayout(parts: List<List<String>>): String {
|
||||||
|
return parts.joinToString(";") { group -> group.joinToString(",") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAospSystemUiSideArrowToken(token: String): Boolean {
|
||||||
|
val button = layoutButtonName(token)
|
||||||
|
return button == AOSP_SYSUI_LEFT_ARROW_BUTTON || button == AOSP_SYSUI_RIGHT_ARROW_BUTTON
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAospSystemUiPrimaryButton(token: String): Boolean {
|
||||||
|
return when (layoutButtonName(token)) {
|
||||||
|
"back",
|
||||||
|
"recent",
|
||||||
|
-> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewConfiguration
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class AospTaskbarTouchController(
|
||||||
|
private val buttonIdProvider: (View) -> NavButtonId?,
|
||||||
|
private val controllerProvider: (View) -> Any?,
|
||||||
|
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||||
|
private val updateContext: (Any?, View?) -> Unit,
|
||||||
|
private val performAction: (Any?, NavButtonAction) -> Unit,
|
||||||
|
private val performHomeLongPressAction: (Any?, NavButtonAction) -> Unit,
|
||||||
|
) {
|
||||||
|
private val longPressRunnables = WeakHashMap<Any, Runnable>()
|
||||||
|
private val longPressTriggered = WeakHashMap<Any, Boolean>()
|
||||||
|
|
||||||
|
fun handle(view: View, event: MotionEvent): Boolean {
|
||||||
|
val buttonId = buttonIdProvider(view) ?: return false
|
||||||
|
val controller = controllerProvider(view) ?: return false
|
||||||
|
val config = configProvider(buttonId)
|
||||||
|
if (config.pressAction == NavButtonAction.STOCK &&
|
||||||
|
config.longPressAction == NavButtonAction.STOCK
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
longPressTriggered[view] = false
|
||||||
|
if (config.longPressAction != NavButtonAction.STOCK) {
|
||||||
|
scheduleLongPress(view, controller, buttonId, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP,
|
||||||
|
MotionEvent.ACTION_CANCEL,
|
||||||
|
-> {
|
||||||
|
val triggered = longPressTriggered[view] == true
|
||||||
|
cancelLongPress(view)
|
||||||
|
longPressTriggered.remove(view)
|
||||||
|
if (triggered) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||||
|
when {
|
||||||
|
config.pressAction != NavButtonAction.STOCK -> {
|
||||||
|
performAction(controller, config.pressAction)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
config.longPressAction != NavButtonAction.STOCK &&
|
||||||
|
event.eventTime - event.downTime >= ViewConfiguration.getLongPressTimeout() -> {
|
||||||
|
performAction(controller, stockActionFor(buttonId))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonId == NavButtonId.BACK -> {
|
||||||
|
performAction(controller, NavButtonAction.BACK)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleLongPress(
|
||||||
|
view: View,
|
||||||
|
controller: Any,
|
||||||
|
buttonId: NavButtonId,
|
||||||
|
config: NavButtonConfig,
|
||||||
|
) {
|
||||||
|
cancelLongPress(view)
|
||||||
|
val longPressRunnable = Runnable {
|
||||||
|
longPressTriggered[view] = true
|
||||||
|
updateContext(controller, view)
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
if (buttonId == NavButtonId.HOME) {
|
||||||
|
performHomeLongPressAction(controller, config.longPressAction)
|
||||||
|
} else {
|
||||||
|
performAction(controller, config.longPressAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
longPressRunnables[view] = longPressRunnable
|
||||||
|
view.postDelayed(longPressRunnable, config.longPressDurationMs.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelLongPress(view: View) {
|
||||||
|
longPressRunnables.remove(view)?.let(view::removeCallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
|
||||||
|
internal fun defaultLauncherPackageName(context: Context): String? {
|
||||||
|
val intent = Intent(Intent.ACTION_MAIN).apply {
|
||||||
|
addCategory(Intent.CATEGORY_HOME)
|
||||||
|
}
|
||||||
|
val resolveInfo = context.packageManager.resolveActivity(
|
||||||
|
intent,
|
||||||
|
PackageManager.MATCH_DEFAULT_ONLY,
|
||||||
|
)
|
||||||
|
return resolveInfo?.activityInfo?.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun topPackageName(context: Context, log: (String) -> Unit): String? {
|
||||||
|
return topPackageFromActivityTaskManager(log)
|
||||||
|
?: topPackageFromRunningTasks(context, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun topPackageFromActivityTaskManager(log: (String) -> Unit): String? {
|
||||||
|
return try {
|
||||||
|
val service = callStaticMethod(
|
||||||
|
findClass("android.app.ActivityTaskManager", null),
|
||||||
|
"getService",
|
||||||
|
) ?: return null
|
||||||
|
val taskInfo = callMethod(service, "getFocusedRootTaskInfo")
|
||||||
|
extractPackageFromTaskInfo(taskInfo)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("ActivityTaskManager.getFocusedRootTaskInfo failed: ${error.javaClass.simpleName}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPackageFromTaskInfo(taskInfo: Any?): String? {
|
||||||
|
if (taskInfo == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val pkg = packageNameFromComponent(getFieldValue(taskInfo, "topActivity"))
|
||||||
|
return pkg ?: packageNameFromActivityInfo(getFieldValue(taskInfo, "topActivityInfo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun topPackageFromRunningTasks(context: Context, log: (String) -> Unit): String? {
|
||||||
|
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||||
|
?: return null
|
||||||
|
return try {
|
||||||
|
activityManager.getRunningTasks(1)
|
||||||
|
.firstOrNull()
|
||||||
|
?.topActivity
|
||||||
|
?.packageName
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("ActivityManager.getRunningTasks failed: ${error.javaClass.simpleName}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun packageNameFromComponent(component: Any?): String? {
|
||||||
|
return (component as? ComponentName)?.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun packageNameFromActivityInfo(info: Any?): String? {
|
||||||
|
if (info == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
findField(info.javaClass, "packageName").get(info)?.toString()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun shouldSkipForegroundPackage(packageName: String?, defaultLauncher: String?): Boolean {
|
||||||
|
return packageName == null ||
|
||||||
|
packageName == SYSTEMUI_PACKAGE ||
|
||||||
|
packageName == SAMSUNG_LAUNCHER_PACKAGE ||
|
||||||
|
packageName in AOSP_LAUNCHER_PACKAGES ||
|
||||||
|
packageName == defaultLauncher
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun applicationLabel(packageName: String, packageManager: PackageManager): String {
|
||||||
|
return try {
|
||||||
|
val appInfo: ApplicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||||
|
packageManager.getApplicationLabel(appInfo).toString()
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewConfiguration
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class HomeStockLongPressGuard(
|
||||||
|
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||||
|
private val longPressTriggered: (Any) -> Boolean,
|
||||||
|
private val sendCanceledKeyUp: (Any) -> Unit,
|
||||||
|
) {
|
||||||
|
private val guards = WeakHashMap<Any, Runnable>()
|
||||||
|
private val canceled = WeakHashMap<Any, Boolean>()
|
||||||
|
|
||||||
|
fun schedule(target: Any, buttonId: NavButtonId) {
|
||||||
|
if (buttonId != NavButtonId.HOME) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (configProvider(buttonId).longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val view = target as? View ?: return
|
||||||
|
val delayMs = (ViewConfiguration.getLongPressTimeout() - STOCK_LONG_PRESS_GUARD_EARLY_MS)
|
||||||
|
.coerceAtLeast(STOCK_LONG_PRESS_GUARD_MIN_DELAY_MS)
|
||||||
|
val guard = Runnable {
|
||||||
|
if (!view.isPressed || longPressTriggered(target)) {
|
||||||
|
return@Runnable
|
||||||
|
}
|
||||||
|
canceled[target] = true
|
||||||
|
sendCanceledKeyUp(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
guards[target] = guard
|
||||||
|
view.postDelayed(guard, delayMs.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(target: Any) {
|
||||||
|
guards.remove(target)?.let { guard ->
|
||||||
|
(target as? View)?.removeCallbacks(guard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(target: Any) {
|
||||||
|
canceled.remove(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isCanceled(target: Any): Boolean {
|
||||||
|
return canceled[target] == true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Process
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.KeyEvent
|
||||||
|
|
||||||
|
internal fun sendInjectedKeyPress(context: Context?, keyCode: Int, log: (String) -> Unit) {
|
||||||
|
try {
|
||||||
|
val inputManager = inputManagerForInjection(context) ?: run {
|
||||||
|
log("InputManager unavailable for keyCode=$keyCode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val downTime = SystemClock.uptimeMillis()
|
||||||
|
injectKeyEvent(inputManager, KeyEvent(downTime, downTime, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||||
|
injectKeyEvent(inputManager, KeyEvent(downTime, SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode, 0))
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Key injection failed for keyCode=$keyCode: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun inputManagerForInjection(context: Context?): Any? {
|
||||||
|
context?.getSystemService(Context.INPUT_SERVICE)?.let { return it }
|
||||||
|
|
||||||
|
return try {
|
||||||
|
callStaticMethod(findClass(INPUT_MANAGER_CLASS, null), "getInstance")
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
try {
|
||||||
|
callStaticMethod(findClass(INPUT_MANAGER_GLOBAL_CLASS, null), "getInstance")
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun injectKeyEvent(inputManager: Any, keyEvent: KeyEvent) {
|
||||||
|
val intType = Int::class.javaPrimitiveType!!
|
||||||
|
val methods = findMethodsNamed(inputManager.javaClass, "injectInputEvent")
|
||||||
|
val method = methods.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 2 &&
|
||||||
|
types[0].isAssignableFrom(KeyEvent::class.java) &&
|
||||||
|
types[1] == intType
|
||||||
|
} ?: methods.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 3 &&
|
||||||
|
types[0].isAssignableFrom(KeyEvent::class.java) &&
|
||||||
|
types[1] == intType &&
|
||||||
|
types[2] == intType
|
||||||
|
} ?: throw NoSuchMethodException("injectInputEvent")
|
||||||
|
|
||||||
|
if (method.parameterTypes.size == 2) {
|
||||||
|
method.invoke(inputManager, keyEvent, INPUT_EVENT_INJECTION_SYNC_NONE)
|
||||||
|
} else {
|
||||||
|
method.invoke(inputManager, keyEvent, INPUT_EVENT_INJECTION_SYNC_NONE, Process.INVALID_UID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
internal const val TAG = "NavButtons"
|
||||||
|
internal const val ASSIST_INVOCATION_TYPE_UNKNOWN = 0
|
||||||
|
internal const val ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS = 5
|
||||||
|
internal const val INPUT_MANAGER_CLASS = "android.hardware.input.InputManager"
|
||||||
|
internal const val INPUT_MANAGER_GLOBAL_CLASS = "android.hardware.input.InputManagerGlobal"
|
||||||
|
internal const val INPUT_EVENT_INJECTION_SYNC_NONE = 0
|
||||||
|
internal const val ACTION_PASS_TO_USER = 1
|
||||||
|
internal const val HOME_LONG_PRESS_DEDUP_WINDOW_MS = 1_000L
|
||||||
|
internal const val REMOTE_PREFERENCES_RETRY_DELAY_MS = 1_000L
|
||||||
|
internal const val REMOTE_PREFERENCES_LOAD_ATTEMPTS = 15
|
||||||
|
internal const val STOCK_LONG_PRESS_GUARD_EARLY_MS = 75
|
||||||
|
internal const val STOCK_LONG_PRESS_GUARD_MIN_DELAY_MS = 100
|
||||||
|
|
||||||
|
internal const val SYSTEMUI_PACKAGE = "com.android.systemui"
|
||||||
|
internal const val ANDROID_PACKAGE = "android"
|
||||||
|
internal const val SYSTEM_SERVER_PHONE_WINDOW_MANAGER = "com.android.server.policy.PhoneWindowManager"
|
||||||
|
internal val SYSTEMUI_KEY_BUTTON_VIEW_CLASSES = listOf(
|
||||||
|
"com.android.systemui.navigationbar.views.buttons.KeyButtonView",
|
||||||
|
"com.android.systemui.navigationbar.buttons.KeyButtonView",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal const val SAMSUNG_PHONE_LAYOUT_PROVIDER =
|
||||||
|
"com.android.systemui.navigationbar.layout.LayoutProviderImpl"
|
||||||
|
internal const val SAMSUNG_TABLET_LAYOUT_PROVIDER =
|
||||||
|
"com.android.systemui.navigationbar.layout.TabletLayoutProviderImpl"
|
||||||
|
internal const val SAMSUNG_NAVIGATION_BAR_VIEW =
|
||||||
|
"com.android.systemui.navigationbar.views.SamsungNavigationBarView"
|
||||||
|
internal const val SAMSUNG_LAUNCHER_PACKAGE = "com.sec.android.app.launcher"
|
||||||
|
internal const val SAMSUNG_NAV_VIEW_PARENT_DEPTH = 8
|
||||||
|
|
||||||
|
internal val AOSP_LAUNCHER_PACKAGES = setOf(
|
||||||
|
"com.android.launcher3",
|
||||||
|
"com.google.android.apps.nexuslauncher",
|
||||||
|
)
|
||||||
|
internal val TARGET_PACKAGES = AOSP_LAUNCHER_PACKAGES + SYSTEMUI_PACKAGE
|
||||||
|
internal const val AOSP_TASKBAR_NAV_BUTTON_CONTROLLER =
|
||||||
|
"com.android.launcher3.taskbar.TaskbarNavButtonController"
|
||||||
|
internal const val AOSP_TASKBAR_BACK_BUTTON = 1
|
||||||
|
internal const val AOSP_TASKBAR_HOME_BUTTON = 2
|
||||||
|
internal const val AOSP_TASKBAR_RECENTS_BUTTON = 4
|
||||||
|
internal const val AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER =
|
||||||
|
"com.android.launcher3.taskbar.NavbarButtonsViewController"
|
||||||
|
internal const val AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO = 0.45f
|
||||||
|
internal const val TARGET_HOOK_RETRY_ATTEMPTS = 120
|
||||||
|
internal const val TARGET_HOOK_RETRY_DELAY_MS = 250L
|
||||||
|
internal val AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES = listOf(
|
||||||
|
"com.android.systemui.navigationbar.views.NavigationBarInflaterView",
|
||||||
|
"com.android.systemui.navigationbar.NavigationBarInflaterView",
|
||||||
|
)
|
||||||
|
internal val AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES = listOf(
|
||||||
|
"com.android.systemui.navigationbar.NavigationBarTransitions",
|
||||||
|
"com.android.systemui.navigationbar.views.NavigationBarTransitions",
|
||||||
|
)
|
||||||
|
internal val AOSP_KEY_BUTTON_DRAWABLE_CLASSES = listOf(
|
||||||
|
"com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable",
|
||||||
|
"com.android.systemui.navigationbar.buttons.KeyButtonDrawable",
|
||||||
|
)
|
||||||
|
internal val AOSP_KEY_BUTTON_DRAWABLE_STATE_CLASSES = listOf(
|
||||||
|
"com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable\$ShadowDrawableState",
|
||||||
|
"com.android.systemui.navigationbar.buttons.KeyButtonDrawable\$ShadowDrawableState",
|
||||||
|
)
|
||||||
|
internal const val AOSP_SYSUI_LEFT_ARROW_BUTTON = "key(21:)"
|
||||||
|
internal const val AOSP_SYSUI_RIGHT_ARROW_BUTTON = "key(22:)"
|
||||||
|
|
||||||
|
internal const val ACTION_PRIVILEGED_ACTION =
|
||||||
|
"se.ajpanton.navbuttons.action.PRIVILEGED_ACTION"
|
||||||
|
internal const val EXTRA_PRIVILEGED_ACTION = "privileged_action"
|
||||||
|
internal const val EXTRA_PRIVILEGED_CALLER = "privileged_caller"
|
||||||
|
internal const val EXTRA_PRIVILEGED_ACTION_TOKEN = "privileged_action_token"
|
||||||
|
internal const val EXTRA_PRIVILEGED_KEY_CODE = "privileged_key_code"
|
||||||
|
internal const val PRIVILEGED_ACTION_KILL_FOREGROUND_APP = "kill_foreground_app"
|
||||||
|
internal const val PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT = "toggle_flashlight"
|
||||||
|
internal const val PRIVILEGED_ACTION_SEND_KEY_PRESS = "send_key_press"
|
||||||
|
|
||||||
|
internal const val HONEYSPACE_NAV_BUTTON_VIEW =
|
||||||
|
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView"
|
||||||
|
internal const val HONEYSPACE_NAV_BUTTON_CONTAINER =
|
||||||
|
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarButtonsLayout"
|
||||||
|
internal const val HONEYSPACE_LONG_PRESS_RUNNABLE = "f.a"
|
||||||
|
internal const val HONEYSPACE_LONG_PRESS_CASE = 8
|
||||||
|
internal const val HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH = 8
|
||||||
|
internal const val HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS = 150L
|
||||||
|
internal const val HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS = 600L
|
||||||
|
|
||||||
|
internal const val NAV_LAYOUT_PHONE_DEFAULT = "left,recent;home;back,right"
|
||||||
|
internal const val NAV_LAYOUT_PHONE_REVERSED = "left,back;home;recent,right"
|
||||||
|
internal const val NAV_LAYOUT_TABLET_DEFAULT = "left;recent,gap,home,gap,back;right"
|
||||||
|
internal const val NAV_LAYOUT_TABLET_REVERSED = "left;back,gap,home,gap,recent;right"
|
||||||
|
internal const val NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT = "left;back,gap,home,gap,recent;r"
|
||||||
|
internal const val NAV_LAYOUT_WINNER_DEFAULT = "left,gap,back,gap,home,gap,recent;;right"
|
||||||
|
internal const val NAV_LAYOUT_WINNER_REVERSED = "left,gap,recent,gap,home,gap,back;;right"
|
||||||
|
internal const val NAV_LAYOUT_UNFOLDED_DEFAULT = "left;;back,gap,home,gap,recent,gap,right"
|
||||||
|
internal const val NAV_LAYOUT_UNFOLDED_REVERSED = "left;;recent,gap,home,gap,back,gap,right"
|
||||||
|
internal const val BUTTON_DISPATCHER_SEARCH_DEPTH = 4
|
||||||
|
internal const val SIDE_ARROW_REFRESH_DELAY_MS = 100L
|
||||||
|
internal const val PHONE_BUTTON_RATIO = 0.22220000624656677
|
||||||
|
internal const val TABLET_BUTTON_RATIO = 0.2070000022649765
|
||||||
|
internal const val SIDE_ARROW_GAP_RATIO = 0.013000000268220901
|
||||||
|
internal const val PHONE_SPACE_RATIO = 0.10999999940395355
|
||||||
|
internal const val TABLET_SPACE_RATIO = 0.10999999940395355
|
||||||
|
internal const val TABLET_SPACE_SIDE_PADDING_RATIO = 0.033250000327825546
|
||||||
|
internal const val SIDE_ARROW_REDUCE_RATIO = 0.75
|
||||||
|
|
||||||
|
internal fun Int.coerceLongPressDuration(): Int {
|
||||||
|
return coerceIn(
|
||||||
|
NavButtonSettingsStore.MIN_DURATION_MS,
|
||||||
|
NavButtonSettingsStore.MAX_DURATION_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
|
||||||
|
internal fun stockActionFor(buttonId: NavButtonId): NavButtonAction {
|
||||||
|
return when (buttonId) {
|
||||||
|
NavButtonId.BACK -> NavButtonAction.BACK
|
||||||
|
NavButtonId.HOME -> NavButtonAction.HOME
|
||||||
|
NavButtonId.RECENTS -> NavButtonAction.RECENTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isSideArrowKeyCode(keyCode: Int): Boolean {
|
||||||
|
return keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buttonIdForKeyCode(keyCode: Int): NavButtonId? {
|
||||||
|
return when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_BACK -> NavButtonId.BACK
|
||||||
|
KeyEvent.KEYCODE_HOME -> NavButtonId.HOME
|
||||||
|
KeyEvent.KEYCODE_APP_SWITCH -> NavButtonId.RECENTS
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buttonIdForAospTaskbarCode(buttonCode: Int?): NavButtonId? {
|
||||||
|
return when (buttonCode) {
|
||||||
|
AOSP_TASKBAR_BACK_BUTTON -> NavButtonId.BACK
|
||||||
|
AOSP_TASKBAR_HOME_BUTTON -> NavButtonId.HOME
|
||||||
|
AOSP_TASKBAR_RECENTS_BUTTON -> NavButtonId.RECENTS
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
|
import android.view.KeyEvent
|
||||||
|
|
||||||
|
internal class PrivilegedActionBridge(
|
||||||
|
private val contextProvider: () -> Context?,
|
||||||
|
private val contextUpdater: (Context) -> Unit,
|
||||||
|
private val currentApplicationProvider: () -> Context?,
|
||||||
|
private val settingsPreferencesProvider: () -> android.content.SharedPreferences?,
|
||||||
|
private val log: (String, Throwable?) -> Unit,
|
||||||
|
private val killForegroundApp: () -> Unit,
|
||||||
|
private val toggleFlashlight: () -> Unit,
|
||||||
|
private val sendKeyPress: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
private var receiverRegistered = false
|
||||||
|
private var token: String? = null
|
||||||
|
|
||||||
|
fun registerReceiver() {
|
||||||
|
if (receiverRegistered) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentContext = contextProvider() ?: currentApplicationProvider() ?: return
|
||||||
|
contextUpdater(currentContext.applicationContext ?: currentContext)
|
||||||
|
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != ACTION_PRIVILEGED_ACTION ||
|
||||||
|
context == null ||
|
||||||
|
!isAllowedSender(context, intent, sentFromPackageCompat(), sentFromUidCompat())
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contextUpdater(context.applicationContext ?: context)
|
||||||
|
perform(intent.getStringExtra(EXTRA_PRIVILEGED_ACTION), intent.getIntExtra(EXTRA_PRIVILEGED_KEY_CODE, KeyEvent.KEYCODE_UNKNOWN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentContext.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(ACTION_PRIVILEGED_ACTION),
|
||||||
|
Context.RECEIVER_EXPORTED,
|
||||||
|
)
|
||||||
|
if (currentContext.packageName != ANDROID_PACKAGE) {
|
||||||
|
token = readToken()
|
||||||
|
}
|
||||||
|
receiverRegistered = true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Privileged action receiver failed", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun request(action: String, keyCode: Int? = null) {
|
||||||
|
val currentContext = contextProvider()
|
||||||
|
if (currentContext == null) {
|
||||||
|
perform(action, keyCode ?: KeyEvent.KEYCODE_UNKNOWN)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = Intent(ACTION_PRIVILEGED_ACTION)
|
||||||
|
.setPackage(ANDROID_PACKAGE)
|
||||||
|
.putExtra(EXTRA_PRIVILEGED_ACTION, action)
|
||||||
|
.putExtra(EXTRA_PRIVILEGED_CALLER, caller(currentContext, action))
|
||||||
|
if (keyCode != null) {
|
||||||
|
intent.putExtra(EXTRA_PRIVILEGED_KEY_CODE, keyCode)
|
||||||
|
}
|
||||||
|
readToken()?.let { intent.putExtra(EXTRA_PRIVILEGED_ACTION_TOKEN, it) }
|
||||||
|
currentContext.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun perform(action: String?, keyCode: Int) {
|
||||||
|
when (action) {
|
||||||
|
PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp()
|
||||||
|
PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight()
|
||||||
|
PRIVILEGED_ACTION_SEND_KEY_PRESS -> {
|
||||||
|
if (keyCode != KeyEvent.KEYCODE_UNKNOWN) {
|
||||||
|
sendKeyPress(keyCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAllowedSender(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent,
|
||||||
|
senderPackage: String?,
|
||||||
|
senderUid: Int,
|
||||||
|
): Boolean {
|
||||||
|
if (senderPackage == SYSTEMUI_PACKAGE || senderPackage in AOSP_LAUNCHER_PACKAGES) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val caller = getParcelableExtraCompat(intent, EXTRA_PRIVILEGED_CALLER, PendingIntent::class.java)
|
||||||
|
if (caller?.creatorPackage == SYSTEMUI_PACKAGE || caller?.creatorPackage in AOSP_LAUNCHER_PACKAGES) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (caller?.creatorUid == Process.SYSTEM_UID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedToken = token ?: if (context.packageName != ANDROID_PACKAGE) {
|
||||||
|
readToken()?.also { token = it }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (expectedToken != null && expectedToken == intent.getStringExtra(EXTRA_PRIVILEGED_ACTION_TOKEN)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderUid == Process.SYSTEM_UID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return context.packageManager.getPackagesForUid(senderUid)
|
||||||
|
?.any { it == SYSTEMUI_PACKAGE || it in AOSP_LAUNCHER_PACKAGES } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readToken(): String? {
|
||||||
|
return settingsPreferencesProvider()?.getString(NavButtonSettingsStore.KEY_PRIVILEGED_ACTION_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun caller(context: Context, action: String): PendingIntent {
|
||||||
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
val intent = Intent("$ACTION_PRIVILEGED_ACTION.CALLER").setPackage(context.packageName)
|
||||||
|
return PendingIntent.getBroadcast(context, action.hashCode(), intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BroadcastReceiver.sentFromUidCompat(): Int {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
sentFromUid
|
||||||
|
} else {
|
||||||
|
Process.INVALID_UID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BroadcastReceiver.sentFromPackageCompat(): String? {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
sentFromPackage
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> getParcelableExtraCompat(intent: Intent, name: String, type: Class<T>): T? {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent.getParcelableExtra(name, type)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
type.cast(intent.getParcelableExtra(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
internal fun findClass(className: String, classLoader: ClassLoader?): Class<*> {
|
||||||
|
return Class.forName(className, false, classLoader)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findClassIfExists(className: String, classLoader: ClassLoader?): Class<*>? {
|
||||||
|
return try {
|
||||||
|
findClass(className, classLoader)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findFirstClass(classNames: List<String>, classLoader: ClassLoader?): Class<*> {
|
||||||
|
classNames.forEach { className ->
|
||||||
|
findClassIfExists(className, classLoader)?.let { return it }
|
||||||
|
}
|
||||||
|
throw ClassNotFoundException(classNames.joinToString())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findFirstClassIfExists(
|
||||||
|
classNames: List<String>,
|
||||||
|
classLoader: ClassLoader?,
|
||||||
|
): Class<*>? {
|
||||||
|
return classNames.firstNotNullOfOrNull { className ->
|
||||||
|
findClassIfExists(className, classLoader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findMethodIfExists(
|
||||||
|
clazz: Class<*>,
|
||||||
|
methodName: String,
|
||||||
|
vararg parameterTypes: Class<*>,
|
||||||
|
): Method? {
|
||||||
|
return try {
|
||||||
|
findMethod(clazz, methodName, *parameterTypes)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findDeclaredMethodsNamed(clazz: Class<*>, methodName: String): List<Method> {
|
||||||
|
return clazz.declaredMethods
|
||||||
|
.filter { it.name == methodName }
|
||||||
|
.onEach { it.isAccessible = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun findMethodsNamed(clazz: Class<*>, methodName: String): List<Method> {
|
||||||
|
val methodsBySignature = linkedMapOf<String, Method>()
|
||||||
|
var current: Class<*>? = clazz
|
||||||
|
while (current != null) {
|
||||||
|
current.declaredMethods
|
||||||
|
.filter { it.name == methodName }
|
||||||
|
.forEach { method ->
|
||||||
|
method.isAccessible = true
|
||||||
|
methodsBySignature.putIfAbsent(method.toGenericString(), method)
|
||||||
|
}
|
||||||
|
current = current.superclass
|
||||||
|
}
|
||||||
|
return methodsBySignature.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(NoSuchMethodException::class)
|
||||||
|
internal fun findMethod(
|
||||||
|
clazz: Class<*>,
|
||||||
|
methodName: String,
|
||||||
|
vararg parameterTypes: Class<*>,
|
||||||
|
): Method {
|
||||||
|
var current: Class<*>? = clazz
|
||||||
|
while (current != null) {
|
||||||
|
try {
|
||||||
|
return current.getDeclaredMethod(methodName, *parameterTypes).apply {
|
||||||
|
isAccessible = true
|
||||||
|
}
|
||||||
|
} catch (_: NoSuchMethodException) {
|
||||||
|
current = current.superclass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw NoSuchMethodException("${clazz.name}#$methodName")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(NoSuchFieldException::class)
|
||||||
|
internal fun findField(clazz: Class<*>, fieldName: String): Field {
|
||||||
|
var current: Class<*>? = clazz
|
||||||
|
while (current != null) {
|
||||||
|
try {
|
||||||
|
return current.getDeclaredField(fieldName).apply {
|
||||||
|
isAccessible = true
|
||||||
|
}
|
||||||
|
} catch (_: NoSuchFieldException) {
|
||||||
|
current = current.superclass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw NoSuchFieldException("${clazz.name}#$fieldName")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(ReflectiveOperationException::class)
|
||||||
|
internal fun callStaticMethod(clazz: Class<*>, methodName: String): Any? {
|
||||||
|
return findMethod(clazz, methodName).invoke(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(ReflectiveOperationException::class)
|
||||||
|
internal fun callMethod(target: Any, methodName: String): Any? {
|
||||||
|
return callMethod(target, methodName, emptyArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(ReflectiveOperationException::class)
|
||||||
|
internal fun callMethod(
|
||||||
|
target: Any,
|
||||||
|
methodName: String,
|
||||||
|
parameterTypes: Array<Class<*>>,
|
||||||
|
vararg args: Any?,
|
||||||
|
): Any? {
|
||||||
|
return findMethod(target.javaClass, methodName, *parameterTypes).invoke(target, *args)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun callIntIntMethodIfExists(
|
||||||
|
target: Any,
|
||||||
|
methodNames: List<String>,
|
||||||
|
first: Int,
|
||||||
|
second: Int,
|
||||||
|
): Boolean {
|
||||||
|
methodNames.forEach { methodName ->
|
||||||
|
findMethodsNamed(target.javaClass, methodName)
|
||||||
|
.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size >= 2 &&
|
||||||
|
types[0] == Int::class.javaPrimitiveType &&
|
||||||
|
types[1] == Int::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
?.let { method ->
|
||||||
|
return try {
|
||||||
|
method.invoke(target, first, second)
|
||||||
|
true
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun callIntMethodIfExists(target: Any, methodName: String, value: Int): Boolean {
|
||||||
|
return findMethodsNamed(target.javaClass, methodName)
|
||||||
|
.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 1 && types[0] == Int::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
?.let { method ->
|
||||||
|
runCatching { method.invoke(target, value) }.isSuccess
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun callIntIntMethodIfExists(target: Any, methodName: String, first: Int, second: Int): Boolean {
|
||||||
|
return findMethodsNamed(target.javaClass, methodName)
|
||||||
|
.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 2 &&
|
||||||
|
types[0] == Int::class.javaPrimitiveType &&
|
||||||
|
types[1] == Int::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
?.let { method ->
|
||||||
|
runCatching { method.invoke(target, first, second) }.isSuccess
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun callFloatMethodIfExists(target: Any, methodName: String, value: Float): Boolean {
|
||||||
|
return findMethodsNamed(target.javaClass, methodName)
|
||||||
|
.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 1 && types[0] == Float::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
?.let { method ->
|
||||||
|
runCatching { method.invoke(target, value) }.isSuccess
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun callSetButtonImageIfExists(
|
||||||
|
target: Any,
|
||||||
|
id: Int,
|
||||||
|
lightDrawable: Drawable,
|
||||||
|
darkDrawable: Drawable,
|
||||||
|
): Boolean {
|
||||||
|
return findMethodsNamed(target.javaClass, "setButtonImage")
|
||||||
|
.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 3 &&
|
||||||
|
types[0] == Int::class.javaPrimitiveType &&
|
||||||
|
Drawable::class.java.isAssignableFrom(types[1]) &&
|
||||||
|
Drawable::class.java.isAssignableFrom(types[2])
|
||||||
|
}
|
||||||
|
?.let { method ->
|
||||||
|
runCatching { method.invoke(target, id, lightDrawable, darkDrawable) }.isSuccess
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun callMethodIfExists(target: Any, methodName: String): Any? {
|
||||||
|
return findMethodsNamed(target.javaClass, methodName)
|
||||||
|
.firstOrNull { method -> method.parameterTypes.isEmpty() }
|
||||||
|
?.let { method ->
|
||||||
|
runCatching { method.invoke(target) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun callBooleanMethodIfExists(target: Any, methodName: String, value: Boolean): Boolean {
|
||||||
|
return findMethodsNamed(target.javaClass, methodName)
|
||||||
|
.firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 1 && types[0] == Boolean::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
?.let { method ->
|
||||||
|
runCatching { method.invoke(target, value) }.isSuccess
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getFieldValue(target: Any?, fieldName: String): Any? {
|
||||||
|
if (target == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
findField(target.javaClass, fieldName).get(target)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getIntField(target: Any?, fieldName: String): Int {
|
||||||
|
val value = getFieldValue(target, fieldName)
|
||||||
|
return value as? Int ?: Int.MIN_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun setFieldIfExists(target: Any, fieldName: String, value: Any?) {
|
||||||
|
runCatching {
|
||||||
|
findField(target.javaClass, fieldName).set(target, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class SamsungHoneyspaceHooks(
|
||||||
|
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||||
|
private val deoptimizeMethod: (Method?) -> Unit,
|
||||||
|
private val prepareLongPressHook: (Method) -> Unit,
|
||||||
|
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||||
|
private val sideArrowsEnabled: () -> Boolean,
|
||||||
|
private val sideArrowLongPresses: SideArrowLongPressController,
|
||||||
|
private val registerSettingsReceiver: () -> Unit,
|
||||||
|
private val contextProvider: () -> Context?,
|
||||||
|
private val contextUpdater: (Context) -> Unit,
|
||||||
|
private val handlerProvider: () -> Handler?,
|
||||||
|
private val performAction: (NavButtonAction) -> Unit,
|
||||||
|
private val sendKeyPress: (Int) -> Unit,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val longPressTriggered = WeakHashMap<Any, Boolean>()
|
||||||
|
private val trackedNavContainers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||||
|
private val nativeSideArrowStates = WeakHashMap<Any, Boolean>()
|
||||||
|
private val homeStockLongPressGuard = HomeStockLongPressGuard(
|
||||||
|
configProvider = configProvider,
|
||||||
|
longPressTriggered = { longPressTriggered[it] == true },
|
||||||
|
sendCanceledKeyUp = ::sendCanceledKeyUp,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun install(classLoader: ClassLoader?): Boolean {
|
||||||
|
val navButtonClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_VIEW, classLoader) ?: run {
|
||||||
|
log("Launcher class not found: $HONEYSPACE_NAV_BUTTON_VIEW")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val onTouchEventMethod = findMethodIfExists(
|
||||||
|
navButtonClass,
|
||||||
|
"onTouchEvent",
|
||||||
|
MotionEvent::class.java,
|
||||||
|
) ?: run {
|
||||||
|
log("Launcher onTouchEvent() method not found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val runnableClass = findClassIfExists(HONEYSPACE_LONG_PRESS_RUNNABLE, classLoader) ?: run {
|
||||||
|
log("Launcher long-press runnable not found: $HONEYSPACE_LONG_PRESS_RUNNABLE")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val runMethod = findMethodIfExists(runnableClass, "run") ?: run {
|
||||||
|
log("Launcher run() method not found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
deoptimizeMethod(onTouchEventMethod)
|
||||||
|
prepareLongPressHook(runMethod)
|
||||||
|
hookSideArrowLayout(classLoader)
|
||||||
|
|
||||||
|
hookMethod(onTouchEventMethod) { chain ->
|
||||||
|
val navButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
|
||||||
|
val motionEvent = chain.args[0] as? MotionEvent ?: return@hookMethod chain.proceed()
|
||||||
|
val keyCode = callMethod(navButtonView, "getKeyCode") as? Int ?: Int.MIN_VALUE
|
||||||
|
if (isSideArrowKeyCode(keyCode)) {
|
||||||
|
updateContextFromView(navButtonView)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
trackNavContainerFrom(navButtonView as? View)
|
||||||
|
|
||||||
|
return@hookMethod handleNativeSideArrowTouch(navButtonView, keyCode, motionEvent) {
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||||
|
updateContextFromView(navButtonView)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
trackNavContainerFrom(navButtonView as? View)
|
||||||
|
|
||||||
|
when (motionEvent.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
longPressTriggered[navButtonView] = false
|
||||||
|
homeStockLongPressGuard.cancel(navButtonView)
|
||||||
|
homeStockLongPressGuard.clear(navButtonView)
|
||||||
|
val result = chain.proceed()
|
||||||
|
rescheduleHoneyspaceLongPress(
|
||||||
|
navButtonView,
|
||||||
|
runnableClass,
|
||||||
|
configProvider(buttonId).longPressDurationMs,
|
||||||
|
)
|
||||||
|
homeStockLongPressGuard.schedule(navButtonView, buttonId)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
val triggered = longPressTriggered[navButtonView] == true
|
||||||
|
if (triggered) {
|
||||||
|
return@hookMethod proceedWithCanceledAction(motionEvent) {
|
||||||
|
chain.proceed()
|
||||||
|
}.also {
|
||||||
|
homeStockLongPressGuard.cancel(navButtonView)
|
||||||
|
longPressTriggered.remove(navButtonView)
|
||||||
|
homeStockLongPressGuard.clear(navButtonView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pressAction = configProvider(buttonId).pressAction
|
||||||
|
val stockHomePressCanceled =
|
||||||
|
buttonId == NavButtonId.HOME && homeStockLongPressGuard.isCanceled(navButtonView)
|
||||||
|
val shouldOverridePress =
|
||||||
|
pressAction != NavButtonAction.STOCK &&
|
||||||
|
((navButtonView as? View)?.isPressed == true || stockHomePressCanceled) &&
|
||||||
|
!triggered
|
||||||
|
|
||||||
|
if (shouldOverridePress) {
|
||||||
|
markLongClicked(navButtonView)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = chain.proceed()
|
||||||
|
if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) {
|
||||||
|
dispatchHomeTap(navButtonView)
|
||||||
|
}
|
||||||
|
if (shouldOverridePress) {
|
||||||
|
updateContextFromView(navButtonView)
|
||||||
|
performAction(pressAction)
|
||||||
|
}
|
||||||
|
homeStockLongPressGuard.cancel(navButtonView)
|
||||||
|
longPressTriggered.remove(navButtonView)
|
||||||
|
homeStockLongPressGuard.clear(navButtonView)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
homeStockLongPressGuard.cancel(navButtonView)
|
||||||
|
longPressTriggered.remove(navButtonView)
|
||||||
|
homeStockLongPressGuard.clear(navButtonView)
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hookMethod(runMethod) { chain ->
|
||||||
|
val caseId = getIntField(chain.thisObject, "b")
|
||||||
|
if (caseId != HONEYSPACE_LONG_PRESS_CASE) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = getFieldValue(chain.thisObject, "c")
|
||||||
|
if (!navButtonClass.isInstance(target)) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
val navButtonView = target ?: return@hookMethod chain.proceed()
|
||||||
|
val keyCode = callMethod(navButtonView, "getKeyCode") as Int
|
||||||
|
if (isSideArrowKeyCode(keyCode)) {
|
||||||
|
return@hookMethod null
|
||||||
|
}
|
||||||
|
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||||
|
val longPressAction = configProvider(buttonId).longPressAction
|
||||||
|
longPressTriggered[navButtonView] = true
|
||||||
|
if (longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromView(navButtonView)
|
||||||
|
markLongClicked(navButtonView)
|
||||||
|
homeStockLongPressGuard.cancel(navButtonView)
|
||||||
|
performAction(longPressAction)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSideArrowLayouts(reason: String) {
|
||||||
|
val containers = trackedNavContainers.toList()
|
||||||
|
containers.forEach { container ->
|
||||||
|
refreshSideArrowLayout(container as? ViewGroup ?: return@forEach, reason)
|
||||||
|
scheduleDelayedSideArrowRefresh(container, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookSideArrowLayout(classLoader: ClassLoader?) {
|
||||||
|
val containerClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_CONTAINER, classLoader) ?: run {
|
||||||
|
log("Honeyspace side arrows container class not found: $HONEYSPACE_NAV_BUTTON_CONTAINER")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
"onAttachedToWindow",
|
||||||
|
"onLayout",
|
||||||
|
"onVisibilityChanged",
|
||||||
|
"onWindowVisibilityChanged",
|
||||||
|
"drawableStateChanged",
|
||||||
|
).forEach { methodName ->
|
||||||
|
findDeclaredMethodsNamed(containerClass, methodName).forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
val container = chain.thisObject as? ViewGroup
|
||||||
|
?: return@hookMethod result
|
||||||
|
updateContextFromView(container)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
trackNavContainer(container)
|
||||||
|
refreshSideArrowLayout(container, "after ${method.name}")
|
||||||
|
scheduleDelayedSideArrowRefresh(container, "after ${method.name}")
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hookNativeSideArrowRefresh(containerClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookNativeSideArrowRefresh(containerClass: Class<*>) {
|
||||||
|
listOf("g", "e", "d").forEach { methodName ->
|
||||||
|
findMethodsNamed(containerClass, methodName).forEach { method ->
|
||||||
|
val parameterTypes = method.parameterTypes
|
||||||
|
val supported =
|
||||||
|
(methodName == "g" && parameterTypes.size == 1 && parameterTypes[0] == Boolean::class.javaPrimitiveType) ||
|
||||||
|
(methodName == "e" && parameterTypes.isEmpty()) ||
|
||||||
|
(methodName == "d" && parameterTypes.size == 1 && parameterTypes[0] == Float::class.javaPrimitiveType)
|
||||||
|
if (!supported) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val container = chain.thisObject as? ViewGroup
|
||||||
|
val result = chain.proceed()
|
||||||
|
if (container != null && sideArrowsEnabled()) {
|
||||||
|
restoreHoneyspaceSideArrowDrawables(container)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackNavContainerFrom(view: View?) {
|
||||||
|
var current = view?.parent as? View
|
||||||
|
repeat(HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH) {
|
||||||
|
val target = current ?: return
|
||||||
|
if (isHoneyspaceNavButtonContainer(target)) {
|
||||||
|
trackNavContainer(target)
|
||||||
|
val container = target as ViewGroup
|
||||||
|
refreshSideArrowLayout(container, "from touch")
|
||||||
|
scheduleDelayedSideArrowRefresh(container, "from touch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = target.parent as? View
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackNavContainer(container: View) {
|
||||||
|
if (trackedNavContainers.add(container)) {
|
||||||
|
container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
|
||||||
|
refreshSideArrowLayout(view as ViewGroup, "layout listener")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleDelayedSideArrowRefresh(container: ViewGroup, reason: String) {
|
||||||
|
listOf(
|
||||||
|
HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS,
|
||||||
|
HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS,
|
||||||
|
).forEach { delayMs ->
|
||||||
|
handlerProvider()?.postDelayed(
|
||||||
|
{ refreshSideArrowLayout(container, "$reason delayed ${delayMs}ms") },
|
||||||
|
delayMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSideArrowLayout(container: ViewGroup, reason: String) {
|
||||||
|
try {
|
||||||
|
applyNativeSideArrows(container, reason)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Honeyspace side arrows refresh failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun applyNativeSideArrows(container: ViewGroup, reason: String) {
|
||||||
|
val buttonMap = getFieldValue(container, "i") as? MutableMap<Any, Any> ?: run {
|
||||||
|
log("Honeyspace side arrows native apply skipped: button map missing reason=$reason")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val enabled = sideArrowsEnabled()
|
||||||
|
val targetSlots = honeyspaceSideArrowSlots(container, enabled)
|
||||||
|
val currentSlots = getFieldValue(container, "u") as? BooleanArray
|
||||||
|
val alreadyApplied = nativeSideArrowStates[container] == enabled &&
|
||||||
|
if (enabled) {
|
||||||
|
buttonMap[HONEYSPACE_LEFT_ARROW_BUTTON_TYPE] != null &&
|
||||||
|
buttonMap[HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE] != null &&
|
||||||
|
getIntField(container, "v") == 2 &&
|
||||||
|
currentSlots?.contentEquals(targetSlots) == true &&
|
||||||
|
honeyspaceSideArrowDrawablesPresent(buttonMap)
|
||||||
|
} else {
|
||||||
|
getIntField(container, "v") == 0 &&
|
||||||
|
currentSlots?.contentEquals(targetSlots) == true
|
||||||
|
}
|
||||||
|
if (alreadyApplied) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HONEYSPACE_EXTRA_BUTTON_TYPES.forEach { buttonMap.remove(it) }
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
val created = HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow ->
|
||||||
|
createHoneyspaceExtraButton(container, arrow.type, arrow.keyCode, arrow.direction)
|
||||||
|
?.also { buttonMap[arrow.type] = it } != null
|
||||||
|
}
|
||||||
|
if (!created) {
|
||||||
|
log("Honeyspace side arrows native apply skipped: extra button creation failed reason=$reason")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFieldIfExists(container, "u", targetSlots)
|
||||||
|
setFieldIfExists(container, "v", 2)
|
||||||
|
} else {
|
||||||
|
setFieldIfExists(container, "u", targetSlots)
|
||||||
|
setFieldIfExists(container, "v", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeSideArrowStates[container] = enabled
|
||||||
|
callBooleanMethodIfExists(container, "g", false)
|
||||||
|
restoreHoneyspaceSideArrowDrawables(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNativeSideArrowTouch(
|
||||||
|
view: Any,
|
||||||
|
keyCode: Int,
|
||||||
|
event: MotionEvent,
|
||||||
|
proceed: () -> Any?,
|
||||||
|
): Any? {
|
||||||
|
return when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
sideArrowLongPresses.startAfter(view, keyCode, proceed)
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP,
|
||||||
|
MotionEvent.ACTION_CANCEL,
|
||||||
|
-> {
|
||||||
|
sideArrowLongPresses.finish(view)
|
||||||
|
proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markLongClicked(navButtonView: Any) {
|
||||||
|
try {
|
||||||
|
callMethod(
|
||||||
|
navButtonView,
|
||||||
|
"setLongClicked",
|
||||||
|
arrayOf(Boolean::class.javaPrimitiveType!!),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("setLongClicked failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContextFromView(value: Any) {
|
||||||
|
val currentContext = (value as? View)?.context ?: return
|
||||||
|
contextUpdater(currentContext.applicationContext ?: currentContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendCanceledKeyUp(navButtonView: Any) {
|
||||||
|
if (callIntIntMethodIfExists(
|
||||||
|
navButtonView,
|
||||||
|
listOf("sendEvent", "sendKeyEvent"),
|
||||||
|
KeyEvent.ACTION_UP,
|
||||||
|
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val inputManager = inputManagerForInjection(contextProvider()) ?: run {
|
||||||
|
log("InputManager unavailable for Honeyspace Home cancel.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
injectKeyEvent(
|
||||||
|
inputManager,
|
||||||
|
KeyEvent(
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
KeyEvent.ACTION_UP,
|
||||||
|
KeyEvent.KEYCODE_HOME,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
KeyEvent.KEYCODE_UNKNOWN,
|
||||||
|
0,
|
||||||
|
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Honeyspace Home cancel failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchHomeTap(navButtonView: Any) {
|
||||||
|
if (callIntIntMethodIfExists(navButtonView, listOf("sendEvent", "sendKeyEvent"), KeyEvent.ACTION_DOWN, 0) &&
|
||||||
|
callIntIntMethodIfExists(navButtonView, listOf("sendEvent", "sendKeyEvent"), KeyEvent.ACTION_UP, 0)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendKeyPress(KeyEvent.KEYCODE_HOME)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun proceedWithCanceledAction(
|
||||||
|
motionEvent: MotionEvent,
|
||||||
|
proceed: () -> Any?,
|
||||||
|
): Any? {
|
||||||
|
val originalAction = motionEvent.action
|
||||||
|
motionEvent.action = MotionEvent.ACTION_CANCEL
|
||||||
|
return try {
|
||||||
|
proceed()
|
||||||
|
} finally {
|
||||||
|
motionEvent.action = originalAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.graphics.drawable.LayerDrawable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
|
||||||
|
internal fun honeyspaceSideArrowSlots(container: ViewGroup, enabled: Boolean): BooleanArray {
|
||||||
|
if (!enabled) {
|
||||||
|
return BooleanArray(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultOrder = getFieldValue(container, "k") as? Boolean ?: true
|
||||||
|
return if (defaultOrder) {
|
||||||
|
// Stock order consumes recent, home, back(+back_alt); insert arrows around home.
|
||||||
|
booleanArrayOf(false, true, false, true, false, false)
|
||||||
|
} else {
|
||||||
|
// Reversed order consumes back_alt, back(+back_alt), home, recent.
|
||||||
|
booleanArrayOf(false, false, true, false, true, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun honeyspaceSideArrowDrawablesPresent(buttonMap: Map<Any, Any>): Boolean {
|
||||||
|
return HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow ->
|
||||||
|
(buttonMap[arrow.type] as? ImageView)?.drawable != null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun restoreHoneyspaceSideArrowDrawables(container: ViewGroup) {
|
||||||
|
val buttonMap = getFieldValue(container, "i") as? Map<*, *> ?: return
|
||||||
|
val darkIntensity = getFieldValue(container, "w") as? Float ?: 0f
|
||||||
|
HONEYSPACE_SIDE_ARROW_BUTTONS.forEach { arrow ->
|
||||||
|
restoreHoneyspaceSideArrowDrawable(
|
||||||
|
buttonMap[arrow.type] as? ImageView,
|
||||||
|
arrow.direction,
|
||||||
|
darkIntensity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isHoneyspaceNavButtonContainer(view: View): Boolean {
|
||||||
|
return view.javaClass.name == HONEYSPACE_NAV_BUTTON_CONTAINER ||
|
||||||
|
view.javaClass.simpleName == "NavigationBarButtonsLayout" ||
|
||||||
|
honeyspaceViewIdName(view) == "navbar_button_container"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun rescheduleHoneyspaceLongPress(
|
||||||
|
navButtonView: Any,
|
||||||
|
runnableClass: Class<*>,
|
||||||
|
durationMs: Int,
|
||||||
|
) {
|
||||||
|
val view = navButtonView as? View ?: return
|
||||||
|
val longPressRunnable = findHoneyspaceLongPressRunnable(navButtonView, runnableClass) ?: return
|
||||||
|
view.removeCallbacks(longPressRunnable)
|
||||||
|
view.postDelayed(longPressRunnable, durationMs.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun createHoneyspaceExtraButton(
|
||||||
|
container: ViewGroup,
|
||||||
|
buttonType: Int,
|
||||||
|
keyCode: Int,
|
||||||
|
direction: SideArrowDirection,
|
||||||
|
): View? {
|
||||||
|
val layoutId = container.context.resources.getIdentifier(
|
||||||
|
"layout_taskbar_nav_button",
|
||||||
|
"layout",
|
||||||
|
container.context.packageName,
|
||||||
|
).takeIf { it != 0 } ?: return null
|
||||||
|
|
||||||
|
val button = LayoutInflater.from(container.context)
|
||||||
|
.inflate(layoutId, container, false) as? View ?: return null
|
||||||
|
val drawable = createHoneyspaceSideArrowLayerDrawable(container.context, direction)
|
||||||
|
val setupMethod = findMethodsNamed(button.javaClass, "c").firstOrNull { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 9 &&
|
||||||
|
types[0] == Int::class.javaPrimitiveType &&
|
||||||
|
Drawable::class.java.isAssignableFrom(types[1]) &&
|
||||||
|
types[2] == Int::class.javaPrimitiveType &&
|
||||||
|
types[3] == Int::class.javaPrimitiveType
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val args = arrayOf(
|
||||||
|
buttonType,
|
||||||
|
drawable,
|
||||||
|
keyCode,
|
||||||
|
0,
|
||||||
|
getFieldValue(container, "c"),
|
||||||
|
getFieldValue(container, "d"),
|
||||||
|
getFieldValue(container, "e"),
|
||||||
|
getFieldValue(container, "f"),
|
||||||
|
getFieldValue(container, "h"),
|
||||||
|
)
|
||||||
|
return if (runCatching { setupMethod.invoke(button, *args) }.isSuccess) {
|
||||||
|
button
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findHoneyspaceLongPressRunnable(
|
||||||
|
navButtonView: Any,
|
||||||
|
runnableClass: Class<*>,
|
||||||
|
): Runnable? {
|
||||||
|
var current: Class<*>? = navButtonView.javaClass
|
||||||
|
while (current != null) {
|
||||||
|
current.declaredFields.forEach { field ->
|
||||||
|
try {
|
||||||
|
field.isAccessible = true
|
||||||
|
val value = field.get(navButtonView) ?: return@forEach
|
||||||
|
if (!runnableClass.isInstance(value)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (getIntField(value, "b") != HONEYSPACE_LONG_PRESS_CASE) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (getFieldValue(value, "c") !== navButtonView) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
return value as Runnable
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.superclass
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun honeyspaceViewIdName(view: View): String {
|
||||||
|
val id = view.id
|
||||||
|
if (id == View.NO_ID) {
|
||||||
|
return "no_id"
|
||||||
|
}
|
||||||
|
return runCatching { view.resources.getResourceEntryName(id) }.getOrElse { id.toString() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreHoneyspaceSideArrowDrawable(
|
||||||
|
image: ImageView?,
|
||||||
|
direction: SideArrowDirection,
|
||||||
|
darkIntensity: Float,
|
||||||
|
) {
|
||||||
|
image ?: return
|
||||||
|
if (image.drawable != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val drawable = createHoneyspaceSideArrowLayerDrawable(image.context, direction)
|
||||||
|
applyHoneyspaceDarkIntensity(drawable, darkIntensity)
|
||||||
|
image.setImageDrawable(drawable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyHoneyspaceDarkIntensity(drawable: Drawable, darkIntensity: Float) {
|
||||||
|
val layerDrawable = drawable as? LayerDrawable ?: return
|
||||||
|
if (layerDrawable.numberOfLayers < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val clamped = darkIntensity.coerceIn(0f, 1f)
|
||||||
|
layerDrawable.getDrawable(0).alpha = ((1f - clamped) * 255f).toInt()
|
||||||
|
layerDrawable.getDrawable(1).alpha = (clamped * 255f).toInt()
|
||||||
|
layerDrawable.invalidateSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createHoneyspaceSideArrowLayerDrawable(context: Context, direction: SideArrowDirection): Drawable {
|
||||||
|
return LayerDrawable(
|
||||||
|
arrayOf(
|
||||||
|
sideArrowBitmapDrawable(context, direction, Color.WHITE),
|
||||||
|
sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.view.View
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class SamsungSystemUiHooks(
|
||||||
|
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||||
|
private val deoptimizeMethod: (Method?) -> Unit,
|
||||||
|
private val sideArrowsEnabled: () -> Boolean,
|
||||||
|
private val registerSettingsReceiver: () -> Unit,
|
||||||
|
private val contextProvider: () -> Context?,
|
||||||
|
private val contextUpdater: (Context) -> Unit,
|
||||||
|
private val handlerProvider: () -> Handler?,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val trackedNavigationBarViews = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||||
|
private val startupRefreshScheduled = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||||
|
private val sideArrowLayoutKinds = WeakHashMap<Any, SamsungSideArrowLayoutKind>()
|
||||||
|
private var foldedSideArrowProviderHookInstalled = false
|
||||||
|
private var navigationBarViewHookInstalled = false
|
||||||
|
|
||||||
|
fun installFoldedSideArrows(classLoader: ClassLoader?) {
|
||||||
|
if (!Build.MANUFACTURER.equals("samsung", ignoreCase = true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hookFoldedLayoutProvider(classLoader)
|
||||||
|
hookNavigationBarView(classLoader)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trackNavigationBarView(startView: Any) {
|
||||||
|
var current: View? = startView as? View ?: return
|
||||||
|
repeat(SAMSUNG_NAV_VIEW_PARENT_DEPTH) {
|
||||||
|
val target = current ?: return
|
||||||
|
if (target.javaClass.name == SAMSUNG_NAVIGATION_BAR_VIEW) {
|
||||||
|
trackNavigationBar(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = target.parent as? View
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshNavigationBars() {
|
||||||
|
val views = trackedNavigationBarViews.toList()
|
||||||
|
if (views.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
views.forEach { navBarView ->
|
||||||
|
try {
|
||||||
|
refreshNavigationBar(navBarView)
|
||||||
|
if (sideArrowsEnabled()) {
|
||||||
|
handlerProvider()?.postDelayed(
|
||||||
|
{ refreshNavigationBar(navBarView) },
|
||||||
|
SIDE_ARROW_REFRESH_DELAY_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Side arrows reinflate failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookFoldedLayoutProvider(classLoader: ClassLoader?) {
|
||||||
|
if (foldedSideArrowProviderHookInstalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foldedSideArrowProviderHookInstalled = true
|
||||||
|
|
||||||
|
listOf(SAMSUNG_PHONE_LAYOUT_PROVIDER, SAMSUNG_TABLET_LAYOUT_PROVIDER)
|
||||||
|
.forEach { className ->
|
||||||
|
val providerClass = findClassIfExists(className, classLoader) ?: run {
|
||||||
|
log("Side arrows provider class not found: $className")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
findMethodsNamed(providerClass, "getLayout")
|
||||||
|
.filter { method -> method.returnType == String::class.java }
|
||||||
|
.forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
val layout = result as? String ?: return@hookMethod result
|
||||||
|
if (!sideArrowsEnabled()) {
|
||||||
|
return@hookMethod layout
|
||||||
|
}
|
||||||
|
|
||||||
|
val sideArrowLayout = addSamsungSideArrows(
|
||||||
|
layout,
|
||||||
|
findContext(chain.thisObject) ?: contextProvider(),
|
||||||
|
) { index ->
|
||||||
|
log("Folded side arrows custom key id not found: nav_custom_key_$index")
|
||||||
|
}
|
||||||
|
if (sideArrowLayout.layout != layout) {
|
||||||
|
chain.thisObject?.let { provider ->
|
||||||
|
sideArrowLayoutKinds[provider] = sideArrowLayout.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sideArrowLayout.layout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hookFoldedLayoutProviderSizing(providerClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookFoldedLayoutProviderSizing(providerClass: Class<*>) {
|
||||||
|
findMethodsNamed(providerClass, "getButtonWidth")
|
||||||
|
.filter(::isSamsungPointBooleanIntMethod)
|
||||||
|
.forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
samsungSideArrowSize(
|
||||||
|
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||||
|
PHONE_BUTTON_RATIO, TABLET_BUTTON_RATIO,
|
||||||
|
) ?: chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findMethodsNamed(providerClass, "getButtonDistanceSize")
|
||||||
|
.filter(::isSamsungPointBooleanIntMethod)
|
||||||
|
.forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
samsungSideArrowSize(
|
||||||
|
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||||
|
SIDE_ARROW_GAP_RATIO, SIDE_ARROW_GAP_RATIO,
|
||||||
|
) ?: chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findMethodsNamed(providerClass, "getSpaceWidth")
|
||||||
|
.filter { method ->
|
||||||
|
method.returnType == Int::class.javaPrimitiveType &&
|
||||||
|
method.parameterTypes.firstOrNull() == android.graphics.Point::class.java
|
||||||
|
}
|
||||||
|
.forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
samsungSideArrowSize(
|
||||||
|
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||||
|
PHONE_SPACE_RATIO, TABLET_SPACE_RATIO,
|
||||||
|
) ?: chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findMethodsNamed(providerClass, "getSpaceSidePadding")
|
||||||
|
.filter { method ->
|
||||||
|
method.returnType == Int::class.javaPrimitiveType &&
|
||||||
|
method.parameterTypes.firstOrNull() == android.graphics.Point::class.java
|
||||||
|
}
|
||||||
|
.forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
samsungSideArrowSize(
|
||||||
|
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||||
|
0.0, TABLET_SPACE_SIDE_PADDING_RATIO,
|
||||||
|
) ?: chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookNavigationBarView(classLoader: ClassLoader?) {
|
||||||
|
if (navigationBarViewHookInstalled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigationBarViewHookInstalled = true
|
||||||
|
|
||||||
|
val navBarViewClass = findClassIfExists(SAMSUNG_NAVIGATION_BAR_VIEW, classLoader) ?: run {
|
||||||
|
log("Folded side arrows nav view class not found: $SAMSUNG_NAVIGATION_BAR_VIEW")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
"onAttachedToWindow",
|
||||||
|
"onFinishInflate",
|
||||||
|
"reInflateNavBarLayout",
|
||||||
|
"updateLayoutProviderView",
|
||||||
|
).forEach { methodName ->
|
||||||
|
findDeclaredMethodsNamed(navBarViewClass, methodName).forEach { method ->
|
||||||
|
deoptimizeMethod(method)
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val navBarView = chain.thisObject
|
||||||
|
navBarView?.let { prepareNavigationBar(it) }
|
||||||
|
val result = chain.proceed()
|
||||||
|
navBarView?.let { prepareNavigationBar(it) }
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareNavigationBar(navBarView: Any) {
|
||||||
|
updateContextFromView(navBarView)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
trackNavigationBar(navBarView)
|
||||||
|
if (sideArrowsEnabled()) {
|
||||||
|
handlerProvider()?.postDelayed(
|
||||||
|
{ configureFoldedSideArrowButtons(navBarView) },
|
||||||
|
SIDE_ARROW_REFRESH_DELAY_MS,
|
||||||
|
)
|
||||||
|
scheduleStartupRefresh(navBarView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trackNavigationBar(navBarView: Any) {
|
||||||
|
trackedNavigationBarViews.add(navBarView)
|
||||||
|
configureFoldedSideArrowButtons(navBarView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleStartupRefresh(navBarView: Any) {
|
||||||
|
if (!startupRefreshScheduled.add(navBarView)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlerProvider()?.postDelayed(
|
||||||
|
{ refreshNavigationBar(navBarView) },
|
||||||
|
SIDE_ARROW_REFRESH_DELAY_MS * 4,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshNavigationBar(navBarView: Any) {
|
||||||
|
try {
|
||||||
|
configureFoldedSideArrowButtons(navBarView)
|
||||||
|
callMethodIfExists(navBarView, "updateLayoutProviderView")
|
||||||
|
callMethod(navBarView, "reInflateNavBarLayout", emptyArray<Class<*>>())
|
||||||
|
configureFoldedSideArrowButtons(navBarView)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Side arrows nav refresh failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureFoldedSideArrowButtons(navBarView: Any) {
|
||||||
|
if (sideArrowsEnabled()) {
|
||||||
|
configureSamsungSideArrowDispatcherButtons(
|
||||||
|
navBarView,
|
||||||
|
navBarView,
|
||||||
|
contextProvider(),
|
||||||
|
::findContext,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContextFromView(value: Any) {
|
||||||
|
val viewContext = (value as? View)?.context ?: return
|
||||||
|
contextUpdater(viewContext.applicationContext ?: viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findContext(target: Any?): Context? {
|
||||||
|
var current: Class<*>? = target?.javaClass ?: return null
|
||||||
|
while (current != null) {
|
||||||
|
current.declaredFields.forEach { field ->
|
||||||
|
try {
|
||||||
|
field.isAccessible = true
|
||||||
|
val value = field.get(target)
|
||||||
|
if (value is Context) {
|
||||||
|
return value.applicationContext ?: value
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.superclass
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Point
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.SparseArray
|
||||||
|
import android.view.View
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal enum class SamsungSideArrowLayoutKind {
|
||||||
|
PHONE,
|
||||||
|
TABLET,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class SamsungSideArrowLayout(
|
||||||
|
val layout: String,
|
||||||
|
val kind: SamsungSideArrowLayoutKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun addSamsungSideArrows(
|
||||||
|
layout: String,
|
||||||
|
sourceContext: Context?,
|
||||||
|
onMissingCustomKeyId: (Int) -> Unit,
|
||||||
|
): SamsungSideArrowLayout {
|
||||||
|
val leftKey = navCustomKeyToken(sourceContext, SideArrowDirection.LEFT.keyCode, 1, onMissingCustomKeyId)
|
||||||
|
val rightKey = navCustomKeyToken(sourceContext, SideArrowDirection.RIGHT.keyCode, 2, onMissingCustomKeyId)
|
||||||
|
return when (layout) {
|
||||||
|
NAV_LAYOUT_PHONE_DEFAULT ->
|
||||||
|
SamsungSideArrowLayout("left,recent;$leftKey,home,$rightKey;back,right", SamsungSideArrowLayoutKind.PHONE)
|
||||||
|
NAV_LAYOUT_PHONE_REVERSED ->
|
||||||
|
SamsungSideArrowLayout("left,back;$leftKey,home,$rightKey;recent,right", SamsungSideArrowLayoutKind.PHONE)
|
||||||
|
NAV_LAYOUT_TABLET_DEFAULT ->
|
||||||
|
SamsungSideArrowLayout("left;recent,$leftKey,home,$rightKey,back;right", SamsungSideArrowLayoutKind.TABLET)
|
||||||
|
NAV_LAYOUT_TABLET_REVERSED ->
|
||||||
|
SamsungSideArrowLayout("left;back,$leftKey,home,$rightKey,recent;right", SamsungSideArrowLayoutKind.TABLET)
|
||||||
|
NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT ->
|
||||||
|
SamsungSideArrowLayout("left;back,$leftKey,home,$rightKey,recent;r", SamsungSideArrowLayoutKind.TABLET)
|
||||||
|
NAV_LAYOUT_WINNER_DEFAULT ->
|
||||||
|
SamsungSideArrowLayout("left,back,$leftKey,home,$rightKey,recent;;right", SamsungSideArrowLayoutKind.TABLET)
|
||||||
|
NAV_LAYOUT_WINNER_REVERSED ->
|
||||||
|
SamsungSideArrowLayout("left,recent,$leftKey,home,$rightKey,back;;right", SamsungSideArrowLayoutKind.TABLET)
|
||||||
|
NAV_LAYOUT_UNFOLDED_DEFAULT ->
|
||||||
|
SamsungSideArrowLayout("left;;extrakey,back,$leftKey,home,$rightKey,recent,right", SamsungSideArrowLayoutKind.TABLET)
|
||||||
|
NAV_LAYOUT_UNFOLDED_REVERSED ->
|
||||||
|
SamsungSideArrowLayout("left;;extrakey,recent,$leftKey,home,$rightKey,back,right", SamsungSideArrowLayoutKind.TABLET)
|
||||||
|
else -> addSamsungSideArrowsAroundHome(layout, leftKey, rightKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSamsungSideArrowsAroundHome(
|
||||||
|
layout: String,
|
||||||
|
leftKey: String,
|
||||||
|
rightKey: String,
|
||||||
|
): SamsungSideArrowLayout {
|
||||||
|
if (layout.contains("navkey(${SideArrowDirection.LEFT.keyCode}:") ||
|
||||||
|
layout.contains("navkey(${SideArrowDirection.RIGHT.keyCode}:")
|
||||||
|
) {
|
||||||
|
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val groups = layout.split(";").toMutableList()
|
||||||
|
val homeGroupIndex = groups.indexOfFirst { group ->
|
||||||
|
group.split(",").any { it == "home" }
|
||||||
|
}
|
||||||
|
if (homeGroupIndex < 0) {
|
||||||
|
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tokens = groups[homeGroupIndex]
|
||||||
|
.split(",")
|
||||||
|
.filter { it.isNotBlank() && it != "gap" }
|
||||||
|
.toMutableList()
|
||||||
|
val homeIndex = tokens.indexOf("home")
|
||||||
|
if (homeIndex < 0) {
|
||||||
|
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.add(homeIndex, leftKey)
|
||||||
|
tokens.add(homeIndex + 2, rightKey)
|
||||||
|
groups[homeGroupIndex] = tokens.joinToString(",")
|
||||||
|
|
||||||
|
val kind = if (groups.size == 3 && groups[1] == "$leftKey,home,$rightKey") {
|
||||||
|
SamsungSideArrowLayoutKind.PHONE
|
||||||
|
} else {
|
||||||
|
SamsungSideArrowLayoutKind.TABLET
|
||||||
|
}
|
||||||
|
return SamsungSideArrowLayout(groups.joinToString(";"), kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun navCustomKeyId(sourceContext: Context?, index: Int): Int {
|
||||||
|
return sourceContext?.resources?.getIdentifier(
|
||||||
|
"nav_custom_key_$index",
|
||||||
|
"id",
|
||||||
|
sourceContext.packageName,
|
||||||
|
) ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isSamsungPointBooleanIntMethod(method: Method): Boolean {
|
||||||
|
val parameterTypes = method.parameterTypes
|
||||||
|
return method.returnType == Int::class.javaPrimitiveType &&
|
||||||
|
parameterTypes.size >= 2 &&
|
||||||
|
parameterTypes[0] == Point::class.java &&
|
||||||
|
parameterTypes[1] == Boolean::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun samsungSideArrowSize(
|
||||||
|
provider: Any?,
|
||||||
|
args: List<Any?>,
|
||||||
|
layoutKinds: Map<Any, SamsungSideArrowLayoutKind>,
|
||||||
|
enabled: Boolean,
|
||||||
|
phoneRatio: Double,
|
||||||
|
tabletRatio: Double,
|
||||||
|
): Int? {
|
||||||
|
if (!enabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val displaySize = args.firstOrNull() as? Point ?: return null
|
||||||
|
val kind = provider?.let { layoutKinds[it] } ?: SamsungSideArrowLayoutKind.PHONE
|
||||||
|
val ratio = if (kind == SamsungSideArrowLayoutKind.TABLET) tabletRatio else phoneRatio
|
||||||
|
return (minOf(displaySize.x, displaySize.y) * ratio * SIDE_ARROW_REDUCE_RATIO).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun configureSamsungSideArrowDispatcherButtons(
|
||||||
|
source: Any,
|
||||||
|
darkIntensitySource: Any?,
|
||||||
|
fallbackContext: Context?,
|
||||||
|
contextFromObject: (Any) -> Context?,
|
||||||
|
): Boolean {
|
||||||
|
val navContext = samsungNavigationBarContext(source, fallbackContext, contextFromObject) ?: return false
|
||||||
|
val dispatcherProxy = findSamsungButtonDispatcherProxy(source)
|
||||||
|
|
||||||
|
if (dispatcherProxy != null) {
|
||||||
|
configureSamsungSideArrowDispatcherButton(
|
||||||
|
dispatcherProxy,
|
||||||
|
darkIntensitySource,
|
||||||
|
navContext,
|
||||||
|
index = 1,
|
||||||
|
direction = SideArrowDirection.LEFT,
|
||||||
|
)
|
||||||
|
configureSamsungSideArrowDispatcherButton(
|
||||||
|
dispatcherProxy,
|
||||||
|
darkIntensitySource,
|
||||||
|
navContext,
|
||||||
|
index = 2,
|
||||||
|
direction = SideArrowDirection.RIGHT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatcherProxy != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun samsungNavigationBarContext(
|
||||||
|
source: Any,
|
||||||
|
fallbackContext: Context?,
|
||||||
|
contextFromObject: (Any) -> Context?,
|
||||||
|
): Context? {
|
||||||
|
return (callMethodIfExists(source, "getNavigationBarContext") as? Context)
|
||||||
|
?.let { it.applicationContext ?: it }
|
||||||
|
?: contextFromObject(source)
|
||||||
|
?: fallbackContext
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureSamsungSideArrowDispatcherButton(
|
||||||
|
dispatcherProxy: Any,
|
||||||
|
darkIntensitySource: Any?,
|
||||||
|
context: Context,
|
||||||
|
index: Int,
|
||||||
|
direction: SideArrowDirection,
|
||||||
|
) {
|
||||||
|
val id = navCustomKeyId(context, index)
|
||||||
|
if (id == 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callIntMethodIfExists(dispatcherProxy, "addButton", id)
|
||||||
|
callSetButtonImageIfExists(
|
||||||
|
dispatcherProxy,
|
||||||
|
id,
|
||||||
|
sideArrowBitmapDrawable(context, direction, Color.WHITE),
|
||||||
|
sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)),
|
||||||
|
)
|
||||||
|
callIntIntMethodIfExists(dispatcherProxy, "setButtonVisibility", id, View.VISIBLE)
|
||||||
|
if (darkIntensitySource != null) {
|
||||||
|
setSamsungSideArrowDarkIntensity(darkIntensitySource, id, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSamsungButtonDispatcherProxy(root: Any): Any? {
|
||||||
|
findSamsungButtonDispatcherProxyFromMethods(root)?.let { return it }
|
||||||
|
|
||||||
|
val visited = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||||
|
val queue = ArrayDeque<Pair<Any, Int>>()
|
||||||
|
queue.add(root to 0)
|
||||||
|
|
||||||
|
while (queue.isNotEmpty()) {
|
||||||
|
val (target, depth) = queue.removeFirst()
|
||||||
|
if (!visited.add(target)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isSamsungButtonDispatcherProxy(target)) {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
findSamsungButtonDispatcherProxyFromMethods(target)?.let { return it }
|
||||||
|
if (depth >= BUTTON_DISPATCHER_SEARCH_DEPTH) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: Class<*>? = target.javaClass
|
||||||
|
while (current != null) {
|
||||||
|
current.declaredFields.forEach { field ->
|
||||||
|
try {
|
||||||
|
field.isAccessible = true
|
||||||
|
val value = field.get(target) ?: return@forEach
|
||||||
|
if (shouldSearchSamsungButtonDispatcherField(value)) {
|
||||||
|
queue.add(value to depth + 1)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.superclass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSamsungButtonDispatcherProxyFromMethods(target: Any): Any? {
|
||||||
|
findMethodsNamed(target.javaClass, "getButtonDispatcherProxy")
|
||||||
|
.firstOrNull { it.parameterTypes.isEmpty() }
|
||||||
|
?.let { method ->
|
||||||
|
runCatching { method.invoke(target) }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf(::isSamsungButtonDispatcherProxy)
|
||||||
|
?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSamsungButtonDispatcherProxy(target: Any): Boolean {
|
||||||
|
return findMethodsNamed(target.javaClass, "setButtonImage").any { method ->
|
||||||
|
val types = method.parameterTypes
|
||||||
|
types.size == 3 &&
|
||||||
|
types[0] == Int::class.javaPrimitiveType &&
|
||||||
|
Drawable::class.java.isAssignableFrom(types[1]) &&
|
||||||
|
Drawable::class.java.isAssignableFrom(types[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldSearchSamsungButtonDispatcherField(value: Any): Boolean {
|
||||||
|
val className = value.javaClass.name
|
||||||
|
return className.startsWith("com.android.systemui") ||
|
||||||
|
className.startsWith("com.samsung.systemui") ||
|
||||||
|
className.startsWith("java.util") ||
|
||||||
|
className.startsWith("android.util")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setSamsungSideArrowDarkIntensity(navBarView: Any, id: Int, context: Context) {
|
||||||
|
val dispatchers = getFieldValue(navBarView, "mButtonDispatchers") as? SparseArray<*> ?: return
|
||||||
|
val dispatcher = dispatchers.get(id) ?: return
|
||||||
|
val intensity = findSamsungReferenceDarkIntensity(context, dispatchers)
|
||||||
|
callFloatMethodIfExists(dispatcher, "setDarkIntensity", intensity)
|
||||||
|
setFieldIfExists(dispatcher, "mDarkIntensity", intensity)
|
||||||
|
getFieldValue(dispatcher, "mImageDrawable")?.let { drawable ->
|
||||||
|
callFloatMethodIfExists(drawable, "setDarkIntensity", intensity)
|
||||||
|
}
|
||||||
|
getFieldValue(dispatcher, "mViews")
|
||||||
|
.let { it as? Iterable<*> }
|
||||||
|
?.forEach { view ->
|
||||||
|
if (view != null) {
|
||||||
|
callFloatMethodIfExists(view, "setDarkIntensity", intensity)
|
||||||
|
setFieldIfExists(view, "mDarkIntensity", intensity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSamsungReferenceDarkIntensity(context: Context, dispatchers: SparseArray<*>): Float {
|
||||||
|
listOf("home", "back", "recent").forEach { name ->
|
||||||
|
val id = context.resources.getIdentifier(name, "id", context.packageName)
|
||||||
|
val value = getFieldValue(dispatchers.get(id), "mDarkIntensity") as? Float
|
||||||
|
if (value != null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navCustomKeyToken(
|
||||||
|
sourceContext: Context?,
|
||||||
|
keyCode: Int,
|
||||||
|
index: Int,
|
||||||
|
onMissingCustomKeyId: (Int) -> Unit,
|
||||||
|
): String {
|
||||||
|
val customKeyId = navCustomKeyId(sourceContext, index)
|
||||||
|
if (customKeyId == 0) {
|
||||||
|
onMissingCustomKeyId(index)
|
||||||
|
}
|
||||||
|
return "navkey($keyCode:$customKeyId)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.ViewConfiguration
|
||||||
|
|
||||||
|
internal class SettingsRepository(
|
||||||
|
private val isSystemServerProcess: () -> Boolean,
|
||||||
|
private val contextProvider: () -> Context?,
|
||||||
|
private val contextUpdater: (Context) -> Unit,
|
||||||
|
private val currentApplicationProvider: () -> Context?,
|
||||||
|
private val remotePreferencesProvider: (String) -> SharedPreferences,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
private val onChanged: () -> Unit,
|
||||||
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var remotePreferences: SharedPreferences? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var remotePreferencesRetryAfterMs = 0L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var remotePreferencesLoading = false
|
||||||
|
|
||||||
|
private var settingsChangedReceiverRegistered = false
|
||||||
|
private val buttonConfigOverrides = mutableMapOf<NavButtonId, NavButtonConfig>()
|
||||||
|
private var sideArrowsEnabledOverride: Boolean? = null
|
||||||
|
private var sideArrowLongPressActionOverride: SideArrowLongPressAction? = null
|
||||||
|
private var sideArrowLongPressDurationOverride: Int? = null
|
||||||
|
private var sideArrowRepeatSpeedOverride: Float? = null
|
||||||
|
|
||||||
|
fun preferences(): SharedPreferences? {
|
||||||
|
remotePreferences?.let { return it }
|
||||||
|
if (isSystemServerProcess()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
startPreferencesLoad()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return loadPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerChangedReceiver() {
|
||||||
|
if (settingsChangedReceiverRegistered) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentContext = currentApplicationProvider()?.applicationContext
|
||||||
|
?: contextProvider()?.applicationContext
|
||||||
|
?: contextProvider()
|
||||||
|
?: return
|
||||||
|
contextUpdater(currentContext)
|
||||||
|
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action != NavButtonSettingsStore.ACTION_SETTINGS_CHANGED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateOverrides(intent)
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentContext.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED),
|
||||||
|
Context.RECEIVER_EXPORTED,
|
||||||
|
)
|
||||||
|
settingsChangedReceiverRegistered = true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Settings receiver failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getButtonConfig(buttonId: NavButtonId): NavButtonConfig {
|
||||||
|
buttonConfigOverrides[buttonId]?.let { return it }
|
||||||
|
val preferences = preferences()
|
||||||
|
val defaultDurationMs = ViewConfiguration.getLongPressTimeout().coerceLongPressDuration()
|
||||||
|
return NavButtonConfig(
|
||||||
|
pressAction = NavButtonAction.fromStorageValue(
|
||||||
|
preferences?.getString(NavButtonSettingsStore.pressActionKey(buttonId), null),
|
||||||
|
),
|
||||||
|
longPressAction = NavButtonAction.fromStorageValue(
|
||||||
|
preferences?.getString(NavButtonSettingsStore.longPressActionKey(buttonId), null),
|
||||||
|
),
|
||||||
|
longPressDurationMs = (
|
||||||
|
preferences?.getInt(
|
||||||
|
NavButtonSettingsStore.longPressDurationKey(buttonId),
|
||||||
|
defaultDurationMs,
|
||||||
|
) ?: defaultDurationMs
|
||||||
|
).coerceLongPressDuration(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSideArrowsEnabled(): Boolean {
|
||||||
|
return sideArrowsEnabledOverride
|
||||||
|
?: (preferences()?.getBoolean(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, false) ?: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sideArrowLongPressAction(): SideArrowLongPressAction {
|
||||||
|
sideArrowLongPressActionOverride?.let { return it }
|
||||||
|
return SideArrowLongPressAction.fromStorageValue(
|
||||||
|
preferences()?.getString(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION, null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sideArrowLongPressDurationMs(): Int {
|
||||||
|
sideArrowLongPressDurationOverride?.let { return it }
|
||||||
|
val defaultDurationMs = ViewConfiguration.getLongPressTimeout()
|
||||||
|
return (
|
||||||
|
preferences()?.getInt(
|
||||||
|
NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS,
|
||||||
|
defaultDurationMs,
|
||||||
|
) ?: defaultDurationMs
|
||||||
|
).coerceLongPressDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sideArrowRepeatIntervalMs(): Long {
|
||||||
|
val speed = sanitizeSideArrowRepeatSpeed(
|
||||||
|
sideArrowRepeatSpeedOverride
|
||||||
|
?: preferences()?.getFloat(
|
||||||
|
NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||||
|
NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||||
|
)
|
||||||
|
?: NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||||
|
)
|
||||||
|
return (1_000f / speed).toLong().coerceAtLeast(1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPreferencesLoad() {
|
||||||
|
if (remotePreferences != null || remotePreferencesLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (contextProvider()?.packageName == ANDROID_PACKAGE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
if (now < remotePreferencesRetryAfterMs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remotePreferencesLoading = true
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
repeat(REMOTE_PREFERENCES_LOAD_ATTEMPTS) {
|
||||||
|
if (loadPreferences() != null) {
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
Thread.sleep(REMOTE_PREFERENCES_RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
remotePreferencesLoading = false
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadPreferences(): SharedPreferences? {
|
||||||
|
remotePreferences?.let { return it }
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
if (now < remotePreferencesRetryAfterMs) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
remotePreferencesProvider(NavButtonSettingsStore.PREFERENCES_NAME)
|
||||||
|
}.getOrElse { error ->
|
||||||
|
remotePreferencesRetryAfterMs = now + REMOTE_PREFERENCES_RETRY_DELAY_MS
|
||||||
|
log("Remote preferences unavailable: ${error.javaClass.simpleName}")
|
||||||
|
null
|
||||||
|
}?.also {
|
||||||
|
remotePreferences = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateOverrides(intent: Intent) {
|
||||||
|
NavButtonId.entries.forEach { buttonId ->
|
||||||
|
val defaultDurationMs = ViewConfiguration.getLongPressTimeout().coerceLongPressDuration()
|
||||||
|
buttonConfigOverrides[buttonId] = NavButtonConfig(
|
||||||
|
pressAction = NavButtonAction.fromStorageValue(
|
||||||
|
intent.getStringExtra(NavButtonSettingsStore.pressActionKey(buttonId)),
|
||||||
|
),
|
||||||
|
longPressAction = NavButtonAction.fromStorageValue(
|
||||||
|
intent.getStringExtra(NavButtonSettingsStore.longPressActionKey(buttonId)),
|
||||||
|
),
|
||||||
|
longPressDurationMs = intent.getIntExtra(
|
||||||
|
NavButtonSettingsStore.longPressDurationKey(buttonId),
|
||||||
|
defaultDurationMs,
|
||||||
|
).coerceLongPressDuration(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sideArrowsEnabledOverride = intent.getBooleanExtra(
|
||||||
|
NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED,
|
||||||
|
sideArrowsEnabledOverride ?: false,
|
||||||
|
)
|
||||||
|
sideArrowLongPressActionOverride = SideArrowLongPressAction.fromStorageValue(
|
||||||
|
intent.getStringExtra(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION),
|
||||||
|
)
|
||||||
|
sideArrowLongPressDurationOverride = intent.getIntExtra(
|
||||||
|
NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS,
|
||||||
|
ViewConfiguration.getLongPressTimeout(),
|
||||||
|
).coerceLongPressDuration()
|
||||||
|
sideArrowRepeatSpeedOverride = sanitizeSideArrowRepeatSpeed(
|
||||||
|
intent.getFloatExtra(
|
||||||
|
NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||||
|
NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitizeSideArrowRepeatSpeed(stepsPerSecond: Float): Float {
|
||||||
|
return if (stepsPerSecond.isFinite()) {
|
||||||
|
stepsPerSecond.coerceIn(
|
||||||
|
NavButtonSettingsStore.MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||||
|
NavButtonSettingsStore.MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.view.View
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class SideArrowLongPressController(
|
||||||
|
private val handlerProvider: () -> Handler,
|
||||||
|
private val actionProvider: () -> SideArrowLongPressAction,
|
||||||
|
private val durationProvider: () -> Int,
|
||||||
|
private val repeatIntervalProvider: () -> Long,
|
||||||
|
private val sendKeyPress: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
private val triggered = WeakHashMap<Any, Boolean>()
|
||||||
|
private val longPressRunnables = WeakHashMap<Any, Runnable>()
|
||||||
|
private val repeatRunnables = WeakHashMap<Any, Runnable>()
|
||||||
|
|
||||||
|
fun start(target: Any, keyCode: Int) {
|
||||||
|
reset(target)
|
||||||
|
schedule(target, keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startAfter(target: Any, keyCode: Int, proceed: () -> Any?): Any? {
|
||||||
|
reset(target)
|
||||||
|
val result = proceed()
|
||||||
|
schedule(target, keyCode)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish(target: Any): Boolean {
|
||||||
|
val wasTriggered = triggered[target] == true
|
||||||
|
cancel(target)
|
||||||
|
triggered.remove(target)
|
||||||
|
return wasTriggered
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reset(target: Any) {
|
||||||
|
triggered[target] = false
|
||||||
|
cancel(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedule(target: Any, keyCode: Int) {
|
||||||
|
val view = target as? View ?: return
|
||||||
|
val handler = handlerProvider()
|
||||||
|
val longPressRunnable = Runnable {
|
||||||
|
triggered[target] = true
|
||||||
|
when (actionProvider()) {
|
||||||
|
SideArrowLongPressAction.NOTHING -> Unit
|
||||||
|
SideArrowLongPressAction.SINGLE_STEP -> sendKeyPress(keyCode)
|
||||||
|
SideArrowLongPressAction.MULTIPLE_STEPS -> {
|
||||||
|
sendKeyPress(keyCode)
|
||||||
|
val repeatRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
sendKeyPress(keyCode)
|
||||||
|
handler.postDelayed(this, repeatIntervalProvider())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repeatRunnables[target] = repeatRunnable
|
||||||
|
handler.postDelayed(repeatRunnable, repeatIntervalProvider())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
longPressRunnables[target] = longPressRunnable
|
||||||
|
view.postDelayed(longPressRunnable, durationProvider().toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancel(target: Any) {
|
||||||
|
val view = target as? View
|
||||||
|
longPressRunnables.remove(target)?.let { runnable ->
|
||||||
|
view?.removeCallbacks(runnable) ?: handlerProvider().removeCallbacks(runnable)
|
||||||
|
}
|
||||||
|
repeatRunnables.remove(target)?.let { runnable ->
|
||||||
|
handlerProvider().removeCallbacks(runnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.ColorFilter
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.widget.ImageView
|
||||||
|
|
||||||
|
internal enum class SideArrowDirection(val keyCode: Int) {
|
||||||
|
LEFT(KeyEvent.KEYCODE_DPAD_LEFT),
|
||||||
|
RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT),
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class AospSideArrowViews(
|
||||||
|
val left: ImageView,
|
||||||
|
val right: ImageView,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class HoneyspaceSideArrowButton(
|
||||||
|
val type: Int,
|
||||||
|
val keyCode: Int,
|
||||||
|
val direction: SideArrowDirection,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun navStarArrowIconSizePx(context: Context): Int {
|
||||||
|
return (context.resources.displayMetrics.density * SIDE_ARROW_ICON_DP + 0.5f).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sideArrowBitmapDrawable(
|
||||||
|
context: Context,
|
||||||
|
direction: SideArrowDirection,
|
||||||
|
color: Int,
|
||||||
|
): Drawable {
|
||||||
|
val size = navStarArrowIconSizePx(context).coerceAtLeast(1)
|
||||||
|
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
SideArrowDrawable(direction, color, size).apply {
|
||||||
|
setBounds(0, 0, size, size)
|
||||||
|
draw(canvas)
|
||||||
|
}
|
||||||
|
return BitmapDrawable(context.resources, bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val HONEYSPACE_SIDE_ARROW_BUTTONS = listOf(
|
||||||
|
HoneyspaceSideArrowButton(
|
||||||
|
HONEYSPACE_LEFT_ARROW_BUTTON_TYPE,
|
||||||
|
SideArrowDirection.LEFT.keyCode,
|
||||||
|
SideArrowDirection.LEFT,
|
||||||
|
),
|
||||||
|
HoneyspaceSideArrowButton(
|
||||||
|
HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE,
|
||||||
|
SideArrowDirection.RIGHT.keyCode,
|
||||||
|
SideArrowDirection.RIGHT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal const val HONEYSPACE_LEFT_ARROW_BUTTON_TYPE = 64
|
||||||
|
internal const val HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE = 128
|
||||||
|
internal val HONEYSPACE_EXTRA_BUTTON_TYPES =
|
||||||
|
HONEYSPACE_SIDE_ARROW_BUTTONS.map { it.type } + listOf(256, 512, 1024)
|
||||||
|
|
||||||
|
private const val SIDE_ARROW_ICON_DP = 32f
|
||||||
|
private const val SIDE_ARROW_GLYPH_SCALE = 0.8f
|
||||||
|
private const val SIDE_ARROW_STROKE_WIDTH = 1.45f
|
||||||
|
|
||||||
|
private class SideArrowDrawable(
|
||||||
|
private val direction: SideArrowDirection,
|
||||||
|
color: Int,
|
||||||
|
private val iconSizePx: Int,
|
||||||
|
private val colorProvider: (() -> Int)? = null,
|
||||||
|
) : Drawable() {
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
this.color = color
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeCap = Paint.Cap.SQUARE
|
||||||
|
strokeJoin = Paint.Join.MITER
|
||||||
|
}
|
||||||
|
private val path = Path()
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas) {
|
||||||
|
val bounds = bounds
|
||||||
|
val width = bounds.width().toFloat()
|
||||||
|
val height = bounds.height().toFloat()
|
||||||
|
if (width <= 0f || height <= 0f) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val size = minOf(width, height, iconSizePx.toFloat()) * SIDE_ARROW_GLYPH_SCALE
|
||||||
|
val left = bounds.left + (width - size) / 2f
|
||||||
|
val top = bounds.top + (height - size) / 2f
|
||||||
|
val scale = size / 24f
|
||||||
|
|
||||||
|
colorProvider?.let { provider ->
|
||||||
|
val alpha = paint.alpha
|
||||||
|
paint.color = provider()
|
||||||
|
paint.alpha = alpha
|
||||||
|
}
|
||||||
|
path.reset()
|
||||||
|
if (direction == SideArrowDirection.LEFT) {
|
||||||
|
path.moveTo(left + 15f * scale, top + 7f * scale)
|
||||||
|
path.lineTo(left + 10f * scale, top + 12f * scale)
|
||||||
|
path.lineTo(left + 15f * scale, top + 17f * scale)
|
||||||
|
} else {
|
||||||
|
path.moveTo(left + 9f * scale, top + 7f * scale)
|
||||||
|
path.lineTo(left + 14f * scale, top + 12f * scale)
|
||||||
|
path.lineTo(left + 9f * scale, top + 17f * scale)
|
||||||
|
}
|
||||||
|
paint.strokeWidth = SIDE_ARROW_STROKE_WIDTH * scale
|
||||||
|
canvas.drawPath(path, paint)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAlpha(alpha: Int) {
|
||||||
|
paint.alpha = alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||||
|
paint.colorFilter = colorFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIntrinsicWidth(): Int {
|
||||||
|
return iconSizePx
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIntrinsicHeight(): Int {
|
||||||
|
return iconSizePx
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Android framework")
|
||||||
|
override fun getOpacity(): Int {
|
||||||
|
return PixelFormat.TRANSLUCENT
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.view.KeyEvent
|
||||||
|
|
||||||
|
internal class SystemServerButtonOverrideController(
|
||||||
|
private val handlerProvider: () -> Handler,
|
||||||
|
private val performPressAction: (NavButtonAction) -> Unit,
|
||||||
|
private val performLongPressAction: (NavButtonId, NavButtonAction) -> Unit,
|
||||||
|
) {
|
||||||
|
private val longPressRunnables = mutableMapOf<Int, Runnable>()
|
||||||
|
private val longPressTriggered = mutableMapOf<Int, Boolean>()
|
||||||
|
|
||||||
|
fun handle(keyEvent: KeyEvent, buttonId: NavButtonId, config: NavButtonConfig): Boolean {
|
||||||
|
val pressAction = config.pressAction
|
||||||
|
val longPressAction = config.longPressAction
|
||||||
|
val keyCode = keyEvent.keyCode
|
||||||
|
|
||||||
|
return when (keyEvent.action) {
|
||||||
|
KeyEvent.ACTION_DOWN -> {
|
||||||
|
if (longPressAction != NavButtonAction.STOCK && keyEvent.repeatCount == 0) {
|
||||||
|
schedule(keyCode, buttonId, longPressAction, config.longPressDurationMs)
|
||||||
|
}
|
||||||
|
pressAction != NavButtonAction.STOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEvent.ACTION_UP -> {
|
||||||
|
val triggered = longPressTriggered[keyCode] == true
|
||||||
|
val elapsed = keyEvent.eventTime - keyEvent.downTime >= config.longPressDurationMs
|
||||||
|
cancel(keyCode)
|
||||||
|
if (triggered || (longPressAction != NavButtonAction.STOCK && elapsed)) {
|
||||||
|
if (!triggered) {
|
||||||
|
performLongPressAction(buttonId, longPressAction)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
if (pressAction != NavButtonAction.STOCK && !keyEvent.isCanceled) {
|
||||||
|
performPressAction(pressAction)
|
||||||
|
}
|
||||||
|
pressAction != NavButtonAction.STOCK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> pressAction != NavButtonAction.STOCK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun schedule(
|
||||||
|
keyCode: Int,
|
||||||
|
buttonId: NavButtonId,
|
||||||
|
action: NavButtonAction,
|
||||||
|
durationMs: Int,
|
||||||
|
) {
|
||||||
|
if (longPressRunnables[keyCode] != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val runnable = Runnable {
|
||||||
|
longPressRunnables.remove(keyCode)
|
||||||
|
longPressTriggered[keyCode] = true
|
||||||
|
performLongPressAction(buttonId, action)
|
||||||
|
}
|
||||||
|
longPressTriggered[keyCode] = false
|
||||||
|
longPressRunnables[keyCode] = runnable
|
||||||
|
handlerProvider().postDelayed(runnable, durationMs.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancel(keyCode: Int) {
|
||||||
|
longPressRunnables.remove(keyCode)?.let { runnable ->
|
||||||
|
handlerProvider().removeCallbacks(runnable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
internal class SystemServerHooks(
|
||||||
|
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||||
|
private val handlerProvider: () -> android.os.Handler,
|
||||||
|
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||||
|
private val updateContextFromField: (Any?, String) -> Unit,
|
||||||
|
private val registerSettingsReceiver: () -> Unit,
|
||||||
|
private val registerPrivilegedReceiver: () -> Unit,
|
||||||
|
private val performAction: (NavButtonAction) -> Unit,
|
||||||
|
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
|
||||||
|
private val contextUpdater: (Context?) -> Unit,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val buttons = SystemServerButtonOverrideController(
|
||||||
|
handlerProvider = handlerProvider,
|
||||||
|
performPressAction = performAction,
|
||||||
|
performLongPressAction = { buttonId, action ->
|
||||||
|
if (buttonId == NavButtonId.HOME) {
|
||||||
|
performHomeLongPressAction(action)
|
||||||
|
} else {
|
||||||
|
performAction(action)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fun install(classLoader: ClassLoader?): Boolean {
|
||||||
|
val phoneWindowManagerClass = findClassIfExists(SYSTEM_SERVER_PHONE_WINDOW_MANAGER, classLoader)
|
||||||
|
?: run {
|
||||||
|
log("PhoneWindowManager class not found: $SYSTEM_SERVER_PHONE_WINDOW_MANAGER")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var installed = false
|
||||||
|
|
||||||
|
val longPressOnHomeWithEvent = findMethodIfExists(
|
||||||
|
phoneWindowManagerClass,
|
||||||
|
"handleLongPressOnHome",
|
||||||
|
KeyEvent::class.java,
|
||||||
|
)
|
||||||
|
val longPressOnHomeLegacy = findMethodIfExists(
|
||||||
|
phoneWindowManagerClass,
|
||||||
|
"handleLongPressOnHome",
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
Long::class.javaPrimitiveType!!,
|
||||||
|
)
|
||||||
|
val launchAssistActionMethod = findMethodIfExists(
|
||||||
|
phoneWindowManagerClass,
|
||||||
|
"launchAssistAction",
|
||||||
|
String::class.java,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
Long::class.javaPrimitiveType!!,
|
||||||
|
Int::class.javaPrimitiveType!!,
|
||||||
|
)
|
||||||
|
val initMethods = findMethodsNamed(phoneWindowManagerClass, "init")
|
||||||
|
.filter { method -> method.parameterTypes.firstOrNull() == Context::class.java }
|
||||||
|
val keyDispatchMethods = findMethodsNamed(phoneWindowManagerClass, "interceptKeyBeforeDispatching")
|
||||||
|
.filter { method ->
|
||||||
|
method.returnType == Long::class.javaPrimitiveType &&
|
||||||
|
method.parameterTypes.any { it == KeyEvent::class.java }
|
||||||
|
}
|
||||||
|
val keyQueueingMethods = findMethodsNamed(phoneWindowManagerClass, "interceptKeyBeforeQueueing")
|
||||||
|
.filter { method ->
|
||||||
|
method.returnType == Int::class.javaPrimitiveType &&
|
||||||
|
method.parameterTypes.any { it == KeyEvent::class.java }
|
||||||
|
}
|
||||||
|
|
||||||
|
initMethods.forEach { method ->
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val result = chain.proceed()
|
||||||
|
contextUpdater(chain.args.firstOrNull() as? Context)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
registerPrivilegedReceiver()
|
||||||
|
result
|
||||||
|
}
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
keyDispatchMethods.forEach { method ->
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@hookMethod chain.proceed()
|
||||||
|
val config = configProvider(buttonId)
|
||||||
|
if (config.pressAction == NavButtonAction.STOCK &&
|
||||||
|
config.longPressAction == NavButtonAction.STOCK
|
||||||
|
) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromField(chain.thisObject, "mContext")
|
||||||
|
if (buttons.handle(keyEvent, buttonId, config)) {
|
||||||
|
return@hookMethod -1L
|
||||||
|
}
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
keyQueueingMethods.forEach { method ->
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@hookMethod chain.proceed()
|
||||||
|
val config = configProvider(buttonId)
|
||||||
|
if (config.pressAction == NavButtonAction.STOCK &&
|
||||||
|
config.longPressAction == NavButtonAction.STOCK
|
||||||
|
) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromField(chain.thisObject, "mContext")
|
||||||
|
val consume = buttons.handle(keyEvent, buttonId, config)
|
||||||
|
val result = chain.proceed() as? Int ?: 0
|
||||||
|
if (consume) result and ACTION_PASS_TO_USER.inv() else result
|
||||||
|
}
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
listOfNotNull(longPressOnHomeWithEvent, longPressOnHomeLegacy).forEach { method ->
|
||||||
|
hookMethod(method) { chain ->
|
||||||
|
val longPressAction = configProvider(NavButtonId.HOME).longPressAction
|
||||||
|
if (longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
updateContextFromField(chain.thisObject, "mContext")
|
||||||
|
performHomeLongPressAction(longPressAction)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launchAssistActionMethod != null) {
|
||||||
|
hookMethod(launchAssistActionMethod) { chain ->
|
||||||
|
val invocationType = chain.args[3] as? Int ?: ASSIST_INVOCATION_TYPE_UNKNOWN
|
||||||
|
if (invocationType != ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
val longPressAction = configProvider(NavButtonId.HOME).longPressAction
|
||||||
|
if (longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromField(chain.thisObject, "mContext")
|
||||||
|
performHomeLongPressAction(longPressAction)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
installed = true
|
||||||
|
}
|
||||||
|
return installed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
|
import android.view.View
|
||||||
|
import java.util.WeakHashMap
|
||||||
|
|
||||||
|
internal class SystemUiLongPressController(
|
||||||
|
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||||
|
private val updateContext: (Any) -> Unit,
|
||||||
|
private val cancelKeyPress: (Any) -> Unit,
|
||||||
|
private val markLongClicked: (Any) -> Unit,
|
||||||
|
private val performAction: (NavButtonAction) -> Unit,
|
||||||
|
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
|
||||||
|
) {
|
||||||
|
private val triggered = WeakHashMap<Any, Boolean>()
|
||||||
|
private val runnables = WeakHashMap<Any, Runnable>()
|
||||||
|
|
||||||
|
fun reset(target: Any) {
|
||||||
|
triggered[target] = false
|
||||||
|
cancel(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun schedule(target: Any, buttonId: NavButtonId) {
|
||||||
|
val view = target as? View ?: return
|
||||||
|
val config = configProvider(buttonId)
|
||||||
|
if (config.longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
(getFieldValue(target, "mCheckLongPress") as? Runnable)?.let(view::removeCallbacks)
|
||||||
|
|
||||||
|
val runnable = Runnable {
|
||||||
|
updateContext(target)
|
||||||
|
triggered[target] = true
|
||||||
|
cancelKeyPress(target)
|
||||||
|
markLongClicked(target)
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
if (buttonId == NavButtonId.HOME) {
|
||||||
|
performHomeLongPressAction(config.longPressAction)
|
||||||
|
} else {
|
||||||
|
performAction(config.longPressAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runnables[target] = runnable
|
||||||
|
view.postDelayed(runnable, config.longPressDurationMs.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isTriggered(target: Any): Boolean {
|
||||||
|
return triggered[target] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markTriggered(target: Any) {
|
||||||
|
triggered[target] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(target: Any) {
|
||||||
|
cancel(target)
|
||||||
|
triggered.remove(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancel(target: Any) {
|
||||||
|
val view = target as? View ?: return
|
||||||
|
runnables.remove(target)?.let(view::removeCallbacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package se.ajpanton.navbuttons
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import io.github.libxposed.api.XposedInterface
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
|
||||||
|
internal class SystemUiNavHooks(
|
||||||
|
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||||
|
private val deoptimizeMethod: (Method?) -> Unit,
|
||||||
|
private val prepareLongPressHook: (Method) -> Unit,
|
||||||
|
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||||
|
private val sideArrowLongPresses: SideArrowLongPressController,
|
||||||
|
private val registerSettingsReceiver: () -> Unit,
|
||||||
|
private val contextUpdater: (Context) -> Unit,
|
||||||
|
private val performAction: (NavButtonAction) -> Unit,
|
||||||
|
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
|
||||||
|
private val installSamsungSideArrows: (ClassLoader?) -> Unit,
|
||||||
|
private val trackSamsungNavigationBarView: (Any) -> Unit,
|
||||||
|
private val installAospSideArrows: (ClassLoader?) -> Unit,
|
||||||
|
private val configureAospSideArrowButton: (Any?) -> Unit,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
private val logError: (String, Throwable) -> Unit,
|
||||||
|
) {
|
||||||
|
private val longPresses = SystemUiLongPressController(
|
||||||
|
configProvider = configProvider,
|
||||||
|
updateContext = ::updateContextFromView,
|
||||||
|
cancelKeyPress = ::cancelKeyPress,
|
||||||
|
markLongClicked = ::markLongClicked,
|
||||||
|
performAction = performAction,
|
||||||
|
performHomeLongPressAction = performHomeLongPressAction,
|
||||||
|
)
|
||||||
|
private val homeStockLongPressGuard = HomeStockLongPressGuard(
|
||||||
|
configProvider = configProvider,
|
||||||
|
longPressTriggered = longPresses::isTriggered,
|
||||||
|
sendCanceledKeyUp = ::sendCanceledKeyUp,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun install(classLoader: ClassLoader?): Boolean {
|
||||||
|
val keyButtonClass = findFirstClassIfExists(SYSTEMUI_KEY_BUTTON_VIEW_CLASSES, classLoader) ?: run {
|
||||||
|
log("SystemUI key button class not found: ${SYSTEMUI_KEY_BUTTON_VIEW_CLASSES.joinToString()}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val onTouchEventMethod = findMethodIfExists(
|
||||||
|
keyButtonClass,
|
||||||
|
"onTouchEvent",
|
||||||
|
MotionEvent::class.java,
|
||||||
|
) ?: run {
|
||||||
|
log("SystemUI onTouchEvent() method not found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val runnableClass = findClassIfExists(
|
||||||
|
"${keyButtonClass.name}\$1",
|
||||||
|
classLoader,
|
||||||
|
) ?: run {
|
||||||
|
log("SystemUI long-press runnable not found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val runMethod = findMethodIfExists(runnableClass, "run") ?: run {
|
||||||
|
log("SystemUI run() method not found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val sendEventMethods = findMethodsNamed(keyButtonClass, "sendEvent").filter { method ->
|
||||||
|
val parameterTypes = method.parameterTypes
|
||||||
|
parameterTypes.isNotEmpty() && parameterTypes[0] == Int::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
|
||||||
|
deoptimizeMethod(onTouchEventMethod)
|
||||||
|
prepareLongPressHook(runMethod)
|
||||||
|
sendEventMethods.forEach(deoptimizeMethod)
|
||||||
|
sendEventMethods.forEach(::hookSendEvent)
|
||||||
|
installSamsungSideArrows(classLoader)
|
||||||
|
installAospSideArrows(classLoader)
|
||||||
|
|
||||||
|
hookMethod(onTouchEventMethod) { chain ->
|
||||||
|
val keyButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
|
||||||
|
val motionEvent = chain.args[0] as? MotionEvent ?: return@hookMethod chain.proceed()
|
||||||
|
val keyCode = getIntField(keyButtonView, "mCode")
|
||||||
|
if (isSideArrowKeyCode(keyCode)) {
|
||||||
|
updateContextFromView(keyButtonView)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
trackSamsungNavigationBarView(keyButtonView)
|
||||||
|
configureAospSideArrowButton(keyButtonView)
|
||||||
|
return@hookMethod handleNativeSideArrowTouch(keyButtonView, keyCode, motionEvent) {
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||||
|
|
||||||
|
when (motionEvent.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
updateContextFromView(keyButtonView)
|
||||||
|
registerSettingsReceiver()
|
||||||
|
trackSamsungNavigationBarView(keyButtonView)
|
||||||
|
longPresses.reset(keyButtonView)
|
||||||
|
homeStockLongPressGuard.cancel(keyButtonView)
|
||||||
|
homeStockLongPressGuard.clear(keyButtonView)
|
||||||
|
val result = chain.proceed()
|
||||||
|
longPresses.schedule(keyButtonView, buttonId)
|
||||||
|
homeStockLongPressGuard.schedule(keyButtonView, buttonId)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (longPresses.isTriggered(keyButtonView)) {
|
||||||
|
return@hookMethod proceedWithCanceledAction(motionEvent) {
|
||||||
|
chain.proceed()
|
||||||
|
}.also {
|
||||||
|
homeStockLongPressGuard.cancel(keyButtonView)
|
||||||
|
longPresses.clear(keyButtonView)
|
||||||
|
homeStockLongPressGuard.clear(keyButtonView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonConfig = configProvider(buttonId)
|
||||||
|
val pressAction = buttonConfig.pressAction
|
||||||
|
val stockHomePressCanceled =
|
||||||
|
buttonId == NavButtonId.HOME && homeStockLongPressGuard.isCanceled(keyButtonView)
|
||||||
|
val shouldOverridePress =
|
||||||
|
pressAction != NavButtonAction.STOCK &&
|
||||||
|
((keyButtonView as? View)?.isPressed == true || stockHomePressCanceled) &&
|
||||||
|
getFieldValue(keyButtonView, "mLongClicked") != true
|
||||||
|
|
||||||
|
if (shouldOverridePress) {
|
||||||
|
markLongClicked(keyButtonView)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = chain.proceed()
|
||||||
|
if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) {
|
||||||
|
dispatchHomeTap(keyButtonView)
|
||||||
|
}
|
||||||
|
if (shouldOverridePress) {
|
||||||
|
updateContextFromView(keyButtonView)
|
||||||
|
performAction(pressAction)
|
||||||
|
}
|
||||||
|
homeStockLongPressGuard.cancel(keyButtonView)
|
||||||
|
longPresses.clear(keyButtonView)
|
||||||
|
homeStockLongPressGuard.clear(keyButtonView)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
homeStockLongPressGuard.cancel(keyButtonView)
|
||||||
|
longPresses.clear(keyButtonView)
|
||||||
|
homeStockLongPressGuard.clear(keyButtonView)
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hookMethod(runMethod) { chain ->
|
||||||
|
val keyButtonView = getFieldValue(chain.thisObject, "this\$0")
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
val keyCode = getIntField(keyButtonView, "mCode")
|
||||||
|
if (isSideArrowKeyCode(keyCode)) {
|
||||||
|
return@hookMethod null
|
||||||
|
}
|
||||||
|
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||||
|
val longPressAction = configProvider(buttonId).longPressAction
|
||||||
|
if (longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContextFromView(keyButtonView)
|
||||||
|
longPresses.markTriggered(keyButtonView)
|
||||||
|
cancelKeyPress(keyButtonView)
|
||||||
|
markLongClicked(keyButtonView)
|
||||||
|
if (buttonId == NavButtonId.HOME) {
|
||||||
|
performHomeLongPressAction(longPressAction)
|
||||||
|
} else {
|
||||||
|
performAction(longPressAction)
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hookSendEvent(sendEventMethod: Method) {
|
||||||
|
hookMethod(sendEventMethod) { chain ->
|
||||||
|
val keyButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
|
||||||
|
val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode"))
|
||||||
|
?: return@hookMethod chain.proceed()
|
||||||
|
if (buttonId != NavButtonId.HOME) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
val action = chain.args.firstOrNull() as? Int ?: return@hookMethod chain.proceed()
|
||||||
|
val longPressAction = configProvider(buttonId).longPressAction
|
||||||
|
if (longPressAction == NavButtonAction.STOCK) {
|
||||||
|
return@hookMethod chain.proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == KeyEvent.ACTION_DOWN && sendEventFlags(chain.args).hasFlag(KeyEvent.FLAG_LONG_PRESS)) {
|
||||||
|
return@hookMethod null
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNativeSideArrowTouch(
|
||||||
|
view: Any,
|
||||||
|
keyCode: Int,
|
||||||
|
event: MotionEvent,
|
||||||
|
proceed: () -> Any?,
|
||||||
|
): Any? {
|
||||||
|
return when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
sideArrowLongPresses.startAfter(view, keyCode, proceed)
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP,
|
||||||
|
MotionEvent.ACTION_CANCEL,
|
||||||
|
-> {
|
||||||
|
sideArrowLongPresses.finish(view)
|
||||||
|
proceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> proceed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markLongClicked(keyButtonView: Any) {
|
||||||
|
try {
|
||||||
|
findField(keyButtonView.javaClass, "mLongClicked").setBoolean(keyButtonView, true)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
logError("Failed to set mLongClicked", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContextFromView(value: Any) {
|
||||||
|
val viewContext = (value as? View)?.context ?: return
|
||||||
|
contextUpdater(viewContext.applicationContext ?: viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelKeyPress(keyButtonView: Any) {
|
||||||
|
if (buttonIdForKeyCode(getIntField(keyButtonView, "mCode")) == NavButtonId.HOME &&
|
||||||
|
homeStockLongPressGuard.isCanceled(keyButtonView)
|
||||||
|
) {
|
||||||
|
abortKeyGesture(keyButtonView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
abortKeyGesture(keyButtonView)
|
||||||
|
sendCanceledKeyUp(keyButtonView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abortKeyGesture(keyButtonView: Any) {
|
||||||
|
try {
|
||||||
|
callMethod(keyButtonView, "abortCurrentGesture")
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Older/newer SystemUI variants may not expose this helper.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendCanceledKeyUp(keyButtonView: Any) {
|
||||||
|
try {
|
||||||
|
callMethod(
|
||||||
|
keyButtonView,
|
||||||
|
"sendEvent",
|
||||||
|
arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
|
||||||
|
KeyEvent.ACTION_UP,
|
||||||
|
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("sendEvent cancel failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchHomeTap(keyButtonView: Any) {
|
||||||
|
try {
|
||||||
|
callMethod(
|
||||||
|
keyButtonView,
|
||||||
|
"sendEvent",
|
||||||
|
arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
|
||||||
|
KeyEvent.ACTION_DOWN,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
callMethod(
|
||||||
|
keyButtonView,
|
||||||
|
"sendEvent",
|
||||||
|
arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
|
||||||
|
KeyEvent.ACTION_UP,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
log("Guarded HOME tap failed: ${error.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun proceedWithCanceledAction(
|
||||||
|
motionEvent: MotionEvent,
|
||||||
|
proceed: () -> Any?,
|
||||||
|
): Any? {
|
||||||
|
val originalAction = motionEvent.action
|
||||||
|
motionEvent.action = MotionEvent.ACTION_CANCEL
|
||||||
|
return try {
|
||||||
|
proceed()
|
||||||
|
} finally {
|
||||||
|
motionEvent.action = originalAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendEventFlags(args: List<*>): Int {
|
||||||
|
return args.getOrNull(1) as? Int ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.hasFlag(flag: Int): Boolean {
|
||||||
|
return this and flag == flag
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user