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