Wire nav button actions into module

This commit is contained in:
ajp_anton
2026-04-24 20:42:46 +00:00
parent a3e6855745
commit 9d57654bb2
6 changed files with 962 additions and 29 deletions
+1
View File
@@ -1,4 +1,5 @@
.gradle/
.kotlin/
build/
app/build/
local.properties
+3 -2
View File
@@ -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")
+6
View File
@@ -14,6 +14,12 @@
android:supportsRtl="true"
android:theme="@style/Theme.NavButtons">
<provider
android:name="io.github.libxposed.service.XposedProvider"
android:authorities="${applicationId}.XposedService"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<activity
android:name=".SettingsActivity"
android:exported="true"
@@ -1,8 +1,12 @@
package se.ajpanton.navbuttons
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import android.view.ViewConfiguration
import androidx.annotation.StringRes
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
enum class NavButtonId(
val preferencePrefix: String,
@@ -36,12 +40,17 @@ data class NavButtonConfig(
)
class NavButtonSettingsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(
private val localPreferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE,
)
init {
registerRemotePreferencesListener(localPreferences)
}
fun getConfig(buttonId: NavButtonId): NavButtonConfig {
val preferences = currentPreferences()
return NavButtonConfig(
pressAction = NavButtonAction.fromStorageValue(
preferences.getString(pressActionKey(buttonId), null),
@@ -59,21 +68,21 @@ class NavButtonSettingsStore(context: Context) {
}
fun setPressAction(buttonId: NavButtonId, action: NavButtonAction) {
preferences.edit()
.putString(pressActionKey(buttonId), action.storageValue)
.apply()
persistChanges {
putString(pressActionKey(buttonId), action.storageValue)
}
}
fun setLongPressAction(buttonId: NavButtonId, action: NavButtonAction) {
preferences.edit()
.putString(longPressActionKey(buttonId), action.storageValue)
.apply()
persistChanges {
putString(longPressActionKey(buttonId), action.storageValue)
}
}
fun setLongPressDurationMs(buttonId: NavButtonId, durationMs: Int) {
preferences.edit()
.putInt(longPressDurationKey(buttonId), sanitizeDuration(durationMs))
.apply()
persistChanges {
putInt(longPressDurationKey(buttonId), sanitizeDuration(durationMs))
}
}
fun defaultLongPressDurationMs(): Int {
@@ -84,11 +93,40 @@ class NavButtonSettingsStore(context: Context) {
return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS)
}
private fun currentPreferences(): SharedPreferences {
return remotePreferences ?: localPreferences
}
private fun persistChanges(write: SharedPreferences.Editor.() -> 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)
}
}
}
@@ -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<Any, Boolean>()
private val systemUiHomeStockLongPressGuards = WeakHashMap<Any, Runnable>()
private val systemUiHomeStockPressCanceled = WeakHashMap<Any, Boolean>()
private val honeyspaceLongPressTriggered = WeakHashMap<Any, Boolean>()
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<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()
}
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<Any?>()
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"
@@ -1,2 +1,3 @@
android
com.android.systemui
com.sec.android.app.launcher