Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963dbd7b5f | ||
|
|
4ba27bd9cf | ||
|
|
aaa471ec33 | ||
|
|
6f308a823c | ||
|
|
6b11e76504 | ||
|
|
60769980cd | ||
|
|
c2ee5a5581 | ||
|
|
465dec356d | ||
|
|
1fc63aef8d | ||
|
|
e9686f6cb3 | ||
|
|
d9b55c6811 | ||
|
|
0956bbc4d7 |
@@ -4,3 +4,4 @@ build/
|
||||
app/build/
|
||||
local/
|
||||
local.properties
|
||||
*.log
|
||||
|
||||
@@ -4,26 +4,69 @@ NavButtons is an Android LSPosed module for customizing the three-button navigat
|
||||
|
||||
It lets you choose what should happen when Back, Home, or Recents is pressed or long-pressed, and lets you configure the long-press duration per button.
|
||||
|
||||
It also adds side arrow keys into the navigation bar for cursor movement.
|
||||
|
||||
## AI Assistance
|
||||
|
||||
This project was developed with help from AI-assisted coding tools. The design
|
||||
decisions, physical device testing, and final behaviour are still manually reviewed.
|
||||
|
||||
## Features
|
||||
|
||||
- Configure press and long-press actions for Back, Home, and Recents.
|
||||
- Keep the stock Android behavior with the `Stock` action.
|
||||
- Disable a press with the `None` action.
|
||||
- Reassign navigation actions: Back, Home, Assistant, and Recents.
|
||||
- Kill the foreground app.
|
||||
- Toggle auto-rotate.
|
||||
- Toggle the flashlight.
|
||||
- Individually configure custom long-press durations for all buttons.
|
||||
- Add optional side-arrow buttons for moving the text cursor left and right.
|
||||
- Long-press side arrows for repeated cursor movement with configurable speed.
|
||||
- Test long-press durations from the settings screen.
|
||||
|
||||
Supported actions:
|
||||
|
||||
- `Stock`: keep the default behavior for that button.
|
||||
- `None`: suppress the button.
|
||||
- `Back`: perform the normal Back action.
|
||||
- `Home`: perform the normal Home action.
|
||||
- `Assistant`: open the assistant.
|
||||
- `Recents`: open recent apps.
|
||||
- `Kill foreground app`: force close the currently active app.
|
||||
- `Toggle auto-rotate`: toggle the device's auto-rotate setting.
|
||||
- `Toggle flashlight`: toggle the flashlight.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android device using three-button navigation.
|
||||
- LSPosed.
|
||||
- Xposed API 101 or newer.
|
||||
- The module enabled for System UI.
|
||||
- LSPosed with Xposed API 101 or newer.
|
||||
- Android (One UI, AOSP, Google APIs, LineageOS) version listed in the compatibility list.
|
||||
- The module scope enabled for the relevant navigation packages.
|
||||
|
||||
This has only been tested on a Samsung Galaxy Z Fold5 running One UI 8.0 / Android 16. Compatibility may vary between Android versions and vendor System UI implementations.
|
||||
The module declares scope entries for:
|
||||
|
||||
## AI Assistance
|
||||
- `android` and `system`, used for privileged actions such as killing the foreground app.
|
||||
- `com.android.systemui`, used for phone navigation bars.
|
||||
- `com.android.launcher3` and `com.google.android.apps.nexuslauncher`, used for AOSP, Google APIs, and LineageOS taskbar navigation.
|
||||
- `com.sec.android.app.launcher`, used for Samsung/One UI unfolded taskbar navigation.
|
||||
|
||||
This project was developed with help from AI-assisted coding tools.
|
||||
## Compatibility
|
||||
|
||||
Supported targets are expected to support all features.
|
||||
|
||||
Samsung, tested on real hardware:
|
||||
|
||||
- One UI 8 / Android 16 on a Galaxy Z Fold5.
|
||||
|
||||
AOSP emulator images:
|
||||
|
||||
- Android 14 / API 34.
|
||||
- Android 15 / API 35.
|
||||
- Android 16 / API 36.
|
||||
|
||||
Google APIs emulator images:
|
||||
|
||||
- Android 15 / API 35.
|
||||
- Android 16 / API 36.
|
||||
|
||||
LineageOS emulator images:
|
||||
|
||||
- LineageOS 22.2 / Android 15 / API 35.
|
||||
- LineageOS 23.2 / Android 16 / API 36.
|
||||
|
||||
Emulator validation used Magisk and LSPosed. Emulator results confirm compatibility with those images, not with every ROM derived from the same Android version. Vendor System UI and launcher implementations can differ enough to require separate support. On emulators, flashlight support is validated by confirming that the flashlight signal is sent; real hardware behavior depends on the device camera/flash implementation.
|
||||
|
||||
@@ -54,7 +54,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "se.ajpanton.navbuttons"
|
||||
minSdk = 35
|
||||
minSdk = 34
|
||||
targetSdk = 35
|
||||
versionCode = project.findProperty("nbVersionCode")?.toString()?.toIntOrNull() ?: 1777070659
|
||||
versionName = project.findProperty("nbVersionName")?.toString() ?: "0.3"
|
||||
@@ -86,9 +86,5 @@ 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")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
compileOnly("io.github.libxposed:api:102.0.0")
|
||||
}
|
||||
|
||||
Vendored
+2
-23
@@ -1,28 +1,7 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-adaptresourcefilecontents META-INF/xposed/java_init.list
|
||||
-dontobfuscate
|
||||
|
||||
-keep class se.ajpanton.navbuttons.NavButtons
|
||||
-keepclassmembers class se.ajpanton.navbuttons.NavButtons {
|
||||
-keep,allowoptimization,allowobfuscation public class * extends io.github.libxposed.api.XposedModule {
|
||||
public <init>();
|
||||
}
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" />
|
||||
|
||||
<queries>
|
||||
<package android:name="org.lsposed.manager" />
|
||||
<package android:name="com.android.systemui" />
|
||||
<package android:name="com.android.launcher3" />
|
||||
<package android:name="com.google.android.apps.nexuslauncher" />
|
||||
<package android:name="com.sec.android.app.launcher" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:allowBackup="false"
|
||||
android:description="@string/xposed_desc"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:forceQueryable="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.hardware.camera2.CameraAccessException
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.os.Handler
|
||||
import android.provider.Settings
|
||||
import android.view.KeyEvent
|
||||
import android.widget.Toast
|
||||
|
||||
internal class ActionExecutor(
|
||||
private val contextProvider: () -> Context?,
|
||||
private val handlerProvider: () -> Handler,
|
||||
private val requestPrivilegedAction: (String, Int?) -> Unit,
|
||||
private val log: (String) -> Unit,
|
||||
private val logError: (String, Throwable) -> Unit,
|
||||
) {
|
||||
private var flashlightCameraId: String? = null
|
||||
private var flashlightEnabled = false
|
||||
|
||||
fun perform(action: NavButtonAction) {
|
||||
when (action) {
|
||||
NavButtonAction.STOCK,
|
||||
NavButtonAction.NONE,
|
||||
-> Unit
|
||||
|
||||
NavButtonAction.BACK -> sendConfiguredKeyPress(KeyEvent.KEYCODE_BACK)
|
||||
NavButtonAction.HOME -> sendConfiguredKeyPress(KeyEvent.KEYCODE_HOME)
|
||||
NavButtonAction.ASSISTANT -> sendConfiguredKeyPress(KeyEvent.KEYCODE_ASSIST)
|
||||
NavButtonAction.RECENTS -> sendConfiguredKeyPress(KeyEvent.KEYCODE_APP_SWITCH)
|
||||
NavButtonAction.KILL_FOREGROUND_APP -> performPrivilegedActionOrDirect(PRIVILEGED_ACTION_KILL_FOREGROUND_APP)
|
||||
NavButtonAction.TOGGLE_AUTO_ROTATE -> toggleAutoRotate()
|
||||
NavButtonAction.TOGGLE_FLASHLIGHT -> performPrivilegedActionOrDirect(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSideArrowKeyPress(keyCode: Int) {
|
||||
sendConfiguredKeyPress(keyCode)
|
||||
}
|
||||
|
||||
fun sendKeyPress(keyCode: Int) {
|
||||
sendInjectedKeyPress(contextProvider(), keyCode, log)
|
||||
}
|
||||
|
||||
fun killForegroundApp() {
|
||||
handlerProvider().post {
|
||||
try {
|
||||
val context = contextProvider() ?: run {
|
||||
log("Context unavailable, cannot kill app.")
|
||||
return@post
|
||||
}
|
||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
if (activityManager == null) {
|
||||
log("ActivityManager unavailable.")
|
||||
return@post
|
||||
}
|
||||
|
||||
val defaultLauncher = defaultLauncherPackageName(context)
|
||||
val foregroundApp = topPackageName(context, log)
|
||||
if (foregroundApp != null && !shouldSkipForegroundPackage(foregroundApp, defaultLauncher)) {
|
||||
val appLabel = applicationLabel(foregroundApp, context.packageManager)
|
||||
log("Killing: $foregroundApp ($appLabel)")
|
||||
callMethod(activityManager, "forceStopPackage", arrayOf(String::class.java), foregroundApp)
|
||||
showToast("Killed: $appLabel")
|
||||
} else {
|
||||
log("Nothing to kill.")
|
||||
showToast("Nothing to kill.")
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
logError("Error in killForegroundApp", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFlashlight() {
|
||||
val context = contextProvider() ?: run {
|
||||
log("Context unavailable, cannot toggle flashlight.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val cameraManager = context.getSystemService(CameraManager::class.java)
|
||||
val cameraId = flashlightCameraId ?: findFlashlightCameraId(cameraManager)
|
||||
if (cameraId == null) {
|
||||
log("No flashlight-capable camera found.")
|
||||
showToast("No flashlight found.")
|
||||
return
|
||||
}
|
||||
|
||||
flashlightCameraId = cameraId
|
||||
val enabled = !flashlightEnabled
|
||||
cameraManager.setTorchMode(cameraId, enabled)
|
||||
flashlightEnabled = enabled
|
||||
log("Flashlight: ${if (enabled) "On" else "Off"}")
|
||||
showToast("Flashlight: ${if (enabled) "On" else "Off"}")
|
||||
} catch (error: CameraAccessException) {
|
||||
log("Toggle flashlight failed: ${error.javaClass.simpleName}")
|
||||
showToast("Flashlight toggle failed.")
|
||||
} catch (error: SecurityException) {
|
||||
log("Toggle flashlight permission denied.")
|
||||
showToast("Flashlight permission denied.")
|
||||
} catch (error: Throwable) {
|
||||
log("Toggle flashlight failed: ${error.javaClass.simpleName}")
|
||||
showToast("Flashlight toggle failed.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendConfiguredKeyPress(keyCode: Int) {
|
||||
if (contextProvider()?.packageName in AOSP_LAUNCHER_PACKAGES) {
|
||||
requestPrivilegedAction(PRIVILEGED_ACTION_SEND_KEY_PRESS, keyCode)
|
||||
} else {
|
||||
sendKeyPress(keyCode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performPrivilegedActionOrDirect(action: String) {
|
||||
if (contextProvider()?.packageName == ANDROID_PACKAGE) {
|
||||
when (action) {
|
||||
PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp()
|
||||
PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight()
|
||||
}
|
||||
} else {
|
||||
requestPrivilegedAction(action, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleAutoRotate() {
|
||||
val context = contextProvider() ?: run {
|
||||
log("Context unavailable, cannot toggle auto rotate.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val enabled = toggleAutoRotateViaRotationPolicy(context)
|
||||
?: toggleAutoRotateSetting(context)
|
||||
showToast("Auto rotate: ${if (enabled) "On" else "Off"}")
|
||||
} catch (error: Throwable) {
|
||||
log("Toggle auto rotate failed: ${error.javaClass.simpleName}")
|
||||
showToast("Auto rotate toggle failed.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleAutoRotateViaRotationPolicy(context: Context): Boolean? {
|
||||
return runCatching {
|
||||
val rotationPolicy = findClass("com.android.internal.view.RotationPolicy", null)
|
||||
val isLocked = findMethod(
|
||||
rotationPolicy,
|
||||
"isRotationLocked",
|
||||
Context::class.java,
|
||||
).invoke(null, context) as Boolean
|
||||
val enabled = isLocked
|
||||
findMethod(
|
||||
rotationPolicy,
|
||||
"setRotationLock",
|
||||
Context::class.java,
|
||||
Boolean::class.javaPrimitiveType!!,
|
||||
).invoke(null, context, !enabled)
|
||||
enabled
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun toggleAutoRotateSetting(context: Context): Boolean {
|
||||
val resolver = context.contentResolver
|
||||
val enabled = Settings.System.getInt(resolver, Settings.System.ACCELEROMETER_ROTATION, 0) != 1
|
||||
Settings.System.putInt(resolver, Settings.System.ACCELEROMETER_ROTATION, if (enabled) 1 else 0)
|
||||
return enabled
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
val context = contextProvider() ?: return
|
||||
try {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
} catch (error: Throwable) {
|
||||
log("Toast failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFlashlightCameraId(cameraManager: CameraManager): String? {
|
||||
return cameraManager.cameraIdList.firstOrNull { cameraId ->
|
||||
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
||||
val hasFlash = characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
|
||||
val lensFacing = characteristics.get(CameraCharacteristics.LENS_FACING)
|
||||
hasFlash && lensFacing == CameraCharacteristics.LENS_FACING_BACK
|
||||
} ?: cameraManager.cameraIdList.firstOrNull { cameraId ->
|
||||
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
||||
characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import java.lang.reflect.Method
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class AospLauncherTaskbarHooks(
|
||||
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||
private val deoptimizeMethod: (Method?) -> Unit,
|
||||
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||
private val sideArrowsEnabled: () -> Boolean,
|
||||
private val sideArrowLongPresses: SideArrowLongPressController,
|
||||
private val actions: ActionExecutor,
|
||||
private val registerSettingsReceiver: () -> Unit,
|
||||
private val contextUpdater: (Context) -> Unit,
|
||||
private val requestPrivilegedAction: (String, Int?) -> Unit,
|
||||
private val wasHomeLongPressHandledRecently: () -> Boolean,
|
||||
private val noteHomeLongPressHandled: () -> Unit,
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
private val buttonIds = WeakHashMap<Any, NavButtonId>()
|
||||
private val controllers = WeakHashMap<Any, Any>()
|
||||
private val trackedContainers = Collections.newSetFromMap(WeakHashMap<ViewGroup, Boolean>())
|
||||
private val buttons = WeakHashMap<ViewGroup, MutableMap<NavButtonId, View>>()
|
||||
private val sideArrows = WeakHashMap<ViewGroup, AospSideArrowViews>()
|
||||
private val sideButtonTranslations = WeakHashMap<View, Float>()
|
||||
private var tintHookInstalled = false
|
||||
|
||||
private val touchController = AospTaskbarTouchController(
|
||||
buttonIdProvider = { buttonIds[it] },
|
||||
controllerProvider = { controllers[it] },
|
||||
configProvider = configProvider,
|
||||
updateContext = ::updateContextFromTaskbar,
|
||||
performAction = ::performAction,
|
||||
performHomeLongPressAction = ::performHomeLongPressAction,
|
||||
)
|
||||
|
||||
fun install(classLoader: ClassLoader?): Boolean {
|
||||
val controllerClass = findClassIfExists(AOSP_TASKBAR_NAV_BUTTON_CONTROLLER, classLoader) ?: run {
|
||||
log("AOSP taskbar controller class not found: $AOSP_TASKBAR_NAV_BUTTON_CONTROLLER")
|
||||
return false
|
||||
}
|
||||
val navButtonsViewControllerClass = findClassIfExists(
|
||||
AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER,
|
||||
classLoader,
|
||||
)
|
||||
val clickMethod = findMethodIfExists(
|
||||
controllerClass,
|
||||
"onButtonClick",
|
||||
Int::class.javaPrimitiveType!!,
|
||||
View::class.java,
|
||||
) ?: findMethodIfExists(
|
||||
controllerClass,
|
||||
"onButtonClick",
|
||||
View::class.java,
|
||||
Int::class.javaPrimitiveType!!,
|
||||
) ?: run {
|
||||
log("AOSP taskbar onButtonClick() method not found.")
|
||||
return false
|
||||
}
|
||||
val longClickMethod = findMethodIfExists(
|
||||
controllerClass,
|
||||
"onButtonLongClick",
|
||||
Int::class.javaPrimitiveType!!,
|
||||
View::class.java,
|
||||
) ?: findMethodIfExists(
|
||||
controllerClass,
|
||||
"onButtonLongClick",
|
||||
View::class.java,
|
||||
Int::class.javaPrimitiveType!!,
|
||||
) ?: run {
|
||||
log("AOSP taskbar onButtonLongClick() method not found.")
|
||||
return false
|
||||
}
|
||||
val sendBackKeyEventMethod = findMethodIfExists(
|
||||
controllerClass,
|
||||
"sendBackKeyEvent",
|
||||
Int::class.javaPrimitiveType!!,
|
||||
Boolean::class.javaPrimitiveType!!,
|
||||
)
|
||||
|
||||
deoptimizeMethod(clickMethod)
|
||||
deoptimizeMethod(longClickMethod)
|
||||
deoptimizeMethod(sendBackKeyEventMethod)
|
||||
hookButtonMapping(navButtonsViewControllerClass, controllerClass)
|
||||
hookTintUpdates()
|
||||
registerSettingsReceiver()
|
||||
|
||||
hookMethod(clickMethod) { chain ->
|
||||
val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int)
|
||||
?: return@hookMethod chain.proceed()
|
||||
val pressAction = configProvider(buttonId).pressAction
|
||||
if (pressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
updateContextFromTaskbar(chain.thisObject, chain.args.firstOrNull { it is View })
|
||||
performAction(chain.thisObject, pressAction)
|
||||
null
|
||||
}
|
||||
|
||||
hookMethod(longClickMethod) { chain ->
|
||||
val buttonId = buttonIdForAospTaskbarCode(chain.args.firstOrNull { it is Int } as? Int)
|
||||
?: return@hookMethod chain.proceed()
|
||||
val longPressAction = configProvider(buttonId).longPressAction
|
||||
if (longPressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
val view = chain.args.firstOrNull { it is View }
|
||||
if (view != null && buttonIds.containsKey(view)) {
|
||||
return@hookMethod true
|
||||
}
|
||||
|
||||
updateContextFromTaskbar(chain.thisObject, view)
|
||||
if (buttonId == NavButtonId.HOME) {
|
||||
performHomeLongPressAction(chain.thisObject, longPressAction)
|
||||
} else {
|
||||
performAction(chain.thisObject, longPressAction)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
if (sendBackKeyEventMethod != null) {
|
||||
hookMethod(sendBackKeyEventMethod) { chain ->
|
||||
val pressAction = configProvider(NavButtonId.BACK).pressAction
|
||||
val longPressAction = configProvider(NavButtonId.BACK).longPressAction
|
||||
if (pressAction == NavButtonAction.STOCK && longPressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
updateContextFromField(chain.thisObject, "mContext")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun refreshSideArrows() {
|
||||
trackedContainers.toList().forEach(::refreshSideArrows)
|
||||
}
|
||||
|
||||
private fun hookButtonMapping(
|
||||
navButtonsViewControllerClass: Class<*>?,
|
||||
controllerClass: Class<*>,
|
||||
) {
|
||||
if (navButtonsViewControllerClass == null) {
|
||||
log("AOSP navbar buttons controller class not found: $AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER")
|
||||
return
|
||||
}
|
||||
|
||||
listOf(
|
||||
arrayOf(
|
||||
Int::class.javaPrimitiveType!!,
|
||||
Int::class.javaPrimitiveType!!,
|
||||
ViewGroup::class.java,
|
||||
controllerClass,
|
||||
Int::class.javaPrimitiveType!!,
|
||||
),
|
||||
arrayOf(
|
||||
Int::class.javaPrimitiveType!!,
|
||||
Int::class.javaPrimitiveType!!,
|
||||
ViewGroup::class.java,
|
||||
controllerClass,
|
||||
Int::class.javaPrimitiveType!!,
|
||||
Int::class.javaPrimitiveType!!,
|
||||
),
|
||||
).forEach { parameterTypes ->
|
||||
val addButtonMethod = findMethodIfExists(
|
||||
navButtonsViewControllerClass,
|
||||
"addButton",
|
||||
*parameterTypes,
|
||||
) ?: return@forEach
|
||||
deoptimizeMethod(addButtonMethod)
|
||||
hookMethod(addButtonMethod) { chain ->
|
||||
val result = chain.proceed()
|
||||
val view = result as? View ?: return@hookMethod result
|
||||
trackButton(
|
||||
view,
|
||||
chain.args.getOrNull(1) as? Int,
|
||||
chain.args.getOrNull(3),
|
||||
chain.args.getOrNull(2) as? ViewGroup,
|
||||
)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookTintUpdates() {
|
||||
if (tintHookInstalled) {
|
||||
return
|
||||
}
|
||||
tintHookInstalled = true
|
||||
|
||||
findDeclaredMethodsNamed(ImageView::class.java, "setImageTintList").forEach { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val result = chain.proceed()
|
||||
refreshSideArrowTint(chain.thisObject as? ImageView)
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
findDeclaredMethodsNamed(ImageView::class.java, "setColorFilter").forEach { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val result = chain.proceed()
|
||||
refreshSideArrowTint(chain.thisObject as? ImageView)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackButton(
|
||||
view: View,
|
||||
buttonCode: Int?,
|
||||
controller: Any?,
|
||||
parent: ViewGroup?,
|
||||
) {
|
||||
val buttonId = buttonIdForAospTaskbarCode(buttonCode) ?: return
|
||||
buttonIds[view] = buttonId
|
||||
if (controller != null) {
|
||||
controllers[view] = controller
|
||||
}
|
||||
view.setOnTouchListener { touchedView, event -> touchController.handle(touchedView, event) }
|
||||
|
||||
updateContextFromView(view)
|
||||
registerSettingsReceiver()
|
||||
val container = (view.parent as? ViewGroup) ?: parent ?: return
|
||||
buttons.getOrPut(container) { mutableMapOf() }[buttonId] = view
|
||||
trackContainer(container)
|
||||
refreshSideArrows(container)
|
||||
}
|
||||
|
||||
private fun trackContainer(container: ViewGroup) {
|
||||
if (trackedContainers.add(container)) {
|
||||
container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
|
||||
refreshSideArrows(view as ViewGroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSideArrows(container: ViewGroup) {
|
||||
try {
|
||||
val containerButtons = buttons[container].orEmpty()
|
||||
val home = containerButtons[NavButtonId.HOME] as? ImageView ?: return
|
||||
if (home.parent != container) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!sideArrowsEnabled()) {
|
||||
removeSideArrows(container)
|
||||
resetSideButtonTranslations(container)
|
||||
return
|
||||
}
|
||||
|
||||
val arrows = sideArrows.getOrPut(container) {
|
||||
AospSideArrowViews(
|
||||
createSideArrowView(container, home, SideArrowDirection.LEFT),
|
||||
createSideArrowView(container, home, SideArrowDirection.RIGHT),
|
||||
)
|
||||
}
|
||||
configureSideArrowView(arrows.left, home, SideArrowDirection.LEFT)
|
||||
configureSideArrowView(arrows.right, home, SideArrowDirection.RIGHT)
|
||||
placeAospTaskbarSideArrows(
|
||||
container,
|
||||
home,
|
||||
arrows,
|
||||
containerButtons,
|
||||
sideButtonTranslations,
|
||||
)
|
||||
applySideArrowTint(arrows, home)
|
||||
} catch (error: Throwable) {
|
||||
log("AOSP side arrows refresh failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSideArrowView(
|
||||
container: ViewGroup,
|
||||
home: ImageView,
|
||||
direction: SideArrowDirection,
|
||||
): ImageView {
|
||||
return ImageView(container.context).apply {
|
||||
id = View.generateViewId()
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
contentDescription = direction.name.lowercase()
|
||||
setOnTouchListener { view, event ->
|
||||
handleSideArrowTouch(view, direction.keyCode, event)
|
||||
}
|
||||
configureSideArrowView(this, home, direction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureSideArrowView(
|
||||
arrow: ImageView,
|
||||
home: ImageView,
|
||||
direction: SideArrowDirection,
|
||||
) {
|
||||
arrow.minimumWidth = home.minimumWidth
|
||||
arrow.minimumHeight = home.minimumHeight
|
||||
arrow.setPadding(0, 0, 0, 0)
|
||||
arrow.scaleType = ImageView.ScaleType.CENTER
|
||||
arrow.setImageDrawable(sideArrowBitmapDrawable(arrow.context, direction, Color.WHITE))
|
||||
}
|
||||
|
||||
private fun removeSideArrows(container: ViewGroup) {
|
||||
sideArrows[container]?.let { arrows ->
|
||||
removeFromParent(arrows.left)
|
||||
removeFromParent(arrows.right)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetSideButtonTranslations(container: ViewGroup) {
|
||||
buttons[container].orEmpty().values.forEach { button ->
|
||||
sideButtonTranslations.remove(button)?.let { button.translationX = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySideArrowTint(arrows: AospSideArrowViews, source: ImageView) {
|
||||
listOf(arrows.left, arrows.right).forEach { arrow ->
|
||||
arrow.imageTintList = source.imageTintList
|
||||
arrow.colorFilter = source.colorFilter
|
||||
arrow.imageAlpha = source.imageAlpha
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSideArrowTint(source: ImageView?) {
|
||||
val container = source?.parent as? ViewGroup ?: return
|
||||
val containerButtons = buttons[container] ?: return
|
||||
val arrows = sideArrows[container] ?: return
|
||||
if (source !in containerButtons.values) {
|
||||
return
|
||||
}
|
||||
val tintSource = (containerButtons[NavButtonId.HOME] ?: containerButtons.values.firstOrNull()) as? ImageView
|
||||
?: return
|
||||
applySideArrowTint(arrows, tintSource)
|
||||
}
|
||||
|
||||
private fun handleSideArrowTouch(view: View, keyCode: Int, event: MotionEvent): Boolean {
|
||||
updateContextFromView(view)
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
view.isPressed = true
|
||||
sideArrowLongPresses.start(view, keyCode)
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
-> {
|
||||
val triggered = sideArrowLongPresses.finish(view)
|
||||
view.isPressed = false
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP && !triggered) {
|
||||
view.performClick()
|
||||
actions.sendSideArrowKeyPress(keyCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateContextFromTaskbar(controller: Any?, view: Any?) {
|
||||
if (view is View) {
|
||||
updateContextFromView(view)
|
||||
} else {
|
||||
updateContextFromField(controller, "mContext")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContextFromView(view: View) {
|
||||
contextUpdater(view.context.applicationContext ?: view.context)
|
||||
}
|
||||
|
||||
private fun updateContextFromField(target: Any?, fieldName: String) {
|
||||
val currentContext = getFieldValue(target, fieldName) as? Context ?: return
|
||||
contextUpdater(currentContext.applicationContext ?: currentContext)
|
||||
}
|
||||
|
||||
private fun performHomeLongPressAction(controller: Any?, action: NavButtonAction) {
|
||||
if (wasHomeLongPressHandledRecently()) {
|
||||
return
|
||||
}
|
||||
noteHomeLongPressHandled()
|
||||
performAction(controller, action)
|
||||
}
|
||||
|
||||
private fun performAction(controller: Any?, action: NavButtonAction) {
|
||||
when (action) {
|
||||
NavButtonAction.STOCK,
|
||||
NavButtonAction.NONE,
|
||||
-> Unit
|
||||
|
||||
NavButtonAction.BACK -> {
|
||||
if (!callTaskbarMethod(controller, "executeBack", arrayOf(KeyEvent::class.java), null)) {
|
||||
actions.perform(action)
|
||||
}
|
||||
}
|
||||
|
||||
NavButtonAction.HOME -> {
|
||||
if (!callTaskbarMethod(controller, "navigateHome", emptyArray())) {
|
||||
actions.perform(action)
|
||||
}
|
||||
}
|
||||
|
||||
NavButtonAction.ASSISTANT -> {
|
||||
if (!callTaskbarMethod(controller, "onLongPressHome", emptyArray())) {
|
||||
actions.perform(action)
|
||||
}
|
||||
}
|
||||
|
||||
NavButtonAction.RECENTS -> {
|
||||
if (!callTaskbarMethod(controller, "navigateToOverview", emptyArray())) {
|
||||
actions.perform(action)
|
||||
}
|
||||
}
|
||||
|
||||
NavButtonAction.KILL_FOREGROUND_APP -> {
|
||||
requestPrivilegedAction(PRIVILEGED_ACTION_KILL_FOREGROUND_APP, null)
|
||||
}
|
||||
|
||||
NavButtonAction.TOGGLE_FLASHLIGHT -> {
|
||||
requestPrivilegedAction(PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT, null)
|
||||
}
|
||||
|
||||
NavButtonAction.TOGGLE_AUTO_ROTATE -> actions.perform(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun callTaskbarMethod(
|
||||
controller: Any?,
|
||||
methodName: String,
|
||||
parameterTypes: Array<Class<*>>,
|
||||
vararg args: Any?,
|
||||
): Boolean {
|
||||
if (controller == null) {
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
callMethod(controller, methodName, parameterTypes, *args)
|
||||
true
|
||||
} catch (error: Throwable) {
|
||||
log("AOSP taskbar $methodName failed: ${error.javaClass.simpleName}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.graphics.Point
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal fun placeAospTaskbarSideArrows(
|
||||
container: ViewGroup,
|
||||
home: ImageView,
|
||||
arrows: AospSideArrowViews,
|
||||
buttons: Map<NavButtonId, View>,
|
||||
sideButtonTranslations: WeakHashMap<View, Float>,
|
||||
) {
|
||||
val host = findAospTaskbarArrowHost(container) ?: return
|
||||
val sideButtons = listOfNotNull(buttons[NavButtonId.BACK], buttons[NavButtonId.RECENTS])
|
||||
.filter { it.width > 0 && it.height > 0 }
|
||||
if (home.width <= 0 || home.height <= 0 || sideButtons.size < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
addAospTaskbarArrowToHost(host, arrows.left, home)
|
||||
addAospTaskbarArrowToHost(host, arrows.right, home)
|
||||
|
||||
val homeCenter = centerInHost(home, host)
|
||||
val leftButton = sideButtons.filter { centerInHost(it, host).x < homeCenter.x }
|
||||
.maxByOrNull { centerInHost(it, host).x }
|
||||
?: return
|
||||
val rightButton = sideButtons.filter { centerInHost(it, host).x > homeCenter.x }
|
||||
.minByOrNull { centerInHost(it, host).x }
|
||||
?: return
|
||||
|
||||
translateAospTaskbarSideButton(
|
||||
leftButton,
|
||||
-aospTaskbarSideButtonShift(arrows.left),
|
||||
sideButtonTranslations,
|
||||
)
|
||||
translateAospTaskbarSideButton(
|
||||
rightButton,
|
||||
aospTaskbarSideButtonShift(arrows.right),
|
||||
sideButtonTranslations,
|
||||
)
|
||||
placeAospTaskbarArrow(arrows.left, midpoint(centerInHost(leftButton, host), homeCenter))
|
||||
placeAospTaskbarArrow(arrows.right, midpoint(centerInHost(rightButton, host), homeCenter))
|
||||
}
|
||||
|
||||
internal fun removeFromParent(view: View) {
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
}
|
||||
|
||||
private fun translateAospTaskbarSideButton(
|
||||
button: View,
|
||||
shiftPx: Float,
|
||||
sideButtonTranslations: WeakHashMap<View, Float>,
|
||||
) {
|
||||
val originalTranslation = sideButtonTranslations.getOrPut(button) { button.translationX }
|
||||
button.translationX = originalTranslation + shiftPx
|
||||
}
|
||||
|
||||
private fun aospTaskbarSideButtonShift(arrow: View): Float {
|
||||
return navStarArrowIconSizePx(arrow.context) * AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO
|
||||
}
|
||||
|
||||
private fun findAospTaskbarArrowHost(start: View): FrameLayout? {
|
||||
var current = start.parent as? View
|
||||
repeat(5) {
|
||||
if (current is FrameLayout) {
|
||||
return current as FrameLayout
|
||||
}
|
||||
current = current?.parent as? View
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun addAospTaskbarArrowToHost(
|
||||
host: FrameLayout,
|
||||
arrow: ImageView,
|
||||
home: ImageView,
|
||||
) {
|
||||
val width = navStarArrowIconSizePx(arrow.context)
|
||||
val height = home.height.coerceAtLeast(width)
|
||||
if (arrow.parent != host) {
|
||||
removeFromParent(arrow)
|
||||
host.addView(arrow, FrameLayout.LayoutParams(width, height))
|
||||
} else {
|
||||
arrow.layoutParams = (arrow.layoutParams as? FrameLayout.LayoutParams)
|
||||
?.apply {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
?: FrameLayout.LayoutParams(width, height)
|
||||
}
|
||||
arrow.bringToFront()
|
||||
arrow.translationZ = home.translationZ + 1f
|
||||
}
|
||||
|
||||
private fun centerInHost(view: View, host: View): Point {
|
||||
val viewLocation = IntArray(2)
|
||||
val hostLocation = IntArray(2)
|
||||
view.getLocationOnScreen(viewLocation)
|
||||
host.getLocationOnScreen(hostLocation)
|
||||
return Point(
|
||||
viewLocation[0] - hostLocation[0] + view.width / 2,
|
||||
viewLocation[1] - hostLocation[1] + view.height / 2,
|
||||
)
|
||||
}
|
||||
|
||||
private fun midpoint(first: Point, second: Point): Point {
|
||||
return Point((first.x + second.x) / 2, (first.y + second.y) / 2)
|
||||
}
|
||||
|
||||
private fun placeAospTaskbarArrow(arrow: View, center: Point) {
|
||||
val width = arrow.width.takeIf { it > 0 }
|
||||
?: arrow.layoutParams?.width?.takeIf { it > 0 }
|
||||
?: 0
|
||||
val height = arrow.height.takeIf { it > 0 }
|
||||
?: arrow.layoutParams?.height?.takeIf { it > 0 }
|
||||
?: 0
|
||||
arrow.x = center.x - width / 2f
|
||||
arrow.y = center.y - height / 2f
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import java.lang.reflect.Method
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class AospSystemUiHooks(
|
||||
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||
private val deoptimizeMethod: (Method?) -> Unit,
|
||||
private val sideArrowsEnabled: () -> Boolean,
|
||||
private val registerSettingsReceiver: () -> Unit,
|
||||
private val contextUpdater: (Context) -> Unit,
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
private val trackedInflaters = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||
private val sideArrowDirections = WeakHashMap<Any, SideArrowDirection>()
|
||||
private var sideArrowHookInstalled = false
|
||||
private var darkIntensity = 0f
|
||||
|
||||
fun installSideArrows(classLoader: ClassLoader?) {
|
||||
if (sideArrowHookInstalled || Build.MANUFACTURER.equals("samsung", ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
val inflaterClass = findFirstClassIfExists(AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES, classLoader)
|
||||
?: return
|
||||
sideArrowHookInstalled = true
|
||||
registerSettingsReceiver()
|
||||
hookDarkIntensity(classLoader)
|
||||
|
||||
findMethodIfExists(inflaterClass, "getDefaultLayout")?.let { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
chain.thisObject?.let(::trackInflater)
|
||||
val layout = chain.proceed() as? String ?: return@hookMethod null
|
||||
if (sideArrowsEnabled()) {
|
||||
addAospSystemUiSideArrows(layout)
|
||||
} else {
|
||||
removeAospSystemUiSideArrows(layout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findMethodIfExists(inflaterClass, "inflateLayout", String::class.java)?.let { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
chain.thisObject?.let(::trackInflater)
|
||||
val layout = chain.args.getOrNull(0) as? String
|
||||
if (layout != null) {
|
||||
chain.args[0] = if (sideArrowsEnabled()) {
|
||||
addAospSystemUiSideArrows(layout)
|
||||
} else {
|
||||
removeAospSystemUiSideArrows(layout)
|
||||
}
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
findMethodIfExists(
|
||||
inflaterClass,
|
||||
"inflateButtons",
|
||||
Array<String>::class.java,
|
||||
ViewGroup::class.java,
|
||||
Boolean::class.javaPrimitiveType!!,
|
||||
Boolean::class.javaPrimitiveType!!,
|
||||
)?.let { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val tokens = (chain.args.getOrNull(0) as? Array<*>)
|
||||
?.mapNotNull { it as? String }
|
||||
?: return@hookMethod chain.proceed()
|
||||
val parent = chain.args.getOrNull(1) as? ViewGroup
|
||||
?: return@hookMethod chain.proceed()
|
||||
val childCountBefore = parent.childCount
|
||||
val result = chain.proceed()
|
||||
trackInflatedSideArrows(tokens, parent, childCountBefore)
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSideArrowLayouts() {
|
||||
trackedInflaters.toList().forEach(::refreshSideArrowLayout)
|
||||
}
|
||||
|
||||
fun configureSideArrowButton(value: Any?) {
|
||||
val view = value as? ImageView ?: return
|
||||
val direction = sideArrowDirections[view] ?: return
|
||||
updateContextFromView(view)
|
||||
createSideArrowDrawable(view, direction)?.let {
|
||||
view.setImageDrawable(it)
|
||||
callFloatMethodIfExists(view, "setDarkIntensity", darkIntensity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackInflater(inflater: Any) {
|
||||
trackedInflaters.add(inflater)
|
||||
(inflater as? View)?.let {
|
||||
updateContextFromView(it)
|
||||
registerSettingsReceiver()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSideArrowLayout(inflater: Any) {
|
||||
try {
|
||||
clearInflaterViews(inflater)
|
||||
callMethod(inflater, "inflateLayout", arrayOf(String::class.java), null)
|
||||
} catch (error: Throwable) {
|
||||
log("AOSP SystemUI side arrows refresh failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearInflaterViews(inflater: Any) {
|
||||
callMethodIfExists(inflater, "clearDispatcherViews")
|
||||
val view = inflater as? View ?: return
|
||||
val ids = listOf("ends_group", "center_group").mapNotNull { name ->
|
||||
view.resources.getIdentifier(name, "id", view.context.packageName).takeIf { it != 0 }
|
||||
}
|
||||
listOf("mHorizontal", "mVertical").forEach { fieldName ->
|
||||
val root = getFieldValue(inflater, fieldName) as? ViewGroup ?: return@forEach
|
||||
ids.forEach { id ->
|
||||
(root.findViewById<View>(id) as? ViewGroup)?.removeAllViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackInflatedSideArrows(
|
||||
tokens: List<String>,
|
||||
parent: ViewGroup,
|
||||
childCountBefore: Int,
|
||||
) {
|
||||
val directions = tokens.mapNotNull { token ->
|
||||
when (layoutButtonName(token)) {
|
||||
AOSP_SYSUI_LEFT_ARROW_BUTTON -> SideArrowDirection.LEFT
|
||||
AOSP_SYSUI_RIGHT_ARROW_BUTTON -> SideArrowDirection.RIGHT
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (directions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val buttons = (childCountBefore until parent.childCount)
|
||||
.flatMap { index -> findKeyButtons(parent.getChildAt(index)) }
|
||||
.toMutableList()
|
||||
directions.forEach { direction ->
|
||||
val button = buttons.firstOrNull {
|
||||
getIntField(it, "mCode") == direction.keyCode
|
||||
} ?: return@forEach
|
||||
buttons.remove(button)
|
||||
sideArrowDirections[button] = direction
|
||||
configureSideArrowButton(button)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookDarkIntensity(classLoader: ClassLoader?) {
|
||||
val transitionsClass = findFirstClassIfExists(
|
||||
AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES,
|
||||
classLoader,
|
||||
) ?: return
|
||||
findMethodIfExists(
|
||||
transitionsClass,
|
||||
"applyDarkIntensity",
|
||||
Float::class.javaPrimitiveType!!,
|
||||
)?.let { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val result = chain.proceed()
|
||||
darkIntensity = (chain.args.getOrNull(0) as? Float)?.coerceIn(0f, 1f)
|
||||
?: darkIntensity
|
||||
updateSideArrowDarkIntensity()
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSideArrowDarkIntensity() {
|
||||
sideArrowDirections.keys.toList().forEach { view ->
|
||||
callFloatMethodIfExists(view, "setDarkIntensity", darkIntensity)
|
||||
setFieldIfExists(view, "mDarkIntensity", darkIntensity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findKeyButtons(view: View): List<ImageView> {
|
||||
val result = mutableListOf<ImageView>()
|
||||
if (view is ImageView && getFieldValue(view, "mCode") != null) {
|
||||
result.add(view)
|
||||
}
|
||||
if (view is ViewGroup) {
|
||||
repeat(view.childCount) { index ->
|
||||
result.addAll(findKeyButtons(view.getChildAt(index)))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun createSideArrowDrawable(
|
||||
value: Any?,
|
||||
direction: SideArrowDirection,
|
||||
): Drawable? {
|
||||
val view = value as? View ?: return null
|
||||
val classLoader = view.javaClass.classLoader ?: return null
|
||||
return try {
|
||||
val keyButtonDrawableClass = findFirstClass(AOSP_KEY_BUTTON_DRAWABLE_CLASSES, classLoader)
|
||||
val stateClass = findFirstClass(AOSP_KEY_BUTTON_DRAWABLE_STATE_CLASSES, classLoader)
|
||||
val state = stateClass
|
||||
.getDeclaredConstructor(
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
Boolean::class.javaPrimitiveType,
|
||||
Boolean::class.javaPrimitiveType,
|
||||
)
|
||||
.apply { isAccessible = true }
|
||||
.newInstance(Color.WHITE, Color.rgb(55, 58, 62), false, false)
|
||||
val drawable = keyButtonDrawableClass
|
||||
.getDeclaredConstructor(Drawable::class.java, stateClass)
|
||||
.apply { isAccessible = true }
|
||||
.newInstance(
|
||||
sideArrowBitmapDrawable(view.context, direction, Color.WHITE),
|
||||
state,
|
||||
) as? Drawable
|
||||
drawable?.also {
|
||||
callFloatMethodIfExists(it, "setDarkIntensity", darkIntensity)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
log("AOSP SystemUI side arrow drawable failed: ${error.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContextFromView(view: View) {
|
||||
contextUpdater(view.context.applicationContext ?: view.context)
|
||||
}
|
||||
|
||||
private fun addAospSystemUiSideArrows(layout: String): String {
|
||||
val parts = splitAospSystemUiLayout(layout) ?: return layout
|
||||
val withoutArrows = parts.map { group ->
|
||||
group.filterNot(::isAospSystemUiSideArrowToken).toMutableList()
|
||||
}
|
||||
val center = withoutArrows[1]
|
||||
val homeIndex = center.indexOfFirst { layoutButtonName(it) == "home" }
|
||||
if (homeIndex < 0) {
|
||||
return layout
|
||||
}
|
||||
|
||||
if (center.any { layoutButtonName(it) == "back" } &&
|
||||
center.any { layoutButtonName(it) == "recent" }
|
||||
) {
|
||||
center.add(homeIndex, AOSP_SYSUI_LEFT_ARROW_BUTTON)
|
||||
center.add(homeIndex + 2, AOSP_SYSUI_RIGHT_ARROW_BUTTON)
|
||||
} else {
|
||||
val leftButton = withoutArrows[0].firstOrNull(::isAospSystemUiPrimaryButton)
|
||||
?: return layout
|
||||
val rightButton = withoutArrows[2].firstOrNull(::isAospSystemUiPrimaryButton)
|
||||
?: return layout
|
||||
val homeButton = center[homeIndex]
|
||||
withoutArrows[0].removeAll(::isAospSystemUiPrimaryButton)
|
||||
withoutArrows[2].removeAll(::isAospSystemUiPrimaryButton)
|
||||
center.clear()
|
||||
center.addAll(
|
||||
listOf(
|
||||
layoutButtonName(leftButton),
|
||||
AOSP_SYSUI_LEFT_ARROW_BUTTON,
|
||||
layoutButtonName(homeButton),
|
||||
AOSP_SYSUI_RIGHT_ARROW_BUTTON,
|
||||
layoutButtonName(rightButton),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return joinAospSystemUiLayout(withoutArrows)
|
||||
}
|
||||
|
||||
private fun removeAospSystemUiSideArrows(layout: String): String {
|
||||
val parts = splitAospSystemUiLayout(layout) ?: return layout
|
||||
return joinAospSystemUiLayout(
|
||||
parts.map { group -> group.filterNot(::isAospSystemUiSideArrowToken) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun layoutButtonName(token: String): String {
|
||||
return token.substringBefore("[")
|
||||
}
|
||||
|
||||
private fun splitAospSystemUiLayout(layout: String): List<List<String>>? {
|
||||
val parts = layout.split(";", limit = 3)
|
||||
if (parts.size != 3) {
|
||||
return null
|
||||
}
|
||||
return parts.map { group -> group.split(",").filter { it.isNotBlank() } }
|
||||
}
|
||||
|
||||
private fun joinAospSystemUiLayout(parts: List<List<String>>): String {
|
||||
return parts.joinToString(";") { group -> group.joinToString(",") }
|
||||
}
|
||||
|
||||
private fun isAospSystemUiSideArrowToken(token: String): Boolean {
|
||||
val button = layoutButtonName(token)
|
||||
return button == AOSP_SYSUI_LEFT_ARROW_BUTTON || button == AOSP_SYSUI_RIGHT_ARROW_BUTTON
|
||||
}
|
||||
|
||||
private fun isAospSystemUiPrimaryButton(token: String): Boolean {
|
||||
return when (layoutButtonName(token)) {
|
||||
"back",
|
||||
"recent",
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class AospTaskbarTouchController(
|
||||
private val buttonIdProvider: (View) -> NavButtonId?,
|
||||
private val controllerProvider: (View) -> Any?,
|
||||
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||
private val updateContext: (Any?, View?) -> Unit,
|
||||
private val performAction: (Any?, NavButtonAction) -> Unit,
|
||||
private val performHomeLongPressAction: (Any?, NavButtonAction) -> Unit,
|
||||
) {
|
||||
private val longPressRunnables = WeakHashMap<Any, Runnable>()
|
||||
private val longPressTriggered = WeakHashMap<Any, Boolean>()
|
||||
|
||||
fun handle(view: View, event: MotionEvent): Boolean {
|
||||
val buttonId = buttonIdProvider(view) ?: return false
|
||||
val controller = controllerProvider(view) ?: return false
|
||||
val config = configProvider(buttonId)
|
||||
if (config.pressAction == NavButtonAction.STOCK &&
|
||||
config.longPressAction == NavButtonAction.STOCK
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
longPressTriggered[view] = false
|
||||
if (config.longPressAction != NavButtonAction.STOCK) {
|
||||
scheduleLongPress(view, controller, buttonId, config)
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
-> {
|
||||
val triggered = longPressTriggered[view] == true
|
||||
cancelLongPress(view)
|
||||
longPressTriggered.remove(view)
|
||||
if (triggered) {
|
||||
return true
|
||||
}
|
||||
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
||||
when {
|
||||
config.pressAction != NavButtonAction.STOCK -> {
|
||||
performAction(controller, config.pressAction)
|
||||
return true
|
||||
}
|
||||
|
||||
config.longPressAction != NavButtonAction.STOCK &&
|
||||
event.eventTime - event.downTime >= ViewConfiguration.getLongPressTimeout() -> {
|
||||
performAction(controller, stockActionFor(buttonId))
|
||||
return true
|
||||
}
|
||||
|
||||
buttonId == NavButtonId.BACK -> {
|
||||
performAction(controller, NavButtonAction.BACK)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun scheduleLongPress(
|
||||
view: View,
|
||||
controller: Any,
|
||||
buttonId: NavButtonId,
|
||||
config: NavButtonConfig,
|
||||
) {
|
||||
cancelLongPress(view)
|
||||
val longPressRunnable = Runnable {
|
||||
longPressTriggered[view] = true
|
||||
updateContext(controller, view)
|
||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
if (buttonId == NavButtonId.HOME) {
|
||||
performHomeLongPressAction(controller, config.longPressAction)
|
||||
} else {
|
||||
performAction(controller, config.longPressAction)
|
||||
}
|
||||
}
|
||||
longPressRunnables[view] = longPressRunnable
|
||||
view.postDelayed(longPressRunnable, config.longPressDurationMs.toLong())
|
||||
}
|
||||
|
||||
private fun cancelLongPress(view: View) {
|
||||
longPressRunnables.remove(view)?.let(view::removeCallbacks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
internal fun defaultLauncherPackageName(context: Context): String? {
|
||||
val intent = Intent(Intent.ACTION_MAIN).apply {
|
||||
addCategory(Intent.CATEGORY_HOME)
|
||||
}
|
||||
val resolveInfo = context.packageManager.resolveActivity(
|
||||
intent,
|
||||
PackageManager.MATCH_DEFAULT_ONLY,
|
||||
)
|
||||
return resolveInfo?.activityInfo?.packageName
|
||||
}
|
||||
|
||||
internal fun topPackageName(context: Context, log: (String) -> Unit): String? {
|
||||
return topPackageFromActivityTaskManager(log)
|
||||
?: topPackageFromRunningTasks(context, log)
|
||||
}
|
||||
|
||||
private fun topPackageFromActivityTaskManager(log: (String) -> Unit): String? {
|
||||
return try {
|
||||
val service = callStaticMethod(
|
||||
findClass("android.app.ActivityTaskManager", null),
|
||||
"getService",
|
||||
) ?: return null
|
||||
val taskInfo = callMethod(service, "getFocusedRootTaskInfo")
|
||||
extractPackageFromTaskInfo(taskInfo)
|
||||
} catch (error: Throwable) {
|
||||
log("ActivityTaskManager.getFocusedRootTaskInfo failed: ${error.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractPackageFromTaskInfo(taskInfo: Any?): String? {
|
||||
if (taskInfo == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val pkg = packageNameFromComponent(getFieldValue(taskInfo, "topActivity"))
|
||||
return pkg ?: packageNameFromActivityInfo(getFieldValue(taskInfo, "topActivityInfo"))
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun topPackageFromRunningTasks(context: Context, log: (String) -> Unit): String? {
|
||||
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
||||
?: return null
|
||||
return try {
|
||||
activityManager.getRunningTasks(1)
|
||||
.firstOrNull()
|
||||
?.topActivity
|
||||
?.packageName
|
||||
} catch (error: Throwable) {
|
||||
log("ActivityManager.getRunningTasks failed: ${error.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun packageNameFromComponent(component: Any?): String? {
|
||||
return (component as? ComponentName)?.packageName
|
||||
}
|
||||
|
||||
private fun packageNameFromActivityInfo(info: Any?): String? {
|
||||
if (info == null) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
findField(info.javaClass, "packageName").get(info)?.toString()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldSkipForegroundPackage(packageName: String?, defaultLauncher: String?): Boolean {
|
||||
return packageName == null ||
|
||||
packageName == SYSTEMUI_PACKAGE ||
|
||||
packageName == SAMSUNG_LAUNCHER_PACKAGE ||
|
||||
packageName in AOSP_LAUNCHER_PACKAGES ||
|
||||
packageName == defaultLauncher
|
||||
}
|
||||
|
||||
internal fun applicationLabel(packageName: String, packageManager: PackageManager): String {
|
||||
return try {
|
||||
val appInfo: ApplicationInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
packageName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class HomeStockLongPressGuard(
|
||||
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||
private val longPressTriggered: (Any) -> Boolean,
|
||||
private val sendCanceledKeyUp: (Any) -> Unit,
|
||||
) {
|
||||
private val guards = WeakHashMap<Any, Runnable>()
|
||||
private val canceled = WeakHashMap<Any, Boolean>()
|
||||
|
||||
fun schedule(target: Any, buttonId: NavButtonId) {
|
||||
if (buttonId != NavButtonId.HOME) {
|
||||
return
|
||||
}
|
||||
if (configProvider(buttonId).longPressAction == NavButtonAction.STOCK) {
|
||||
return
|
||||
}
|
||||
|
||||
val view = target 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 || longPressTriggered(target)) {
|
||||
return@Runnable
|
||||
}
|
||||
canceled[target] = true
|
||||
sendCanceledKeyUp(target)
|
||||
}
|
||||
|
||||
guards[target] = guard
|
||||
view.postDelayed(guard, delayMs.toLong())
|
||||
}
|
||||
|
||||
fun cancel(target: Any) {
|
||||
guards.remove(target)?.let { guard ->
|
||||
(target as? View)?.removeCallbacks(guard)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(target: Any) {
|
||||
canceled.remove(target)
|
||||
}
|
||||
|
||||
fun isCanceled(target: Any): Boolean {
|
||||
return canceled[target] == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Process
|
||||
import android.os.SystemClock
|
||||
import android.view.KeyEvent
|
||||
|
||||
internal fun sendInjectedKeyPress(context: Context?, keyCode: Int, log: (String) -> Unit) {
|
||||
try {
|
||||
val inputManager = inputManagerForInjection(context) ?: run {
|
||||
log("InputManager unavailable for keyCode=$keyCode")
|
||||
return
|
||||
}
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
injectKeyEvent(inputManager, KeyEvent(downTime, downTime, KeyEvent.ACTION_DOWN, keyCode, 0))
|
||||
injectKeyEvent(inputManager, KeyEvent(downTime, SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode, 0))
|
||||
} catch (error: Throwable) {
|
||||
log("Key injection failed for keyCode=$keyCode: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun inputManagerForInjection(context: Context?): Any? {
|
||||
context?.getSystemService(Context.INPUT_SERVICE)?.let { return it }
|
||||
|
||||
return try {
|
||||
callStaticMethod(findClass(INPUT_MANAGER_CLASS, null), "getInstance")
|
||||
} catch (_: Throwable) {
|
||||
try {
|
||||
callStaticMethod(findClass(INPUT_MANAGER_GLOBAL_CLASS, null), "getInstance")
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun injectKeyEvent(inputManager: Any, keyEvent: KeyEvent) {
|
||||
val intType = Int::class.javaPrimitiveType!!
|
||||
val methods = findMethodsNamed(inputManager.javaClass, "injectInputEvent")
|
||||
val method = methods.firstOrNull { method ->
|
||||
val types = method.parameterTypes
|
||||
types.size == 2 &&
|
||||
types[0].isAssignableFrom(KeyEvent::class.java) &&
|
||||
types[1] == intType
|
||||
} ?: methods.firstOrNull { method ->
|
||||
val types = method.parameterTypes
|
||||
types.size == 3 &&
|
||||
types[0].isAssignableFrom(KeyEvent::class.java) &&
|
||||
types[1] == intType &&
|
||||
types[2] == intType
|
||||
} ?: throw NoSuchMethodException("injectInputEvent")
|
||||
|
||||
if (method.parameterTypes.size == 2) {
|
||||
method.invoke(inputManager, keyEvent, INPUT_EVENT_INJECTION_SYNC_NONE)
|
||||
} else {
|
||||
method.invoke(inputManager, keyEvent, INPUT_EVENT_INJECTION_SYNC_NONE, Process.INVALID_UID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
internal const val TAG = "NavButtons"
|
||||
internal const val ASSIST_INVOCATION_TYPE_UNKNOWN = 0
|
||||
internal const val ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS = 5
|
||||
internal const val INPUT_MANAGER_CLASS = "android.hardware.input.InputManager"
|
||||
internal const val INPUT_MANAGER_GLOBAL_CLASS = "android.hardware.input.InputManagerGlobal"
|
||||
internal const val INPUT_EVENT_INJECTION_SYNC_NONE = 0
|
||||
internal const val ACTION_PASS_TO_USER = 1
|
||||
internal const val HOME_LONG_PRESS_DEDUP_WINDOW_MS = 1_000L
|
||||
internal const val REMOTE_PREFERENCES_RETRY_DELAY_MS = 1_000L
|
||||
internal const val REMOTE_PREFERENCES_LOAD_ATTEMPTS = 15
|
||||
internal const val STOCK_LONG_PRESS_GUARD_EARLY_MS = 75
|
||||
internal const val STOCK_LONG_PRESS_GUARD_MIN_DELAY_MS = 100
|
||||
|
||||
internal const val SYSTEMUI_PACKAGE = "com.android.systemui"
|
||||
internal const val ANDROID_PACKAGE = "android"
|
||||
internal const val SYSTEM_SERVER_PHONE_WINDOW_MANAGER = "com.android.server.policy.PhoneWindowManager"
|
||||
internal val SYSTEMUI_KEY_BUTTON_VIEW_CLASSES = listOf(
|
||||
"com.android.systemui.navigationbar.views.buttons.KeyButtonView",
|
||||
"com.android.systemui.navigationbar.buttons.KeyButtonView",
|
||||
)
|
||||
|
||||
internal const val SAMSUNG_PHONE_LAYOUT_PROVIDER =
|
||||
"com.android.systemui.navigationbar.layout.LayoutProviderImpl"
|
||||
internal const val SAMSUNG_TABLET_LAYOUT_PROVIDER =
|
||||
"com.android.systemui.navigationbar.layout.TabletLayoutProviderImpl"
|
||||
internal const val SAMSUNG_NAVIGATION_BAR_VIEW =
|
||||
"com.android.systemui.navigationbar.views.SamsungNavigationBarView"
|
||||
internal const val SAMSUNG_LAUNCHER_PACKAGE = "com.sec.android.app.launcher"
|
||||
internal const val SAMSUNG_NAV_VIEW_PARENT_DEPTH = 8
|
||||
|
||||
internal val AOSP_LAUNCHER_PACKAGES = setOf(
|
||||
"com.android.launcher3",
|
||||
"com.google.android.apps.nexuslauncher",
|
||||
)
|
||||
internal val TARGET_PACKAGES = AOSP_LAUNCHER_PACKAGES + SYSTEMUI_PACKAGE
|
||||
internal const val AOSP_TASKBAR_NAV_BUTTON_CONTROLLER =
|
||||
"com.android.launcher3.taskbar.TaskbarNavButtonController"
|
||||
internal const val AOSP_TASKBAR_BACK_BUTTON = 1
|
||||
internal const val AOSP_TASKBAR_HOME_BUTTON = 2
|
||||
internal const val AOSP_TASKBAR_RECENTS_BUTTON = 4
|
||||
internal const val AOSP_NAVBAR_BUTTONS_VIEW_CONTROLLER =
|
||||
"com.android.launcher3.taskbar.NavbarButtonsViewController"
|
||||
internal const val AOSP_TASKBAR_SIDE_BUTTON_SHIFT_RATIO = 0.45f
|
||||
internal const val TARGET_HOOK_RETRY_ATTEMPTS = 120
|
||||
internal const val TARGET_HOOK_RETRY_DELAY_MS = 250L
|
||||
internal val AOSP_NAVIGATION_BAR_INFLATER_VIEW_CLASSES = listOf(
|
||||
"com.android.systemui.navigationbar.views.NavigationBarInflaterView",
|
||||
"com.android.systemui.navigationbar.NavigationBarInflaterView",
|
||||
)
|
||||
internal val AOSP_NAVIGATION_BAR_TRANSITIONS_CLASSES = listOf(
|
||||
"com.android.systemui.navigationbar.NavigationBarTransitions",
|
||||
"com.android.systemui.navigationbar.views.NavigationBarTransitions",
|
||||
)
|
||||
internal val AOSP_KEY_BUTTON_DRAWABLE_CLASSES = listOf(
|
||||
"com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable",
|
||||
"com.android.systemui.navigationbar.buttons.KeyButtonDrawable",
|
||||
)
|
||||
internal val AOSP_KEY_BUTTON_DRAWABLE_STATE_CLASSES = listOf(
|
||||
"com.android.systemui.navigationbar.views.buttons.KeyButtonDrawable\$ShadowDrawableState",
|
||||
"com.android.systemui.navigationbar.buttons.KeyButtonDrawable\$ShadowDrawableState",
|
||||
)
|
||||
internal const val AOSP_SYSUI_LEFT_ARROW_BUTTON = "key(21:)"
|
||||
internal const val AOSP_SYSUI_RIGHT_ARROW_BUTTON = "key(22:)"
|
||||
|
||||
internal const val ACTION_PRIVILEGED_ACTION =
|
||||
"se.ajpanton.navbuttons.action.PRIVILEGED_ACTION"
|
||||
internal const val EXTRA_PRIVILEGED_ACTION = "privileged_action"
|
||||
internal const val EXTRA_PRIVILEGED_CALLER = "privileged_caller"
|
||||
internal const val EXTRA_PRIVILEGED_ACTION_TOKEN = "privileged_action_token"
|
||||
internal const val EXTRA_PRIVILEGED_KEY_CODE = "privileged_key_code"
|
||||
internal const val PRIVILEGED_ACTION_KILL_FOREGROUND_APP = "kill_foreground_app"
|
||||
internal const val PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT = "toggle_flashlight"
|
||||
internal const val PRIVILEGED_ACTION_SEND_KEY_PRESS = "send_key_press"
|
||||
|
||||
internal const val HONEYSPACE_NAV_BUTTON_VIEW =
|
||||
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarKeyButtonView"
|
||||
internal const val HONEYSPACE_NAV_BUTTON_CONTAINER =
|
||||
"com.honeyspace.ui.honeypots.taskbar.presentation.NavigationBarButtonsLayout"
|
||||
internal const val HONEYSPACE_LONG_PRESS_RUNNABLE = "f.a"
|
||||
internal const val HONEYSPACE_LONG_PRESS_CASE = 8
|
||||
internal const val HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH = 8
|
||||
internal const val HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS = 150L
|
||||
internal const val HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS = 600L
|
||||
|
||||
internal const val NAV_LAYOUT_PHONE_DEFAULT = "left,recent;home;back,right"
|
||||
internal const val NAV_LAYOUT_PHONE_REVERSED = "left,back;home;recent,right"
|
||||
internal const val NAV_LAYOUT_TABLET_DEFAULT = "left;recent,gap,home,gap,back;right"
|
||||
internal const val NAV_LAYOUT_TABLET_REVERSED = "left;back,gap,home,gap,recent;right"
|
||||
internal const val NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT = "left;back,gap,home,gap,recent;r"
|
||||
internal const val NAV_LAYOUT_WINNER_DEFAULT = "left,gap,back,gap,home,gap,recent;;right"
|
||||
internal const val NAV_LAYOUT_WINNER_REVERSED = "left,gap,recent,gap,home,gap,back;;right"
|
||||
internal const val NAV_LAYOUT_UNFOLDED_DEFAULT = "left;;back,gap,home,gap,recent,gap,right"
|
||||
internal const val NAV_LAYOUT_UNFOLDED_REVERSED = "left;;recent,gap,home,gap,back,gap,right"
|
||||
internal const val BUTTON_DISPATCHER_SEARCH_DEPTH = 4
|
||||
internal const val SIDE_ARROW_REFRESH_DELAY_MS = 100L
|
||||
internal const val PHONE_BUTTON_RATIO = 0.22220000624656677
|
||||
internal const val TABLET_BUTTON_RATIO = 0.2070000022649765
|
||||
internal const val SIDE_ARROW_GAP_RATIO = 0.013000000268220901
|
||||
internal const val PHONE_SPACE_RATIO = 0.10999999940395355
|
||||
internal const val TABLET_SPACE_RATIO = 0.10999999940395355
|
||||
internal const val TABLET_SPACE_SIDE_PADDING_RATIO = 0.033250000327825546
|
||||
internal const val SIDE_ARROW_REDUCE_RATIO = 0.75
|
||||
|
||||
internal fun Int.coerceLongPressDuration(): Int {
|
||||
return coerceIn(
|
||||
NavButtonSettingsStore.MIN_DURATION_MS,
|
||||
NavButtonSettingsStore.MAX_DURATION_MS,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.view.KeyEvent
|
||||
|
||||
internal fun stockActionFor(buttonId: NavButtonId): NavButtonAction {
|
||||
return when (buttonId) {
|
||||
NavButtonId.BACK -> NavButtonAction.BACK
|
||||
NavButtonId.HOME -> NavButtonAction.HOME
|
||||
NavButtonId.RECENTS -> NavButtonAction.RECENTS
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isSideArrowKeyCode(keyCode: Int): Boolean {
|
||||
return keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buttonIdForAospTaskbarCode(buttonCode: Int?): NavButtonId? {
|
||||
return when (buttonCode) {
|
||||
AOSP_TASKBAR_BACK_BUTTON -> NavButtonId.BACK
|
||||
AOSP_TASKBAR_HOME_BUTTON -> NavButtonId.HOME
|
||||
AOSP_TASKBAR_RECENTS_BUTTON -> NavButtonId.RECENTS
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.view.ViewConfiguration
|
||||
import androidx.annotation.StringRes
|
||||
import io.github.libxposed.service.XposedService
|
||||
import io.github.libxposed.service.XposedServiceHelper
|
||||
import java.util.UUID
|
||||
|
||||
enum class NavButtonId(
|
||||
val preferencePrefix: String,
|
||||
@@ -39,6 +40,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,
|
||||
@@ -52,6 +69,7 @@ class NavButtonSettingsStore(context: Context) {
|
||||
)
|
||||
|
||||
init {
|
||||
ensurePrivilegedActionToken()
|
||||
registerRemotePreferencesListener(localPreferences)
|
||||
}
|
||||
|
||||
@@ -91,12 +109,78 @@ 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())
|
||||
}
|
||||
|
||||
fun sanitizeDuration(durationMs: Int): Int {
|
||||
return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS)
|
||||
return durationMs.coerceIn(MIN_DURATION_MS, MAX_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 {
|
||||
@@ -115,6 +199,15 @@ class NavButtonSettingsStore(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePrivilegedActionToken() {
|
||||
if (localPreferences.contains(KEY_PRIVILEGED_ACTION_TOKEN)) {
|
||||
return
|
||||
}
|
||||
writeToPreferences(localPreferences, System.currentTimeMillis()) {
|
||||
putString(KEY_PRIVILEGED_ACTION_TOKEN, UUID.randomUUID().toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeToPreferences(
|
||||
preferences: SharedPreferences,
|
||||
updatedAtMs: Long,
|
||||
@@ -129,18 +222,27 @@ 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"
|
||||
const val KEY_PRIVILEGED_ACTION_TOKEN = "privileged_action_token"
|
||||
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
|
||||
const val MIN_DURATION_MS = 100
|
||||
const val MAX_DURATION_MS = 10_000
|
||||
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 +263,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 +324,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 +349,33 @@ 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_PRIVILEGED_ACTION_TOKEN)) {
|
||||
editor.putString(KEY_PRIVILEGED_ACTION_TOKEN, source.getString(KEY_PRIVILEGED_ACTION_TOKEN, null))
|
||||
} else {
|
||||
editor.remove(KEY_PRIVILEGED_ACTION_TOKEN)
|
||||
}
|
||||
|
||||
if (source.contains(KEY_LAST_UPDATED_AT_MS)) {
|
||||
editor.putLong(KEY_LAST_UPDATED_AT_MS, source.getLong(KEY_LAST_UPDATED_AT_MS, 0L))
|
||||
} else {
|
||||
@@ -252,7 +386,7 @@ class NavButtonSettingsStore(context: Context) {
|
||||
}
|
||||
|
||||
private fun sanitizeDuration(durationMs: Int): Int {
|
||||
return durationMs.coerceIn(MIN_LONG_PRESS_DURATION_MS, MAX_LONG_PRESS_DURATION_MS)
|
||||
return durationMs.coerceIn(MIN_DURATION_MS, MAX_DURATION_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.view.KeyEvent
|
||||
|
||||
internal class PrivilegedActionBridge(
|
||||
private val contextProvider: () -> Context?,
|
||||
private val contextUpdater: (Context) -> Unit,
|
||||
private val currentApplicationProvider: () -> Context?,
|
||||
private val settingsPreferencesProvider: () -> android.content.SharedPreferences?,
|
||||
private val log: (String, Throwable?) -> Unit,
|
||||
private val killForegroundApp: () -> Unit,
|
||||
private val toggleFlashlight: () -> Unit,
|
||||
private val sendKeyPress: (Int) -> Unit,
|
||||
) {
|
||||
private var receiverRegistered = false
|
||||
private var token: String? = null
|
||||
|
||||
fun registerReceiver() {
|
||||
if (receiverRegistered) {
|
||||
return
|
||||
}
|
||||
val currentContext = contextProvider() ?: currentApplicationProvider() ?: return
|
||||
contextUpdater(currentContext.applicationContext ?: currentContext)
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != ACTION_PRIVILEGED_ACTION ||
|
||||
context == null ||
|
||||
!isAllowedSender(context, intent, sentFromPackageCompat(), sentFromUidCompat())
|
||||
) {
|
||||
return
|
||||
}
|
||||
contextUpdater(context.applicationContext ?: context)
|
||||
perform(intent.getStringExtra(EXTRA_PRIVILEGED_ACTION), intent.getIntExtra(EXTRA_PRIVILEGED_KEY_CODE, KeyEvent.KEYCODE_UNKNOWN))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
currentContext.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(ACTION_PRIVILEGED_ACTION),
|
||||
Context.RECEIVER_EXPORTED,
|
||||
)
|
||||
if (currentContext.packageName != ANDROID_PACKAGE) {
|
||||
token = readToken()
|
||||
}
|
||||
receiverRegistered = true
|
||||
} catch (error: Throwable) {
|
||||
log("Privileged action receiver failed", error)
|
||||
}
|
||||
}
|
||||
|
||||
fun request(action: String, keyCode: Int? = null) {
|
||||
val currentContext = contextProvider()
|
||||
if (currentContext == null) {
|
||||
perform(action, keyCode ?: KeyEvent.KEYCODE_UNKNOWN)
|
||||
return
|
||||
}
|
||||
val intent = Intent(ACTION_PRIVILEGED_ACTION)
|
||||
.setPackage(ANDROID_PACKAGE)
|
||||
.putExtra(EXTRA_PRIVILEGED_ACTION, action)
|
||||
.putExtra(EXTRA_PRIVILEGED_CALLER, caller(currentContext, action))
|
||||
if (keyCode != null) {
|
||||
intent.putExtra(EXTRA_PRIVILEGED_KEY_CODE, keyCode)
|
||||
}
|
||||
readToken()?.let { intent.putExtra(EXTRA_PRIVILEGED_ACTION_TOKEN, it) }
|
||||
currentContext.sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun perform(action: String?, keyCode: Int) {
|
||||
when (action) {
|
||||
PRIVILEGED_ACTION_KILL_FOREGROUND_APP -> killForegroundApp()
|
||||
PRIVILEGED_ACTION_TOGGLE_FLASHLIGHT -> toggleFlashlight()
|
||||
PRIVILEGED_ACTION_SEND_KEY_PRESS -> {
|
||||
if (keyCode != KeyEvent.KEYCODE_UNKNOWN) {
|
||||
sendKeyPress(keyCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAllowedSender(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
senderPackage: String?,
|
||||
senderUid: Int,
|
||||
): Boolean {
|
||||
if (senderPackage == SYSTEMUI_PACKAGE || senderPackage in AOSP_LAUNCHER_PACKAGES) {
|
||||
return true
|
||||
}
|
||||
|
||||
val caller = getParcelableExtraCompat(intent, EXTRA_PRIVILEGED_CALLER, PendingIntent::class.java)
|
||||
if (caller?.creatorPackage == SYSTEMUI_PACKAGE || caller?.creatorPackage in AOSP_LAUNCHER_PACKAGES) {
|
||||
return true
|
||||
}
|
||||
if (caller?.creatorUid == Process.SYSTEM_UID) {
|
||||
return true
|
||||
}
|
||||
|
||||
val expectedToken = token ?: if (context.packageName != ANDROID_PACKAGE) {
|
||||
readToken()?.also { token = it }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (expectedToken != null && expectedToken == intent.getStringExtra(EXTRA_PRIVILEGED_ACTION_TOKEN)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (senderUid == Process.SYSTEM_UID) {
|
||||
return true
|
||||
}
|
||||
return context.packageManager.getPackagesForUid(senderUid)
|
||||
?.any { it == SYSTEMUI_PACKAGE || it in AOSP_LAUNCHER_PACKAGES } == true
|
||||
}
|
||||
|
||||
private fun readToken(): String? {
|
||||
return settingsPreferencesProvider()?.getString(NavButtonSettingsStore.KEY_PRIVILEGED_ACTION_TOKEN, null)
|
||||
}
|
||||
|
||||
private fun caller(context: Context, action: String): PendingIntent {
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val intent = Intent("$ACTION_PRIVILEGED_ACTION.CALLER").setPackage(context.packageName)
|
||||
return PendingIntent.getBroadcast(context, action.hashCode(), intent, flags)
|
||||
}
|
||||
|
||||
private fun BroadcastReceiver.sentFromUidCompat(): Int {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
sentFromUid
|
||||
} else {
|
||||
Process.INVALID_UID
|
||||
}
|
||||
}
|
||||
|
||||
private fun BroadcastReceiver.sentFromPackageCompat(): String? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
sentFromPackage
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> getParcelableExtraCompat(intent: Intent, name: String, type: Class<T>): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(name, type)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
type.cast(intent.getParcelableExtra(name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
|
||||
internal fun findClass(className: String, classLoader: ClassLoader?): Class<*> {
|
||||
return Class.forName(className, false, classLoader)
|
||||
}
|
||||
|
||||
internal fun findClassIfExists(className: String, classLoader: ClassLoader?): Class<*>? {
|
||||
return try {
|
||||
findClass(className, classLoader)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun findFirstClass(classNames: List<String>, classLoader: ClassLoader?): Class<*> {
|
||||
classNames.forEach { className ->
|
||||
findClassIfExists(className, classLoader)?.let { return it }
|
||||
}
|
||||
throw ClassNotFoundException(classNames.joinToString())
|
||||
}
|
||||
|
||||
internal fun findFirstClassIfExists(
|
||||
classNames: List<String>,
|
||||
classLoader: ClassLoader?,
|
||||
): Class<*>? {
|
||||
return classNames.firstNotNullOfOrNull { className ->
|
||||
findClassIfExists(className, classLoader)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun findMethodIfExists(
|
||||
clazz: Class<*>,
|
||||
methodName: String,
|
||||
vararg parameterTypes: Class<*>,
|
||||
): Method? {
|
||||
return try {
|
||||
findMethod(clazz, methodName, *parameterTypes)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun findDeclaredMethodsNamed(clazz: Class<*>, methodName: String): List<Method> {
|
||||
return clazz.declaredMethods
|
||||
.filter { it.name == methodName }
|
||||
.onEach { it.isAccessible = true }
|
||||
}
|
||||
|
||||
internal fun findMethodsNamed(clazz: Class<*>, methodName: String): List<Method> {
|
||||
val methodsBySignature = linkedMapOf<String, Method>()
|
||||
var current: Class<*>? = clazz
|
||||
while (current != null) {
|
||||
current.declaredMethods
|
||||
.filter { it.name == methodName }
|
||||
.forEach { method ->
|
||||
method.isAccessible = true
|
||||
methodsBySignature.putIfAbsent(method.toGenericString(), method)
|
||||
}
|
||||
current = current.superclass
|
||||
}
|
||||
return methodsBySignature.values.toList()
|
||||
}
|
||||
|
||||
@Throws(NoSuchMethodException::class)
|
||||
internal fun findMethod(
|
||||
clazz: Class<*>,
|
||||
methodName: String,
|
||||
vararg parameterTypes: Class<*>,
|
||||
): Method {
|
||||
var current: Class<*>? = clazz
|
||||
while (current != null) {
|
||||
try {
|
||||
return current.getDeclaredMethod(methodName, *parameterTypes).apply {
|
||||
isAccessible = true
|
||||
}
|
||||
} catch (_: NoSuchMethodException) {
|
||||
current = current.superclass
|
||||
}
|
||||
}
|
||||
throw NoSuchMethodException("${clazz.name}#$methodName")
|
||||
}
|
||||
|
||||
@Throws(NoSuchFieldException::class)
|
||||
internal fun findField(clazz: Class<*>, fieldName: String): Field {
|
||||
var current: Class<*>? = clazz
|
||||
while (current != null) {
|
||||
try {
|
||||
return current.getDeclaredField(fieldName).apply {
|
||||
isAccessible = true
|
||||
}
|
||||
} catch (_: NoSuchFieldException) {
|
||||
current = current.superclass
|
||||
}
|
||||
}
|
||||
throw NoSuchFieldException("${clazz.name}#$fieldName")
|
||||
}
|
||||
|
||||
@Throws(ReflectiveOperationException::class)
|
||||
internal fun callStaticMethod(clazz: Class<*>, methodName: String): Any? {
|
||||
return findMethod(clazz, methodName).invoke(null)
|
||||
}
|
||||
|
||||
@Throws(ReflectiveOperationException::class)
|
||||
internal fun callMethod(target: Any, methodName: String): Any? {
|
||||
return callMethod(target, methodName, emptyArray())
|
||||
}
|
||||
|
||||
@Throws(ReflectiveOperationException::class)
|
||||
internal fun callMethod(
|
||||
target: Any,
|
||||
methodName: String,
|
||||
parameterTypes: Array<Class<*>>,
|
||||
vararg args: Any?,
|
||||
): Any? {
|
||||
return findMethod(target.javaClass, methodName, *parameterTypes).invoke(target, *args)
|
||||
}
|
||||
|
||||
internal fun callIntIntMethodIfExists(
|
||||
target: Any,
|
||||
methodNames: List<String>,
|
||||
first: Int,
|
||||
second: Int,
|
||||
): Boolean {
|
||||
methodNames.forEach { methodName ->
|
||||
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 ->
|
||||
return try {
|
||||
method.invoke(target, first, second)
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal fun callMethodIfExists(target: Any, methodName: String): Any? {
|
||||
return findMethodsNamed(target.javaClass, methodName)
|
||||
.firstOrNull { method -> method.parameterTypes.isEmpty() }
|
||||
?.let { method ->
|
||||
runCatching { method.invoke(target) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal fun getFieldValue(target: Any?, fieldName: String): Any? {
|
||||
if (target == null) {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
findField(target.javaClass, fieldName).get(target)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getIntField(target: Any?, fieldName: String): Int {
|
||||
val value = getFieldValue(target, fieldName)
|
||||
return value as? Int ?: Int.MIN_VALUE
|
||||
}
|
||||
|
||||
internal fun setFieldIfExists(target: Any, fieldName: String, value: Any?) {
|
||||
runCatching {
|
||||
findField(target.javaClass, fieldName).set(target, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.SystemClock
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import java.lang.reflect.Method
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class SamsungHoneyspaceHooks(
|
||||
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||
private val deoptimizeMethod: (Method?) -> Unit,
|
||||
private val prepareLongPressHook: (Method) -> Unit,
|
||||
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||
private val sideArrowsEnabled: () -> Boolean,
|
||||
private val sideArrowLongPresses: SideArrowLongPressController,
|
||||
private val registerSettingsReceiver: () -> Unit,
|
||||
private val contextProvider: () -> Context?,
|
||||
private val contextUpdater: (Context) -> Unit,
|
||||
private val handlerProvider: () -> Handler?,
|
||||
private val performAction: (NavButtonAction) -> Unit,
|
||||
private val sendKeyPress: (Int) -> Unit,
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
private val longPressTriggered = WeakHashMap<Any, Boolean>()
|
||||
private val trackedNavContainers = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||
private val nativeSideArrowStates = WeakHashMap<Any, Boolean>()
|
||||
private val homeStockLongPressGuard = HomeStockLongPressGuard(
|
||||
configProvider = configProvider,
|
||||
longPressTriggered = { longPressTriggered[it] == true },
|
||||
sendCanceledKeyUp = ::sendCanceledKeyUp,
|
||||
)
|
||||
|
||||
fun install(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
|
||||
}
|
||||
|
||||
val runMethod = findMethodIfExists(runnableClass, "run") ?: run {
|
||||
log("Launcher run() method not found.")
|
||||
return false
|
||||
}
|
||||
|
||||
deoptimizeMethod(onTouchEventMethod)
|
||||
prepareLongPressHook(runMethod)
|
||||
hookSideArrowLayout(classLoader)
|
||||
|
||||
hookMethod(onTouchEventMethod) { chain ->
|
||||
val navButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
|
||||
val motionEvent = chain.args[0] as? MotionEvent ?: return@hookMethod chain.proceed()
|
||||
val keyCode = callMethod(navButtonView, "getKeyCode") as? Int ?: Int.MIN_VALUE
|
||||
if (isSideArrowKeyCode(keyCode)) {
|
||||
updateContextFromView(navButtonView)
|
||||
registerSettingsReceiver()
|
||||
trackNavContainerFrom(navButtonView as? View)
|
||||
|
||||
return@hookMethod handleNativeSideArrowTouch(navButtonView, keyCode, motionEvent) {
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||
updateContextFromView(navButtonView)
|
||||
registerSettingsReceiver()
|
||||
trackNavContainerFrom(navButtonView as? View)
|
||||
|
||||
when (motionEvent.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
longPressTriggered[navButtonView] = false
|
||||
homeStockLongPressGuard.cancel(navButtonView)
|
||||
homeStockLongPressGuard.clear(navButtonView)
|
||||
val result = chain.proceed()
|
||||
rescheduleHoneyspaceLongPress(
|
||||
navButtonView,
|
||||
runnableClass,
|
||||
configProvider(buttonId).longPressDurationMs,
|
||||
)
|
||||
homeStockLongPressGuard.schedule(navButtonView, buttonId)
|
||||
result
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
val triggered = longPressTriggered[navButtonView] == true
|
||||
if (triggered) {
|
||||
return@hookMethod proceedWithCanceledAction(motionEvent) {
|
||||
chain.proceed()
|
||||
}.also {
|
||||
homeStockLongPressGuard.cancel(navButtonView)
|
||||
longPressTriggered.remove(navButtonView)
|
||||
homeStockLongPressGuard.clear(navButtonView)
|
||||
}
|
||||
}
|
||||
|
||||
val pressAction = configProvider(buttonId).pressAction
|
||||
val stockHomePressCanceled =
|
||||
buttonId == NavButtonId.HOME && homeStockLongPressGuard.isCanceled(navButtonView)
|
||||
val shouldOverridePress =
|
||||
pressAction != NavButtonAction.STOCK &&
|
||||
((navButtonView as? View)?.isPressed == true || stockHomePressCanceled) &&
|
||||
!triggered
|
||||
|
||||
if (shouldOverridePress) {
|
||||
markLongClicked(navButtonView)
|
||||
}
|
||||
|
||||
val result = chain.proceed()
|
||||
if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) {
|
||||
dispatchHomeTap(navButtonView)
|
||||
}
|
||||
if (shouldOverridePress) {
|
||||
updateContextFromView(navButtonView)
|
||||
performAction(pressAction)
|
||||
}
|
||||
homeStockLongPressGuard.cancel(navButtonView)
|
||||
longPressTriggered.remove(navButtonView)
|
||||
homeStockLongPressGuard.clear(navButtonView)
|
||||
result
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
homeStockLongPressGuard.cancel(navButtonView)
|
||||
longPressTriggered.remove(navButtonView)
|
||||
homeStockLongPressGuard.clear(navButtonView)
|
||||
chain.proceed()
|
||||
}
|
||||
|
||||
else -> chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
hookMethod(runMethod) { chain ->
|
||||
val caseId = getIntField(chain.thisObject, "b")
|
||||
if (caseId != HONEYSPACE_LONG_PRESS_CASE) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
val target = getFieldValue(chain.thisObject, "c")
|
||||
if (!navButtonClass.isInstance(target)) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
val navButtonView = target ?: return@hookMethod chain.proceed()
|
||||
val keyCode = callMethod(navButtonView, "getKeyCode") as Int
|
||||
if (isSideArrowKeyCode(keyCode)) {
|
||||
return@hookMethod null
|
||||
}
|
||||
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||
val longPressAction = configProvider(buttonId).longPressAction
|
||||
longPressTriggered[navButtonView] = true
|
||||
if (longPressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
updateContextFromView(navButtonView)
|
||||
markLongClicked(navButtonView)
|
||||
homeStockLongPressGuard.cancel(navButtonView)
|
||||
performAction(longPressAction)
|
||||
null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun refreshSideArrowLayouts(reason: String) {
|
||||
val containers = trackedNavContainers.toList()
|
||||
containers.forEach { container ->
|
||||
refreshSideArrowLayout(container as? ViewGroup ?: return@forEach, reason)
|
||||
scheduleDelayedSideArrowRefresh(container, reason)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookSideArrowLayout(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 ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val result = chain.proceed()
|
||||
val container = chain.thisObject as? ViewGroup
|
||||
?: return@hookMethod result
|
||||
updateContextFromView(container)
|
||||
registerSettingsReceiver()
|
||||
trackNavContainer(container)
|
||||
refreshSideArrowLayout(container, "after ${method.name}")
|
||||
scheduleDelayedSideArrowRefresh(container, "after ${method.name}")
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hookNativeSideArrowRefresh(containerClass)
|
||||
}
|
||||
|
||||
private fun hookNativeSideArrowRefresh(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
|
||||
}
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val container = chain.thisObject as? ViewGroup
|
||||
val result = chain.proceed()
|
||||
if (container != null && sideArrowsEnabled()) {
|
||||
restoreHoneyspaceSideArrowDrawables(container)
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackNavContainerFrom(view: View?) {
|
||||
var current = view?.parent as? View
|
||||
repeat(HONEYSPACE_NAV_CONTAINER_PARENT_DEPTH) {
|
||||
val target = current ?: return
|
||||
if (isHoneyspaceNavButtonContainer(target)) {
|
||||
trackNavContainer(target)
|
||||
val container = target as ViewGroup
|
||||
refreshSideArrowLayout(container, "from touch")
|
||||
scheduleDelayedSideArrowRefresh(container, "from touch")
|
||||
return
|
||||
}
|
||||
current = target.parent as? View
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackNavContainer(container: View) {
|
||||
if (trackedNavContainers.add(container)) {
|
||||
container.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ ->
|
||||
refreshSideArrowLayout(view as ViewGroup, "layout listener")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleDelayedSideArrowRefresh(container: ViewGroup, reason: String) {
|
||||
listOf(
|
||||
HONEYSPACE_SIDE_ARROW_SHORT_REFRESH_DELAY_MS,
|
||||
HONEYSPACE_SIDE_ARROW_LONG_REFRESH_DELAY_MS,
|
||||
).forEach { delayMs ->
|
||||
handlerProvider()?.postDelayed(
|
||||
{ refreshSideArrowLayout(container, "$reason delayed ${delayMs}ms") },
|
||||
delayMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSideArrowLayout(container: ViewGroup, reason: String) {
|
||||
try {
|
||||
applyNativeSideArrows(container, reason)
|
||||
} catch (error: Throwable) {
|
||||
log("Honeyspace side arrows refresh failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun applyNativeSideArrows(container: ViewGroup, reason: String) {
|
||||
val buttonMap = getFieldValue(container, "i") as? MutableMap<Any, Any> ?: run {
|
||||
log("Honeyspace side arrows native apply skipped: button map missing reason=$reason")
|
||||
return
|
||||
}
|
||||
val enabled = sideArrowsEnabled()
|
||||
val targetSlots = honeyspaceSideArrowSlots(container, enabled)
|
||||
val currentSlots = getFieldValue(container, "u") as? BooleanArray
|
||||
val alreadyApplied = nativeSideArrowStates[container] == enabled &&
|
||||
if (enabled) {
|
||||
buttonMap[HONEYSPACE_LEFT_ARROW_BUTTON_TYPE] != null &&
|
||||
buttonMap[HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE] != 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) {
|
||||
val created = HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow ->
|
||||
createHoneyspaceExtraButton(container, arrow.type, arrow.keyCode, arrow.direction)
|
||||
?.also { buttonMap[arrow.type] = it } != null
|
||||
}
|
||||
if (!created) {
|
||||
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)
|
||||
}
|
||||
|
||||
nativeSideArrowStates[container] = enabled
|
||||
callBooleanMethodIfExists(container, "g", false)
|
||||
restoreHoneyspaceSideArrowDrawables(container)
|
||||
}
|
||||
|
||||
private fun handleNativeSideArrowTouch(
|
||||
view: Any,
|
||||
keyCode: Int,
|
||||
event: MotionEvent,
|
||||
proceed: () -> Any?,
|
||||
): Any? {
|
||||
return when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
sideArrowLongPresses.startAfter(view, keyCode, proceed)
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
-> {
|
||||
sideArrowLongPresses.finish(view)
|
||||
proceed()
|
||||
}
|
||||
|
||||
else -> proceed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun markLongClicked(navButtonView: Any) {
|
||||
try {
|
||||
callMethod(
|
||||
navButtonView,
|
||||
"setLongClicked",
|
||||
arrayOf(Boolean::class.javaPrimitiveType!!),
|
||||
true,
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
log("setLongClicked failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContextFromView(value: Any) {
|
||||
val currentContext = (value as? View)?.context ?: return
|
||||
contextUpdater(currentContext.applicationContext ?: currentContext)
|
||||
}
|
||||
|
||||
private fun sendCanceledKeyUp(navButtonView: Any) {
|
||||
if (callIntIntMethodIfExists(
|
||||
navButtonView,
|
||||
listOf("sendEvent", "sendKeyEvent"),
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val inputManager = inputManagerForInjection(contextProvider()) ?: run {
|
||||
log("InputManager unavailable for Honeyspace Home cancel.")
|
||||
return
|
||||
}
|
||||
val now = SystemClock.uptimeMillis()
|
||||
injectKeyEvent(
|
||||
inputManager,
|
||||
KeyEvent(
|
||||
now,
|
||||
now,
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_HOME,
|
||||
0,
|
||||
0,
|
||||
KeyEvent.KEYCODE_UNKNOWN,
|
||||
0,
|
||||
KeyEvent.FLAG_CANCELED or KeyEvent.FLAG_CANCELED_LONG_PRESS,
|
||||
),
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
log("Honeyspace Home cancel failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchHomeTap(navButtonView: Any) {
|
||||
if (callIntIntMethodIfExists(navButtonView, listOf("sendEvent", "sendKeyEvent"), KeyEvent.ACTION_DOWN, 0) &&
|
||||
callIntIntMethodIfExists(navButtonView, listOf("sendEvent", "sendKeyEvent"), KeyEvent.ACTION_UP, 0)
|
||||
) {
|
||||
return
|
||||
}
|
||||
sendKeyPress(KeyEvent.KEYCODE_HOME)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
|
||||
internal fun honeyspaceSideArrowSlots(container: 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)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun honeyspaceSideArrowDrawablesPresent(buttonMap: Map<Any, Any>): Boolean {
|
||||
return HONEYSPACE_SIDE_ARROW_BUTTONS.all { arrow ->
|
||||
(buttonMap[arrow.type] as? ImageView)?.drawable != null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun restoreHoneyspaceSideArrowDrawables(container: ViewGroup) {
|
||||
val buttonMap = getFieldValue(container, "i") as? Map<*, *> ?: return
|
||||
val darkIntensity = getFieldValue(container, "w") as? Float ?: 0f
|
||||
HONEYSPACE_SIDE_ARROW_BUTTONS.forEach { arrow ->
|
||||
restoreHoneyspaceSideArrowDrawable(
|
||||
buttonMap[arrow.type] as? ImageView,
|
||||
arrow.direction,
|
||||
darkIntensity,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isHoneyspaceNavButtonContainer(view: View): Boolean {
|
||||
return view.javaClass.name == HONEYSPACE_NAV_BUTTON_CONTAINER ||
|
||||
view.javaClass.simpleName == "NavigationBarButtonsLayout" ||
|
||||
honeyspaceViewIdName(view) == "navbar_button_container"
|
||||
}
|
||||
|
||||
internal fun rescheduleHoneyspaceLongPress(
|
||||
navButtonView: Any,
|
||||
runnableClass: Class<*>,
|
||||
durationMs: Int,
|
||||
) {
|
||||
val view = navButtonView as? View ?: return
|
||||
val longPressRunnable = findHoneyspaceLongPressRunnable(navButtonView, runnableClass) ?: return
|
||||
view.removeCallbacks(longPressRunnable)
|
||||
view.postDelayed(longPressRunnable, durationMs.toLong())
|
||||
}
|
||||
|
||||
internal fun createHoneyspaceExtraButton(
|
||||
container: 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 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 honeyspaceViewIdName(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 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? 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 createHoneyspaceSideArrowLayerDrawable(context: Context, direction: SideArrowDirection): Drawable {
|
||||
return LayerDrawable(
|
||||
arrayOf(
|
||||
sideArrowBitmapDrawable(context, direction, Color.WHITE),
|
||||
sideArrowBitmapDrawable(context, direction, Color.rgb(55, 58, 62)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.view.View
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import java.lang.reflect.Method
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class SamsungSystemUiHooks(
|
||||
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||
private val deoptimizeMethod: (Method?) -> Unit,
|
||||
private val sideArrowsEnabled: () -> Boolean,
|
||||
private val registerSettingsReceiver: () -> Unit,
|
||||
private val contextProvider: () -> Context?,
|
||||
private val contextUpdater: (Context) -> Unit,
|
||||
private val handlerProvider: () -> Handler?,
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
private val trackedNavigationBarViews = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||
private val startupRefreshScheduled = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||
private val sideArrowLayoutKinds = WeakHashMap<Any, SamsungSideArrowLayoutKind>()
|
||||
private var foldedSideArrowProviderHookInstalled = false
|
||||
private var navigationBarViewHookInstalled = false
|
||||
|
||||
fun installFoldedSideArrows(classLoader: ClassLoader?) {
|
||||
if (!Build.MANUFACTURER.equals("samsung", ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
hookFoldedLayoutProvider(classLoader)
|
||||
hookNavigationBarView(classLoader)
|
||||
registerSettingsReceiver()
|
||||
}
|
||||
|
||||
fun trackNavigationBarView(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) {
|
||||
trackNavigationBar(target)
|
||||
return
|
||||
}
|
||||
current = target.parent as? View
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshNavigationBars() {
|
||||
val views = trackedNavigationBarViews.toList()
|
||||
if (views.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
views.forEach { navBarView ->
|
||||
try {
|
||||
refreshNavigationBar(navBarView)
|
||||
if (sideArrowsEnabled()) {
|
||||
handlerProvider()?.postDelayed(
|
||||
{ refreshNavigationBar(navBarView) },
|
||||
SIDE_ARROW_REFRESH_DELAY_MS,
|
||||
)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
log("Side arrows reinflate failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val result = chain.proceed()
|
||||
val layout = result as? String ?: return@hookMethod result
|
||||
if (!sideArrowsEnabled()) {
|
||||
return@hookMethod layout
|
||||
}
|
||||
|
||||
val sideArrowLayout = addSamsungSideArrows(
|
||||
layout,
|
||||
findContext(chain.thisObject) ?: contextProvider(),
|
||||
) { index ->
|
||||
log("Folded side arrows custom key id not found: nav_custom_key_$index")
|
||||
}
|
||||
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(::isSamsungPointBooleanIntMethod)
|
||||
.forEach { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
samsungSideArrowSize(
|
||||
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||
PHONE_BUTTON_RATIO, TABLET_BUTTON_RATIO,
|
||||
) ?: chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
findMethodsNamed(providerClass, "getButtonDistanceSize")
|
||||
.filter(::isSamsungPointBooleanIntMethod)
|
||||
.forEach { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
samsungSideArrowSize(
|
||||
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||
SIDE_ARROW_GAP_RATIO, SIDE_ARROW_GAP_RATIO,
|
||||
) ?: chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
findMethodsNamed(providerClass, "getSpaceWidth")
|
||||
.filter { method ->
|
||||
method.returnType == Int::class.javaPrimitiveType &&
|
||||
method.parameterTypes.firstOrNull() == android.graphics.Point::class.java
|
||||
}
|
||||
.forEach { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
samsungSideArrowSize(
|
||||
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||
PHONE_SPACE_RATIO, TABLET_SPACE_RATIO,
|
||||
) ?: chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
findMethodsNamed(providerClass, "getSpaceSidePadding")
|
||||
.filter { method ->
|
||||
method.returnType == Int::class.javaPrimitiveType &&
|
||||
method.parameterTypes.firstOrNull() == android.graphics.Point::class.java
|
||||
}
|
||||
.forEach { method ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
samsungSideArrowSize(
|
||||
chain.thisObject, chain.args, sideArrowLayoutKinds, sideArrowsEnabled(),
|
||||
0.0, TABLET_SPACE_SIDE_PADDING_RATIO,
|
||||
) ?: chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookNavigationBarView(classLoader: ClassLoader?) {
|
||||
if (navigationBarViewHookInstalled) {
|
||||
return
|
||||
}
|
||||
navigationBarViewHookInstalled = 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 ->
|
||||
deoptimizeMethod(method)
|
||||
hookMethod(method) { chain ->
|
||||
val navBarView = chain.thisObject
|
||||
navBarView?.let { prepareNavigationBar(it) }
|
||||
val result = chain.proceed()
|
||||
navBarView?.let { prepareNavigationBar(it) }
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareNavigationBar(navBarView: Any) {
|
||||
updateContextFromView(navBarView)
|
||||
registerSettingsReceiver()
|
||||
trackNavigationBar(navBarView)
|
||||
if (sideArrowsEnabled()) {
|
||||
handlerProvider()?.postDelayed(
|
||||
{ configureFoldedSideArrowButtons(navBarView) },
|
||||
SIDE_ARROW_REFRESH_DELAY_MS,
|
||||
)
|
||||
scheduleStartupRefresh(navBarView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun trackNavigationBar(navBarView: Any) {
|
||||
trackedNavigationBarViews.add(navBarView)
|
||||
configureFoldedSideArrowButtons(navBarView)
|
||||
}
|
||||
|
||||
private fun scheduleStartupRefresh(navBarView: Any) {
|
||||
if (!startupRefreshScheduled.add(navBarView)) {
|
||||
return
|
||||
}
|
||||
handlerProvider()?.postDelayed(
|
||||
{ refreshNavigationBar(navBarView) },
|
||||
SIDE_ARROW_REFRESH_DELAY_MS * 4,
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshNavigationBar(navBarView: Any) {
|
||||
try {
|
||||
configureFoldedSideArrowButtons(navBarView)
|
||||
callMethodIfExists(navBarView, "updateLayoutProviderView")
|
||||
callMethod(navBarView, "reInflateNavBarLayout", emptyArray<Class<*>>())
|
||||
configureFoldedSideArrowButtons(navBarView)
|
||||
} catch (error: Throwable) {
|
||||
log("Side arrows nav refresh failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureFoldedSideArrowButtons(navBarView: Any) {
|
||||
if (sideArrowsEnabled()) {
|
||||
configureSamsungSideArrowDispatcherButtons(
|
||||
navBarView,
|
||||
navBarView,
|
||||
contextProvider(),
|
||||
::findContext,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContextFromView(value: Any) {
|
||||
val viewContext = (value as? View)?.context ?: return
|
||||
contextUpdater(viewContext.applicationContext ?: viewContext)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Point
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.SparseArray
|
||||
import android.view.View
|
||||
import java.lang.reflect.Method
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal enum class SamsungSideArrowLayoutKind {
|
||||
PHONE,
|
||||
TABLET,
|
||||
}
|
||||
|
||||
internal data class SamsungSideArrowLayout(
|
||||
val layout: String,
|
||||
val kind: SamsungSideArrowLayoutKind,
|
||||
)
|
||||
|
||||
internal fun addSamsungSideArrows(
|
||||
layout: String,
|
||||
sourceContext: Context?,
|
||||
onMissingCustomKeyId: (Int) -> Unit,
|
||||
): SamsungSideArrowLayout {
|
||||
val leftKey = navCustomKeyToken(sourceContext, SideArrowDirection.LEFT.keyCode, 1, onMissingCustomKeyId)
|
||||
val rightKey = navCustomKeyToken(sourceContext, SideArrowDirection.RIGHT.keyCode, 2, onMissingCustomKeyId)
|
||||
return when (layout) {
|
||||
NAV_LAYOUT_PHONE_DEFAULT ->
|
||||
SamsungSideArrowLayout("left,recent;$leftKey,home,$rightKey;back,right", SamsungSideArrowLayoutKind.PHONE)
|
||||
NAV_LAYOUT_PHONE_REVERSED ->
|
||||
SamsungSideArrowLayout("left,back;$leftKey,home,$rightKey;recent,right", SamsungSideArrowLayoutKind.PHONE)
|
||||
NAV_LAYOUT_TABLET_DEFAULT ->
|
||||
SamsungSideArrowLayout("left;recent,$leftKey,home,$rightKey,back;right", SamsungSideArrowLayoutKind.TABLET)
|
||||
NAV_LAYOUT_TABLET_REVERSED ->
|
||||
SamsungSideArrowLayout("left;back,$leftKey,home,$rightKey,recent;right", SamsungSideArrowLayoutKind.TABLET)
|
||||
NAV_LAYOUT_TABLET_REVERSED_SHORT_RIGHT ->
|
||||
SamsungSideArrowLayout("left;back,$leftKey,home,$rightKey,recent;r", SamsungSideArrowLayoutKind.TABLET)
|
||||
NAV_LAYOUT_WINNER_DEFAULT ->
|
||||
SamsungSideArrowLayout("left,back,$leftKey,home,$rightKey,recent;;right", SamsungSideArrowLayoutKind.TABLET)
|
||||
NAV_LAYOUT_WINNER_REVERSED ->
|
||||
SamsungSideArrowLayout("left,recent,$leftKey,home,$rightKey,back;;right", SamsungSideArrowLayoutKind.TABLET)
|
||||
NAV_LAYOUT_UNFOLDED_DEFAULT ->
|
||||
SamsungSideArrowLayout("left;;extrakey,back,$leftKey,home,$rightKey,recent,right", SamsungSideArrowLayoutKind.TABLET)
|
||||
NAV_LAYOUT_UNFOLDED_REVERSED ->
|
||||
SamsungSideArrowLayout("left;;extrakey,recent,$leftKey,home,$rightKey,back,right", SamsungSideArrowLayoutKind.TABLET)
|
||||
else -> addSamsungSideArrowsAroundHome(layout, leftKey, rightKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSamsungSideArrowsAroundHome(
|
||||
layout: String,
|
||||
leftKey: String,
|
||||
rightKey: String,
|
||||
): SamsungSideArrowLayout {
|
||||
if (layout.contains("navkey(${SideArrowDirection.LEFT.keyCode}:") ||
|
||||
layout.contains("navkey(${SideArrowDirection.RIGHT.keyCode}:")
|
||||
) {
|
||||
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
|
||||
}
|
||||
|
||||
val groups = layout.split(";").toMutableList()
|
||||
val homeGroupIndex = groups.indexOfFirst { group ->
|
||||
group.split(",").any { it == "home" }
|
||||
}
|
||||
if (homeGroupIndex < 0) {
|
||||
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
|
||||
}
|
||||
|
||||
val tokens = groups[homeGroupIndex]
|
||||
.split(",")
|
||||
.filter { it.isNotBlank() && it != "gap" }
|
||||
.toMutableList()
|
||||
val homeIndex = tokens.indexOf("home")
|
||||
if (homeIndex < 0) {
|
||||
return SamsungSideArrowLayout(layout, SamsungSideArrowLayoutKind.PHONE)
|
||||
}
|
||||
|
||||
tokens.add(homeIndex, leftKey)
|
||||
tokens.add(homeIndex + 2, rightKey)
|
||||
groups[homeGroupIndex] = tokens.joinToString(",")
|
||||
|
||||
val kind = if (groups.size == 3 && groups[1] == "$leftKey,home,$rightKey") {
|
||||
SamsungSideArrowLayoutKind.PHONE
|
||||
} else {
|
||||
SamsungSideArrowLayoutKind.TABLET
|
||||
}
|
||||
return SamsungSideArrowLayout(groups.joinToString(";"), kind)
|
||||
}
|
||||
|
||||
internal fun navCustomKeyId(sourceContext: Context?, index: Int): Int {
|
||||
return sourceContext?.resources?.getIdentifier(
|
||||
"nav_custom_key_$index",
|
||||
"id",
|
||||
sourceContext.packageName,
|
||||
) ?: 0
|
||||
}
|
||||
|
||||
internal fun isSamsungPointBooleanIntMethod(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
|
||||
}
|
||||
|
||||
internal fun samsungSideArrowSize(
|
||||
provider: Any?,
|
||||
args: List<Any?>,
|
||||
layoutKinds: Map<Any, SamsungSideArrowLayoutKind>,
|
||||
enabled: Boolean,
|
||||
phoneRatio: Double,
|
||||
tabletRatio: Double,
|
||||
): Int? {
|
||||
if (!enabled) {
|
||||
return null
|
||||
}
|
||||
val displaySize = args.firstOrNull() as? Point ?: return null
|
||||
val kind = provider?.let { layoutKinds[it] } ?: SamsungSideArrowLayoutKind.PHONE
|
||||
val ratio = if (kind == SamsungSideArrowLayoutKind.TABLET) tabletRatio else phoneRatio
|
||||
return (minOf(displaySize.x, displaySize.y) * ratio * SIDE_ARROW_REDUCE_RATIO).toInt()
|
||||
}
|
||||
|
||||
internal fun configureSamsungSideArrowDispatcherButtons(
|
||||
source: Any,
|
||||
darkIntensitySource: Any?,
|
||||
fallbackContext: Context?,
|
||||
contextFromObject: (Any) -> Context?,
|
||||
): Boolean {
|
||||
val navContext = samsungNavigationBarContext(source, fallbackContext, contextFromObject) ?: return false
|
||||
val dispatcherProxy = findSamsungButtonDispatcherProxy(source)
|
||||
|
||||
if (dispatcherProxy != null) {
|
||||
configureSamsungSideArrowDispatcherButton(
|
||||
dispatcherProxy,
|
||||
darkIntensitySource,
|
||||
navContext,
|
||||
index = 1,
|
||||
direction = SideArrowDirection.LEFT,
|
||||
)
|
||||
configureSamsungSideArrowDispatcherButton(
|
||||
dispatcherProxy,
|
||||
darkIntensitySource,
|
||||
navContext,
|
||||
index = 2,
|
||||
direction = SideArrowDirection.RIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
return dispatcherProxy != null
|
||||
}
|
||||
|
||||
private fun samsungNavigationBarContext(
|
||||
source: Any,
|
||||
fallbackContext: Context?,
|
||||
contextFromObject: (Any) -> Context?,
|
||||
): Context? {
|
||||
return (callMethodIfExists(source, "getNavigationBarContext") as? Context)
|
||||
?.let { it.applicationContext ?: it }
|
||||
?: contextFromObject(source)
|
||||
?: fallbackContext
|
||||
}
|
||||
|
||||
private fun configureSamsungSideArrowDispatcherButton(
|
||||
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) {
|
||||
setSamsungSideArrowDarkIntensity(darkIntensitySource, id, context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findSamsungButtonDispatcherProxy(root: Any): Any? {
|
||||
findSamsungButtonDispatcherProxyFromMethods(root)?.let { return it }
|
||||
|
||||
val visited = Collections.newSetFromMap(WeakHashMap<Any, Boolean>())
|
||||
val queue = ArrayDeque<Pair<Any, Int>>()
|
||||
queue.add(root to 0)
|
||||
|
||||
while (queue.isNotEmpty()) {
|
||||
val (target, depth) = queue.removeFirst()
|
||||
if (!visited.add(target)) {
|
||||
continue
|
||||
}
|
||||
if (isSamsungButtonDispatcherProxy(target)) {
|
||||
return target
|
||||
}
|
||||
findSamsungButtonDispatcherProxyFromMethods(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 (shouldSearchSamsungButtonDispatcherField(value)) {
|
||||
queue.add(value to depth + 1)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
current = current.superclass
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findSamsungButtonDispatcherProxyFromMethods(target: Any): Any? {
|
||||
findMethodsNamed(target.javaClass, "getButtonDispatcherProxy")
|
||||
.firstOrNull { it.parameterTypes.isEmpty() }
|
||||
?.let { method ->
|
||||
runCatching { method.invoke(target) }
|
||||
.getOrNull()
|
||||
?.takeIf(::isSamsungButtonDispatcherProxy)
|
||||
?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isSamsungButtonDispatcherProxy(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 shouldSearchSamsungButtonDispatcherField(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 setSamsungSideArrowDarkIntensity(navBarView: Any, id: Int, context: Context) {
|
||||
val dispatchers = getFieldValue(navBarView, "mButtonDispatchers") as? SparseArray<*> ?: return
|
||||
val dispatcher = dispatchers.get(id) ?: return
|
||||
val intensity = findSamsungReferenceDarkIntensity(context, 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 findSamsungReferenceDarkIntensity(context: Context, dispatchers: SparseArray<*>): Float {
|
||||
listOf("home", "back", "recent").forEach { name ->
|
||||
val id = context.resources.getIdentifier(name, "id", context.packageName)
|
||||
val value = getFieldValue(dispatchers.get(id), "mDarkIntensity") as? Float
|
||||
if (value != null) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
private fun navCustomKeyToken(
|
||||
sourceContext: Context?,
|
||||
keyCode: Int,
|
||||
index: Int,
|
||||
onMissingCustomKeyId: (Int) -> Unit,
|
||||
): String {
|
||||
val customKeyId = navCustomKeyId(sourceContext, index)
|
||||
if (customKeyId == 0) {
|
||||
onMissingCustomKeyId(index)
|
||||
}
|
||||
return "navkey($keyCode:$customKeyId)"
|
||||
}
|
||||
@@ -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,14 @@ 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
|
||||
|
||||
class SettingsActivity : Activity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@@ -61,6 +66,49 @@ class SettingsActivity : Activity() {
|
||||
false,
|
||||
)
|
||||
if (index > 0) {
|
||||
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)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
|
||||
private fun sendSettingsChangedBroadcast() {
|
||||
val intent = Intent(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED)
|
||||
NavButtonId.entries.forEach { buttonId ->
|
||||
val config = settingsStore.getConfig(buttonId)
|
||||
intent.putExtra(NavButtonSettingsStore.pressActionKey(buttonId), config.pressAction.storageValue)
|
||||
intent.putExtra(NavButtonSettingsStore.longPressActionKey(buttonId), config.longPressAction.storageValue)
|
||||
intent.putExtra(NavButtonSettingsStore.longPressDurationKey(buttonId), config.longPressDurationMs)
|
||||
}
|
||||
intent.putExtra(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, settingsStore.isSideArrowsEnabled())
|
||||
intent.putExtra(
|
||||
NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION,
|
||||
settingsStore.getSideArrowLongPressAction().storageValue,
|
||||
)
|
||||
intent.putExtra(
|
||||
NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS,
|
||||
settingsStore.getSideArrowLongPressDurationMs(),
|
||||
)
|
||||
intent.putExtra(
|
||||
NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||
settingsStore.getSideArrowRepeatStepsPerSecond(),
|
||||
)
|
||||
sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun applySectionTopMargin(sectionView: View) {
|
||||
val marginTop = resources.getDimensionPixelSize(R.dimen.section_spacing)
|
||||
val layoutParams = sectionView.layoutParams as? ViewGroup.MarginLayoutParams
|
||||
if (layoutParams != null) {
|
||||
@@ -75,11 +123,6 @@ class SettingsActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
NavButtonSectionController(sectionView, buttonId).bind()
|
||||
sectionsContainer.addView(sectionView)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTestPreview(durationMs: Int) {
|
||||
stopTestPreview()
|
||||
previewScheduled = true
|
||||
@@ -96,6 +139,91 @@ 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 fun attachTestButton(button: Button, durationProvider: () -> Int) {
|
||||
button.setOnTouchListener { view, event ->
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
view.isPressed = true
|
||||
startTestPreview(durationProvider())
|
||||
true
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
-> {
|
||||
view.isPressed = false
|
||||
stopTestPreview()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> bindSpinner(
|
||||
spinner: Spinner,
|
||||
items: List<T>,
|
||||
selectedItem: T,
|
||||
labelRes: (T) -> Int,
|
||||
onSelected: (T) -> Unit,
|
||||
) {
|
||||
val adapter = ArrayAdapter(
|
||||
this,
|
||||
R.layout.item_spinner_selected,
|
||||
items.map { getString(labelRes(it)) },
|
||||
)
|
||||
adapter.setDropDownViewResource(R.layout.item_spinner_dropdown)
|
||||
spinner.adapter = adapter
|
||||
spinner.setSelection(items.indexOf(selectedItem).coerceAtLeast(0), false)
|
||||
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
onSelected(items[position])
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
private inner class NavButtonSectionController(
|
||||
sectionView: View,
|
||||
private val buttonId: NavButtonId,
|
||||
@@ -130,7 +258,7 @@ class SettingsActivity : Activity() {
|
||||
applyDuration(config.longPressDurationMs, persist = false)
|
||||
bindDurationInput()
|
||||
bindStepButtons()
|
||||
bindTestButton()
|
||||
attachTestButton(testButton, ::resolveDurationForUse)
|
||||
}
|
||||
|
||||
private fun bindActionSpinner(
|
||||
@@ -138,26 +266,14 @@ class SettingsActivity : Activity() {
|
||||
selectedAction: NavButtonAction,
|
||||
storeAction: (NavButtonId, NavButtonAction) -> Unit,
|
||||
) {
|
||||
val labels = NavButtonAction.entries.map { getString(it.labelRes) }
|
||||
val adapter = ArrayAdapter(
|
||||
this@SettingsActivity,
|
||||
R.layout.item_spinner_selected,
|
||||
labels,
|
||||
)
|
||||
adapter.setDropDownViewResource(R.layout.item_spinner_dropdown)
|
||||
spinner.adapter = adapter
|
||||
spinner.setSelection(NavButtonAction.entries.indexOf(selectedAction), false)
|
||||
spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>?,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long,
|
||||
) {
|
||||
storeAction(buttonId, NavButtonAction.entries[position])
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
bindSpinner(
|
||||
spinner,
|
||||
NavButtonAction.entries,
|
||||
selectedAction,
|
||||
NavButtonAction::labelRes,
|
||||
) { action ->
|
||||
storeAction(buttonId, action)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +308,7 @@ class SettingsActivity : Activity() {
|
||||
if (parsed in NavButtonSettingsStore.MIN_DURATION_MS..NavButtonSettingsStore.MAX_DURATION_MS) {
|
||||
lastValidDurationMs = parsed
|
||||
settingsStore.setLongPressDurationMs(buttonId, parsed)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -206,67 +323,6 @@ class SettingsActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
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 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(
|
||||
@@ -300,7 +356,272 @@ class SettingsActivity : Activity() {
|
||||
lastValidDurationMs = sanitized
|
||||
if (persist) {
|
||||
settingsStore.setLongPressDurationMs(buttonId, sanitized)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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()
|
||||
attachTestButton(testButton, ::resolveDurationForUse)
|
||||
updateOptionsEnabled()
|
||||
|
||||
sideArrowsCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
settingsStore.setSideArrowsEnabled(isChecked)
|
||||
updateOptionsEnabled()
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLongPressActionSpinner() {
|
||||
bindSpinner(
|
||||
longPressActionSpinner,
|
||||
SideArrowLongPressAction.entries,
|
||||
settingsStore.getSideArrowLongPressAction(),
|
||||
SideArrowLongPressAction::labelRes,
|
||||
) { action ->
|
||||
settingsStore.setSideArrowLongPressAction(action)
|
||||
updateRepeatSpeedVisibility(action)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
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)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun bindDurationButtons() {
|
||||
attachRepeatButton(durationMinusButton) {
|
||||
adjustDuration(-NavButtonSettingsStore.DURATION_STEP_MS)
|
||||
}
|
||||
attachRepeatButton(durationPlusButton) {
|
||||
adjustDuration(NavButtonSettingsStore.DURATION_STEP_MS)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
sendSettingsChangedBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.view.ViewConfiguration
|
||||
|
||||
internal class SettingsRepository(
|
||||
private val isSystemServerProcess: () -> Boolean,
|
||||
private val contextProvider: () -> Context?,
|
||||
private val contextUpdater: (Context) -> Unit,
|
||||
private val currentApplicationProvider: () -> Context?,
|
||||
private val remotePreferencesProvider: (String) -> SharedPreferences,
|
||||
private val log: (String) -> Unit,
|
||||
private val onChanged: () -> Unit,
|
||||
) {
|
||||
@Volatile
|
||||
private var remotePreferences: SharedPreferences? = null
|
||||
|
||||
@Volatile
|
||||
private var remotePreferencesRetryAfterMs = 0L
|
||||
|
||||
@Volatile
|
||||
private var remotePreferencesLoading = false
|
||||
|
||||
private var settingsChangedReceiverRegistered = false
|
||||
private val buttonConfigOverrides = mutableMapOf<NavButtonId, NavButtonConfig>()
|
||||
private var sideArrowsEnabledOverride: Boolean? = null
|
||||
private var sideArrowLongPressActionOverride: SideArrowLongPressAction? = null
|
||||
private var sideArrowLongPressDurationOverride: Int? = null
|
||||
private var sideArrowRepeatSpeedOverride: Float? = null
|
||||
|
||||
fun preferences(): SharedPreferences? {
|
||||
remotePreferences?.let { return it }
|
||||
if (isSystemServerProcess()) {
|
||||
return null
|
||||
}
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
startPreferencesLoad()
|
||||
return null
|
||||
}
|
||||
return loadPreferences()
|
||||
}
|
||||
|
||||
fun registerChangedReceiver() {
|
||||
if (settingsChangedReceiverRegistered) {
|
||||
return
|
||||
}
|
||||
val currentContext = currentApplicationProvider()?.applicationContext
|
||||
?: contextProvider()?.applicationContext
|
||||
?: contextProvider()
|
||||
?: return
|
||||
contextUpdater(currentContext)
|
||||
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != NavButtonSettingsStore.ACTION_SETTINGS_CHANGED) {
|
||||
return
|
||||
}
|
||||
updateOverrides(intent)
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
currentContext.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(NavButtonSettingsStore.ACTION_SETTINGS_CHANGED),
|
||||
Context.RECEIVER_EXPORTED,
|
||||
)
|
||||
settingsChangedReceiverRegistered = true
|
||||
} catch (error: Throwable) {
|
||||
log("Settings receiver failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getButtonConfig(buttonId: NavButtonId): NavButtonConfig {
|
||||
buttonConfigOverrides[buttonId]?.let { return it }
|
||||
val preferences = preferences()
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
fun isSideArrowsEnabled(): Boolean {
|
||||
return sideArrowsEnabledOverride
|
||||
?: (preferences()?.getBoolean(NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED, false) ?: false)
|
||||
}
|
||||
|
||||
fun sideArrowLongPressAction(): SideArrowLongPressAction {
|
||||
sideArrowLongPressActionOverride?.let { return it }
|
||||
return SideArrowLongPressAction.fromStorageValue(
|
||||
preferences()?.getString(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION, null),
|
||||
)
|
||||
}
|
||||
|
||||
fun sideArrowLongPressDurationMs(): Int {
|
||||
sideArrowLongPressDurationOverride?.let { return it }
|
||||
val defaultDurationMs = ViewConfiguration.getLongPressTimeout()
|
||||
return (
|
||||
preferences()?.getInt(
|
||||
NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS,
|
||||
defaultDurationMs,
|
||||
) ?: defaultDurationMs
|
||||
).coerceLongPressDuration()
|
||||
}
|
||||
|
||||
fun sideArrowRepeatIntervalMs(): Long {
|
||||
val speed = sanitizeSideArrowRepeatSpeed(
|
||||
sideArrowRepeatSpeedOverride
|
||||
?: preferences()?.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 startPreferencesLoad() {
|
||||
if (remotePreferences != null || remotePreferencesLoading) {
|
||||
return
|
||||
}
|
||||
if (contextProvider()?.packageName == ANDROID_PACKAGE) {
|
||||
return
|
||||
}
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (now < remotePreferencesRetryAfterMs) {
|
||||
return
|
||||
}
|
||||
remotePreferencesLoading = true
|
||||
Thread {
|
||||
try {
|
||||
repeat(REMOTE_PREFERENCES_LOAD_ATTEMPTS) {
|
||||
if (loadPreferences() != null) {
|
||||
return@Thread
|
||||
}
|
||||
Thread.sleep(REMOTE_PREFERENCES_RETRY_DELAY_MS)
|
||||
}
|
||||
} finally {
|
||||
remotePreferencesLoading = false
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun loadPreferences(): SharedPreferences? {
|
||||
remotePreferences?.let { return it }
|
||||
val now = SystemClock.uptimeMillis()
|
||||
if (now < remotePreferencesRetryAfterMs) {
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
remotePreferencesProvider(NavButtonSettingsStore.PREFERENCES_NAME)
|
||||
}.getOrElse { error ->
|
||||
remotePreferencesRetryAfterMs = now + REMOTE_PREFERENCES_RETRY_DELAY_MS
|
||||
log("Remote preferences unavailable: ${error.javaClass.simpleName}")
|
||||
null
|
||||
}?.also {
|
||||
remotePreferences = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateOverrides(intent: Intent) {
|
||||
NavButtonId.entries.forEach { buttonId ->
|
||||
val defaultDurationMs = ViewConfiguration.getLongPressTimeout().coerceLongPressDuration()
|
||||
buttonConfigOverrides[buttonId] = NavButtonConfig(
|
||||
pressAction = NavButtonAction.fromStorageValue(
|
||||
intent.getStringExtra(NavButtonSettingsStore.pressActionKey(buttonId)),
|
||||
),
|
||||
longPressAction = NavButtonAction.fromStorageValue(
|
||||
intent.getStringExtra(NavButtonSettingsStore.longPressActionKey(buttonId)),
|
||||
),
|
||||
longPressDurationMs = intent.getIntExtra(
|
||||
NavButtonSettingsStore.longPressDurationKey(buttonId),
|
||||
defaultDurationMs,
|
||||
).coerceLongPressDuration(),
|
||||
)
|
||||
}
|
||||
sideArrowsEnabledOverride = intent.getBooleanExtra(
|
||||
NavButtonSettingsStore.KEY_SIDE_ARROWS_ENABLED,
|
||||
sideArrowsEnabledOverride ?: false,
|
||||
)
|
||||
sideArrowLongPressActionOverride = SideArrowLongPressAction.fromStorageValue(
|
||||
intent.getStringExtra(NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_ACTION),
|
||||
)
|
||||
sideArrowLongPressDurationOverride = intent.getIntExtra(
|
||||
NavButtonSettingsStore.KEY_SIDE_ARROW_LONG_PRESS_DURATION_MS,
|
||||
ViewConfiguration.getLongPressTimeout(),
|
||||
).coerceLongPressDuration()
|
||||
sideArrowRepeatSpeedOverride = sanitizeSideArrowRepeatSpeed(
|
||||
intent.getFloatExtra(
|
||||
NavButtonSettingsStore.KEY_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||
NavButtonSettingsStore.DEFAULT_SIDE_ARROW_REPEAT_STEPS_PER_SECOND,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.os.Handler
|
||||
import android.view.View
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class SideArrowLongPressController(
|
||||
private val handlerProvider: () -> Handler,
|
||||
private val actionProvider: () -> SideArrowLongPressAction,
|
||||
private val durationProvider: () -> Int,
|
||||
private val repeatIntervalProvider: () -> Long,
|
||||
private val sendKeyPress: (Int) -> Unit,
|
||||
) {
|
||||
private val triggered = WeakHashMap<Any, Boolean>()
|
||||
private val longPressRunnables = WeakHashMap<Any, Runnable>()
|
||||
private val repeatRunnables = WeakHashMap<Any, Runnable>()
|
||||
|
||||
fun start(target: Any, keyCode: Int) {
|
||||
reset(target)
|
||||
schedule(target, keyCode)
|
||||
}
|
||||
|
||||
fun startAfter(target: Any, keyCode: Int, proceed: () -> Any?): Any? {
|
||||
reset(target)
|
||||
val result = proceed()
|
||||
schedule(target, keyCode)
|
||||
return result
|
||||
}
|
||||
|
||||
fun finish(target: Any): Boolean {
|
||||
val wasTriggered = triggered[target] == true
|
||||
cancel(target)
|
||||
triggered.remove(target)
|
||||
return wasTriggered
|
||||
}
|
||||
|
||||
private fun reset(target: Any) {
|
||||
triggered[target] = false
|
||||
cancel(target)
|
||||
}
|
||||
|
||||
private fun schedule(target: Any, keyCode: Int) {
|
||||
val view = target as? View ?: return
|
||||
val handler = handlerProvider()
|
||||
val longPressRunnable = Runnable {
|
||||
triggered[target] = true
|
||||
when (actionProvider()) {
|
||||
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, repeatIntervalProvider())
|
||||
}
|
||||
}
|
||||
repeatRunnables[target] = repeatRunnable
|
||||
handler.postDelayed(repeatRunnable, repeatIntervalProvider())
|
||||
}
|
||||
}
|
||||
}
|
||||
longPressRunnables[target] = longPressRunnable
|
||||
view.postDelayed(longPressRunnable, durationProvider().toLong())
|
||||
}
|
||||
|
||||
private fun cancel(target: Any) {
|
||||
val view = target as? View
|
||||
longPressRunnables.remove(target)?.let { runnable ->
|
||||
view?.removeCallbacks(runnable) ?: handlerProvider().removeCallbacks(runnable)
|
||||
}
|
||||
repeatRunnables.remove(target)?.let { runnable ->
|
||||
handlerProvider().removeCallbacks(runnable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.KeyEvent
|
||||
import android.widget.ImageView
|
||||
|
||||
internal enum class SideArrowDirection(val keyCode: Int) {
|
||||
LEFT(KeyEvent.KEYCODE_DPAD_LEFT),
|
||||
RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT),
|
||||
}
|
||||
|
||||
internal data class AospSideArrowViews(
|
||||
val left: ImageView,
|
||||
val right: ImageView,
|
||||
)
|
||||
|
||||
internal data class HoneyspaceSideArrowButton(
|
||||
val type: Int,
|
||||
val keyCode: Int,
|
||||
val direction: SideArrowDirection,
|
||||
)
|
||||
|
||||
internal fun navStarArrowIconSizePx(context: Context): Int {
|
||||
return (context.resources.displayMetrics.density * SIDE_ARROW_ICON_DP + 0.5f).toInt()
|
||||
}
|
||||
|
||||
internal 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)
|
||||
}
|
||||
|
||||
internal val HONEYSPACE_SIDE_ARROW_BUTTONS = listOf(
|
||||
HoneyspaceSideArrowButton(
|
||||
HONEYSPACE_LEFT_ARROW_BUTTON_TYPE,
|
||||
SideArrowDirection.LEFT.keyCode,
|
||||
SideArrowDirection.LEFT,
|
||||
),
|
||||
HoneyspaceSideArrowButton(
|
||||
HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE,
|
||||
SideArrowDirection.RIGHT.keyCode,
|
||||
SideArrowDirection.RIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
internal const val HONEYSPACE_LEFT_ARROW_BUTTON_TYPE = 64
|
||||
internal const val HONEYSPACE_RIGHT_ARROW_BUTTON_TYPE = 128
|
||||
internal val HONEYSPACE_EXTRA_BUTTON_TYPES =
|
||||
HONEYSPACE_SIDE_ARROW_BUTTONS.map { it.type } + listOf(256, 512, 1024)
|
||||
|
||||
private const val SIDE_ARROW_ICON_DP = 32f
|
||||
private const val SIDE_ARROW_GLYPH_SCALE = 0.8f
|
||||
private const val SIDE_ARROW_STROKE_WIDTH = 1.45f
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.os.Handler
|
||||
import android.view.KeyEvent
|
||||
|
||||
internal class SystemServerButtonOverrideController(
|
||||
private val handlerProvider: () -> Handler,
|
||||
private val performPressAction: (NavButtonAction) -> Unit,
|
||||
private val performLongPressAction: (NavButtonId, NavButtonAction) -> Unit,
|
||||
) {
|
||||
private val longPressRunnables = mutableMapOf<Int, Runnable>()
|
||||
private val longPressTriggered = mutableMapOf<Int, Boolean>()
|
||||
|
||||
fun handle(keyEvent: KeyEvent, buttonId: NavButtonId, config: NavButtonConfig): Boolean {
|
||||
val pressAction = config.pressAction
|
||||
val longPressAction = config.longPressAction
|
||||
val keyCode = keyEvent.keyCode
|
||||
|
||||
return when (keyEvent.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
if (longPressAction != NavButtonAction.STOCK && keyEvent.repeatCount == 0) {
|
||||
schedule(keyCode, buttonId, longPressAction, config.longPressDurationMs)
|
||||
}
|
||||
pressAction != NavButtonAction.STOCK
|
||||
}
|
||||
|
||||
KeyEvent.ACTION_UP -> {
|
||||
val triggered = longPressTriggered[keyCode] == true
|
||||
val elapsed = keyEvent.eventTime - keyEvent.downTime >= config.longPressDurationMs
|
||||
cancel(keyCode)
|
||||
if (triggered || (longPressAction != NavButtonAction.STOCK && elapsed)) {
|
||||
if (!triggered) {
|
||||
performLongPressAction(buttonId, longPressAction)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
if (pressAction != NavButtonAction.STOCK && !keyEvent.isCanceled) {
|
||||
performPressAction(pressAction)
|
||||
}
|
||||
pressAction != NavButtonAction.STOCK
|
||||
}
|
||||
}
|
||||
|
||||
else -> pressAction != NavButtonAction.STOCK
|
||||
}
|
||||
}
|
||||
|
||||
private fun schedule(
|
||||
keyCode: Int,
|
||||
buttonId: NavButtonId,
|
||||
action: NavButtonAction,
|
||||
durationMs: Int,
|
||||
) {
|
||||
if (longPressRunnables[keyCode] != null) {
|
||||
return
|
||||
}
|
||||
val runnable = Runnable {
|
||||
longPressRunnables.remove(keyCode)
|
||||
longPressTriggered[keyCode] = true
|
||||
performLongPressAction(buttonId, action)
|
||||
}
|
||||
longPressTriggered[keyCode] = false
|
||||
longPressRunnables[keyCode] = runnable
|
||||
handlerProvider().postDelayed(runnable, durationMs.toLong())
|
||||
}
|
||||
|
||||
private fun cancel(keyCode: Int) {
|
||||
longPressRunnables.remove(keyCode)?.let { runnable ->
|
||||
handlerProvider().removeCallbacks(runnable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.view.KeyEvent
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import java.lang.reflect.Method
|
||||
|
||||
internal class SystemServerHooks(
|
||||
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||
private val handlerProvider: () -> android.os.Handler,
|
||||
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||
private val updateContextFromField: (Any?, String) -> Unit,
|
||||
private val registerSettingsReceiver: () -> Unit,
|
||||
private val registerPrivilegedReceiver: () -> Unit,
|
||||
private val performAction: (NavButtonAction) -> Unit,
|
||||
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
|
||||
private val contextUpdater: (Context?) -> Unit,
|
||||
private val log: (String) -> Unit,
|
||||
) {
|
||||
private val buttons = SystemServerButtonOverrideController(
|
||||
handlerProvider = handlerProvider,
|
||||
performPressAction = performAction,
|
||||
performLongPressAction = { buttonId, action ->
|
||||
if (buttonId == NavButtonId.HOME) {
|
||||
performHomeLongPressAction(action)
|
||||
} else {
|
||||
performAction(action)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun install(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!!,
|
||||
)
|
||||
val initMethods = findMethodsNamed(phoneWindowManagerClass, "init")
|
||||
.filter { method -> method.parameterTypes.firstOrNull() == Context::class.java }
|
||||
val keyDispatchMethods = findMethodsNamed(phoneWindowManagerClass, "interceptKeyBeforeDispatching")
|
||||
.filter { method ->
|
||||
method.returnType == Long::class.javaPrimitiveType &&
|
||||
method.parameterTypes.any { it == KeyEvent::class.java }
|
||||
}
|
||||
val keyQueueingMethods = findMethodsNamed(phoneWindowManagerClass, "interceptKeyBeforeQueueing")
|
||||
.filter { method ->
|
||||
method.returnType == Int::class.javaPrimitiveType &&
|
||||
method.parameterTypes.any { it == KeyEvent::class.java }
|
||||
}
|
||||
|
||||
initMethods.forEach { method ->
|
||||
hookMethod(method) { chain ->
|
||||
val result = chain.proceed()
|
||||
contextUpdater(chain.args.firstOrNull() as? Context)
|
||||
registerSettingsReceiver()
|
||||
registerPrivilegedReceiver()
|
||||
result
|
||||
}
|
||||
installed = true
|
||||
}
|
||||
|
||||
keyDispatchMethods.forEach { method ->
|
||||
hookMethod(method) { chain ->
|
||||
val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent
|
||||
?: return@hookMethod chain.proceed()
|
||||
val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@hookMethod chain.proceed()
|
||||
val config = configProvider(buttonId)
|
||||
if (config.pressAction == NavButtonAction.STOCK &&
|
||||
config.longPressAction == NavButtonAction.STOCK
|
||||
) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
updateContextFromField(chain.thisObject, "mContext")
|
||||
if (buttons.handle(keyEvent, buttonId, config)) {
|
||||
return@hookMethod -1L
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
installed = true
|
||||
}
|
||||
|
||||
keyQueueingMethods.forEach { method ->
|
||||
hookMethod(method) { chain ->
|
||||
val keyEvent = chain.args.firstOrNull { it is KeyEvent } as? KeyEvent
|
||||
?: return@hookMethod chain.proceed()
|
||||
val buttonId = buttonIdForKeyCode(keyEvent.keyCode) ?: return@hookMethod chain.proceed()
|
||||
val config = configProvider(buttonId)
|
||||
if (config.pressAction == NavButtonAction.STOCK &&
|
||||
config.longPressAction == NavButtonAction.STOCK
|
||||
) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
updateContextFromField(chain.thisObject, "mContext")
|
||||
val consume = buttons.handle(keyEvent, buttonId, config)
|
||||
val result = chain.proceed() as? Int ?: 0
|
||||
if (consume) result and ACTION_PASS_TO_USER.inv() else result
|
||||
}
|
||||
installed = true
|
||||
}
|
||||
|
||||
listOfNotNull(longPressOnHomeWithEvent, longPressOnHomeLegacy).forEach { method ->
|
||||
hookMethod(method) { chain ->
|
||||
val longPressAction = configProvider(NavButtonId.HOME).longPressAction
|
||||
if (longPressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
updateContextFromField(chain.thisObject, "mContext")
|
||||
performHomeLongPressAction(longPressAction)
|
||||
null
|
||||
}
|
||||
installed = true
|
||||
}
|
||||
|
||||
if (launchAssistActionMethod != null) {
|
||||
hookMethod(launchAssistActionMethod) { chain ->
|
||||
val invocationType = chain.args[3] as? Int ?: ASSIST_INVOCATION_TYPE_UNKNOWN
|
||||
if (invocationType != ASSIST_INVOCATION_TYPE_HOME_BUTTON_LONG_PRESS) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
val longPressAction = configProvider(NavButtonId.HOME).longPressAction
|
||||
if (longPressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
updateContextFromField(chain.thisObject, "mContext")
|
||||
performHomeLongPressAction(longPressAction)
|
||||
null
|
||||
}
|
||||
installed = true
|
||||
}
|
||||
return installed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import java.util.WeakHashMap
|
||||
|
||||
internal class SystemUiLongPressController(
|
||||
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||
private val updateContext: (Any) -> Unit,
|
||||
private val cancelKeyPress: (Any) -> Unit,
|
||||
private val markLongClicked: (Any) -> Unit,
|
||||
private val performAction: (NavButtonAction) -> Unit,
|
||||
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
|
||||
) {
|
||||
private val triggered = WeakHashMap<Any, Boolean>()
|
||||
private val runnables = WeakHashMap<Any, Runnable>()
|
||||
|
||||
fun reset(target: Any) {
|
||||
triggered[target] = false
|
||||
cancel(target)
|
||||
}
|
||||
|
||||
fun schedule(target: Any, buttonId: NavButtonId) {
|
||||
val view = target as? View ?: return
|
||||
val config = configProvider(buttonId)
|
||||
if (config.longPressAction == NavButtonAction.STOCK) {
|
||||
return
|
||||
}
|
||||
(getFieldValue(target, "mCheckLongPress") as? Runnable)?.let(view::removeCallbacks)
|
||||
|
||||
val runnable = Runnable {
|
||||
updateContext(target)
|
||||
triggered[target] = true
|
||||
cancelKeyPress(target)
|
||||
markLongClicked(target)
|
||||
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
if (buttonId == NavButtonId.HOME) {
|
||||
performHomeLongPressAction(config.longPressAction)
|
||||
} else {
|
||||
performAction(config.longPressAction)
|
||||
}
|
||||
}
|
||||
runnables[target] = runnable
|
||||
view.postDelayed(runnable, config.longPressDurationMs.toLong())
|
||||
}
|
||||
|
||||
fun isTriggered(target: Any): Boolean {
|
||||
return triggered[target] == true
|
||||
}
|
||||
|
||||
fun markTriggered(target: Any) {
|
||||
triggered[target] = true
|
||||
}
|
||||
|
||||
fun clear(target: Any) {
|
||||
cancel(target)
|
||||
triggered.remove(target)
|
||||
}
|
||||
|
||||
private fun cancel(target: Any) {
|
||||
val view = target as? View ?: return
|
||||
runnables.remove(target)?.let(view::removeCallbacks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package se.ajpanton.navbuttons
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import java.lang.reflect.Method
|
||||
|
||||
internal class SystemUiNavHooks(
|
||||
private val hookMethod: (Method, XposedInterface.Hooker) -> Unit,
|
||||
private val deoptimizeMethod: (Method?) -> Unit,
|
||||
private val prepareLongPressHook: (Method) -> Unit,
|
||||
private val configProvider: (NavButtonId) -> NavButtonConfig,
|
||||
private val sideArrowLongPresses: SideArrowLongPressController,
|
||||
private val registerSettingsReceiver: () -> Unit,
|
||||
private val contextUpdater: (Context) -> Unit,
|
||||
private val performAction: (NavButtonAction) -> Unit,
|
||||
private val performHomeLongPressAction: (NavButtonAction) -> Unit,
|
||||
private val installSamsungSideArrows: (ClassLoader?) -> Unit,
|
||||
private val trackSamsungNavigationBarView: (Any) -> Unit,
|
||||
private val installAospSideArrows: (ClassLoader?) -> Unit,
|
||||
private val configureAospSideArrowButton: (Any?) -> Unit,
|
||||
private val log: (String) -> Unit,
|
||||
private val logError: (String, Throwable) -> Unit,
|
||||
) {
|
||||
private val longPresses = SystemUiLongPressController(
|
||||
configProvider = configProvider,
|
||||
updateContext = ::updateContextFromView,
|
||||
cancelKeyPress = ::cancelKeyPress,
|
||||
markLongClicked = ::markLongClicked,
|
||||
performAction = performAction,
|
||||
performHomeLongPressAction = performHomeLongPressAction,
|
||||
)
|
||||
private val homeStockLongPressGuard = HomeStockLongPressGuard(
|
||||
configProvider = configProvider,
|
||||
longPressTriggered = longPresses::isTriggered,
|
||||
sendCanceledKeyUp = ::sendCanceledKeyUp,
|
||||
)
|
||||
|
||||
fun install(classLoader: ClassLoader?): Boolean {
|
||||
val keyButtonClass = findFirstClassIfExists(SYSTEMUI_KEY_BUTTON_VIEW_CLASSES, classLoader) ?: run {
|
||||
log("SystemUI key button class not found: ${SYSTEMUI_KEY_BUTTON_VIEW_CLASSES.joinToString()}")
|
||||
return false
|
||||
}
|
||||
val onTouchEventMethod = findMethodIfExists(
|
||||
keyButtonClass,
|
||||
"onTouchEvent",
|
||||
MotionEvent::class.java,
|
||||
) ?: run {
|
||||
log("SystemUI onTouchEvent() method not found.")
|
||||
return false
|
||||
}
|
||||
|
||||
val runnableClass = findClassIfExists(
|
||||
"${keyButtonClass.name}\$1",
|
||||
classLoader,
|
||||
) ?: run {
|
||||
log("SystemUI long-press runnable not found.")
|
||||
return false
|
||||
}
|
||||
|
||||
val runMethod = findMethodIfExists(runnableClass, "run") ?: run {
|
||||
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
|
||||
}
|
||||
|
||||
deoptimizeMethod(onTouchEventMethod)
|
||||
prepareLongPressHook(runMethod)
|
||||
sendEventMethods.forEach(deoptimizeMethod)
|
||||
sendEventMethods.forEach(::hookSendEvent)
|
||||
installSamsungSideArrows(classLoader)
|
||||
installAospSideArrows(classLoader)
|
||||
|
||||
hookMethod(onTouchEventMethod) { chain ->
|
||||
val keyButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
|
||||
val motionEvent = chain.args[0] as? MotionEvent ?: return@hookMethod chain.proceed()
|
||||
val keyCode = getIntField(keyButtonView, "mCode")
|
||||
if (isSideArrowKeyCode(keyCode)) {
|
||||
updateContextFromView(keyButtonView)
|
||||
registerSettingsReceiver()
|
||||
trackSamsungNavigationBarView(keyButtonView)
|
||||
configureAospSideArrowButton(keyButtonView)
|
||||
return@hookMethod handleNativeSideArrowTouch(keyButtonView, keyCode, motionEvent) {
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||
|
||||
when (motionEvent.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
updateContextFromView(keyButtonView)
|
||||
registerSettingsReceiver()
|
||||
trackSamsungNavigationBarView(keyButtonView)
|
||||
longPresses.reset(keyButtonView)
|
||||
homeStockLongPressGuard.cancel(keyButtonView)
|
||||
homeStockLongPressGuard.clear(keyButtonView)
|
||||
val result = chain.proceed()
|
||||
longPresses.schedule(keyButtonView, buttonId)
|
||||
homeStockLongPressGuard.schedule(keyButtonView, buttonId)
|
||||
result
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (longPresses.isTriggered(keyButtonView)) {
|
||||
return@hookMethod proceedWithCanceledAction(motionEvent) {
|
||||
chain.proceed()
|
||||
}.also {
|
||||
homeStockLongPressGuard.cancel(keyButtonView)
|
||||
longPresses.clear(keyButtonView)
|
||||
homeStockLongPressGuard.clear(keyButtonView)
|
||||
}
|
||||
}
|
||||
|
||||
val buttonConfig = configProvider(buttonId)
|
||||
val pressAction = buttonConfig.pressAction
|
||||
val stockHomePressCanceled =
|
||||
buttonId == NavButtonId.HOME && homeStockLongPressGuard.isCanceled(keyButtonView)
|
||||
val shouldOverridePress =
|
||||
pressAction != NavButtonAction.STOCK &&
|
||||
((keyButtonView as? View)?.isPressed == true || stockHomePressCanceled) &&
|
||||
getFieldValue(keyButtonView, "mLongClicked") != true
|
||||
|
||||
if (shouldOverridePress) {
|
||||
markLongClicked(keyButtonView)
|
||||
}
|
||||
|
||||
val result = chain.proceed()
|
||||
if (stockHomePressCanceled && pressAction == NavButtonAction.STOCK) {
|
||||
dispatchHomeTap(keyButtonView)
|
||||
}
|
||||
if (shouldOverridePress) {
|
||||
updateContextFromView(keyButtonView)
|
||||
performAction(pressAction)
|
||||
}
|
||||
homeStockLongPressGuard.cancel(keyButtonView)
|
||||
longPresses.clear(keyButtonView)
|
||||
homeStockLongPressGuard.clear(keyButtonView)
|
||||
result
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
homeStockLongPressGuard.cancel(keyButtonView)
|
||||
longPresses.clear(keyButtonView)
|
||||
homeStockLongPressGuard.clear(keyButtonView)
|
||||
chain.proceed()
|
||||
}
|
||||
|
||||
else -> chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
hookMethod(runMethod) { chain ->
|
||||
val keyButtonView = getFieldValue(chain.thisObject, "this\$0")
|
||||
?: return@hookMethod chain.proceed()
|
||||
val keyCode = getIntField(keyButtonView, "mCode")
|
||||
if (isSideArrowKeyCode(keyCode)) {
|
||||
return@hookMethod null
|
||||
}
|
||||
val buttonId = buttonIdForKeyCode(keyCode) ?: return@hookMethod chain.proceed()
|
||||
val longPressAction = configProvider(buttonId).longPressAction
|
||||
if (longPressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
updateContextFromView(keyButtonView)
|
||||
longPresses.markTriggered(keyButtonView)
|
||||
cancelKeyPress(keyButtonView)
|
||||
markLongClicked(keyButtonView)
|
||||
if (buttonId == NavButtonId.HOME) {
|
||||
performHomeLongPressAction(longPressAction)
|
||||
} else {
|
||||
performAction(longPressAction)
|
||||
}
|
||||
null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hookSendEvent(sendEventMethod: Method) {
|
||||
hookMethod(sendEventMethod) { chain ->
|
||||
val keyButtonView = chain.thisObject ?: return@hookMethod chain.proceed()
|
||||
val buttonId = buttonIdForKeyCode(getIntField(keyButtonView, "mCode"))
|
||||
?: return@hookMethod chain.proceed()
|
||||
if (buttonId != NavButtonId.HOME) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
val action = chain.args.firstOrNull() as? Int ?: return@hookMethod chain.proceed()
|
||||
val longPressAction = configProvider(buttonId).longPressAction
|
||||
if (longPressAction == NavButtonAction.STOCK) {
|
||||
return@hookMethod chain.proceed()
|
||||
}
|
||||
|
||||
if (action == KeyEvent.ACTION_DOWN && sendEventFlags(chain.args).hasFlag(KeyEvent.FLAG_LONG_PRESS)) {
|
||||
return@hookMethod null
|
||||
}
|
||||
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNativeSideArrowTouch(
|
||||
view: Any,
|
||||
keyCode: Int,
|
||||
event: MotionEvent,
|
||||
proceed: () -> Any?,
|
||||
): Any? {
|
||||
return when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
sideArrowLongPresses.startAfter(view, keyCode, proceed)
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
-> {
|
||||
sideArrowLongPresses.finish(view)
|
||||
proceed()
|
||||
}
|
||||
|
||||
else -> proceed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun markLongClicked(keyButtonView: Any) {
|
||||
try {
|
||||
findField(keyButtonView.javaClass, "mLongClicked").setBoolean(keyButtonView, true)
|
||||
} catch (error: Throwable) {
|
||||
logError("Failed to set mLongClicked", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateContextFromView(value: Any) {
|
||||
val viewContext = (value as? View)?.context ?: return
|
||||
contextUpdater(viewContext.applicationContext ?: viewContext)
|
||||
}
|
||||
|
||||
private fun cancelKeyPress(keyButtonView: Any) {
|
||||
if (buttonIdForKeyCode(getIntField(keyButtonView, "mCode")) == NavButtonId.HOME &&
|
||||
homeStockLongPressGuard.isCanceled(keyButtonView)
|
||||
) {
|
||||
abortKeyGesture(keyButtonView)
|
||||
return
|
||||
}
|
||||
|
||||
abortKeyGesture(keyButtonView)
|
||||
sendCanceledKeyUp(keyButtonView)
|
||||
}
|
||||
|
||||
private fun abortKeyGesture(keyButtonView: Any) {
|
||||
try {
|
||||
callMethod(keyButtonView, "abortCurrentGesture")
|
||||
} catch (_: Throwable) {
|
||||
// Older/newer SystemUI variants may not expose this helper.
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendCanceledKeyUp(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 dispatchHomeTap(keyButtonView: Any) {
|
||||
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 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 sendEventFlags(args: List<*>): Int {
|
||||
return args.getOrNull(1) as? Int ?: 0
|
||||
}
|
||||
|
||||
private fun Int.hasFlag(flag: Int): Boolean {
|
||||
return this and flag == flag
|
||||
}
|
||||
}
|
||||
@@ -29,15 +29,6 @@
|
||||
android:textSize="30sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:text="@string/settings_summary"
|
||||
android:textColor="@color/settings_text_secondary"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/button_sections_container"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_button_section"
|
||||
android:elevation="2dp"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/side_arrows_section_title"
|
||||
android:textColor="@color/settings_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/side_arrows_checkbox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:buttonTint="@color/settings_label_text"
|
||||
android:saveEnabled="false"
|
||||
android:text="@string/side_arrows_enable_label"
|
||||
android:textColor="@color/settings_text_primary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/side_arrows_options_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/long_press_action_label"
|
||||
android:textColor="@color/settings_label_text"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/side_arrow_long_press_action_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:background="@drawable/bg_spinner_field"
|
||||
android:dropDownWidth="match_parent"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:popupBackground="@drawable/bg_spinner_popup"
|
||||
android:saveEnabled="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/side_arrow_repeat_speed_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/side_arrow_repeat_speed_label"
|
||||
android:textColor="@color/settings_label_text"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/side_arrow_speed_minus_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/minus_button_label"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="22sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/side_arrow_repeat_speed_input"
|
||||
android:layout_width="88dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:gravity="center"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="numberDecimal"
|
||||
android:maxLength="6"
|
||||
android:saveEnabled="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/settings_input_text"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/side_arrow_speed_plus_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/plus_button_label"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="20sp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/long_press_duration_label"
|
||||
android:textColor="@color/settings_label_text"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/side_arrow_duration_minus_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/minus_button_label"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="22sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/side_arrow_long_press_duration_input"
|
||||
android:layout_width="88dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:gravity="center"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="number"
|
||||
android:maxLength="5"
|
||||
android:saveEnabled="false"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/settings_input_text"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/side_arrow_duration_plus_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/plus_button_label"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="20sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/side_arrow_test_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="54dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:minWidth="160dp"
|
||||
android:text="@string/test_button_label"
|
||||
android:textAllCaps="false"
|
||||
android:textSize="16sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<style name="Theme.NavButtons" parent="@android:style/Theme.DeviceDefault.DayNight">
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@drawable/bg_settings_window</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -2,10 +2,15 @@
|
||||
<string name="app_name">NavButtons</string>
|
||||
<string name="xposed_desc">Customize navigation button press and long-press actions.</string>
|
||||
<string name="settings_title">Nav Button Settings</string>
|
||||
<string name="settings_summary">Choose what each navigation button should do on press and long press, then tune how long a long press should feel.</string>
|
||||
<string name="button_back">Back Button</string>
|
||||
<string name="button_home">Home Button</string>
|
||||
<string name="button_recents">Recents Button</string>
|
||||
<string name="side_arrows_section_title">Side arrows</string>
|
||||
<string name="side_arrows_enable_label">Enable side arrows</string>
|
||||
<string name="side_arrow_long_press_nothing">Nothing</string>
|
||||
<string name="side_arrow_long_press_single_step">Single step</string>
|
||||
<string name="side_arrow_long_press_multiple_steps">Multiple steps</string>
|
||||
<string name="side_arrow_repeat_speed_label">Repeat speed (steps/sec)</string>
|
||||
<string name="press_action_label">When pressed</string>
|
||||
<string name="long_press_action_label">When long-pressed</string>
|
||||
<string name="long_press_duration_label">Long-press duration (ms)</string>
|
||||
|
||||
@@ -3,6 +3,5 @@
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@drawable/bg_settings_window</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -1,3 +1,3 @@
|
||||
minApiVersion=101
|
||||
targetApiVersion=101
|
||||
targetApiVersion=102
|
||||
staticScope=true
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
android
|
||||
system
|
||||
com.android.systemui
|
||||
com.android.launcher3
|
||||
com.google.android.apps.nexuslauncher
|
||||
com.sec.android.app.launcher
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id("com.android.application") version "9.1.1" apply false
|
||||
}
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
version=1.0
|
||||
version=2.2
|
||||
|
||||
Reference in New Issue
Block a user