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/ .gradle/
.kotlin/
build/ build/
app/build/ app/build/
local.properties local.properties
+3 -2
View File
@@ -56,8 +56,8 @@ android {
applicationId = "se.ajpanton.navbuttons" applicationId = "se.ajpanton.navbuttons"
minSdk = 35 minSdk = 35
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1777035207
versionName = "0.1" versionName = "0.1-1777035207"
} }
buildTypes { buildTypes {
@@ -84,6 +84,7 @@ android {
dependencies { dependencies {
implementation("androidx.annotation:annotation:1.7.1") implementation("androidx.annotation:annotation:1.7.1")
implementation("io.github.libxposed:service:101.0.0")
implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1") implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1")
compileOnly("io.github.libxposed:api:101.0.0") compileOnly("io.github.libxposed:api:101.0.0")
+6
View File
@@ -14,6 +14,12 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.NavButtons"> android:theme="@style/Theme.NavButtons">
<provider
android:name="io.github.libxposed.service.XposedProvider"
android:authorities="${applicationId}.XposedService"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="true" android:exported="true"
@@ -1,8 +1,12 @@
package se.ajpanton.navbuttons package se.ajpanton.navbuttons
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import android.view.ViewConfiguration import android.view.ViewConfiguration
import androidx.annotation.StringRes import androidx.annotation.StringRes
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
enum class NavButtonId( enum class NavButtonId(
val preferencePrefix: String, val preferencePrefix: String,
@@ -36,12 +40,17 @@ data class NavButtonConfig(
) )
class NavButtonSettingsStore(context: Context) { class NavButtonSettingsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences( private val localPreferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME, PREFERENCES_NAME,
Context.MODE_PRIVATE, Context.MODE_PRIVATE,
) )
init {
registerRemotePreferencesListener(localPreferences)
}
fun getConfig(buttonId: NavButtonId): NavButtonConfig { fun getConfig(buttonId: NavButtonId): NavButtonConfig {
val preferences = currentPreferences()
return NavButtonConfig( return NavButtonConfig(
pressAction = NavButtonAction.fromStorageValue( pressAction = NavButtonAction.fromStorageValue(
preferences.getString(pressActionKey(buttonId), null), preferences.getString(pressActionKey(buttonId), null),
@@ -59,21 +68,21 @@ class NavButtonSettingsStore(context: Context) {
} }
fun setPressAction(buttonId: NavButtonId, action: NavButtonAction) { fun setPressAction(buttonId: NavButtonId, action: NavButtonAction) {
preferences.edit() persistChanges {
.putString(pressActionKey(buttonId), action.storageValue) putString(pressActionKey(buttonId), action.storageValue)
.apply() }
} }
fun setLongPressAction(buttonId: NavButtonId, action: NavButtonAction) { fun setLongPressAction(buttonId: NavButtonId, action: NavButtonAction) {
preferences.edit() persistChanges {
.putString(longPressActionKey(buttonId), action.storageValue) putString(longPressActionKey(buttonId), action.storageValue)
.apply() }
} }
fun setLongPressDurationMs(buttonId: NavButtonId, durationMs: Int) { fun setLongPressDurationMs(buttonId: NavButtonId, durationMs: Int) {
preferences.edit() persistChanges {
.putInt(longPressDurationKey(buttonId), sanitizeDuration(durationMs)) putInt(longPressDurationKey(buttonId), sanitizeDuration(durationMs))
.apply() }
} }
fun defaultLongPressDurationMs(): Int { fun defaultLongPressDurationMs(): Int {
@@ -84,11 +93,40 @@ class NavButtonSettingsStore(context: Context) {
return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS) 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 { companion object {
private const val TAG = "NavButtons"
const val PREFERENCES_NAME = "nav_button_settings" const val PREFERENCES_NAME = "nav_button_settings"
const val KEY_PRESS_ACTION = "press_action" const val KEY_PRESS_ACTION = "press_action"
const val KEY_LONG_PRESS_ACTION = "long_press_action" const val KEY_LONG_PRESS_ACTION = "long_press_action"
const val KEY_LONG_PRESS_DURATION_MS = "long_press_duration_ms" 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 MIN_LONG_PRESS_DURATION_MS = 100
const val MAX_LONG_PRESS_DURATION_MS = 10_000 const val MAX_LONG_PRESS_DURATION_MS = 10_000
@@ -105,8 +143,110 @@ class NavButtonSettingsStore(context: Context) {
fun longPressDurationKey(buttonId: NavButtonId): String = fun longPressDurationKey(buttonId: NavButtonId): String =
key(buttonId, KEY_LONG_PRESS_DURATION_MS) 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 { private fun key(buttonId: NavButtonId, suffix: String): String {
return "${buttonId.preferencePrefix}_$suffix" 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.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Process
import android.os.SystemClock
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewConfiguration
import android.widget.Toast import android.widget.Toast
import io.github.libxposed.api.XposedModule import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface import io.github.libxposed.api.XposedModuleInterface
import org.lsposed.hiddenapibypass.HiddenApiBypass import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method import java.lang.reflect.Method
import java.util.WeakHashMap
class NavButtons : XposedModule() { 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 context: Context? = null
private var mainHandler: Handler? = null private var mainHandler: Handler? = null
private var systemUiHookInstalled = false private var systemUiHookInstalled = false
private var systemServerHookInstalled = false
private var launcherHookInstalled = 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) { override fun onModuleLoaded(param: XposedModuleInterface.ModuleLoadedParam) {
if (!HiddenApiBypass.addHiddenApiExemptions("")) { if (!HiddenApiBypass.addHiddenApiExemptions("")) {
@@ -39,13 +60,23 @@ class NavButtons : XposedModule() {
handleTargetPackage(param.packageName, param.classLoader) 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?) { private fun handleTargetPackage(packageName: String, classLoader: ClassLoader?) {
if (SYSTEMUI_PACKAGE == packageName) { if (SYSTEMUI_PACKAGE == packageName) {
if (systemUiHookInstalled) { if (systemUiHookInstalled) {
return return
} }
initContextAndHandler() initContextAndHandler()
systemUiHookInstalled = hookSystemUiBackLongPress(classLoader) systemUiHookInstalled = hookSystemUiNavigationButtons(classLoader)
trace("SystemUI hooks installed=$systemUiHookInstalled")
return return
} }
@@ -55,6 +86,7 @@ class NavButtons : XposedModule() {
} }
initContextAndHandler() initContextAndHandler()
launcherHookInstalled = hookHoneyspaceTaskbar(classLoader) 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( val runnableClass = findClassIfExists(
"com.android.systemui.navigationbar.views.buttons.KeyButtonView\$1", "$SYSTEMUI_KEY_BUTTON_VIEW\$1",
classLoader, classLoader,
) ?: run { ) ?: run {
log("SystemUI long-press runnable not found.") log("SystemUI long-press runnable not found.")
@@ -90,31 +135,358 @@ class NavButtons : XposedModule() {
log("SystemUI run() method not found.") log("SystemUI run() method not found.")
return false return false
} }
val sendEventMethods = findMethodsNamed(keyButtonClass, "sendEvent").filter { method ->
val parameterTypes = method.parameterTypes
parameterTypes.isNotEmpty() && parameterTypes[0] == Int::class.javaPrimitiveType
}
deoptimizeMethodIfPresent(onTouchEventMethod)
prepareLongPressHook(runMethod) 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 -> hook(runMethod).intercept { chain ->
val keyButtonView = getFieldValue(chain.thisObject, "this\$0") val keyButtonView = getFieldValue(chain.thisObject, "this\$0")
?: return@intercept chain.proceed() ?: return@intercept chain.proceed()
val code = getIntField(keyButtonView, "mCode") val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode"))
if (code != KeyEvent.KEYCODE_BACK) { ?: return@intercept chain.proceed()
val longPressAction = getButtonConfig(buttonId).longPressAction
if (longPressAction == NavButtonAction.STOCK) {
return@intercept chain.proceed() return@intercept chain.proceed()
} }
if (buttonId == NavButtonId.HOME) {
traceHome("KeyButtonView\$1.run", "long=${longPressAction.storageValue}")
}
updateContextFromView(keyButtonView) updateContextFromView(keyButtonView)
systemUiLongPressTriggered[keyButtonView] = true
cancelSystemUiKeyPress(keyButtonView)
markSystemUiLongClicked(keyButtonView) markSystemUiLongClicked(keyButtonView)
killForegroundApp() if (buttonId == NavButtonId.HOME) {
performHomeLongPressAction(longPressAction)
} else {
performConfiguredAction(longPressAction)
}
null null
} }
return true 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 { private fun hookHoneyspaceTaskbar(classLoader: ClassLoader?): Boolean {
val navButtonClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_VIEW, classLoader) ?: run { val navButtonClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_VIEW, classLoader) ?: run {
log("Launcher class not found: $HONEYSPACE_NAV_BUTTON_VIEW") log("Launcher class not found: $HONEYSPACE_NAV_BUTTON_VIEW")
return false 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 { val runnableClass = findClassIfExists(HONEYSPACE_LONG_PRESS_RUNNABLE, classLoader) ?: run {
log("Launcher long-press runnable not found: $HONEYSPACE_LONG_PRESS_RUNNABLE") log("Launcher long-press runnable not found: $HONEYSPACE_LONG_PRESS_RUNNABLE")
return false return false
@@ -125,8 +497,77 @@ class NavButtons : XposedModule() {
return false return false
} }
deoptimizeMethodIfPresent(onTouchEventMethod)
prepareLongPressHook(runMethod) 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 -> hook(runMethod).intercept { chain ->
val caseId = getIntField(chain.thisObject, "b") val caseId = getIntField(chain.thisObject, "b")
if (caseId != HONEYSPACE_LONG_PRESS_CASE) { if (caseId != HONEYSPACE_LONG_PRESS_CASE) {
@@ -141,20 +582,30 @@ class NavButtons : XposedModule() {
val navButtonView = target ?: return@intercept chain.proceed() val navButtonView = target ?: return@intercept chain.proceed()
val keyCode = callMethod(navButtonView, "getKeyCode") as Int 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() return@intercept chain.proceed()
} }
if (buttonId == NavButtonId.HOME) {
traceHome("Honeyspace runnable", "long=${longPressAction.storageValue}")
}
updateContextFromView(navButtonView) updateContextFromView(navButtonView)
markHoneyspaceLongClicked(navButtonView) markHoneyspaceLongClicked(navButtonView)
killForegroundApp() performConfiguredAction(longPressAction)
null null
} }
return true return true
} }
private fun markSystemUiLongClicked(keyButtonView: Any) { 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) { private fun markHoneyspaceLongClicked(navButtonView: Any) {
@@ -175,6 +626,21 @@ class NavButtons : XposedModule() {
context = currentContext.applicationContext ?: currentContext 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() { private fun killForegroundApp() {
if (mainHandler == null) { if (mainHandler == null) {
initContextAndHandler() initContextAndHandler()
@@ -277,6 +743,34 @@ class NavButtons : XposedModule() {
return value as? Int ?: Int.MIN_VALUE 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? { private fun getPackageNameFromComponent(component: Any?): String? {
return (component as? ComponentName)?.packageName 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) { private fun log(message: String) {
log(Log.INFO, TAG, message) 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) { private fun prepareLongPressHook(runMethod: Method) {
deoptimizeMethodIfPresent( deoptimizeMethodIfPresent(
findMethodIfExists(Handler::class.java, "dispatchMessage", android.os.Message::class.java), 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) @Throws(NoSuchMethodException::class)
private fun findMethod( private fun findMethod(
clazz: Class<*>, clazz: Class<*>,
@@ -419,17 +1196,24 @@ class NavButtons : XposedModule() {
return findMethod(target.javaClass, methodName, *parameterTypes).invoke(target, *args) 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 { private companion object {
const val DEBUG_TRACE = true
const val TAG = "NavButtons" 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 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 SAMSUNG_LAUNCHER_PACKAGE = "com.sec.android.app.launcher"
const val HONEYSPACE_NAV_BUTTON_VIEW = const val HONEYSPACE_NAV_BUTTON_VIEW =
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView" "com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView"
@@ -1,2 +1,3 @@
android
com.android.systemui com.android.systemui
com.sec.android.app.launcher com.sec.android.app.launcher