From 0956bbc4d7bcd6573727a6d20b35a2780ccfbb1d Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Mon, 22 Jun 2026 00:42:17 +0000 Subject: [PATCH] Add side arrow navigation buttons --- .gitignore | 1 + .../navbuttons/NavButtonSettingsStore.kt | 124 +- .../java/se/ajpanton/navbuttons/NavButtons.kt | 1180 ++++++++++++++++- .../ajpanton/navbuttons/SettingsActivity.kt | 463 ++++++- app/src/main/res/layout/activity_settings.xml | 9 - .../res/layout/item_side_arrows_section.xml | 186 +++ app/src/main/res/values/strings.xml | 8 +- .../main/resources/META-INF/xposed/scope.list | 1 + 8 files changed, 1902 insertions(+), 70 deletions(-) create mode 100644 app/src/main/res/layout/item_side_arrows_section.xml diff --git a/.gitignore b/.gitignore index 0598101..b6273e5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/ app/build/ local/ local.properties +*.log diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt index 6b9bed6..5bc9624 100644 --- a/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt +++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtonSettingsStore.kt @@ -39,6 +39,22 @@ enum class NavButtonAction( } } +enum class SideArrowLongPressAction( + val storageValue: String, + @get:StringRes val labelRes: Int, +) { + NOTHING("nothing", R.string.side_arrow_long_press_nothing), + SINGLE_STEP("single_step", R.string.side_arrow_long_press_single_step), + MULTIPLE_STEPS("multiple_steps", R.string.side_arrow_long_press_multiple_steps), + ; + + companion object { + fun fromStorageValue(value: String?): SideArrowLongPressAction { + return entries.firstOrNull { it.storageValue == value } ?: MULTIPLE_STEPS + } + } +} + data class NavButtonConfig( val pressAction: NavButtonAction, val longPressAction: NavButtonAction, @@ -91,6 +107,61 @@ class NavButtonSettingsStore(context: Context) { } } + fun isSideArrowsEnabled(): Boolean { + return currentPreferences().getBoolean(KEY_SIDE_ARROWS_ENABLED, false) + } + + fun setSideArrowsEnabled(enabled: Boolean) { + persistChanges { + putBoolean(KEY_SIDE_ARROWS_ENABLED, enabled) + } + } + + fun getSideArrowLongPressAction(): SideArrowLongPressAction { + return SideArrowLongPressAction.fromStorageValue( + currentPreferences().getString(KEY_SIDE_ARROW_LONG_PRESS_ACTION, null), + ) + } + + fun setSideArrowLongPressAction(action: SideArrowLongPressAction) { + persistChanges { + putString(KEY_SIDE_ARROW_LONG_PRESS_ACTION, action.storageValue) + } + } + + fun getSideArrowRepeatStepsPerSecond(): Float { + return sanitizeSideArrowRepeatSpeed( + currentPreferences().getFloat( + KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ), + ) + } + + fun setSideArrowRepeatStepsPerSecond(stepsPerSecond: Float) { + persistChanges { + putFloat( + KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + sanitizeSideArrowRepeatSpeed(stepsPerSecond), + ) + } + } + + fun getSideArrowLongPressDurationMs(): Int { + return sanitizeDuration( + currentPreferences().getInt( + KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, + defaultLongPressDurationMs(), + ), + ) + } + + fun setSideArrowLongPressDurationMs(durationMs: Int) { + persistChanges { + putInt(KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, sanitizeDuration(durationMs)) + } + } + fun defaultLongPressDurationMs(): Int { return sanitizeDuration(ViewConfiguration.getLongPressTimeout()) } @@ -99,6 +170,17 @@ class NavButtonSettingsStore(context: Context) { return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS) } + fun sanitizeSideArrowRepeatSpeed(stepsPerSecond: Float): Float { + return if (stepsPerSecond.isFinite()) { + stepsPerSecond.coerceIn( + MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ) + } else { + DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND + } + } + private fun currentPreferences(): SharedPreferences { return remotePreferences ?: localPreferences } @@ -129,9 +211,15 @@ class NavButtonSettingsStore(context: Context) { companion object { private const val TAG = "NavButtons" const val PREFERENCES_NAME = "nav_button_settings" + const val ACTION_SETTINGS_CHANGED = "se.ajpanton.navbuttons.SETTINGS_CHANGED" + const val EXTRA_SIDE_ARROWS_ENABLED = "side_arrows_enabled" 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" + const val KEY_SIDE_ARROWS_ENABLED = "side_arrows_enabled" + const val KEY_SIDE_ARROW_LONG_PRESS_ACTION = "side_arrow_long_press_action" + const val KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = "side_arrow_repeat_steps_per_second" + const val KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS = "side_arrow_long_press_duration_ms" private const val KEY_LAST_UPDATED_AT_MS = "last_updated_at_ms" const val MIN_LONG_PRESS_DURATION_MS = 100 @@ -141,6 +229,10 @@ class NavButtonSettingsStore(context: Context) { const val DURATION_STEP_MS = 100 const val HOLD_REPEAT_START_DELAY_MS = 700L const val HOLD_REPEAT_INTERVAL_MS = 100L + const val DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = 20f + const val MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = 0.1f + const val MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND = 100f + const val SIDE_ARROW_REPEAT_SPEED_STEP = 1f fun pressActionKey(buttonId: NavButtonId): String = key(buttonId, KEY_PRESS_ACTION) @@ -161,13 +253,15 @@ class NavButtonSettingsStore(context: Context) { pressActionKey(buttonId), longPressActionKey(buttonId), ) - } + } + KEY_SIDE_ARROW_LONG_PRESS_ACTION } private val durationKeys by lazy(LazyThreadSafetyMode.NONE) { - NavButtonId.entries.map(::longPressDurationKey) + NavButtonId.entries.map(::longPressDurationKey) + KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS } + private val floatKeys = listOf(KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND) + private fun key(buttonId: NavButtonId, suffix: String): String { return "${buttonId.preferencePrefix}_$suffix" } @@ -220,7 +314,10 @@ class NavButtonSettingsStore(context: Context) { } private fun hasStoredConfig(preferences: SharedPreferences): Boolean { - return actionKeys.any(preferences::contains) || durationKeys.any(preferences::contains) + return actionKeys.any(preferences::contains) || + durationKeys.any(preferences::contains) || + floatKeys.any(preferences::contains) || + preferences.contains(KEY_SIDE_ARROWS_ENABLED) } private fun copyPreferences(source: SharedPreferences, target: SharedPreferences) { @@ -242,6 +339,27 @@ class NavButtonSettingsStore(context: Context) { } } + for (key in floatKeys) { + if (source.contains(key)) { + editor.putFloat( + key, + source.getFloat(key, DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND) + .coerceIn( + MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ), + ) + } else { + editor.remove(key) + } + } + + if (source.contains(KEY_SIDE_ARROWS_ENABLED)) { + editor.putBoolean(KEY_SIDE_ARROWS_ENABLED, source.getBoolean(KEY_SIDE_ARROWS_ENABLED, false)) + } else { + editor.remove(KEY_SIDE_ARROWS_ENABLED) + } + if (source.contains(KEY_LAST_UPDATED_AT_MS)) { editor.putLong(KEY_LAST_UPDATED_AT_MS, source.getLong(KEY_LAST_UPDATED_AT_MS, 0L)) } else { diff --git a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt index 3ab64c1..901be9f 100644 --- a/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt +++ b/app/src/main/java/se/ajpanton/navbuttons/NavButtons.kt @@ -1,12 +1,24 @@ package se.ajpanton.navbuttons import android.app.ActivityManager +import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.graphics.Canvas +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PixelFormat +import android.graphics.Point +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable import android.hardware.camera2.CameraAccessException import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraManager @@ -18,15 +30,19 @@ import android.provider.Settings import android.util.Log import android.view.InputEvent import android.view.KeyEvent +import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration +import android.view.ViewGroup +import android.widget.ImageView 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.Collections import java.util.WeakHashMap class NavButtons : XposedModule() { @@ -53,6 +69,17 @@ class NavButtons : XposedModule() { private val honeyspaceLongPressTriggered = WeakHashMap() private val honeyspaceHomeStockLongPressGuards = WeakHashMap() private val honeyspaceHomeStockPressCanceled = WeakHashMap() + private val sideArrowLongPressTriggered = WeakHashMap() + private val sideArrowLongPressRunnables = WeakHashMap() + private val sideArrowRepeatRunnables = WeakHashMap() + private val trackedHoneyspaceNavContainers = Collections.newSetFromMap(WeakHashMap()) + private val honeyspaceNativeSideArrowStates = WeakHashMap() + private val trackedSamsungNavigationBarViews = Collections.newSetFromMap(WeakHashMap()) + private val sideArrowLayoutKinds = WeakHashMap() + private var foldedSideArrowProviderHookInstalled = false + private var samsungNavigationBarViewHookInstalled = false + private var settingsChangedReceiverRegistered = false + private var sideArrowsEnabledOverride: Boolean? = null override fun onModuleLoaded(param: XposedModuleInterface.ModuleLoadedParam) { if (!HiddenApiBypass.addHiddenApiExemptions("")) { @@ -95,7 +122,9 @@ class NavButtons : XposedModule() { initContextAndHandler() launcherHookInstalled = hookHoneyspaceTaskbar(classLoader) trace("Honeyspace hooks installed=$launcherHookInstalled") + return } + } private fun initContextAndHandler() { @@ -152,15 +181,43 @@ class NavButtons : XposedModule() { prepareLongPressHook(runMethod) sendEventMethods.forEach(::deoptimizeMethodIfPresent) sendEventMethods.forEach(::hookSystemUiSendEvent) + hookFoldedSideArrows(classLoader) 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() + val keyCode = getIntField(keyButtonView, "mCode") + if (isSideArrowKeyCode(keyCode)) { + ensureContextFromView(keyButtonView) + registerSettingsChangedReceiver() + trackSamsungNavigationBarView(keyButtonView) + return@intercept when (motionEvent.actionMasked) { + MotionEvent.ACTION_DOWN -> { + sideArrowLongPressTriggered[keyButtonView] = false + cancelSideArrowLongPress(keyButtonView) + val result = chain.proceed() + scheduleSideArrowLongPress(keyButtonView, keyCode) + result + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + cancelSideArrowLongPress(keyButtonView) + sideArrowLongPressTriggered.remove(keyButtonView) + chain.proceed() + } + + else -> chain.proceed() + } + } + val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() when (motionEvent.actionMasked) { MotionEvent.ACTION_DOWN -> { + ensureContextFromView(keyButtonView) + registerSettingsChangedReceiver() + trackSamsungNavigationBarView(keyButtonView) systemUiLongPressTriggered[keyButtonView] = false cancelSystemUiHomeStockLongPressGuard(keyButtonView) systemUiHomeStockPressCanceled.remove(keyButtonView) @@ -240,8 +297,11 @@ class NavButtons : XposedModule() { hook(runMethod).intercept { chain -> val keyButtonView = getFieldValue(chain.thisObject, "this\$0") ?: return@intercept chain.proceed() - val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode")) - ?: return@intercept chain.proceed() + val keyCode = getIntField(keyButtonView, "mCode") + if (isSideArrowKeyCode(keyCode)) { + return@intercept null + } + val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() val longPressAction = getButtonConfig(buttonId).longPressAction if (longPressAction == NavButtonAction.STOCK) { return@intercept chain.proceed() @@ -402,12 +462,41 @@ class NavButtons : XposedModule() { deoptimizeMethodIfPresent(onTouchEventMethod) prepareLongPressHook(runMethod) + hookHoneyspaceSideArrowLayout(classLoader) 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() + val keyCode = callMethod(navButtonView, "getKeyCode") as? Int ?: Int.MIN_VALUE + if (isSideArrowKeyCode(keyCode)) { + ensureContextFromView(navButtonView) + registerSettingsChangedReceiver() + trackHoneyspaceNavContainerFrom(navButtonView as? View) + + return@intercept when (motionEvent.actionMasked) { + MotionEvent.ACTION_DOWN -> { + sideArrowLongPressTriggered[navButtonView] = false + cancelSideArrowLongPress(navButtonView) + val result = chain.proceed() + scheduleSideArrowLongPress(navButtonView, keyCode) + result + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + cancelSideArrowLongPress(navButtonView) + sideArrowLongPressTriggered.remove(navButtonView) + chain.proceed() + } + + else -> chain.proceed() + } + } + val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() + ensureContextFromView(navButtonView) + registerSettingsChangedReceiver() + trackHoneyspaceNavContainerFrom(navButtonView as? View) when (motionEvent.actionMasked) { MotionEvent.ACTION_DOWN -> { @@ -502,6 +591,9 @@ class NavButtons : XposedModule() { val navButtonView = target ?: return@intercept chain.proceed() val keyCode = callMethod(navButtonView, "getKeyCode") as Int + if (isSideArrowKeyCode(keyCode)) { + return@intercept null + } val buttonId = buttonIdForKeyCode(keyCode) ?: return@intercept chain.proceed() val longPressAction = getButtonConfig(buttonId).longPressAction honeyspaceLongPressTriggered[navButtonView] = true @@ -521,6 +613,373 @@ class NavButtons : XposedModule() { return true } + private fun hookHoneyspaceSideArrowLayout(classLoader: ClassLoader?) { + val containerClass = findClassIfExists(HONEYSPACE_NAV_BUTTON_CONTAINER, classLoader) ?: run { + log("Honeyspace side arrows container class not found: $HONEYSPACE_NAV_BUTTON_CONTAINER") + return + } + + listOf( + "onAttachedToWindow", + "onLayout", + "onVisibilityChanged", + "onWindowVisibilityChanged", + "drawableStateChanged", + ).forEach { methodName -> + findDeclaredMethodsNamed(containerClass, methodName).forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + val result = chain.proceed() + val container = chain.thisObject as? android.view.ViewGroup + ?: return@intercept result + ensureContextFromView(container) + registerSettingsChangedReceiver() + trackHoneyspaceNavContainer(container) + refreshHoneyspaceSideArrowLayout(container, "after ${method.name}") + scheduleDelayedHoneyspaceSideArrowRefresh(container, "after ${method.name}") + result + } + } + } + + hookHoneyspaceNativeSideArrowRefresh(containerClass) + } + + private fun hookHoneyspaceNativeSideArrowRefresh(containerClass: Class<*>) { + listOf("g", "e", "d").forEach { methodName -> + findMethodsNamed(containerClass, methodName).forEach { method -> + val parameterTypes = method.parameterTypes + val supported = + (methodName == "g" && parameterTypes.size == 1 && parameterTypes[0] == Boolean::class.javaPrimitiveType) || + (methodName == "e" && parameterTypes.isEmpty()) || + (methodName == "d" && parameterTypes.size == 1 && parameterTypes[0] == Float::class.javaPrimitiveType) + if (!supported) { + return@forEach + } + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + val container = chain.thisObject as? android.view.ViewGroup + val result = chain.proceed() + if (container != null && isSideArrowsEnabled()) { + restoreHoneyspaceSideArrowDrawables(container) + } + result + } + } + } + } + + private fun trackHoneyspaceNavContainerFrom(view: View?) { + var current = view?.parent as? View + repeat(HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH) { + val target = current ?: return + if (isHoneyspaceNavButtonContainer(target)) { + trackHoneyspaceNavContainer(target) + val container = target as android.view.ViewGroup + refreshHoneyspaceSideArrowLayout(container, "from touch") + scheduleDelayedHoneyspaceSideArrowRefresh(container, "from touch") + return + } + current = target.parent as? View + } + } + + private fun trackHoneyspaceNavContainer(container: View) { + if (trackedHoneyspaceNavContainers.add(container)) { + container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + refreshHoneyspaceSideArrowLayout(view as android.view.ViewGroup, "layout listener") + } + } + } + + private fun refreshTrackedHoneyspaceSideArrowLayouts(reason: String) { + val containers = trackedHoneyspaceNavContainers.toList() + containers.forEach { container -> + refreshHoneyspaceSideArrowLayout(container as? android.view.ViewGroup ?: return@forEach, reason) + scheduleDelayedHoneyspaceSideArrowRefresh(container, reason) + } + } + + private fun scheduleDelayedHoneyspaceSideArrowRefresh(container: android.view.ViewGroup, reason: String) { + listOf( + HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS, + HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS, + ).forEach { delayMs -> + mainHandler?.postDelayed( + { refreshHoneyspaceSideArrowLayout(container, "$reason delayed ${delayMs}ms") }, + delayMs, + ) + } + } + + private fun findHoneyspaceNavContainer(view: View): android.view.ViewGroup? { + var current = view.parent as? View + repeat(HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH) { + val target = current ?: return null + if (isHoneyspaceNavButtonContainer(target)) { + return target as? android.view.ViewGroup + } + current = target.parent as? View + } + return null + } + + private fun refreshHoneyspaceSideArrowLayout(container: android.view.ViewGroup, reason: String) { + try { + applyNativeHoneyspaceSideArrows(container, reason) + } catch (error: Throwable) { + log("Honeyspace side arrows refresh failed: ${error.javaClass.simpleName}") + } + } + + @Suppress("UNCHECKED_CAST") + private fun applyNativeHoneyspaceSideArrows(container: android.view.ViewGroup, reason: String) { + val buttonMap = getFieldValue(container, "i") as? MutableMap ?: run { + log("Honeyspace side arrows native apply skipped: button map missing reason=$reason") + return + } + val enabled = isSideArrowsEnabled() + val targetSlots = honeyspaceSideArrowSlots(container, enabled) + val currentSlots = getFieldValue(container, "u") as? BooleanArray + val alreadyApplied = honeyspaceNativeSideArrowStates[container] == enabled && + if (enabled) { + buttonMap[64] != null && + buttonMap[128] != null && + getIntField(container, "v") == 2 && + currentSlots?.contentEquals(targetSlots) == true && + honeyspaceSideArrowDrawablesPresent(buttonMap) + } else { + getIntField(container, "v") == 0 && + currentSlots?.contentEquals(targetSlots) == true + } + if (alreadyApplied) { + return + } + + HONEYSPACE_EXTRA_BUTTON_TYPES.forEach { buttonMap.remove(it) } + + if (enabled) { + createHoneyspaceExtraButton(container, 64, KeyEvent.KEYCODE_DPAD_LEFT, SideArrowDirection.LEFT) + ?.let { buttonMap[64] = it } + createHoneyspaceExtraButton(container, 128, KeyEvent.KEYCODE_DPAD_RIGHT, SideArrowDirection.RIGHT) + ?.let { buttonMap[128] = it } + if (buttonMap[64] == null || buttonMap[128] == null) { + log("Honeyspace side arrows native apply skipped: extra button creation failed reason=$reason") + return + } + setFieldIfExists(container, "u", targetSlots) + setFieldIfExists(container, "v", 2) + } else { + setFieldIfExists(container, "u", targetSlots) + setFieldIfExists(container, "v", 0) + } + + honeyspaceNativeSideArrowStates[container] = enabled + callBooleanMethodIfExists(container, "g", false) + restoreHoneyspaceSideArrowDrawables(container) + } + + private fun honeyspaceSideArrowSlots(container: android.view.ViewGroup, enabled: Boolean): BooleanArray { + if (!enabled) { + return BooleanArray(5) + } + + val defaultOrder = getFieldValue(container, "k") as? Boolean ?: true + return if (defaultOrder) { + // Stock order consumes recent, home, back(+back_alt); insert arrows around home. + booleanArrayOf(false, true, false, true, false, false) + } else { + // Reversed order consumes back_alt, back(+back_alt), home, recent. + booleanArrayOf(false, false, true, false, true, false) + } + } + + private fun honeyspaceSideArrowDrawablesPresent(buttonMap: Map): Boolean { + val left = buttonMap[64] as? ImageView + val right = buttonMap[128] as? ImageView + return left?.drawable != null && right?.drawable != null + } + + private fun restoreHoneyspaceSideArrowDrawables(container: android.view.ViewGroup) { + val buttonMap = getFieldValue(container, "i") as? Map<*, *> ?: return + restoreHoneyspaceSideArrowDrawable( + buttonMap[64] as? ImageView, + SideArrowDirection.LEFT, + getFieldValue(container, "w") as? Float ?: 0f, + ) + restoreHoneyspaceSideArrowDrawable( + buttonMap[128] as? ImageView, + SideArrowDirection.RIGHT, + getFieldValue(container, "w") as? Float ?: 0f, + ) + } + + private fun restoreHoneyspaceSideArrowDrawable( + image: ImageView?, + direction: SideArrowDirection, + darkIntensity: Float, + ) { + image ?: return + if (image.drawable != null) { + return + } + + val drawable = createHoneyspaceSideArrowLayerDrawable(image.context, direction) + applyHoneyspaceDarkIntensity(drawable, darkIntensity) + image.setImageDrawable(drawable) + } + + private fun applyHoneyspaceDarkIntensity(drawable: Drawable, darkIntensity: Float) { + val layerDrawable = drawable as? android.graphics.drawable.LayerDrawable ?: return + if (layerDrawable.numberOfLayers < 2) { + return + } + val clamped = darkIntensity.coerceIn(0f, 1f) + layerDrawable.getDrawable(0).alpha = ((1f - clamped) * 255f).toInt() + layerDrawable.getDrawable(1).alpha = (clamped * 255f).toInt() + layerDrawable.invalidateSelf() + } + + private fun createHoneyspaceExtraButton( + container: android.view.ViewGroup, + buttonType: Int, + keyCode: Int, + direction: SideArrowDirection, + ): View? { + val layoutId = container.context.resources.getIdentifier( + "layout_taskbar_nav_button", + "layout", + container.context.packageName, + ).takeIf { it != 0 } ?: return null + + val button = LayoutInflater.from(container.context) + .inflate(layoutId, container, false) as? View ?: return null + val drawable = createHoneyspaceSideArrowLayerDrawable(container.context, direction) + val setupMethod = findMethodsNamed(button.javaClass, "c").firstOrNull { method -> + val types = method.parameterTypes + types.size == 9 && + types[0] == Int::class.javaPrimitiveType && + Drawable::class.java.isAssignableFrom(types[1]) && + types[2] == Int::class.javaPrimitiveType && + types[3] == Int::class.javaPrimitiveType + } ?: return null + + val args = arrayOf( + buttonType, + drawable, + keyCode, + 0, + getFieldValue(container, "c"), + getFieldValue(container, "d"), + getFieldValue(container, "e"), + getFieldValue(container, "f"), + getFieldValue(container, "h"), + ) + return if (runCatching { setupMethod.invoke(button, *args) }.isSuccess) { + button + } else { + null + } + } + + private fun createHoneyspaceSideArrowLayerDrawable(context: Context, direction: SideArrowDirection): Drawable { + return android.graphics.drawable.LayerDrawable( + arrayOf( + sideArrowBitmapDrawable(context, direction, Color.WHITE), + sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)), + ), + ) + } + + private fun scheduleSideArrowLongPress(navButtonView: Any, keyCode: Int) { + val view = navButtonView as? View ?: return + val handler = mainHandler ?: Handler(Looper.getMainLooper()).also { mainHandler = it } + val longPressRunnable = Runnable { + sideArrowLongPressTriggered[navButtonView] = true + when (getSideArrowLongPressAction()) { + SideArrowLongPressAction.NOTHING -> Unit + SideArrowLongPressAction.SINGLE_STEP -> sendKeyPress(keyCode) + SideArrowLongPressAction.MULTIPLE_STEPS -> { + sendKeyPress(keyCode) + val repeatRunnable = object : Runnable { + override fun run() { + sendKeyPress(keyCode) + handler.postDelayed(this, sideArrowRepeatIntervalMs()) + } + } + sideArrowRepeatRunnables[navButtonView] = repeatRunnable + handler.postDelayed(repeatRunnable, sideArrowRepeatIntervalMs()) + } + } + } + sideArrowLongPressRunnables[navButtonView] = longPressRunnable + view.postDelayed(longPressRunnable, getSideArrowLongPressDurationMs().toLong()) + } + + private fun cancelSideArrowLongPress(navButtonView: Any) { + val view = navButtonView as? View + sideArrowLongPressRunnables.remove(navButtonView)?.let { runnable -> + view?.removeCallbacks(runnable) ?: mainHandler?.removeCallbacks(runnable) + } + sideArrowRepeatRunnables.remove(navButtonView)?.let { runnable -> + mainHandler?.removeCallbacks(runnable) + } + } + + private fun isSideArrowKeyCode(keyCode: Int): Boolean { + return keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + } + + private fun getSideArrowLongPressAction(): SideArrowLongPressAction { + return SideArrowLongPressAction.fromStorageValue( + remotePreferences?.getString(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION, null), + ) + } + + private fun getSideArrowLongPressDurationMs(): Int { + return ( + remotePreferences?.getInt( + NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS, + ViewConfiguration.getLongPressTimeout(), + ) ?: ViewConfiguration.getLongPressTimeout() + ).coerceLongPressDuration() + } + + private fun sideArrowRepeatIntervalMs(): Long { + val speed = sanitizeSideArrowRepeatSpeed( + remotePreferences?.getFloat( + NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ) ?: NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ) + return (1_000f / speed).toLong().coerceAtLeast(1L) + } + + private fun sanitizeSideArrowRepeatSpeed(stepsPerSecond: Float): Float { + return if (stepsPerSecond.isFinite()) { + stepsPerSecond.coerceIn( + NavButtonSettingsStore.MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + NavButtonSettingsStore.MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND, + ) + } else { + NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND + } + } + + private fun isHoneyspaceNavButtonContainer(view: View): Boolean { + return view.javaClass.name == HONEYSPACE_NAV_BUTTON_CONTAINER || + view.javaClass.simpleName == "NavigationBarButtonsLayout" || + viewIdName(view) == "navbar_button_container" + } + + private fun viewIdName(view: View): String { + val id = view.id + if (id == View.NO_ID) { + return "no_id" + } + return runCatching { view.resources.getResourceEntryName(id) }.getOrElse { id.toString() } + } + private fun markSystemUiLongClicked(keyButtonView: Any) { try { findField(keyButtonView.javaClass, "mLongClicked").setBoolean(keyButtonView, true) @@ -585,6 +1044,592 @@ class NavButtons : XposedModule() { } } + private fun hookFoldedSideArrows(classLoader: ClassLoader?) { + hookFoldedLayoutProvider(classLoader) + hookSamsungNavigationBarView(classLoader) + registerSettingsChangedReceiver() + } + + private fun hookFoldedLayoutProvider(classLoader: ClassLoader?) { + if (foldedSideArrowProviderHookInstalled) { + return + } + foldedSideArrowProviderHookInstalled = true + + listOf(SAMSUNG_PHONE_LAYOUT_PROVIDER, SAMSUNG_TABLET_LAYOUT_PROVIDER) + .forEach { className -> + val providerClass = findClassIfExists(className, classLoader) ?: run { + log("Side arrows provider class not found: $className") + return@forEach + } + + findMethodsNamed(providerClass, "getLayout") + .filter { method -> method.returnType == String::class.java } + .forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + val result = chain.proceed() + val layout = result as? String ?: return@intercept result + if (!isSideArrowsEnabled()) { + return@intercept layout + } + + val sideArrowLayout = addSideArrows(layout, chain.thisObject) + if (sideArrowLayout.layout != layout) { + chain.thisObject?.let { provider -> + sideArrowLayoutKinds[provider] = sideArrowLayout.kind + } + } + sideArrowLayout.layout + } + } + + hookFoldedLayoutProviderSizing(providerClass) + } + } + + private fun hookFoldedLayoutProviderSizing(providerClass: Class<*>) { + findMethodsNamed(providerClass, "getButtonWidth") + .filter(::isPointBooleanIntMethod) + .forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + sideArrowSize(chain.thisObject, chain.args, phoneRatio = PHONE_BUTTON_RATIO, tabletRatio = TABLET_BUTTON_RATIO) + ?: chain.proceed() + } + } + + findMethodsNamed(providerClass, "getButtonDistanceSize") + .filter(::isPointBooleanIntMethod) + .forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + sideArrowSize(chain.thisObject, chain.args, phoneRatio = SIDE_ARROW_GAP_RATIO, tabletRatio = SIDE_ARROW_GAP_RATIO) + ?: chain.proceed() + } + } + + findMethodsNamed(providerClass, "getSpaceWidth") + .filter { method -> + method.returnType == Int::class.javaPrimitiveType && + method.parameterTypes.firstOrNull() == Point::class.java + } + .forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + sideArrowSize(chain.thisObject, chain.args, phoneRatio = PHONE_SPACE_RATIO, tabletRatio = TABLET_SPACE_RATIO) + ?: chain.proceed() + } + } + + findMethodsNamed(providerClass, "getSpaceSidePadding") + .filter { method -> + method.returnType == Int::class.javaPrimitiveType && + method.parameterTypes.firstOrNull() == Point::class.java + } + .forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + sideArrowSize(chain.thisObject, chain.args, phoneRatio = 0.0, tabletRatio = TABLET_SPACE_SIDE_PADDING_RATIO) + ?: chain.proceed() + } + } + } + + private fun isPointBooleanIntMethod(method: Method): Boolean { + val parameterTypes = method.parameterTypes + return method.returnType == Int::class.javaPrimitiveType && + parameterTypes.size >= 2 && + parameterTypes[0] == Point::class.java && + parameterTypes[1] == Boolean::class.javaPrimitiveType + } + + private fun sideArrowSize(provider: Any?, args: List, phoneRatio: Double, tabletRatio: Double): Int? { + if (!isSideArrowsEnabled()) { + return null + } + val displaySize = args.firstOrNull() as? Point ?: return null + val kind = provider?.let { sideArrowLayoutKinds[it] } ?: SideArrowLayoutKind.PHONE + val ratio = if (kind == SideArrowLayoutKind.TABLET) tabletRatio else phoneRatio + return (minOf(displaySize.x, displaySize.y) * ratio * SIDE_ARROW_REDUCE_RATIO).toInt() + } + + private fun addSideArrows(layout: String, provider: Any?): SideArrowLayout { + val providerContext = findContext(provider) ?: context + val leftKey = navCustomKeyToken(providerContext, KeyEvent.KEYCODE_DPAD_LEFT, 1) + val rightKey = navCustomKeyToken(providerContext, KeyEvent.KEYCODE_DPAD_RIGHT, 2) + return when (layout) { + NAV_LAYOUT_PHONE_DEFAULT -> + SideArrowLayout("left,recent;$leftKey,home,$rightKey;back,right", SideArrowLayoutKind.PHONE) + NAV_LAYOUT_PHONE_REVERSED -> + SideArrowLayout("left,back;$leftKey,home,$rightKey;recent,right", SideArrowLayoutKind.PHONE) + NAV_LAYOUT_TABLET_DEFAULT -> + SideArrowLayout("left;recent,$leftKey,home,$rightKey,back;right", SideArrowLayoutKind.TABLET) + NAV_LAYOUT_TABLET_REVERSED -> + SideArrowLayout("left;back,$leftKey,home,$rightKey,recent;right", SideArrowLayoutKind.TABLET) + NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT -> + SideArrowLayout("left;back,$leftKey,home,$rightKey,recent;r", SideArrowLayoutKind.TABLET) + NAV_LAYOUT_WINNER_DEFAULT -> + SideArrowLayout("left,back,$leftKey,home,$rightKey,recent;;right", SideArrowLayoutKind.TABLET) + NAV_LAYOUT_WINNER_REVERSED -> + SideArrowLayout("left,recent,$leftKey,home,$rightKey,back;;right", SideArrowLayoutKind.TABLET) + NAV_LAYOUT_UNFOLDED_DEFAULT -> + SideArrowLayout("left;;extrakey,back,$leftKey,home,$rightKey,recent,right", SideArrowLayoutKind.TABLET) + NAV_LAYOUT_UNFOLDED_REVERSED -> + SideArrowLayout("left;;extrakey,recent,$leftKey,home,$rightKey,back,right", SideArrowLayoutKind.TABLET) + else -> SideArrowLayout(layout, SideArrowLayoutKind.PHONE) + } + } + + private fun navCustomKeyToken(sourceContext: Context?, keyCode: Int, index: Int): String { + val customKeyId = navCustomKeyId(sourceContext, index) + if (customKeyId == 0) { + log("Folded side arrows custom key id not found: nav_custom_key_$index") + } + return "navkey($keyCode:$customKeyId)" + } + + private fun navCustomKeyId(sourceContext: Context?, index: Int): Int { + return sourceContext?.resources?.getIdentifier( + "nav_custom_key_$index", + "id", + sourceContext.packageName, + ) ?: 0 + } + + private fun findContext(target: Any?): Context? { + var current: Class<*>? = target?.javaClass ?: return null + while (current != null) { + current.declaredFields.forEach { field -> + try { + field.isAccessible = true + val value = field.get(target) + if (value is Context) { + return value.applicationContext ?: value + } + } catch (_: Throwable) { + return@forEach + } + } + current = current.superclass + } + return null + } + + private fun findButtonDispatcherProxy(root: Any): Any? { + findButtonDispatcherProxyFromMethods(root)?.let { return it } + + val visited = Collections.newSetFromMap(WeakHashMap()) + val queue = ArrayDeque>() + queue.add(root to 0) + + while (queue.isNotEmpty()) { + val (target, depth) = queue.removeFirst() + if (!visited.add(target)) { + continue + } + if (isButtonDispatcherProxy(target)) { + return target + } + findButtonDispatcherProxyFromMethods(target)?.let { return it } + if (depth >= BUTTON_DISPATCHER_SEARCH_DEPTH) { + continue + } + + var current: Class<*>? = target.javaClass + while (current != null) { + current.declaredFields.forEach { field -> + try { + field.isAccessible = true + val value = field.get(target) ?: return@forEach + if (shouldSearchButtonDispatcherField(value)) { + queue.add(value to depth + 1) + } + } catch (_: Throwable) { + return@forEach + } + } + current = current.superclass + } + } + + return null + } + + private fun findButtonDispatcherProxyFromMethods(target: Any): Any? { + findMethodsNamed(target.javaClass, "getButtonDispatcherProxy") + .firstOrNull { it.parameterTypes.isEmpty() } + ?.let { method -> + runCatching { method.invoke(target) } + .getOrNull() + ?.takeIf(::isButtonDispatcherProxy) + ?.let { return it } + } + return null + } + + private fun isButtonDispatcherProxy(target: Any): Boolean { + return findMethodsNamed(target.javaClass, "setButtonImage").any { method -> + val types = method.parameterTypes + types.size == 3 && + types[0] == Int::class.javaPrimitiveType && + Drawable::class.java.isAssignableFrom(types[1]) && + Drawable::class.java.isAssignableFrom(types[2]) + } + } + + private fun shouldSearchButtonDispatcherField(value: Any): Boolean { + val className = value.javaClass.name + return className.startsWith("com.android.systemui") || + className.startsWith("com.samsung.systemui") || + className.startsWith("java.util") || + className.startsWith("android.util") + } + + private fun callIntMethodIfExists(target: Any, methodName: String, value: Int): Boolean { + return findMethodsNamed(target.javaClass, methodName) + .firstOrNull { method -> + val types = method.parameterTypes + types.size == 1 && types[0] == Int::class.javaPrimitiveType + } + ?.let { method -> + runCatching { + method.invoke(target, value) + }.isSuccess + } == true + } + + private fun callIntIntMethodIfExists(target: Any, methodName: String, first: Int, second: Int): Boolean { + return findMethodsNamed(target.javaClass, methodName) + .firstOrNull { method -> + val types = method.parameterTypes + types.size == 2 && + types[0] == Int::class.javaPrimitiveType && + types[1] == Int::class.javaPrimitiveType + } + ?.let { method -> + runCatching { + method.invoke(target, first, second) + }.isSuccess + } == true + } + + private fun callFloatMethodIfExists(target: Any, methodName: String, value: Float): Boolean { + return findMethodsNamed(target.javaClass, methodName) + .firstOrNull { method -> + val types = method.parameterTypes + types.size == 1 && types[0] == Float::class.javaPrimitiveType + } + ?.let { method -> + runCatching { + method.invoke(target, value) + }.isSuccess + } == true + } + + private fun callSetButtonImageIfExists( + target: Any, + id: Int, + lightDrawable: Drawable, + darkDrawable: Drawable, + ): Boolean { + return findMethodsNamed(target.javaClass, "setButtonImage") + .firstOrNull { method -> + val types = method.parameterTypes + types.size == 3 && + types[0] == Int::class.javaPrimitiveType && + Drawable::class.java.isAssignableFrom(types[1]) && + Drawable::class.java.isAssignableFrom(types[2]) + } + ?.let { method -> + runCatching { + method.invoke(target, id, lightDrawable, darkDrawable) + }.isSuccess + } == true + } + + private fun hookSamsungNavigationBarView(classLoader: ClassLoader?) { + if (samsungNavigationBarViewHookInstalled) { + return + } + samsungNavigationBarViewHookInstalled = true + + val navBarViewClass = findClassIfExists(SAMSUNG_NAVIGATION_BAR_VIEW, classLoader) ?: run { + log("Folded side arrows nav view class not found: $SAMSUNG_NAVIGATION_BAR_VIEW") + return + } + + listOf( + "onAttachedToWindow", + "onFinishInflate", + "reInflateNavBarLayout", + "updateLayoutProviderView", + ).forEach { methodName -> + findDeclaredMethodsNamed(navBarViewClass, methodName).forEach { method -> + deoptimizeMethodIfPresent(method) + hook(method).intercept { chain -> + chain.thisObject?.let { navBarView -> + ensureContextFromView(navBarView) + registerSettingsChangedReceiver() + trackSamsungNavigationBar(navBarView) + } + val result = chain.proceed() + result + } + } + } + } + + private fun registerSettingsChangedReceiver() { + if (settingsChangedReceiverRegistered) { + return + } + val currentContext = context ?: return + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != NavButtonSettingsStore.ACTION_SETTINGS_CHANGED) { + return + } + sideArrowsEnabledOverride = intent.getBooleanExtra( + NavButtonSettingsStore.EXTRA_SIDE_ARROWS_ENABLED, + remotePreferences?.getBoolean(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, false) == true, + ) + reInflateTrackedSamsungNavigationBars() + refreshTrackedHoneyspaceSideArrowLayouts("settings changed") + } + } + + try { + currentContext.registerReceiver( + receiver, + IntentFilter(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED), + Context.RECEIVER_EXPORTED, + ) + settingsChangedReceiverRegistered = true + } catch (error: Throwable) { + log("Folded side arrows settings receiver failed: ${error.javaClass.simpleName}") + } + } + + private fun trackSamsungNavigationBarView(startView: Any) { + var current: View? = startView as? View ?: return + repeat(SAMSUNG_NAV_VIEW_PARENT_DEPTH) { + val target = current ?: return + if (target.javaClass.name == SAMSUNG_NAVIGATION_BAR_VIEW) { + trackSamsungNavigationBar(target) + return + } + current = target.parent as? View + } + } + + private fun trackSamsungNavigationBar(navBarView: Any) { + trackedSamsungNavigationBarViews.add(navBarView) + configureFoldedSideArrowButtons(navBarView) + } + + private fun callNoArgMethodIfExists(target: Any, methodName: String): Any? { + return findMethodsNamed(target.javaClass, methodName) + .firstOrNull { method -> method.parameterTypes.isEmpty() } + ?.let { method -> + runCatching { method.invoke(target) }.getOrNull() + } + } + + private fun callMethodIfExists(target: Any, methodName: String): Any? { + return findMethodsNamed(target.javaClass, methodName) + .firstOrNull { method -> method.parameterTypes.isEmpty() } + ?.let { method -> + runCatching { method.invoke(target) }.getOrNull() + } + } + + private fun callBooleanMethodIfExists(target: Any, methodName: String, value: Boolean): Boolean { + return findMethodsNamed(target.javaClass, methodName) + .firstOrNull { method -> + val types = method.parameterTypes + types.size == 1 && types[0] == Boolean::class.javaPrimitiveType + } + ?.let { method -> + runCatching { method.invoke(target, value) }.isSuccess + } ?: false + } + + private fun reInflateTrackedSamsungNavigationBars() { + val views = trackedSamsungNavigationBarViews.toList() + if (views.isEmpty()) { + return + } + + views.forEach { navBarView -> + try { + refreshSamsungNavigationBar(navBarView) + if (isSideArrowsEnabled()) { + mainHandler?.postDelayed( + { + refreshSamsungNavigationBar(navBarView) + }, + SIDE_ARROW_REFRESH_DELAY_MS, + ) + } + } catch (error: Throwable) { + log("Side arrows reinflate failed: ${error.javaClass.simpleName}") + } + } + } + + private fun refreshSamsungNavigationBar(navBarView: Any) { + try { + configureFoldedSideArrowButtons(navBarView) + callMethodIfExists(navBarView, "updateLayoutProviderView") + callMethod(navBarView, "reInflateNavBarLayout", emptyArray>()) + configureFoldedSideArrowButtons(navBarView) + } catch (error: Throwable) { + log("Side arrows nav refresh failed: ${error.javaClass.simpleName}") + } + } + + private fun configureFoldedSideArrowButtons(navBarView: Any) { + if (!isSideArrowsEnabled()) { + return + } + + if (!configureSideArrowDispatcherButtons(navBarView, navBarView)) { + return + } + + } + + private fun configureSideArrowDispatcherButtons( + source: Any, + darkIntensitySource: Any? = source, + ): Boolean { + val navContext = navigationBarContext(source) ?: return false + val dispatcherProxy = findButtonDispatcherProxy(source) + ?: return false + + configureSideArrowDispatcherButton( + dispatcherProxy, + darkIntensitySource, + navContext, + index = 1, + direction = SideArrowDirection.LEFT, + ) + configureSideArrowDispatcherButton( + dispatcherProxy, + darkIntensitySource, + navContext, + index = 2, + direction = SideArrowDirection.RIGHT, + ) + return true + } + + private fun navigationBarContext(source: Any): Context? { + return (callNoArgMethodIfExists(source, "getNavigationBarContext") as? Context) + ?.let { it.applicationContext ?: it } + ?: findContext(source) + ?: context + } + + private fun configureSideArrowDispatcherButton( + dispatcherProxy: Any, + darkIntensitySource: Any?, + context: Context, + index: Int, + direction: SideArrowDirection, + ) { + val id = navCustomKeyId(context, index) + if (id == 0) { + return + } + + callIntMethodIfExists(dispatcherProxy, "addButton", id) + callSetButtonImageIfExists( + dispatcherProxy, + id, + sideArrowBitmapDrawable(context, direction, Color.WHITE), + sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)), + ) + callIntIntMethodIfExists(dispatcherProxy, "setButtonVisibility", id, View.VISIBLE) + if (darkIntensitySource != null) { + setSideArrowDarkIntensity(darkIntensitySource, id) + } + } + + private fun setSideArrowDarkIntensity(navBarView: Any, id: Int) { + val dispatchers = getFieldValue(navBarView, "mButtonDispatchers") as? android.util.SparseArray<*> ?: return + val dispatcher = dispatchers.get(id) ?: return + val intensity = findReferenceDarkIntensity(dispatchers) + callFloatMethodIfExists(dispatcher, "setDarkIntensity", intensity) + setFieldIfExists(dispatcher, "mDarkIntensity", intensity) + getFieldValue(dispatcher, "mImageDrawable")?.let { drawable -> + callFloatMethodIfExists(drawable, "setDarkIntensity", intensity) + } + getFieldValue(dispatcher, "mViews") + .let { it as? Iterable<*> } + ?.forEach { view -> + if (view != null) { + callFloatMethodIfExists(view, "setDarkIntensity", intensity) + setFieldIfExists(view, "mDarkIntensity", intensity) + } + } + } + + private fun findReferenceDarkIntensity(dispatchers: android.util.SparseArray<*>): Float { + listOf("home", "back", "recent").forEach { name -> + val currentContext = context ?: return@forEach + val id = currentContext.resources.getIdentifier(name, "id", currentContext.packageName) + val value = getFieldValue(dispatchers.get(id), "mDarkIntensity") as? Float + if (value != null) { + return value + } + } + return 0f + } + + private fun setFieldIfExists(target: Any, fieldName: String, value: Any?) { + runCatching { + findField(target.javaClass, fieldName).set(target, value) + } + } + + private fun navStarArrowIconSizePx(context: Context): Int { + return (context.resources.displayMetrics.density * SIDE_ARROW_ICON_DP + 0.5f).toInt() + } + + private fun sideArrowBitmapDrawable( + context: Context, + direction: SideArrowDirection, + color: Int, + ): Drawable { + val size = navStarArrowIconSizePx(context).coerceAtLeast(1) + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + SideArrowDrawable(direction, color, size).apply { + setBounds(0, 0, size, size) + draw(canvas) + } + return BitmapDrawable(context.resources, bitmap) + } + + private fun ensureContextFromView(value: Any) { + if (context != null) { + return + } + val viewContext = (value as? View)?.context ?: return + context = viewContext.applicationContext ?: viewContext + if (mainHandler == null) { + mainHandler = Handler(Looper.getMainLooper()) + } + } + + private fun isSideArrowsEnabled(): Boolean { + return sideArrowsEnabledOverride + ?: (remotePreferences?.getBoolean(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, false) == true) + } + private fun injectKeyEvent(inputManager: Any, keyEvent: KeyEvent) { callMethod( inputManager, @@ -1154,6 +2199,12 @@ class NavButtons : XposedModule() { } } + private fun findDeclaredMethodsNamed(clazz: Class<*>, methodName: String): List { + return clazz.declaredMethods + .filter { it.name == methodName } + .onEach { it.isAccessible = true } + } + private fun findMethodsNamed(clazz: Class<*>, methodName: String): List { val methodsBySignature = linkedMapOf() var current: Class<*>? = clazz @@ -1315,6 +2366,89 @@ class NavButtons : XposedModule() { return false } + private enum class SideArrowDirection { + LEFT, + RIGHT, + } + + private enum class SideArrowLayoutKind { + PHONE, + TABLET, + } + + private data class SideArrowLayout( + val layout: String, + val kind: SideArrowLayoutKind, + ) + + private class SideArrowDrawable( + private val direction: SideArrowDirection, + color: Int, + private val iconSizePx: Int, + private val colorProvider: (() -> Int)? = null, + ) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + this.color = color + style = Paint.Style.STROKE + strokeCap = Paint.Cap.SQUARE + strokeJoin = Paint.Join.MITER + } + private val path = Path() + + override fun draw(canvas: Canvas) { + val bounds = bounds + val width = bounds.width().toFloat() + val height = bounds.height().toFloat() + if (width <= 0f || height <= 0f) { + return + } + + val size = minOf(width, height, iconSizePx.toFloat()) * SIDE_ARROW_GLYPH_SCALE + val left = bounds.left + (width - size) / 2f + val top = bounds.top + (height - size) / 2f + val scale = size / 24f + + colorProvider?.let { provider -> + val alpha = paint.alpha + paint.color = provider() + paint.alpha = alpha + } + path.reset() + if (direction == SideArrowDirection.LEFT) { + path.moveTo(left + 15f * scale, top + 7f * scale) + path.lineTo(left + 10f * scale, top + 12f * scale) + path.lineTo(left + 15f * scale, top + 17f * scale) + } else { + path.moveTo(left + 9f * scale, top + 7f * scale) + path.lineTo(left + 14f * scale, top + 12f * scale) + path.lineTo(left + 9f * scale, top + 17f * scale) + } + paint.strokeWidth = SIDE_ARROW_STROKE_WIDTH * scale + canvas.drawPath(path, paint) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + override fun getIntrinsicWidth(): Int { + return iconSizePx + } + + override fun getIntrinsicHeight(): Int { + return iconSizePx + } + + @Deprecated("Deprecated in Android framework") + override fun getOpacity(): Int { + return PixelFormat.TRANSLUCENT + } + } + private companion object { const val DEBUG_TRACE = false const val TAG = "NavButtons" @@ -1329,10 +2463,44 @@ class NavButtons : XposedModule() { const val SYSTEM_SERVER_PHONE_WINDOW_MANAGER = "com.android.server.policy.PhoneWindowManager" const val SYSTEMUI_KEY_BUTTON_VIEW = "com.android.systemui.navigationbar.views.buttons.KeyButtonView" + const val SAMSUNG_PHONE_LAYOUT_PROVIDER = + "com.android.systemui.navigationbar.layout.LayoutProviderImpl" + const val SAMSUNG_TABLET_LAYOUT_PROVIDER = + "com.android.systemui.navigationbar.layout.TabletLayoutProviderImpl" + const val SAMSUNG_NAVIGATION_BAR_VIEW = + "com.android.systemui.navigationbar.views.SamsungNavigationBarView" const val SAMSUNG_LAUNCHER_PACKAGE = "com.sec.android.app.launcher" + const val SIDE_ARROW_ICON_DP = 32f + const val SIDE_ARROW_GLYPH_SCALE = 0.8f + const val SIDE_ARROW_STROKE_WIDTH = 1.45f const val HONEYSPACE_NAV_BUTTON_VIEW = "com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView" + const val HONEYSPACE_NAV_BUTTON_CONTAINER = + "com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarButtonsLayout" const val HONEYSPACE_LONG_PRESS_RUNNABLE = "f.a" const val HONEYSPACE_LONG_PRESS_CASE = 8 + const val HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH = 8 + const val HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS = 150L + const val HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS = 600L + val HONEYSPACE_EXTRA_BUTTON_TYPES = listOf(64, 128, 256, 512, 1024) + const val NAV_LAYOUT_PHONE_DEFAULT = "left,recent;home;back,right" + const val NAV_LAYOUT_PHONE_REVERSED = "left,back;home;recent,right" + const val NAV_LAYOUT_TABLET_DEFAULT = "left;recent,gap,home,gap,back;right" + const val NAV_LAYOUT_TABLET_REVERSED = "left;back,gap,home,gap,recent;right" + const val NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT = "left;back,gap,home,gap,recent;r" + const val NAV_LAYOUT_WINNER_DEFAULT = "left,gap,back,gap,home,gap,recent;;right" + const val NAV_LAYOUT_WINNER_REVERSED = "left,gap,recent,gap,home,gap,back;;right" + const val NAV_LAYOUT_UNFOLDED_DEFAULT = "left;;back,gap,home,gap,recent,gap,right" + const val NAV_LAYOUT_UNFOLDED_REVERSED = "left;;recent,gap,home,gap,back,gap,right" + const val SAMSUNG_NAV_VIEW_PARENT_DEPTH = 8 + const val BUTTON_DISPATCHER_SEARCH_DEPTH = 4 + const val SIDE_ARROW_REFRESH_DELAY_MS = 100L + const val PHONE_BUTTON_RATIO = 0.22220000624656677 + const val TABLET_BUTTON_RATIO = 0.2070000022649765 + const val SIDE_ARROW_GAP_RATIO = 0.013000000268220901 + const val PHONE_SPACE_RATIO = 0.10999999940395355 + const val TABLET_SPACE_RATIO = 0.10999999940395355 + const val TABLET_SPACE_SIDE_PADDING_RATIO = 0.033250000327825546 + const val SIDE_ARROW_REDUCE_RATIO = 0.75 } } diff --git a/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt index e326f71..2080625 100644 --- a/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt +++ b/app/src/main/java/se/ajpanton/navbuttons/SettingsActivity.kt @@ -1,6 +1,7 @@ package se.ajpanton.navbuttons import android.app.Activity +import android.content.Intent import android.os.Bundle import android.os.Handler import android.os.Looper @@ -13,10 +14,15 @@ import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Button +import android.widget.CheckBox import android.widget.EditText import android.widget.LinearLayout import android.widget.Spinner import android.widget.TextView +import android.widget.Toast +import java.text.DecimalFormatSymbols +import java.util.Locale +import java.util.concurrent.TimeUnit class SettingsActivity : Activity() { private val handler = Handler(Looper.getMainLooper()) @@ -61,23 +67,36 @@ class SettingsActivity : Activity() { false, ) if (index > 0) { - val marginTop = resources.getDimensionPixelSize(R.dimen.section_spacing) - val layoutParams = sectionView.layoutParams as? ViewGroup.MarginLayoutParams - if (layoutParams != null) { - layoutParams.topMargin = marginTop - } else { - sectionView.layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ).apply { - topMargin = marginTop - } - } + applySectionTopMargin(sectionView) } NavButtonSectionController(sectionView, buttonId).bind() sectionsContainer.addView(sectionView) } + + val sideArrowsSectionView = inflater.inflate( + R.layout.item_side_arrows_section, + sectionsContainer, + false, + ) + applySectionTopMargin(sideArrowsSectionView) + SideArrowsSectionController(sideArrowsSectionView).bind() + sectionsContainer.addView(sideArrowsSectionView) + } + + private fun applySectionTopMargin(sectionView: View) { + val marginTop = resources.getDimensionPixelSize(R.dimen.section_spacing) + val layoutParams = sectionView.layoutParams as? ViewGroup.MarginLayoutParams + if (layoutParams != null) { + layoutParams.topMargin = marginTop + } else { + sectionView.layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + topMargin = marginTop + } + } } private fun startTestPreview(durationMs: Int) { @@ -96,6 +115,45 @@ class SettingsActivity : Activity() { previewOverlay.visibility = View.GONE } + private fun attachRepeatButton(button: Button, action: () -> Unit) { + var repeated = false + val repeatRunnable = object : Runnable { + override fun run() { + repeated = true + action() + handler.postDelayed(this, NavButtonSettingsStore.HOLD_REPEAT_INTERVAL_MS) + } + } + + button.setOnClickListener { + if (!repeated) { + action() + } + } + + button.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + repeated = false + handler.postDelayed( + repeatRunnable, + NavButtonSettingsStore.HOLD_REPEAT_START_DELAY_MS, + ) + false + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + handler.removeCallbacks(repeatRunnable) + false + } + + else -> false + } + } + } + private inner class NavButtonSectionController( sectionView: View, private val buttonId: NavButtonId, @@ -228,45 +286,6 @@ class SettingsActivity : Activity() { } } - private fun attachRepeatButton(button: Button, action: () -> Unit) { - var repeated = false - val repeatRunnable = object : Runnable { - override fun run() { - repeated = true - action() - handler.postDelayed(this, NavButtonSettingsStore.HOLD_REPEAT_INTERVAL_MS) - } - } - - button.setOnClickListener { - if (!repeated) { - action() - } - } - - button.setOnTouchListener { view, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> { - repeated = false - handler.postDelayed( - repeatRunnable, - NavButtonSettingsStore.HOLD_REPEAT_START_DELAY_MS, - ) - false - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL, - -> { - handler.removeCallbacks(repeatRunnable) - false - } - - else -> false - } - } - } - private fun adjustDuration(deltaMs: Int) { val current = resolveDurationForUse() val updated = (current + deltaMs).coerceIn( @@ -303,4 +322,346 @@ class SettingsActivity : Activity() { } } } + + private inner class SideArrowsSectionController(sectionView: View) { + private val sideArrowsCheckbox: CheckBox = sectionView.findViewById(R.id.side_arrows_checkbox) + private val optionsContainer: LinearLayout = + sectionView.findViewById(R.id.side_arrows_options_container) + private val longPressActionSpinner: Spinner = + sectionView.findViewById(R.id.side_arrow_long_press_action_spinner) + private val repeatSpeedContainer: LinearLayout = + sectionView.findViewById(R.id.side_arrow_repeat_speed_container) + private val repeatSpeedInput: EditText = + sectionView.findViewById(R.id.side_arrow_repeat_speed_input) + private val speedMinusButton: Button = + sectionView.findViewById(R.id.side_arrow_speed_minus_button) + private val speedPlusButton: Button = + sectionView.findViewById(R.id.side_arrow_speed_plus_button) + private val durationInput: EditText = + sectionView.findViewById(R.id.side_arrow_long_press_duration_input) + private val durationMinusButton: Button = + sectionView.findViewById(R.id.side_arrow_duration_minus_button) + private val durationPlusButton: Button = + sectionView.findViewById(R.id.side_arrow_duration_plus_button) + private val testButton: Button = sectionView.findViewById(R.id.side_arrow_test_button) + private val restartSamsungUiButton: Button = + sectionView.findViewById(R.id.restart_samsung_ui_button) + + private val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator + private var lastValidDurationMs = settingsStore.defaultLongPressDurationMs() + private var lastValidRepeatSpeed = NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND + private var updatingDurationText = false + private var updatingRepeatSpeedText = false + + fun bind() { + sideArrowsCheckbox.isChecked = settingsStore.isSideArrowsEnabled() + bindLongPressActionSpinner() + applyRepeatSpeed(settingsStore.getSideArrowRepeatStepsPerSecond(), persist = false) + bindRepeatSpeedInput() + bindRepeatSpeedButtons() + applyDuration(settingsStore.getSideArrowLongPressDurationMs(), persist = false) + bindDurationInput() + bindDurationButtons() + bindTestButton() + updateOptionsEnabled() + + sideArrowsCheckbox.setOnCheckedChangeListener { _, isChecked -> + settingsStore.setSideArrowsEnabled(isChecked) + updateOptionsEnabled() + sendBroadcast( + Intent(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED).putExtra( + NavButtonSettingsStore.EXTRA_SIDE_ARROWS_ENABLED, + isChecked, + ), + ) + } + restartSamsungUiButton.setOnClickListener { + restartSamsungUiButton.isEnabled = false + Toast.makeText( + this@SettingsActivity, + "Restarting One UI Home and SystemUI...", + Toast.LENGTH_SHORT, + ).show() + Thread { + val result = restartSamsungUiProcesses() + handler.post { + restartSamsungUiButton.isEnabled = true + Toast.makeText(this@SettingsActivity, result, Toast.LENGTH_LONG).show() + } + }.start() + } + } + + private fun bindLongPressActionSpinner() { + val labels = SideArrowLongPressAction.entries.map { getString(it.labelRes) } + val adapter = ArrayAdapter( + this@SettingsActivity, + R.layout.item_spinner_selected, + labels, + ) + adapter.setDropDownViewResource(R.layout.item_spinner_dropdown) + longPressActionSpinner.adapter = adapter + longPressActionSpinner.setSelection( + SideArrowLongPressAction.entries.indexOf(settingsStore.getSideArrowLongPressAction()), + false, + ) + longPressActionSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + val action = SideArrowLongPressAction.entries[position] + settingsStore.setSideArrowLongPressAction(action) + updateRepeatSpeedVisibility(action) + } + + override fun onNothingSelected(parent: AdapterView<*>?) = Unit + } + updateRepeatSpeedVisibility(settingsStore.getSideArrowLongPressAction()) + } + + private fun bindRepeatSpeedInput() { + repeatSpeedInput.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + normalizeTypedRepeatSpeed() + } + } + + repeatSpeedInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int, + ) = Unit + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) = Unit + + override fun afterTextChanged(editable: Editable?) { + if (updatingRepeatSpeedText) { + return + } + + val parsed = parseRepeatSpeed(editable?.toString()) ?: return + if (parsed in NavButtonSettingsStore.MIN_SIDE_ARROW_REPEAT_STEPS_PER_SECOND.. + NavButtonSettingsStore.MAX_SIDE_ARROW_REPEAT_STEPS_PER_SECOND + ) { + lastValidRepeatSpeed = parsed + settingsStore.setSideArrowRepeatStepsPerSecond(parsed) + } + } + }) + } + + private fun bindRepeatSpeedButtons() { + attachRepeatButton(speedMinusButton) { + adjustRepeatSpeed(-NavButtonSettingsStore.SIDE_ARROW_REPEAT_SPEED_STEP) + } + attachRepeatButton(speedPlusButton) { + adjustRepeatSpeed(NavButtonSettingsStore.SIDE_ARROW_REPEAT_SPEED_STEP) + } + } + + private fun bindDurationInput() { + durationInput.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + normalizeTypedDuration() + } + } + + durationInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged( + s: CharSequence?, + start: Int, + count: Int, + after: Int, + ) = Unit + + override fun onTextChanged( + s: CharSequence?, + start: Int, + before: Int, + count: Int, + ) = Unit + + override fun afterTextChanged(editable: Editable?) { + if (updatingDurationText) { + return + } + + val parsed = editable?.toString()?.toIntOrNull() ?: return + if (parsed in NavButtonSettingsStore.MIN_DURATION_MS..NavButtonSettingsStore.MAX_DURATION_MS) { + lastValidDurationMs = parsed + settingsStore.setSideArrowLongPressDurationMs(parsed) + } + } + }) + } + + private fun bindDurationButtons() { + attachRepeatButton(durationMinusButton) { + adjustDuration(-NavButtonSettingsStore.DURATION_STEP_MS) + } + attachRepeatButton(durationPlusButton) { + adjustDuration(NavButtonSettingsStore.DURATION_STEP_MS) + } + } + + private fun bindTestButton() { + testButton.setOnTouchListener { view, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + view.isPressed = true + startTestPreview(resolveDurationForUse()) + true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + view.isPressed = false + stopTestPreview() + true + } + + else -> false + } + } + } + + private fun updateOptionsEnabled() { + val enabled = sideArrowsCheckbox.isChecked + optionsContainer.alpha = if (enabled) 1f else 0.45f + setEnabledRecursively(optionsContainer, enabled) + } + + private fun setEnabledRecursively(view: View, enabled: Boolean) { + view.isEnabled = enabled + if (view is ViewGroup) { + repeat(view.childCount) { index -> + setEnabledRecursively(view.getChildAt(index), enabled) + } + } + } + + private fun updateRepeatSpeedVisibility(action: SideArrowLongPressAction) { + repeatSpeedContainer.visibility = + if (action == SideArrowLongPressAction.MULTIPLE_STEPS) View.VISIBLE else View.GONE + } + + private fun adjustRepeatSpeed(delta: Float) { + applyRepeatSpeed(resolveRepeatSpeedForUse() + delta, persist = true) + } + + private fun normalizeTypedRepeatSpeed(): Float { + val parsed = parseRepeatSpeed(repeatSpeedInput.text.toString()) ?: lastValidRepeatSpeed + val normalized = settingsStore.sanitizeSideArrowRepeatSpeed(parsed) + applyRepeatSpeed(normalized, persist = true) + return normalized + } + + private fun resolveRepeatSpeedForUse(): Float { + return normalizeTypedRepeatSpeed() + } + + private fun applyRepeatSpeed(stepsPerSecond: Float, persist: Boolean) { + val sanitized = settingsStore.sanitizeSideArrowRepeatSpeed(stepsPerSecond) + val text = formatRepeatSpeed(sanitized) + updatingRepeatSpeedText = true + if (repeatSpeedInput.text.toString() != text) { + repeatSpeedInput.setText(text) + repeatSpeedInput.setSelection(repeatSpeedInput.text.length) + } + updatingRepeatSpeedText = false + + lastValidRepeatSpeed = sanitized + if (persist) { + settingsStore.setSideArrowRepeatStepsPerSecond(sanitized) + } + } + + private fun parseRepeatSpeed(text: String?): Float? { + return text + ?.trim() + ?.replace(',', '.') + ?.toFloatOrNull() + } + + private fun formatRepeatSpeed(stepsPerSecond: Float): String { + val raw = if (stepsPerSecond == stepsPerSecond.toInt().toFloat()) { + stepsPerSecond.toInt().toString() + } else { + String.format(Locale.US, "%.2f", stepsPerSecond) + .trimEnd('0') + .trimEnd('.') + } + return raw.replace('.', decimalSeparator) + } + + private fun adjustDuration(deltaMs: Int) { + val current = resolveDurationForUse() + val updated = (current + deltaMs).coerceIn( + NavButtonSettingsStore.MIN_DURATION_MS, + NavButtonSettingsStore.MAX_DURATION_MS, + ) + applyDuration(updated, persist = true) + } + + private fun normalizeTypedDuration(): Int { + val parsed = durationInput.text.toString().toIntOrNull() ?: lastValidDurationMs + val normalized = settingsStore.sanitizeDuration(parsed) + applyDuration(normalized, persist = true) + return normalized + } + + private fun resolveDurationForUse(): Int { + return normalizeTypedDuration() + } + + private fun applyDuration(durationMs: Int, persist: Boolean) { + val sanitized = settingsStore.sanitizeDuration(durationMs) + val text = sanitized.toString() + updatingDurationText = true + if (durationInput.text.toString() != text) { + durationInput.setText(text) + durationInput.setSelection(durationInput.text.length) + } + updatingDurationText = false + + lastValidDurationMs = sanitized + if (persist) { + settingsStore.setSideArrowLongPressDurationMs(sanitized) + } + } + + private fun restartSamsungUiProcesses(): String { + return try { + val command = listOf( + "am force-stop com.sec.android.app.launcher", + "killall com.android.systemui", + "sleep 1", + "am start -a android.intent.action.MAIN -c android.intent.category.HOME", + ).joinToString("; ") + val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) + val finished = process.waitFor(10, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + "Restart command timed out." + } else if (process.exitValue() == 0) { + "Restart command completed." + } else { + "Restart command failed. Is root allowed?" + } + } catch (_: Throwable) { + "Restart command failed. Is root allowed?" + } + } + } } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 3d2bfbc..a03d273 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -29,15 +29,6 @@ android:textSize="30sp" android:textStyle="bold" /> - - + + + + + + + + + + + + + + + + + + +