diff --git a/.gitignore b/.gitignore
index a7e04a2..ce988d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.gradle/
+.kotlin/
build/
app/build/
local.properties
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b41ee72..642a8ef 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -56,8 +56,8 @@ android {
applicationId = "se.ajpanton.navbuttons"
minSdk = 35
targetSdk = 35
- versionCode = 1
- versionName = "0.1"
+ versionCode = 1777035207
+ versionName = "0.1-1777035207"
}
buildTypes {
@@ -84,6 +84,7 @@ android {
dependencies {
implementation("androidx.annotation:annotation:1.7.1")
+ implementation("io.github.libxposed:service:101.0.0")
implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1")
compileOnly("io.github.libxposed:api:101.0.0")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b69ad59..cb1a8fe 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,12 @@
android:supportsRtl="true"
android:theme="@style/Theme.NavButtons">
+
+
Unit) {
+ val updatedAtMs = System.currentTimeMillis()
+ writeToPreferences(localPreferences, updatedAtMs, write)
+ val currentRemotePreferences = remotePreferences ?: return
+ try {
+ writeToPreferences(currentRemotePreferences, updatedAtMs, write)
+ } catch (error: Throwable) {
+ remotePreferences = null
+ Log.w(TAG, "Remote preferences write failed", error)
+ }
+ }
+
+ private fun writeToPreferences(
+ preferences: SharedPreferences,
+ updatedAtMs: Long,
+ write: SharedPreferences.Editor.() -> Unit,
+ ) {
+ val editor = preferences.edit()
+ editor.write()
+ editor.putLong(KEY_LAST_UPDATED_AT_MS, updatedAtMs)
+ editor.apply()
+ }
+
companion object {
+ private const val TAG = "NavButtons"
const val PREFERENCES_NAME = "nav_button_settings"
const val KEY_PRESS_ACTION = "press_action"
const val KEY_LONG_PRESS_ACTION = "long_press_action"
const val KEY_LONG_PRESS_DURATION_MS = "long_press_duration_ms"
+ private const val KEY_LAST_UPDATED_AT_MS = "last_updated_at_ms"
const val MIN_LONG_PRESS_DURATION_MS = 100
const val MAX_LONG_PRESS_DURATION_MS = 10_000
@@ -105,8 +143,110 @@ class NavButtonSettingsStore(context: Context) {
fun longPressDurationKey(buttonId: NavButtonId): String =
key(buttonId, KEY_LONG_PRESS_DURATION_MS)
+ @Volatile
+ private var remotePreferences: SharedPreferences? = null
+
+ @Volatile
+ private var remoteListenerRegistered = false
+
+ private val actionKeys by lazy(LazyThreadSafetyMode.NONE) {
+ NavButtonId.entries.flatMap { buttonId ->
+ listOf(
+ pressActionKey(buttonId),
+ longPressActionKey(buttonId),
+ )
+ }
+ }
+
+ private val durationKeys by lazy(LazyThreadSafetyMode.NONE) {
+ NavButtonId.entries.map(::longPressDurationKey)
+ }
+
private fun key(buttonId: NavButtonId, suffix: String): String {
return "${buttonId.preferencePrefix}_$suffix"
}
+
+ private fun registerRemotePreferencesListener(localPreferences: SharedPreferences) {
+ if (remoteListenerRegistered) {
+ return
+ }
+ synchronized(this) {
+ if (remoteListenerRegistered) {
+ return
+ }
+ remoteListenerRegistered = true
+ }
+
+ try {
+ XposedServiceHelper.registerListener(object : XposedServiceHelper.OnServiceListener {
+ override fun onServiceBind(service: XposedService) {
+ val boundRemotePreferences = service.getRemotePreferences(PREFERENCES_NAME)
+ syncPreferences(localPreferences, boundRemotePreferences)
+ remotePreferences = boundRemotePreferences
+ }
+
+ override fun onServiceDied(service: XposedService) {
+ remotePreferences = null
+ }
+ })
+ } catch (error: Throwable) {
+ remoteListenerRegistered = false
+ Log.w(TAG, "Remote preferences listener registration failed", error)
+ }
+ }
+
+ private fun syncPreferences(
+ localPreferences: SharedPreferences,
+ remotePreferences: SharedPreferences,
+ ) {
+ val localUpdatedAtMs = localPreferences.getLong(KEY_LAST_UPDATED_AT_MS, 0L)
+ val remoteUpdatedAtMs = remotePreferences.getLong(KEY_LAST_UPDATED_AT_MS, 0L)
+ val localHasConfig = hasStoredConfig(localPreferences)
+ val remoteHasConfig = hasStoredConfig(remotePreferences)
+
+ when {
+ localUpdatedAtMs > remoteUpdatedAtMs -> copyPreferences(localPreferences, remotePreferences)
+ remoteUpdatedAtMs > localUpdatedAtMs -> copyPreferences(remotePreferences, localPreferences)
+ localHasConfig && !remoteHasConfig -> copyPreferences(localPreferences, remotePreferences)
+ remoteHasConfig && !localHasConfig -> copyPreferences(remotePreferences, localPreferences)
+ localHasConfig && remoteHasConfig -> copyPreferences(localPreferences, remotePreferences)
+ }
+ }
+
+ private fun hasStoredConfig(preferences: SharedPreferences): Boolean {
+ return actionKeys.any(preferences::contains) || durationKeys.any(preferences::contains)
+ }
+
+ private fun copyPreferences(source: SharedPreferences, target: SharedPreferences) {
+ val editor = target.edit()
+
+ for (key in actionKeys) {
+ if (source.contains(key)) {
+ editor.putString(key, source.getString(key, null))
+ } else {
+ editor.remove(key)
+ }
+ }
+
+ for (key in durationKeys) {
+ if (source.contains(key)) {
+ editor.putInt(key, source.getInt(key, sanitizeDuration(ViewConfiguration.getLongPressTimeout())))
+ } else {
+ editor.remove(key)
+ }
+ }
+
+ if (source.contains(KEY_LAST_UPDATED_AT_MS)) {
+ editor.putLong(KEY_LAST_UPDATED_AT_MS, source.getLong(KEY_LAST_UPDATED_AT_MS, 0L))
+ } else {
+ editor.remove(KEY_LAST_UPDATED_AT_MS)
+ }
+
+ editor.apply()
+ }
+
+ private fun sanitizeDuration(durationMs: Int): Int {
+ return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS)
+ }
}
}
diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt
index 5f8bcb4..8859d01 100644
--- a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt
+++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt
@@ -4,26 +4,47 @@ import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
+import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.os.Process
+import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
+import android.view.MotionEvent
import android.view.View
+import android.view.ViewConfiguration
import android.widget.Toast
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface
import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.lang.reflect.Field
import java.lang.reflect.Method
+import java.util.WeakHashMap
class NavButtons : XposedModule() {
+ private val remotePreferences: SharedPreferences? by lazy(LazyThreadSafetyMode.NONE) {
+ runCatching {
+ getRemotePreferences(NavButtonSettingsStore.PREFERENCES_NAME)
+ }.getOrElse { error ->
+ log("Remote preferences unavailable: ${error.javaClass.simpleName}")
+ null
+ }
+ }
private var context: Context? = null
private var mainHandler: Handler? = null
private var systemUiHookInstalled = false
+ private var systemServerHookInstalled = false
private var launcherHookInstalled = false
+ private var lastHomeLongPressHandledAtMs = 0L
+ private val systemUiLongPressTriggered = WeakHashMap()
+ private val systemUiHomeStockLongPressGuards = WeakHashMap()
+ private val systemUiHomeStockPressCanceled = WeakHashMap()
+ private val honeyspaceLongPressTriggered = WeakHashMap()
override fun onModuleLoaded(param: XposedModuleInterface.ModuleLoadedParam) {
if (!HiddenApiBypass.addHiddenApiExemptions("")) {
@@ -39,13 +60,23 @@ class NavButtons : XposedModule() {
handleTargetPackage(param.packageName, param.classLoader)
}
+ override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) {
+ if (systemServerHookInstalled) {
+ return
+ }
+ initContextAndHandler()
+ systemServerHookInstalled = hookSystemServerHomeLongPress(param.classLoader)
+ trace("System server hooks installed=$systemServerHookInstalled")
+ }
+
private fun handleTargetPackage(packageName: String, classLoader: ClassLoader?) {
if (SYSTEMUI_PACKAGE == packageName) {
if (systemUiHookInstalled) {
return
}
initContextAndHandler()
- systemUiHookInstalled = hookSystemUiBackLongPress(classLoader)
+ systemUiHookInstalled = hookSystemUiNavigationButtons(classLoader)
+ trace("SystemUI hooks installed=$systemUiHookInstalled")
return
}
@@ -55,6 +86,7 @@ class NavButtons : XposedModule() {
}
initContextAndHandler()
launcherHookInstalled = hookHoneyspaceTaskbar(classLoader)
+ trace("Honeyspace hooks installed=$launcherHookInstalled")
}
}
@@ -77,9 +109,22 @@ class NavButtons : XposedModule() {
}
}
- private fun hookSystemUiBackLongPress(classLoader: ClassLoader?): Boolean {
+ private fun hookSystemUiNavigationButtons(classLoader: ClassLoader?): Boolean {
+ val keyButtonClass = findClassIfExists(SYSTEMUI_KEY_BUTTON_VIEW, classLoader) ?: run {
+ log("SystemUI key button class not found: $SYSTEMUI_KEY_BUTTON_VIEW")
+ return false
+ }
+ val onTouchEventMethod = findMethodIfExists(
+ keyButtonClass,
+ "onTouchEvent",
+ MotionEvent::class.java,
+ ) ?: run {
+ log("SystemUI onTouchEvent() method not found.")
+ return false
+ }
+
val runnableClass = findClassIfExists(
- "com.android.systemui.navigationbar.views.buttons.KeyButtonView\$1",
+ "$SYSTEMUI_KEY_BUTTON_VIEW\$1",
classLoader,
) ?: run {
log("SystemUI long-press runnable not found.")
@@ -90,31 +135,358 @@ class NavButtons : XposedModule() {
log("SystemUI run() method not found.")
return false
}
+ val sendEventMethods = findMethodsNamed(keyButtonClass, "sendEvent").filter { method ->
+ val parameterTypes = method.parameterTypes
+ parameterTypes.isNotEmpty() && parameterTypes[0] == Int::class.javaPrimitiveType
+ }
+ deoptimizeMethodIfPresent(onTouchEventMethod)
prepareLongPressHook(runMethod)
+ sendEventMethods.forEach(::deoptimizeMethodIfPresent)
+ sendEventMethods.forEach(::hookSystemUiSendEvent)
+
+ hook(onTouchEventMethod).intercept { chain ->
+ val keyButtonView = chain.thisObject ?: return@intercept chain.proceed()
+ val motionEvent = chain.args[0] as? MotionEvent ?: return@intercept chain.proceed()
+ val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode"))
+ ?: return@intercept chain.proceed()
+
+ when (motionEvent.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ systemUiLongPressTriggered[keyButtonView] = false
+ cancelSystemUiHomeStockLongPressGuard(keyButtonView)
+ systemUiHomeStockPressCanceled.remove(keyButtonView)
+ if (buttonId == NavButtonId.HOME) {
+ val config = getButtonConfig(buttonId)
+ traceHome(
+ "KeyButtonView.onTouchEvent DOWN",
+ "press=${config.pressAction.storageValue} long=${config.longPressAction.storageValue} duration=${config.longPressDurationMs}",
+ )
+ }
+ val result = chain.proceed()
+ rescheduleSystemUiLongPress(keyButtonView, buttonId)
+ scheduleSystemUiHomeStockLongPressGuard(keyButtonView, buttonId)
+ result
+ }
+
+ MotionEvent.ACTION_UP -> {
+ if (systemUiLongPressTriggered[keyButtonView] == true) {
+ if (buttonId == NavButtonId.HOME) {
+ traceHome("KeyButtonView.onTouchEvent UP", "converted to CANCEL after long press")
+ }
+ return@intercept proceedWithCanceledAction(motionEvent) {
+ chain.proceed()
+ }.also {
+ cancelSystemUiHomeStockLongPressGuard(keyButtonView)
+ systemUiLongPressTriggered.remove(keyButtonView)
+ systemUiHomeStockPressCanceled.remove(keyButtonView)
+ }
+ }
+
+ val buttonConfig = getButtonConfig(buttonId)
+ val pressAction = buttonConfig.pressAction
+ val stockHomePressCanceled =
+ buttonId == NavButtonId.HOME && systemUiHomeStockPressCanceled[keyButtonView] == true
+ val shouldOverridePress =
+ pressAction != NavButtonAction.STOCK &&
+ ((keyButtonView as? View)?.isPressed == true || stockHomePressCanceled) &&
+ getFieldValue(keyButtonView, "mLongClicked") != true
+
+ if (shouldOverridePress) {
+ markSystemUiLongClicked(keyButtonView)
+ }
+
+ val result = chain.proceed()
+ if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) {
+ dispatchSystemUiHomeTap(keyButtonView)
+ }
+ if (shouldOverridePress) {
+ updateContextFromView(keyButtonView)
+ performConfiguredAction(pressAction)
+ }
+ if (buttonId == NavButtonId.HOME) {
+ traceHome(
+ "KeyButtonView.onTouchEvent UP",
+ "override=$shouldOverridePress ${systemUiHomeState(keyButtonView)}",
+ )
+ }
+ cancelSystemUiHomeStockLongPressGuard(keyButtonView)
+ systemUiHomeStockPressCanceled.remove(keyButtonView)
+ result
+ }
+
+ MotionEvent.ACTION_CANCEL -> {
+ if (buttonId == NavButtonId.HOME) {
+ traceHome("KeyButtonView.onTouchEvent CANCEL", "clearing long-press state")
+ }
+ cancelSystemUiHomeStockLongPressGuard(keyButtonView)
+ systemUiLongPressTriggered.remove(keyButtonView)
+ systemUiHomeStockPressCanceled.remove(keyButtonView)
+ chain.proceed()
+ }
+
+ else -> chain.proceed()
+ }
+ }
hook(runMethod).intercept { chain ->
val keyButtonView = getFieldValue(chain.thisObject, "this\$0")
?: return@intercept chain.proceed()
- val code = getIntField(keyButtonView, "mCode")
- if (code != KeyEvent.KEYCODE_BACK) {
+ val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode"))
+ ?: return@intercept chain.proceed()
+ val longPressAction = getButtonConfig(buttonId).longPressAction
+ if (longPressAction == NavButtonAction.STOCK) {
return@intercept chain.proceed()
}
+ if (buttonId == NavButtonId.HOME) {
+ traceHome("KeyButtonView\$1.run", "long=${longPressAction.storageValue}")
+ }
updateContextFromView(keyButtonView)
+ systemUiLongPressTriggered[keyButtonView] = true
+ cancelSystemUiKeyPress(keyButtonView)
markSystemUiLongClicked(keyButtonView)
- killForegroundApp()
+ if (buttonId == NavButtonId.HOME) {
+ performHomeLongPressAction(longPressAction)
+ } else {
+ performConfiguredAction(longPressAction)
+ }
null
}
return true
}
+ private fun hookSystemUiSendEvent(sendEventMethod: Method) {
+ hook(sendEventMethod).intercept { chain ->
+ val keyButtonView = chain.thisObject ?: return@intercept chain.proceed()
+ val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode"))
+ ?: return@intercept chain.proceed()
+ if (buttonId != NavButtonId.HOME) {
+ return@intercept chain.proceed()
+ }
+
+ val action = chain.args.firstOrNull() as? Int ?: return@intercept chain.proceed()
+ val longPressAction = getButtonConfig(buttonId).longPressAction
+ if (longPressAction == NavButtonAction.STOCK) {
+ return@intercept chain.proceed()
+ }
+
+ if (action == KeyEvent.ACTION_DOWN && sendEventFlags(chain.args).hasFlag(KeyEvent.FLAG_LONG_PRESS)) {
+ traceHome(
+ "KeyButtonView.sendEvent",
+ "suppressed stock long-press action=${keyActionName(action)} " +
+ "args=${sendEventArgsToTraceString(chain.args)} ${systemUiHomeState(keyButtonView)}",
+ )
+ return@intercept null
+ }
+
+ chain.proceed()
+ }
+ }
+
+ private fun hookSystemServerHomeLongPress(classLoader: ClassLoader?): Boolean {
+ val phoneWindowManagerClass = findClassIfExists(SYSTEM_SERVER_PHONE_WINDOW_MANAGER, classLoader)
+ ?: run {
+ log("PhoneWindowManager class not found: $SYSTEM_SERVER_PHONE_WINDOW_MANAGER")
+ return false
+ }
+
+ var installed = false
+
+ val longPressOnHomeWithEvent = findMethodIfExists(
+ phoneWindowManagerClass,
+ "handleLongPressOnHome",
+ KeyEvent::class.java,
+ )
+ val longPressOnHomeLegacy = findMethodIfExists(
+ phoneWindowManagerClass,
+ "handleLongPressOnHome",
+ Int::class.javaPrimitiveType!!,
+ Long::class.javaPrimitiveType!!,
+ )
+ val launchAssistActionMethod = findMethodIfExists(
+ phoneWindowManagerClass,
+ "launchAssistAction",
+ String::class.java,
+ Int::class.javaPrimitiveType!!,
+ Long::class.javaPrimitiveType!!,
+ Int::class.javaPrimitiveType!!,
+ )
+
+ if (longPressOnHomeWithEvent != null) {
+ deoptimizeMethodIfPresent(longPressOnHomeWithEvent)
+ hook(longPressOnHomeWithEvent).intercept { chain ->
+ val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction
+ traceHome("PhoneWindowManager.handleLongPressOnHome(KeyEvent)", "long=${longPressAction.storageValue}")
+ if (longPressAction == NavButtonAction.STOCK) {
+ return@intercept chain.proceed()
+ }
+ updateContextFromField(chain.thisObject, "mContext")
+ performHomeLongPressAction(longPressAction)
+ null
+ }
+ installed = true
+ }
+
+ if (longPressOnHomeLegacy != null) {
+ deoptimizeMethodIfPresent(longPressOnHomeLegacy)
+ hook(longPressOnHomeLegacy).intercept { chain ->
+ val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction
+ traceHome("PhoneWindowManager.handleLongPressOnHome(int,long)", "long=${longPressAction.storageValue}")
+ if (longPressAction == NavButtonAction.STOCK) {
+ return@intercept chain.proceed()
+ }
+ updateContextFromField(chain.thisObject, "mContext")
+ performHomeLongPressAction(longPressAction)
+ null
+ }
+ installed = true
+ }
+
+ if (launchAssistActionMethod != null) {
+ deoptimizeMethodIfPresent(launchAssistActionMethod)
+ hook(launchAssistActionMethod).intercept { chain ->
+ val invocationType = chain.args[3] as? Int ?: ASSIST_INVOCATION_TYPE_UNKNOWN
+ traceHome("PhoneWindowManager.launchAssistAction", "invocationType=$invocationType")
+ if (invocationType != ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS) {
+ return@intercept chain.proceed()
+ }
+
+ val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction
+ if (longPressAction == NavButtonAction.STOCK) {
+ return@intercept chain.proceed()
+ }
+
+ traceHome("PhoneWindowManager.launchAssistAction", "blocked long=${longPressAction.storageValue}")
+ updateContextFromField(chain.thisObject, "mContext")
+ performHomeLongPressAction(longPressAction)
+ null
+ }
+ installed = true
+ }
+ return installed
+ }
+
+ private fun hookSystemUiHomeLongClickIfPresent(navigationBarClass: Class<*>?) {
+ if (navigationBarClass == null) {
+ log("SystemUI navigation bar class not found: $SYSTEMUI_NAVIGATION_BAR")
+ return
+ }
+
+ val onHomeLongClickMethod = findMethodIfExists(
+ navigationBarClass,
+ "onHomeLongClick",
+ View::class.java,
+ ) ?: run {
+ log("SystemUI onHomeLongClick() method not found.")
+ return
+ }
+
+ deoptimizeMethodIfPresent(onHomeLongClickMethod)
+ hook(onHomeLongClickMethod).intercept { chain ->
+ val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction
+ if (longPressAction == NavButtonAction.STOCK) {
+ return@intercept chain.proceed()
+ }
+
+ traceHome("NavigationBar.onHomeLongClick", "long=${longPressAction.storageValue}")
+ val homeView = chain.args[0] as? View
+ if (homeView != null) {
+ updateContextFromView(homeView)
+ systemUiLongPressTriggered[homeView] = true
+ markSystemUiLongClicked(homeView)
+ abortSystemUiNavigationGesture(chain.thisObject, homeView)
+ }
+ performHomeLongPressAction(longPressAction)
+ true
+ }
+ }
+
+ private fun hookSystemUiAssistStartIfPresent(assistManagerClass: Class<*>?) {
+ if (assistManagerClass == null) {
+ log("SystemUI assist manager class not found: $SYSTEMUI_ASSIST_MANAGER")
+ return
+ }
+
+ val startAssistMethod = findMethodIfExists(
+ assistManagerClass,
+ "startAssist",
+ Bundle::class.java,
+ ) ?: run {
+ log("SystemUI startAssist(Bundle) method not found.")
+ return
+ }
+
+ deoptimizeMethodIfPresent(startAssistMethod)
+ hook(startAssistMethod).intercept { chain ->
+ val args = chain.args[0] as? Bundle ?: return@intercept chain.proceed()
+ val invocationType = args.getInt(ASSIST_INVOCATION_TYPE_KEY, ASSIST_INVOCATION_TYPE_UNKNOWN)
+ traceHome("AssistManager.startAssist", "invocationType=$invocationType")
+ if (invocationType != ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS) {
+ return@intercept chain.proceed()
+ }
+
+ val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction
+ if (longPressAction == NavButtonAction.STOCK) {
+ return@intercept chain.proceed()
+ }
+
+ traceHome("AssistManager.startAssist", "blocked long=${longPressAction.storageValue}")
+ performHomeLongPressAction(longPressAction)
+ null
+ }
+ }
+
+ private fun hookSystemUiOverviewProxyStartAssistantIfPresent(overviewProxyServiceClass: Class<*>?) {
+ if (overviewProxyServiceClass == null) {
+ log("SystemUI overview proxy service class not found: $SYSTEMUI_OVERVIEW_PROXY_SERVICE")
+ return
+ }
+
+ val startAssistantMethod = findMethodIfExists(
+ overviewProxyServiceClass,
+ "startAssistant",
+ Bundle::class.java,
+ ) ?: run {
+ log("SystemUI OverviewProxyService.startAssistant(Bundle) method not found.")
+ return
+ }
+
+ deoptimizeMethodIfPresent(startAssistantMethod)
+ hook(startAssistantMethod).intercept { chain ->
+ val args = chain.args[0] as? Bundle ?: return@intercept chain.proceed()
+ val invocationType = args.getInt(ASSIST_INVOCATION_TYPE_KEY, ASSIST_INVOCATION_TYPE_UNKNOWN)
+ traceHome("OverviewProxyService.startAssistant", "invocationType=$invocationType")
+ if (invocationType != ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS) {
+ return@intercept chain.proceed()
+ }
+
+ val longPressAction = getButtonConfig(NavButtonId.HOME).longPressAction
+ if (longPressAction == NavButtonAction.STOCK) {
+ return@intercept chain.proceed()
+ }
+
+ traceHome("OverviewProxyService.startAssistant", "blocked long=${longPressAction.storageValue}")
+ performHomeLongPressAction(longPressAction)
+ null
+ }
+ }
+
private fun hookHoneyspaceTaskbar(classLoader: ClassLoader?): Boolean {
val navButtonClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_VIEW, classLoader) ?: run {
log("Launcher class not found: $HONEYSPACE_NAV_BUTTON_VIEW")
return false
}
+ val onTouchEventMethod = findMethodIfExists(
+ navButtonClass,
+ "onTouchEvent",
+ MotionEvent::class.java,
+ ) ?: run {
+ log("Launcher onTouchEvent() method not found.")
+ return false
+ }
+
val runnableClass = findClassIfExists(HONEYSPACE_LONG_PRESS_RUNNABLE, classLoader) ?: run {
log("Launcher long-press runnable not found: $HONEYSPACE_LONG_PRESS_RUNNABLE")
return false
@@ -125,8 +497,77 @@ class NavButtons : XposedModule() {
return false
}
+ deoptimizeMethodIfPresent(onTouchEventMethod)
prepareLongPressHook(runMethod)
+ hook(onTouchEventMethod).intercept { chain ->
+ val navButtonView = chain.thisObject ?: return@intercept chain.proceed()
+ val motionEvent = chain.args[0] as? MotionEvent ?: return@intercept chain.proceed()
+ val buttonId = buttonIdForKeyCode(callMethod(navButtonView, "getKeyCode") as? Int ?: Int.MIN_VALUE)
+ ?: return@intercept chain.proceed()
+
+ when (motionEvent.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ honeyspaceLongPressTriggered[navButtonView] = false
+ if (buttonId == NavButtonId.HOME) {
+ val config = getButtonConfig(buttonId)
+ traceHome(
+ "Honeyspace.onTouchEvent DOWN",
+ "press=${config.pressAction.storageValue} long=${config.longPressAction.storageValue} duration=${config.longPressDurationMs}",
+ )
+ }
+ val result = chain.proceed()
+ rescheduleHoneyspaceLongPress(navButtonView, runnableClass, buttonId)
+ result
+ }
+
+ MotionEvent.ACTION_UP -> {
+ val longPressTriggered = honeyspaceLongPressTriggered[navButtonView] == true
+ if (longPressTriggered) {
+ if (buttonId == NavButtonId.HOME) {
+ traceHome("Honeyspace.onTouchEvent UP", "converted to CANCEL after long press")
+ }
+ return@intercept proceedWithCanceledAction(motionEvent) {
+ chain.proceed()
+ }.also {
+ honeyspaceLongPressTriggered.remove(navButtonView)
+ }
+ }
+
+ val pressAction = getButtonConfig(buttonId).pressAction
+ val shouldOverridePress =
+ pressAction != NavButtonAction.STOCK &&
+ (navButtonView as? View)?.isPressed == true &&
+ !longPressTriggered
+
+ if (shouldOverridePress) {
+ markHoneyspaceLongClicked(navButtonView)
+ }
+
+ val result = chain.proceed()
+ if (shouldOverridePress) {
+ updateContextFromView(navButtonView)
+ performConfiguredAction(pressAction)
+ }
+ honeyspaceLongPressTriggered.remove(navButtonView)
+ if (buttonId == NavButtonId.HOME) {
+ traceHome("Honeyspace.onTouchEvent UP", "override=$shouldOverridePress")
+ }
+ result
+ }
+
+ MotionEvent.ACTION_CANCEL -> {
+ if (buttonId == NavButtonId.HOME) {
+ traceHome("Honeyspace.onTouchEvent CANCEL", "clearing long-press state")
+ }
+ honeyspaceLongPressTriggered.remove(navButtonView)
+ chain.proceed()
+ }
+
+ else -> chain.proceed()
+ }
+ }
+
hook(runMethod).intercept { chain ->
val caseId = getIntField(chain.thisObject, "b")
if (caseId != HONEYSPACE_LONG_PRESS_CASE) {
@@ -141,20 +582,30 @@ class NavButtons : XposedModule() {
val navButtonView = target ?: return@intercept chain.proceed()
val keyCode = callMethod(navButtonView, "getKeyCode") as Int
- if (keyCode != KeyEvent.KEYCODE_BACK) {
+ val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed()
+ val longPressAction = getButtonConfig(buttonId).longPressAction
+ honeyspaceLongPressTriggered[navButtonView] = true
+ if (longPressAction == NavButtonAction.STOCK) {
return@intercept chain.proceed()
}
+ if (buttonId == NavButtonId.HOME) {
+ traceHome("Honeyspace runnable", "long=${longPressAction.storageValue}")
+ }
updateContextFromView(navButtonView)
markHoneyspaceLongClicked(navButtonView)
- killForegroundApp()
+ performConfiguredAction(longPressAction)
null
}
return true
}
private fun markSystemUiLongClicked(keyButtonView: Any) {
- setBooleanField(keyButtonView, "mLongClicked", true)
+ try {
+ findField(keyButtonView.javaClass, "mLongClicked").setBoolean(keyButtonView, true)
+ } catch (t: Throwable) {
+ log("Failed to set mLongClicked", t)
+ }
}
private fun markHoneyspaceLongClicked(navButtonView: Any) {
@@ -175,6 +626,21 @@ class NavButtons : XposedModule() {
context = currentContext.applicationContext ?: currentContext
}
+ private fun updateContextFromField(target: Any?, fieldName: String) {
+ val currentContext = getFieldValue(target, fieldName) as? Context ?: return
+ context = currentContext.applicationContext ?: currentContext
+ }
+
+ private fun performConfiguredAction(action: NavButtonAction) {
+ when (action) {
+ NavButtonAction.STOCK,
+ NavButtonAction.NONE,
+ -> Unit
+
+ NavButtonAction.KILL_FOREGROUND_APP -> killForegroundApp()
+ }
+ }
+
private fun killForegroundApp() {
if (mainHandler == null) {
initContextAndHandler()
@@ -277,6 +743,34 @@ class NavButtons : XposedModule() {
return value as? Int ?: Int.MIN_VALUE
}
+ private fun getButtonConfig(buttonId: NavButtonId): NavButtonConfig {
+ val preferences = remotePreferences
+ val defaultDurationMs = ViewConfiguration.getLongPressTimeout().coerceLongPressDuration()
+ return NavButtonConfig(
+ pressAction = NavButtonAction.fromStorageValue(
+ preferences?.getString(NavButtonSettingsStore.pressActionKey(buttonId), null),
+ ),
+ longPressAction = NavButtonAction.fromStorageValue(
+ preferences?.getString(NavButtonSettingsStore.longPressActionKey(buttonId), null),
+ ),
+ longPressDurationMs = (
+ preferences?.getInt(
+ NavButtonSettingsStore.longPressDurationKey(buttonId),
+ defaultDurationMs,
+ ) ?: defaultDurationMs
+ ).coerceLongPressDuration(),
+ )
+ }
+
+ private fun buttonIdForKeyCode(keyCode: Int): NavButtonId? {
+ return when (keyCode) {
+ KeyEvent.KEYCODE_BACK -> NavButtonId.BACK
+ KeyEvent.KEYCODE_HOME -> NavButtonId.HOME
+ KeyEvent.KEYCODE_APP_SWITCH -> NavButtonId.RECENTS
+ else -> null
+ }
+ }
+
private fun getPackageNameFromComponent(component: Any?): String? {
return (component as? ComponentName)?.packageName
}
@@ -317,6 +811,27 @@ class NavButtons : XposedModule() {
}
}
+ private fun trace(message: String) {
+ if (!DEBUG_TRACE) {
+ return
+ }
+ val fullMessage =
+ "[proc=${currentProcessLabel()} pid=${Process.myPid()} uptime=${SystemClock.uptimeMillis()}] $message"
+ Log.i(TAG, fullMessage)
+ log(fullMessage)
+ }
+
+ private fun traceHome(source: String, details: String) {
+ trace("HOME $source :: $details")
+ }
+
+ private fun currentProcessLabel(): String {
+ return runCatching {
+ callStaticMethod(findClass("android.app.ActivityThread", null), "currentProcessName")
+ ?.toString()
+ }.getOrNull() ?: context?.packageName ?: "unknown"
+ }
+
private fun log(message: String) {
log(Log.INFO, TAG, message)
}
@@ -331,6 +846,193 @@ class NavButtons : XposedModule() {
}
}
+ private inline fun proceedWithCanceledAction(
+ motionEvent: MotionEvent,
+ proceed: () -> Any?,
+ ): Any? {
+ val originalAction = motionEvent.action
+ motionEvent.action = MotionEvent.ACTION_CANCEL
+ return try {
+ proceed()
+ } finally {
+ motionEvent.action = originalAction
+ }
+ }
+
+ private fun noteHomeLongPressHandled() {
+ lastHomeLongPressHandledAtMs = SystemClock.uptimeMillis()
+ }
+
+ private fun wasHomeLongPressHandledRecently(): Boolean {
+ return SystemClock.uptimeMillis() - lastHomeLongPressHandledAtMs < HOME_LONG_PRESS_DEDUP_WINDOW_MS
+ }
+
+ private fun Int.coerceLongPressDuration(): Int {
+ return coerceIn(
+ NavButtonSettingsStore.MIN_DURATION_MS,
+ NavButtonSettingsStore.MAX_DURATION_MS,
+ )
+ }
+
+ private fun performHomeLongPressAction(action: NavButtonAction) {
+ if (wasHomeLongPressHandledRecently()) {
+ traceHome("performHomeLongPressAction", "deduped action=${action.storageValue}")
+ return
+ }
+ noteHomeLongPressHandled()
+ traceHome("performHomeLongPressAction", "executing action=${action.storageValue}")
+ performConfiguredAction(action)
+ }
+
+ private fun cancelSystemUiKeyPress(keyButtonView: Any) {
+ if (buttonIdForKeyCode(getIntField(keyButtonView, "mCode")) == NavButtonId.HOME &&
+ systemUiHomeStockPressCanceled[keyButtonView] == true
+ ) {
+ traceHome("cancelSystemUiKeyPress", "stock key was already canceled by guard")
+ abortSystemUiKeyGesture(keyButtonView)
+ return
+ }
+
+ traceHome("cancelSystemUiKeyPress", "sending canceled key-up")
+ abortSystemUiKeyGesture(keyButtonView)
+ sendSystemUiCanceledKeyUp(keyButtonView)
+ }
+
+ private fun scheduleSystemUiHomeStockLongPressGuard(keyButtonView: Any, buttonId: NavButtonId) {
+ if (buttonId != NavButtonId.HOME) {
+ return
+ }
+ if (getButtonConfig(buttonId).longPressAction == NavButtonAction.STOCK) {
+ return
+ }
+
+ val view = keyButtonView 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 || systemUiLongPressTriggered[keyButtonView] == true) {
+ return@Runnable
+ }
+ traceHome("stock long-press guard", "canceling original Home key after ${delayMs}ms")
+ systemUiHomeStockPressCanceled[keyButtonView] = true
+ sendSystemUiCanceledKeyUp(keyButtonView)
+ }
+
+ systemUiHomeStockLongPressGuards[keyButtonView] = guard
+ view.postDelayed(guard, delayMs.toLong())
+ }
+
+ private fun cancelSystemUiHomeStockLongPressGuard(keyButtonView: Any) {
+ val guard = systemUiHomeStockLongPressGuards.remove(keyButtonView) ?: return
+ (keyButtonView as? View)?.removeCallbacks(guard)
+ }
+
+ private fun abortSystemUiKeyGesture(keyButtonView: Any) {
+ try {
+ callMethod(keyButtonView, "abortCurrentGesture")
+ } catch (_: Throwable) {
+ // Older/newer SystemUI variants may not expose this helper.
+ }
+ }
+
+ private fun sendSystemUiCanceledKeyUp(keyButtonView: Any) {
+ try {
+ callMethod(
+ keyButtonView,
+ "sendEvent",
+ arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
+ KeyEvent.ACTION_UP,
+ KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
+ )
+ } catch (error: Throwable) {
+ log("sendEvent cancel failed: ${error.javaClass.simpleName}")
+ }
+ }
+
+ private fun dispatchSystemUiHomeTap(keyButtonView: Any) {
+ traceHome("dispatchSystemUiHomeTap", "sending stock tap after guard cancel")
+ try {
+ callMethod(
+ keyButtonView,
+ "sendEvent",
+ arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
+ KeyEvent.ACTION_DOWN,
+ 0,
+ )
+ callMethod(
+ keyButtonView,
+ "sendEvent",
+ arrayOf(Int::class.javaPrimitiveType!!, Int::class.javaPrimitiveType!!),
+ KeyEvent.ACTION_UP,
+ 0,
+ )
+ } catch (error: Throwable) {
+ log("Guarded HOME tap failed: ${error.javaClass.simpleName}")
+ }
+ }
+
+ private fun abortSystemUiNavigationGesture(navigationBar: Any?, homeView: View) {
+ try {
+ callMethod(homeView, "abortCurrentGesture")
+ } catch (_: Throwable) {
+ // Ignore variants without this helper on the home button view.
+ }
+
+ val navigationBarView = getFieldValue(navigationBar, "mView")
+ try {
+ callMethod(navigationBarView ?: return, "abortCurrentGesture")
+ } catch (_: Throwable) {
+ // Ignore variants without this helper on the navigation bar view.
+ }
+ }
+
+ private fun rescheduleSystemUiLongPress(keyButtonView: Any, buttonId: NavButtonId) {
+ val view = keyButtonView as? View ?: return
+ val longPressRunnable = getFieldValue(keyButtonView, "mCheckLongPress") as? Runnable ?: return
+ view.removeCallbacks(longPressRunnable)
+ view.postDelayed(longPressRunnable, getButtonConfig(buttonId).longPressDurationMs.toLong())
+ }
+
+ private fun rescheduleHoneyspaceLongPress(
+ navButtonView: Any,
+ runnableClass: Class<*>,
+ buttonId: NavButtonId,
+ ) {
+ val view = navButtonView as? View ?: return
+ val longPressRunnable = findHoneyspaceLongPressRunnable(navButtonView, runnableClass) ?: return
+ view.removeCallbacks(longPressRunnable)
+ view.postDelayed(longPressRunnable, getButtonConfig(buttonId).longPressDurationMs.toLong())
+ }
+
+ private fun findHoneyspaceLongPressRunnable(
+ navButtonView: Any,
+ runnableClass: Class<*>,
+ ): Runnable? {
+ var current: Class<*>? = navButtonView.javaClass
+ while (current != null) {
+ current.declaredFields.forEach { field ->
+ try {
+ field.isAccessible = true
+ val value = field.get(navButtonView) ?: return@forEach
+ if (!runnableClass.isInstance(value)) {
+ return@forEach
+ }
+ if (getIntField(value, "b") != HONEYSPACE_LONG_PRESS_CASE) {
+ return@forEach
+ }
+ if (getFieldValue(value, "c") !== navButtonView) {
+ return@forEach
+ }
+ return value as Runnable
+ } catch (_: Throwable) {
+ return@forEach
+ }
+ }
+ current = current.superclass
+ }
+ return null
+ }
+
private fun prepareLongPressHook(runMethod: Method) {
deoptimizeMethodIfPresent(
findMethodIfExists(Handler::class.java, "dispatchMessage", android.os.Message::class.java),
@@ -365,6 +1067,81 @@ class NavButtons : XposedModule() {
}
}
+ private fun findMethodsNamed(clazz: Class<*>, methodName: String): List {
+ val methodsBySignature = linkedMapOf()
+ var current: Class<*>? = clazz
+ while (current != null) {
+ current.declaredMethods
+ .filter { it.name == methodName }
+ .forEach { method ->
+ method.isAccessible = true
+ methodsBySignature.putIfAbsent(method.toGenericString(), method)
+ }
+ current = current.superclass
+ }
+ return methodsBySignature.values.toList()
+ }
+
+ private fun systemUiHomeState(keyButtonView: Any): String {
+ return "pressed=${(keyButtonView as? View)?.isPressed == true} " +
+ "longClicked=${getFieldValue(keyButtonView, "mLongClicked") == true} " +
+ "triggered=${systemUiLongPressTriggered[keyButtonView] == true} " +
+ "stockCanceled=${systemUiHomeStockPressCanceled[keyButtonView] == true}"
+ }
+
+ private fun sendEventArgsToTraceString(args: Any?): String {
+ val values = argsToList(args)
+ return values.mapIndexed { index, arg ->
+ if (index == 0 && arg is Int) {
+ keyActionName(arg)
+ } else if (index == 1 && arg is Int) {
+ "flags=0x${arg.toString(16)}"
+ } else {
+ traceValue(arg)
+ }
+ }.joinToString(prefix = "[", postfix = "]")
+ }
+
+ private fun sendEventFlags(args: Any?): Int {
+ return argsToList(args).getOrNull(1) as? Int ?: 0
+ }
+
+ private fun argsToList(args: Any?): List<*> {
+ return when (args) {
+ is Array<*> -> args.asList()
+ is Iterable<*> -> args.toList()
+ null -> emptyList()
+ else -> listOf(args)
+ }
+ }
+
+ private fun traceValue(value: Any?): String {
+ return when (value) {
+ is Long,
+ is Float,
+ is Double,
+ is Boolean,
+ is String,
+ is Int,
+ null,
+ -> value.toString()
+
+ else -> value.javaClass.simpleName
+ }
+ }
+
+ private fun keyActionName(action: Int): String {
+ return when (action) {
+ KeyEvent.ACTION_DOWN -> "DOWN"
+ KeyEvent.ACTION_UP -> "UP"
+ else -> action.toString()
+ }
+ }
+
+ private fun Int.hasFlag(flag: Int): Boolean {
+ return this and flag == flag
+ }
+
@Throws(NoSuchMethodException::class)
private fun findMethod(
clazz: Class<*>,
@@ -419,17 +1196,24 @@ class NavButtons : XposedModule() {
return findMethod(target.javaClass, methodName, *parameterTypes).invoke(target, *args)
}
- private fun setBooleanField(target: Any, fieldName: String, value: Boolean) {
- try {
- findField(target.javaClass, fieldName).setBoolean(target, value)
- } catch (t: Throwable) {
- log("Failed to set $fieldName", t)
- }
- }
-
private companion object {
+ const val DEBUG_TRACE = true
const val TAG = "NavButtons"
+ const val ASSIST_INVOCATION_TYPE_KEY = "invocation_type"
+ const val ASSIST_INVOCATION_TYPE_UNKNOWN = 0
+ const val ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS = 5
+ const val HOME_LONG_PRESS_DEDUP_WINDOW_MS = 1_000L
+ const val STOCK_LONG_PRESS_GUARD_EARLY_MS = 75
+ const val STOCK_LONG_PRESS_GUARD_MIN_DELAY_MS = 100
const val SYSTEMUI_PACKAGE = "com.android.systemui"
+ const val SYSTEM_SERVER_PHONE_WINDOW_MANAGER = "com.android.server.policy.PhoneWindowManager"
+ const val SYSTEMUI_ASSIST_MANAGER = "com.android.systemui.assist.AssistManager"
+ const val SYSTEMUI_KEY_BUTTON_VIEW =
+ "com.android.systemui.navigationbar.views.buttons.KeyButtonView"
+ const val SYSTEMUI_NAVIGATION_BAR =
+ "com.android.systemui.navigationbar.NavigationBar"
+ const val SYSTEMUI_OVERVIEW_PROXY_SERVICE =
+ "com.android.systemui.recents.OverviewProxyService"
const val SAMSUNG_LAUNCHER_PACKAGE = "com.sec.android.app.launcher"
const val HONEYSPACE_NAV_BUTTON_VIEW =
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView"
diff --git a/app/src/main/resources/META-INF/xposed/scope.list b/app/src/main/resources/META-INF/xposed/scope.list
index 90efcf1..b02e6e6 100644
--- a/app/src/main/resources/META-INF/xposed/scope.list
+++ b/app/src/main/resources/META-INF/xposed/scope.list
@@ -1,2 +1,3 @@
+android
com.android.systemui
com.sec.android.app.launcher