From 152673b52d99573799ef96645d855aaeb3c99c7a Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 10 Oct 2026 22:08:27 +0200 Subject: [PATCH 01/19] move fastlane chinese translations to correct folder --- fastlane/metadata/android/{zh_rCN => zh_CN}/full_description.txt | 0 fastlane/metadata/android/{zh_rCN => zh_CN}/short_description.txt | 0 fastlane/metadata/android/{zh_rCN => zh_CN}/title.txt | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename fastlane/metadata/android/{zh_rCN => zh_CN}/full_description.txt (100%) rename fastlane/metadata/android/{zh_rCN => zh_CN}/short_description.txt (100%) rename fastlane/metadata/android/{zh_rCN => zh_CN}/title.txt (100%) diff --git a/fastlane/metadata/android/zh_rCN/full_description.txt b/fastlane/metadata/android/zh_CN/full_description.txt similarity index 100% rename from fastlane/metadata/android/zh_rCN/full_description.txt rename to fastlane/metadata/android/zh_CN/full_description.txt diff --git a/fastlane/metadata/android/zh_rCN/short_description.txt b/fastlane/metadata/android/zh_CN/short_description.txt similarity index 100% rename from fastlane/metadata/android/zh_rCN/short_description.txt rename to fastlane/metadata/android/zh_CN/short_description.txt diff --git a/fastlane/metadata/android/zh_rCN/title.txt b/fastlane/metadata/android/zh_CN/title.txt similarity index 100% rename from fastlane/metadata/android/zh_rCN/title.txt rename to fastlane/metadata/android/zh_CN/title.txt From 813028bc749348298200225c610ea928195c1b25 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 10 Oct 2026 22:08:43 +0200 Subject: [PATCH 02/19] bump version to 4.6.2 --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 86b9f20735..26e755a5cb 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.6.1 +VERSION_NAME=4.6.2 VERSION_CODE=286 From 88cf28483b7b1fc0b8f341b145c3d083400031b5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 18 Oct 2026 19:36:03 +0200 Subject: [PATCH 03/19] refactor system bridge starter code --- .../manager/SystemBridgeConnectionManager.kt | 18 +++++----- .../service/SystemBridgeSetupController.kt | 36 +++++++++++++++++++ .../sysbridge/starter/SystemBridgeStarter.kt | 34 ------------------ 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt index 007501e166..8f585a1dd2 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt @@ -69,7 +69,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( private var systemBridgeFlow: MutableStateFlow = MutableStateFlow(null) override val connectionState: MutableStateFlow = - MutableStateFlow( + MutableStateFlow( SystemBridgeConnectionState.Disconnected( time = SystemClock.elapsedRealtime(), // Get whether the user previously stopped the system bridge. @@ -251,12 +251,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( // the app process and restart it. This is normal, expected behavior and can not be // worked around. Do not grant any other permissions automatically here. - val isWriteSecureSettingsGranted = ContextCompat.checkSelfPermission( - ctx, - Manifest.permission.WRITE_SECURE_SETTINGS, - ) == PERMISSION_GRANTED - - if (!isWriteSecureSettingsGranted) { + if (!isWriteSecureSettingsGranted()) { grantWriteSecureSettings(systemBridge) } @@ -264,7 +259,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( // Only turn on the ADB options to prevent killing if it is running under // the ADB shell user - if (isWriteSecureSettingsGranted && isShellProcess) { + if (isShellProcess && isWriteSecureSettingsGranted()) { // Disable automatic revoking of ADB pairings SettingsUtils.putGlobalSetting( ctx, @@ -284,11 +279,16 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( val isMiuiOptimisationEnabled = SettingsUtils.getGlobalSetting(ctx, MIUI_OPTIMIZATION_SETTING) == 1 - if (isWriteSecureSettingsGranted && isMiuiOptimisationEnabled) { + if (isMiuiOptimisationEnabled && isWriteSecureSettingsGranted()) { SettingsUtils.putGlobalSetting(ctx, MIUI_OPTIMIZATION_SETTING, 0) } } + private fun isWriteSecureSettingsGranted(): Boolean = ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.WRITE_SECURE_SETTINGS, + ) == PERMISSION_GRANTED + private fun grantWriteSecureSettings(systemBridge: ISystemBridge) { val deviceId: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt index 7192330812..2fe0c08a96 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt @@ -19,6 +19,7 @@ import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.isSuccess import io.github.sds100.keymapper.common.utils.onSuccess +import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.sysbridge.adb.AdbManager import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState @@ -56,7 +57,9 @@ class SystemBridgeSetupControllerImpl @Inject constructor( companion object { private const val DEVELOPER_OPTIONS_SETTING = "development_settings_enabled" private const val ADB_WIRELESS_SETTING = "adb_wifi_enabled" + private const val ADB_USB_SETTING = "adb_enabled" private const val START_TIMEOUT_MS = 60_000L + private const val ENABLE_USB_DEBUGGING_ATTEMPTS = 3 } private val activityManager: ActivityManager by lazy { ctx.getSystemService()!! } @@ -127,6 +130,20 @@ class SystemBridgeSetupControllerImpl @Inject constructor( @RequiresApi(Build.VERSION_CODES.R) override fun startWithAdb() { launchStartJob { + // See issue #2294. + // Enabling USB debugging on Android 17 QPR1+ resets ADB entirely and kills Shell processes + // even if Wireless Debugging is turned on. Previously, preventSystemBridgeKilling() + // would enable USB debugging AFTER the system bridge has started, but this now + // kills it. So, enable USB debugging before starting the system bridge. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && + + // MUST use ADB to check because adb_enabled is a redacted value on + // some Android 17 builds. See #2289 + !isUsbDebuggingEnabledOverAdb() + ) { + enableUsbDebuggingWithAdb() + } + connectionManager.startWithAdb() // Wait for the service to connect before turning off wireless debugging @@ -286,6 +303,25 @@ class SystemBridgeSetupControllerImpl @Inject constructor( } } + @RequiresApi(Build.VERSION_CODES.R) + private suspend fun isUsbDebuggingEnabledOverAdb(): Boolean { + return adbManager.executeCommand("settings get global $ADB_USB_SETTING") + .valueOrNull() == "1" + } + + @RequiresApi(Build.VERSION_CODES.R) + private suspend fun enableUsbDebuggingWithAdb() { + repeat(ENABLE_USB_DEBUGGING_ATTEMPTS) { + // Do not do anything with a failure result. This command + // will kill the ADB process so it may return an error, which is expected. + if (adbManager.executeCommand("settings put global $ADB_USB_SETTING 1").isSuccess) { + return + } + } + + Timber.w("Failed to enable USB debugging over ADB.") + } + @RequiresApi(Build.VERSION_CODES.R) override suspend fun isAdbPaired(): Boolean { // Sometimes multiple calls to this function can happen in a short space of time diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt index 2ec812dc66..aac0f1608c 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt @@ -17,10 +17,8 @@ import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success -import io.github.sds100.keymapper.common.utils.isSuccess import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.then -import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.sysbridge.BuildConfig import io.github.sds100.keymapper.sysbridge.IShizukuStarterService import io.github.sds100.keymapper.sysbridge.R @@ -73,8 +71,6 @@ class SystemBridgeStarter @Inject constructor( * assuming it failed (e.g. due to the OEM bug on Xiaomi/MediaTek devices). */ private const val SHIZUKU_USER_SERVICE_TIMEOUT_MS = 5000L - - private const val ENABLE_USB_DEBUGGING_ATTEMPTS = 3 } private fun buildShizukuUserServiceArgs(): Shizuku.UserServiceArgs { @@ -231,42 +227,12 @@ class SystemBridgeStarter @Inject constructor( return KMError.Exception(IllegalStateException("User is locked")) } - // See issue #2294. - // Enabling USB debugging on Android 17 QPR1+ resets ADB entirely and kills Shell processes - // even if Wireless Debugging is turned on. Previously, preventSystemBridgeKilling() - // would enable USB debugging AFTER the system bridge has started, but this now - // kills it. So, enable USB debugging before starting the system bridge. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && - - // MUST use ADB to check because adb_enabled is a redacted value on - // some Android 17 builds. See #2289 - !isUsbDebuggingEnabledOverAdb() - ) { - enableUsbDebugging() - } - return startSystemBridgeWithLock(commandExecutor = adbManager::executeCommand) .onFailure { error -> Timber.e("Failed to start system bridge with ADB: $error") } } - private suspend fun isUsbDebuggingEnabledOverAdb(): Boolean { - return adbManager.executeCommand("settings get global adb_enabled").valueOrNull() == "1" - } - - private suspend fun enableUsbDebugging() { - repeat(ENABLE_USB_DEBUGGING_ATTEMPTS) { - // Do not do anything with a failure result. This command - // will kill the ADB process so it may return an error, which is expected. - if (adbManager.executeCommand("settings put global adb_enabled 1").isSuccess) { - return - } - } - - Timber.w("Failed to enable USB debugging over ADB.") - } - suspend fun startWithRoot() { if (Shell.isAppGrantedRoot() != true) { Timber.e("Root is not granted. Cannot start System Bridge with Root.") From b3de1d29bea2bae02ecb2b419a052c2a5811a8bd Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 18 Oct 2026 20:27:35 +0200 Subject: [PATCH 04/19] fix: use timeout when observing next system bridge setup step in SystemBridgeSetupDelegate --- .../base/expertmode/SystemBridgeSetupDelegate.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt index 90dc1e5f15..e4a9baea83 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt @@ -23,7 +23,9 @@ abstract class SystemBridgeSetupDelegateImpl( ::buildState, ).stateIn( viewModelScope, - SharingStarted.Eagerly, + // Use a timeout so it won't potentially call isAdbPaired when the app + // is in the background. + SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000L), State.Loading, ) @@ -34,16 +36,25 @@ abstract class SystemBridgeSetupDelegateImpl( when (currentState) { is ExpertModeSetupState.AccessibilityService -> useCase.enableAccessibilityService() + is ExpertModeSetupState.NotificationPermission -> useCase.requestNotificationPermission() + is ExpertModeSetupState.LocalNetworkPermission -> useCase.requestLocalNetworkPermission() + is ExpertModeSetupState.SamsungAutoBlocker -> useCase.openSamsungAutoBlockerSettings() + is ExpertModeSetupState.DeveloperOptions -> useCase.enableDeveloperOptions() + is ExpertModeSetupState.WifiNetwork -> useCase.connectWifiNetwork() + is ExpertModeSetupState.WirelessDebugging -> useCase.enableWirelessDebugging() + is ExpertModeSetupState.AdbPairing -> useCase.pairWirelessAdb() + is ExpertModeSetupState.StartService -> useCase.startSystemBridgeWithAdb() + is ExpertModeSetupState.Started -> onFinishClick() } } From 8dc38a40048509c71929889e2c060696749f99e5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 20 Oct 2026 22:03:21 +0200 Subject: [PATCH 05/19] #2069 fix: dpad triggers work reliably again --- CHANGELOG.md | 8 +++ .../base/detection/KeyMapAlgorithm.kt | 62 ++++++++++--------- .../base/keymaps/KeyMapAlgorithmTest.kt | 10 +-- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a586617bf..4e8ccdb7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [4.6.2](https://github.com/sds100/KeyMapper/releases/tag/v4.6.2) + +#### TO BE RELEASED + +## Fixed + +- #2069 dpad triggers work reliably again. + ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) #### 10 October 2026 diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt index 7f4af9c185..e91336f87d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt @@ -581,46 +581,48 @@ class KeyMapAlgorithm( } } - fun onMotionEvent(event: KMGamePadEvent): Boolean { + /** + * @return whether to consume the [KeyEvent]. + */ + fun onInputEvent(event: KMInputEvent): Boolean { if (!detectKeyMaps) return false - // See https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input#dpad - // Some controllers send motion events as well as key events when DPAD buttons - // are pressed, while others just send key events. - // The motion events must be consumed but this means the following key events are also - // consumed so one must rely on converting these motion events oneself. - - val convertedKeyEvents = dpadMotionEventTracker.convertMotionEvent(event) + when (event) { + is KMKeyEvent -> { + if (dpadMotionEventTracker.onKeyEvent(event)) { + return true + } - var consume = false + val device = event.device - for (keyEvent in convertedKeyEvents) { - if (onKeyEventPostFilter(keyEvent)) { - consume = true + if ((device.isExternal && !detectExternalEvents) || + (!device.isExternal && !detectInternalEvents) + ) { + return false + } } - } - return consume - } + is KMGamePadEvent -> { + // See https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input#dpad + // Some controllers send motion events as well as key events when DPAD buttons + // are pressed, while others just send key events. + // The motion events must be consumed but this means the following key events are also + // consumed so one must rely on converting these motion events oneself. - /** - * @return whether to consume the [KeyEvent]. - */ - fun onInputEvent(event: KMInputEvent): Boolean { - if (!detectKeyMaps) return false + val convertedKeyEvents = dpadMotionEventTracker.convertMotionEvent(event) - if (event is KMKeyEvent) { - if (dpadMotionEventTracker.onKeyEvent(event)) { - return true - } + var consume = false - val device = event.device + for (keyEvent in convertedKeyEvents) { + if (onKeyEventPostFilter(keyEvent)) { + consume = true + } + } - if ((device.isExternal && !detectExternalEvents) || - (!device.isExternal && !detectInternalEvents) - ) { - return false + return consume } + + is KMEvdevEvent -> {} } return onKeyEventPostFilter(event) @@ -1036,8 +1038,10 @@ class KeyMapAlgorithm( val vibrateEffect = when { trigger.vibrate -> resolveVibrateEffect(trigger) + forceVibrate.value -> VibrateEffect.CustomDuration(defaultVibrateDuration.value) + else -> null } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt index ed71b52ad4..838cfde5e2 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt @@ -1557,19 +1557,19 @@ class KeyMapAlgorithmTest { ) var motionEvent = createMotionEvent(axisHatY = 1.0f) - val consumeDown1 = controller.onMotionEvent(motionEvent) + val consumeDown1 = controller.onInputEvent(motionEvent) assertThat(consumeDown1, `is`(false)) motionEvent = motionEvent.copy(axisHatX = -1.0f) - val consumeDown2 = controller.onMotionEvent(motionEvent) + val consumeDown2 = controller.onInputEvent(motionEvent) assertThat(consumeDown2, `is`(true)) motionEvent = motionEvent.copy(axisHatX = 0.0f) - val consumeUp2 = controller.onMotionEvent(motionEvent) + val consumeUp2 = controller.onInputEvent(motionEvent) assertThat(consumeUp2, `is`(true)) motionEvent = motionEvent.copy(axisHatY = 0.0f) - val consumeUp1 = controller.onMotionEvent(motionEvent) + val consumeUp1 = controller.onInputEvent(motionEvent) assertThat(consumeUp1, `is`(false)) verify(performActionsUseCase, times(1)).perform( @@ -5253,7 +5253,7 @@ class KeyMapAlgorithmTest { axisHatX: Float = 0.0f, axisHatY: Float = 0.0f, device: InputDeviceInfo = FAKE_CONTROLLER_INPUT_DEVICE, - ): Boolean = controller.onMotionEvent( + ): Boolean = controller.onInputEvent( KMGamePadEvent( metaState = 0, device = device, From 8ba1dc501ae4e1c55532c0fbcdbbf46a7230375e Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 20 Oct 2026 23:16:08 +0200 Subject: [PATCH 06/19] fix: crash when creating launcher shortcut with empty text because the new text field dialog permitted it --- CHANGELOG.md | 1 + .../base/keymaps/ConfigKeyMapOptionsViewModel.kt | 2 +- .../sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt | 2 +- .../keymapper/base/utils/ui/compose/ComposeDialogs.kt | 8 ++++++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8ccdb7d8..f90ae54eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## Fixed - #2069 dpad triggers work reliably again. +- Crash when creating launcher shortcut with empty text because the new text field dialog permitted it. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt index 6a55f17cb7..6c5efa1459 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt @@ -162,7 +162,7 @@ class ConfigKeyMapOptionsViewModel( } } - suspend fun onConfirmCreateShortcut(name: String): String? { + fun onConfirmCreateShortcut(name: String): String? { val dialogState = createShortcutDialogState ?: return null val result = createKeyMapShortcut.pinShortcut(dialogState.keyMapUid, name, dialogState.icon) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt index 47c1382a66..47197fa1f9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt @@ -76,7 +76,7 @@ fun KeyMapOptionsScreen(modifier: Modifier = Modifier, viewModel: ConfigKeyMapOp title = stringResource(R.string.key_map_options_create_shortcut_title), submitButtonText = stringResource(R.string.pos_save), initialText = createShortcutDialogState.defaultName, - onSubmitClick = { newText -> viewModel.onConfirmCreateShortcut(newText) }, + onSubmitClick = viewModel::onConfirmCreateShortcut, onDismissRequest = viewModel::onDismissCreateShortcutDialog, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt index 4e68f0aaaa..b586738ec7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt @@ -78,15 +78,19 @@ fun TextFieldDialog( keyboardController?.show() } - val submit = { + val submit: () -> Unit = { scope.launch { + if (!canBeEmpty && textFieldValue.text.isBlank()) { + error = emptyErrorText + return@launch + } + error = onSubmitClick(textFieldValue.text) if (error == null) { onDismissRequest() } } - Unit } CustomDialog( From 04dc4a605d1f2e73d0347c9edf8de58f5b34678f Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 20 Oct 2026 23:17:39 +0200 Subject: [PATCH 07/19] fix: crash navigating to new key map with TriggerSetupShortcut that was not serializable --- CHANGELOG.md | 1 + .../sds100/keymapper/base/trigger/TriggerSetupShortcut.kt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f90ae54eb4..51b9089805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - #2069 dpad triggers work reliably again. - Crash when creating launcher shortcut with empty text because the new text field dialog permitted it. +- Crash navigating to new key map with TriggerSetupShortcut that was not serializable ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupShortcut.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupShortcut.kt index 6d5d7316dc..92cc855563 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupShortcut.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupShortcut.kt @@ -1,7 +1,9 @@ package io.github.sds100.keymapper.base.trigger import androidx.annotation.Keep +import kotlinx.serialization.Serializable +@Serializable @Keep enum class TriggerSetupShortcut { VOLUME, From e0210b77f569ecbbfe2cffecb5809e68c28e7877 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 20 Oct 2026 23:21:20 +0200 Subject: [PATCH 08/19] remove potential NPEs in ActionsScreen --- .../github/sds100/keymapper/base/actions/ActionsScreen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt index dbee26bb69..a2813cffc0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt @@ -81,11 +81,11 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod val optionsState by viewModel.actionOptionsState.collectAsStateWithLifecycle() val actionTipModel by viewModel.actionsTip.collectAsStateWithLifecycle() - if (optionsState != null) { + optionsState?.let { optionsState -> ActionOptionsBottomSheet( modifier = Modifier.systemBarsPadding(), sheetState = sheetState, - state = optionsState!!, + state = optionsState, onDismissRequest = { viewModel.actionOptionsUid.update { null } }, callback = viewModel, ) @@ -93,10 +93,10 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod val fixKeyEventActionState by viewModel.fixKeyEventActionState.collectAsStateWithLifecycle() - if (fixKeyEventActionState != null) { + fixKeyEventActionState?.let { fixKeyEventActionState -> FixKeyEventActionBottomSheet( modifier = Modifier.systemBarsPadding(), - state = fixKeyEventActionState!!, + state = fixKeyEventActionState, sheetState = sheetState, onDismissRequest = viewModel::dismissFixKeyEventActionBottomSheet, onEnableAccessibilityServiceClick = viewModel::onEnableAccessibilityServiceClick, From 9b2b38b62b525c312b86fd4e057a826fedafd212 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:01:33 +0200 Subject: [PATCH 09/19] fix: input dispatching time out ANR when dispatching accessibility service gestures. This was caused by the gestures being dispatched on the main thread (the same as onKeyEvent) and the system was slow processing the gestures (for whatever reason), which resulted in onKeyEvent taking too long to process. --- CHANGELOG.md | 3 +- .../base/actions/PerformActionsUseCase.kt | 62 ++++++++++++------- .../accessibility/BaseAccessibilityService.kt | 25 ++------ 3 files changed, 45 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b9089805..7f3d12401f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ - #2069 dpad triggers work reliably again. - Crash when creating launcher shortcut with empty text because the new text field dialog permitted it. -- Crash navigating to new key map with TriggerSetupShortcut that was not serializable +- Crash navigating to new key map with TriggerSetupShortcut that was not serializable. +- Fix input dispatching time out ANR when dispatching accessibility service gestures. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index 64a5fbacc3..853cd654ed 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -77,13 +77,17 @@ import io.github.sds100.keymapper.system.volume.VolumeAdapter import io.github.sds100.keymapper.system.volume.VolumeStream import kotlin.math.absoluteValue import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber @@ -140,6 +144,12 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ): PerformActionsUseCaseImpl } + // Use Dispatchers.IO because it dispatching gestures from accessibility services are + // blocking Binder calls. Limit the parallelism to 1 thread so they are dispatched in order. + // This also prevents slow Binder calls that are potentially spammed by the user's key maps + // from starving the thread pool. + private val gestureDispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1) + private val openMenuHelper by lazy { OpenMenuHelper( service, @@ -292,7 +302,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( switchImeInterface.switchIme(action.imeId) // See issue #1064. Wait for the input method to finish switching before returning. - val chosenIme = withTimeoutOrNull(2000) { + val chosenIme = withTimeoutOrNull(2000.milliseconds) { inputMethodAdapter.chosenIme.filterNotNull().first { it.id == action.imeId } } @@ -312,7 +322,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( switchImeInterface.switchIme(nextIme.id) // See issue #1064. Wait for the input method to finish switching before returning. - val chosenIme = withTimeoutOrNull(2000) { + val chosenIme = withTimeoutOrNull(2000.milliseconds) { inputMethodAdapter.chosenIme.filterNotNull().first { it.id == nextIme.id } } @@ -383,7 +393,9 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val point = scaleCoordinate(action.x, action.y, action.screenResolution, displaySize) - result = service.tapScreen(point.x, point.y, inputEventAction) + result = withContext(gestureDispatcher) { + service.tapScreen(point.x, point.y, inputEventAction) + } } is ActionData.SwipeScreen -> { @@ -397,15 +409,17 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val end = scaleCoordinate(action.xEnd, action.yEnd, action.screenResolution, displaySize) - result = service.swipeScreen( - start.x, - start.y, - end.x, - end.y, - action.fingerCount, - action.duration, - inputEventAction, - ) + result = withContext(gestureDispatcher) { + service.swipeScreen( + start.x, + start.y, + end.x, + end.y, + action.fingerCount, + action.duration, + inputEventAction, + ) + } } is ActionData.PinchScreen -> { @@ -415,15 +429,17 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val distance = scaleDistance(action.distance, action.screenResolution, displaySize) - result = service.pinchScreen( - point.x, - point.y, - distance, - action.pinchType, - action.fingerCount, - action.duration, - inputEventAction, - ) + result = withContext(gestureDispatcher) { + service.pinchScreen( + point.x, + point.y, + distance, + action.pinchType, + action.fingerCount, + action.duration, + inputEventAction, + ) + } } is ActionData.Text -> { @@ -879,7 +895,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.SelectAllText -> { result = service.performActionOnNode({ it.isFocused }) { node -> - val text = node.text?.toString().orEmpty() + val text = node.text.orEmpty() if (text.isEmpty()) return@performActionOnNode null val extras = mapOf( @@ -924,7 +940,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) .onSuccess { // Wait 3 seconds so the message isn't shown in the screenshot. - delay(3000) + delay(3000.milliseconds) toastAdapter.show( resourceProvider.getString( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt index f9a436e41f..9b21f83d20 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt @@ -12,8 +12,6 @@ import android.content.res.Configuration import android.graphics.Path import android.graphics.Point import android.os.Build -import android.os.Handler -import android.os.HandlerThread import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo @@ -185,12 +183,6 @@ abstract class BaseAccessibilityService : abstract fun getController(): BaseAccessibilityServiceController? - /** - * Use a separate thread for dispatching gestures so they do not cause an ANR. - */ - private val gestureHandlerThread: HandlerThread = HandlerThread("gesture_thread") - private var gestureHandler: Handler? = null - override fun onCreate() { super.onCreate() Timber.i("Accessibility service: onCreate") @@ -245,13 +237,6 @@ abstract class BaseAccessibilityService : fingerprintGestureCallback?.let { fingerprintGestureController.registerFingerprintGestureCallback(it, null) } - - try { - gestureHandlerThread.start() - gestureHandler = Handler(gestureHandlerThread.looper) - } catch (_: IllegalThreadStateException) { - // do nothing if onServiceConnected is called again - } } override fun onUnbind(intent: Intent?): Boolean { @@ -264,8 +249,6 @@ abstract class BaseAccessibilityService : override fun onDestroy() { lifecycleRegistry.currentState = Lifecycle.State.DESTROYED - gestureHandlerThread.quit() - fingerprintGestureController .unregisterFingerprintGestureCallback(fingerprintGestureCallback) @@ -401,7 +384,7 @@ abstract class BaseAccessibilityService : addStroke(it) }.build() - val success = dispatchGesture(gestureDescription, null, gestureHandler) + val success = dispatchGesture(gestureDescription, null, null) return if (success) { Success(Unit) @@ -504,7 +487,7 @@ abstract class BaseAccessibilityService : } } - val success = dispatchGesture(gestureBuilder.build(), null, gestureHandler) + val success = dispatchGesture(gestureBuilder.build(), null, null) return if (success) { Success(Unit) @@ -557,7 +540,7 @@ abstract class BaseAccessibilityService : gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) } - val success = dispatchGesture(gestureBuilder.build(), null, gestureHandler) + val success = dispatchGesture(gestureBuilder.build(), null, null) return if (success) { Success(Unit) @@ -645,7 +628,7 @@ abstract class BaseAccessibilityService : } override fun performTalkBackGesture(gesture: TalkBackGestureType): KMResult<*> { - return TalkbackGesturePerformer.performTalkBackGesture(this, gesture, gestureHandler) + return TalkbackGesturePerformer.performTalkBackGesture(this, gesture, null) } override fun getActiveDisplayId(): Int { From 8620674a1d07729a28ae58cc0a2c7ad6a769b232 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:13:12 +0200 Subject: [PATCH 10/19] fix: crash fetching subgroups if parent group does not exist in database --- CHANGELOG.md | 1 + .../base/groups/SelectGroupUseCaseImpl.kt | 7 ++++++- .../keymapper/base/home/ListKeyMapsUseCase.kt | 7 ++++++- .../sds100/keymapper/data/db/dao/GroupDao.kt | 6 +----- .../data/entities/KeyMapEntitiesWithGroup.kt | 20 ------------------- .../data/repositories/GroupRepository.kt | 4 +--- .../data/repositories/RoomGroupRepository.kt | 7 +------ 7 files changed, 16 insertions(+), 36 deletions(-) delete mode 100644 data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3d12401f..fe6605513a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Crash when creating launcher shortcut with empty text because the new text field dialog permitted it. - Crash navigating to new key map with TriggerSetupShortcut that was not serializable. - Fix input dispatching time out ANR when dispatching accessibility service gestures. +- Fix crash fetching subgroups if parent group does not exist in database. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCaseImpl.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCaseImpl.kt index e0add1269e..e3e9bcc266 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCaseImpl.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/SelectGroupUseCaseImpl.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update @OptIn(ExperimentalCoroutinesApi::class) @@ -45,7 +46,11 @@ class SelectGroupUseCaseImpl @Inject constructor(private val groupRepository: Gr } else { val parents = getParentsRecursively(groupUid) - return groupRepository.getGroupWithChildren(groupUid).map { groupWithChildren -> + return groupRepository.getGroupWithChildren(groupUid).mapNotNull { groupWithChildren -> + if (groupWithChildren == null) { + return@mapNotNull null + } + val group = GroupEntityMapper.fromEntity(groupWithChildren.group) val children = groupWithChildren.children.map(GroupEntityMapper::fromEntity) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt index e5f195e56c..e880afe181 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext @@ -79,7 +80,11 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } else { val parents = getParentsRecursively(groupUid) - return groupRepository.getGroupWithChildren(groupUid).map { groupWithChildren -> + return groupRepository.getGroupWithChildren(groupUid).mapNotNull { groupWithChildren -> + if (groupWithChildren == null) { + return@mapNotNull null + } + val group = GroupEntityMapper.fromEntity(groupWithChildren.group) val children = groupWithChildren.children.map(GroupEntityMapper::fromEntity) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt index 1fa1a15137..3bc9d07856 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt @@ -8,7 +8,6 @@ import androidx.room.Query import androidx.room.Update import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.GroupEntityWithChildren -import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup import kotlinx.coroutines.flow.Flow @Dao @@ -26,9 +25,6 @@ interface GroupDao { @Query("SELECT * FROM $TABLE_NAME") fun getAll(): Flow> - @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:groupUid)") - fun getKeyMapsByGroup(groupUid: String): Flow - @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") fun getById(uid: String): GroupEntity? @@ -39,7 +35,7 @@ interface GroupDao { fun getByIdFlow(uid: String): Flow @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") - fun getGroupWithSubGroups(uid: String): Flow + fun getGroupWithSubGroups(uid: String): Flow @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_PARENT_UID IS (:uid)") fun getGroupsByParent(uid: String?): Flow> diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt deleted file mode 100644 index 7c6a12a082..0000000000 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntitiesWithGroup.kt +++ /dev/null @@ -1,20 +0,0 @@ -package io.github.sds100.keymapper.data.entities - -import android.os.Parcelable -import androidx.room.Embedded -import androidx.room.Relation -import io.github.sds100.keymapper.data.db.dao.GroupDao -import io.github.sds100.keymapper.data.db.dao.KeyMapDao -import kotlinx.parcelize.Parcelize - -@Parcelize -data class KeyMapEntitiesWithGroup( - @Embedded - val group: GroupEntity, - - @Relation( - parentColumn = GroupDao.KEY_UID, - entityColumn = KeyMapDao.KEY_GROUP_UID, - ) - val keyMaps: List, -) : Parcelable diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt index ec00f476d7..109184204c 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/GroupRepository.kt @@ -2,18 +2,16 @@ package io.github.sds100.keymapper.data.repositories import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.GroupEntityWithChildren -import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup import kotlinx.coroutines.flow.Flow interface GroupRepository { val groups: Flow> - fun getKeyMapsByGroup(groupUid: String): Flow suspend fun getGroup(uid: String): GroupEntity? fun getAllGroups(): Flow> fun getGroups(vararg uid: String): Flow> fun getGroupsByParent(uid: String?): Flow> - fun getGroupWithChildren(uid: String): Flow + fun getGroupWithChildren(uid: String): Flow suspend fun insert(groupEntity: GroupEntity) suspend fun update(groupEntity: GroupEntity) fun delete(uid: String) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt index 5e4b86bf3d..f28dc8502c 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt @@ -5,7 +5,6 @@ import io.github.sds100.keymapper.common.utils.DispatcherProvider import io.github.sds100.keymapper.data.db.dao.GroupDao import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.GroupEntityWithChildren -import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -27,10 +26,6 @@ class RoomGroupRepository @Inject constructor( override val groups: StateFlow> = dao.getAll().stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), emptyList()) - override fun getKeyMapsByGroup(groupUid: String): Flow { - return dao.getKeyMapsByGroup(groupUid).flowOn(dispatchers.io()) - } - override suspend fun getGroup(uid: String): GroupEntity? { return withContext(dispatchers.io()) { dao.getById(uid) } } @@ -47,7 +42,7 @@ class RoomGroupRepository @Inject constructor( return dao.getGroupsByParent(uid).flowOn(dispatchers.io()) } - override fun getGroupWithChildren(uid: String): Flow { + override fun getGroupWithChildren(uid: String): Flow { return dao.getGroupWithSubGroups(uid).flowOn(dispatchers.io()) } From 0d30de87538a3514d4c60d271afb3ed2f0483be2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:19:00 +0200 Subject: [PATCH 11/19] fix: creating answer and end call actions does not crash. This was caused by a nested state.update call. --- CHANGELOG.md | 1 + .../base/actions/ConfigActionsUseCase.kt | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe6605513a..42aae6d33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Crash navigating to new key map with TriggerSetupShortcut that was not serializable. - Fix input dispatching time out ANR when dispatching accessibility service gestures. - Fix crash fetching subgroups if parent group does not exist in database. +- Creating answer and end call does not crash. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt index 2fb3bd8548..da838adc1a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt @@ -51,6 +51,14 @@ class ConfigActionsUseCaseImpl @Inject constructor( keyMap.copy(actionList = newActionList) } + if (data is ActionData.AnswerCall) { + configConstraints.addConstraint(groupUid = null, ConstraintData.PhoneRinging) + } + + if (data is ActionData.EndCall) { + configConstraints.addConstraint(groupUid = null, ConstraintData.InPhoneCall) + } + preferenceRepository.update( Keys.recentlyUsedActions, { old -> @@ -235,7 +243,7 @@ class ConfigActionsUseCaseImpl @Inject constructor( if (data is ActionData.InputKeyEvent) { val containsDpadKey: Boolean = keyMap.trigger.keys - .mapNotNull { it as? KeyEventTriggerKey } + .filterIsInstance() .any { KeyEventUtils.isDpadKeyCode(it.keyCode) } if (KeyEventUtils.isModifierKey(data.keyCode) || containsDpadKey) { @@ -256,14 +264,6 @@ class ConfigActionsUseCaseImpl @Inject constructor( repeat = false } - if (data is ActionData.AnswerCall) { - configConstraints.addConstraint(groupUid = null, ConstraintData.PhoneRinging) - } - - if (data is ActionData.EndCall) { - configConstraints.addConstraint(groupUid = null, ConstraintData.InPhoneCall) - } - return Action( data = data, repeat = repeat, From 14feb49a84a6e4f860d671ebb47aa5ccd474ac5c Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:25:26 +0200 Subject: [PATCH 12/19] fix: crash if unable to find app to open Discord server link in Other trigger setup bottom sheet --- CHANGELOG.md | 1 + .../sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42aae6d33e..6f4a17cc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fix input dispatching time out ANR when dispatching accessibility service gestures. - Fix crash fetching subgroups if parent group does not exist in database. - Creating answer and end call does not crash. +- Crash if unable to find app to open Discord server link in Other trigger setup bottom sheet. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt index 9530e30cc2..031e056adb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt @@ -641,13 +641,14 @@ private fun OtherTriggerSetupBottomSheet( style = MaterialTheme.typography.bodyMedium, ) + val ctx = LocalContext.current val uriHandler = LocalUriHandler.current val helpUrl = stringResource(R.string.url_discord_server_invite) Button( modifier = Modifier.align(Alignment.CenterHorizontally), onClick = { - uriHandler.openUri(helpUrl) + uriHandler.openUriSafe(ctx, helpUrl) }, colors = ButtonDefaults.buttonColors( containerColor = LocalCustomColorsPalette.current.discord, From a8ae63c133cd75cc238385f1ceb619ba7b6a2bd1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:30:41 +0200 Subject: [PATCH 13/19] fix: crash if copying system bridge starter files blocked for too long. --- CHANGELOG.md | 1 + .../sysbridge/manager/SystemBridgeConnectionManager.kt | 4 ++-- .../keymapper/sysbridge/starter/SystemBridgeStarter.kt | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f4a17cc62..224c565232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Fix crash fetching subgroups if parent group does not exist in database. - Creating answer and end call does not crash. - Crash if unable to find app to open Discord server link in Other trigger setup bottom sheet. +- Crash if copying system bridge starter files blocked for too long. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt index 8f585a1dd2..b70d157c50 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt @@ -317,7 +317,7 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( return starter.getStartCommand() } - override fun canStartSystemBridge(): Boolean { + override suspend fun canStartSystemBridge(): Boolean { return starter.canStartSystemBridge() } } @@ -346,7 +346,7 @@ interface SystemBridgeConnectionManager { * nowhere to copy the starter files to because the shared storage is unavailable, which can * happen at any point while the device is running. */ - fun canStartSystemBridge(): Boolean + suspend fun canStartSystemBridge(): Boolean } fun SystemBridgeConnectionManager.isConnected(): Boolean { diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt index aac0f1608c..c8a52b9bce 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt @@ -272,7 +272,7 @@ class SystemBridgeStarter @Inject constructor( * nowhere to copy the starter files to because the shared storage is unavailable, which can * happen at any point while the device is running. */ - fun canStartSystemBridge(): Boolean { + suspend fun canStartSystemBridge(): Boolean { return getStarterFilesDirectory() != null } @@ -295,9 +295,9 @@ class SystemBridgeStarter @Inject constructor( * getExternalFilesDir returns null while the shared storage is not mounted, which can happen * at any point while the device is running and usually resolves itself within a minute. */ - private fun getStarterFilesDirectory(): File? { + private suspend fun getStarterFilesDirectory(): File? = withContext(Dispatchers.IO) { if (buildConfigProvider.sdkInt > Build.VERSION_CODES.R) { - return try { + return@withContext try { ctx.getExternalFilesDir(null)?.parentFile } catch (e: IOException) { null @@ -315,7 +315,7 @@ class SystemBridgeStarter @Inject constructor( e.printStackTrace() } - return protectedStorageDir + return@withContext protectedStorageDir } /** From 19765db871fcf3f55f64137a47da65cfc3839f14 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:45:05 +0200 Subject: [PATCH 14/19] fix: do not intentionally crash when a component registered while already registered in InputEventHub. --- CHANGELOG.md | 1 + .../io/github/sds100/keymapper/base/input/InputEventHub.kt | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 224c565232..8bb3daffcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Creating answer and end call does not crash. - Crash if unable to find app to open Discord server link in Other trigger setup bottom sheet. - Crash if copying system bridge starter files blocked for too long. +- Do not intentionally crash when a component registered while already registered in InputEventHub. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt index b1615136e6..4bd0648edc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt @@ -209,9 +209,6 @@ class InputEventHubImpl @Inject constructor( evdevEventTypes: List, ) { Timber.d("InputEventHub: Registering client $clientId") - if (clients.containsKey(clientId)) { - throw IllegalArgumentException("This client already has a callback registered!") - } clients[clientId] = ClientContext(callback, emptySet(), evdevEventTypes.toSet()) invalidateGrabbedDevices() From e3cdb6e80d7373c1e2257d0e27d988e896f7113c Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:50:36 +0200 Subject: [PATCH 15/19] delete unused code in DisplayUtils --- .../keymapper/common/utils/DisplayUtils.kt | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/DisplayUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/DisplayUtils.kt index 6f0f332b70..cdddfdda7a 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/DisplayUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/DisplayUtils.kt @@ -1,10 +1,7 @@ package io.github.sds100.keymapper.common.utils import android.content.Context -import android.graphics.Point -import android.util.Size import android.util.TypedValue -import androidx.core.content.ContextCompat fun dpToPx(context: Context, dp: Float): Float { return TypedValue.applyDimension( @@ -13,23 +10,3 @@ fun dpToPx(context: Context, dp: Float): Float { context.resources.displayMetrics, ) } - -fun Context.getDisplayDensity(): Float { - return resources.displayMetrics.density -} - -fun Context.getRealDisplaySize(): SizeKM { - val point = Point().apply { - ContextCompat.getDisplayOrDefault(this@getRealDisplaySize).getRealSize(this) - } - - return SizeKM(point.x, point.y) -} - -fun Context.getApplicationDisplaySize(): Size { - val point = Point().apply { - ContextCompat.getDisplayOrDefault(this@getApplicationDisplaySize).getSize(this) - } - - return Size(point.x, point.y) -} From 22984571f7253373c16f70f5ac66c112acc922c0 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 00:58:20 +0200 Subject: [PATCH 16/19] fix: do not allow testing actions if there is an error --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionListItem.kt | 5 +---- .../BaseAccessibilityServiceController.kt | 16 +++++++++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb3daffcc..3ca186545e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Crash if unable to find app to open Discord server link in Other trigger setup bottom sheet. - Crash if copying system bridge starter files blocked for too long. - Do not intentionally crash when a component registered while already registered in InputEventHub. +- Do not crash when testing actions with an error. ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt index 80be591ffb..2dc932542a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt @@ -375,15 +375,13 @@ private fun ExpandedContent( contentDescription = stringResource( R.string.action_list_item_duplicate, ), - tint = MaterialTheme.colorScheme.onSurface, ) } - IconButton(onClick = onTestClick) { + IconButton(onClick = onTestClick, enabled = model.error == null) { Icon( imageVector = Icons.Outlined.PlayArrow, contentDescription = stringResource(R.string.action_list_item_test), - tint = MaterialTheme.colorScheme.onSurface, ) } @@ -391,7 +389,6 @@ private fun ExpandedContent( Icon( imageVector = Icons.Outlined.Settings, contentDescription = stringResource(R.string.action_list_item_edit), - tint = MaterialTheme.colorScheme.onSurface, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt index 40e4b2d6ce..1b0e34e899 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt @@ -429,11 +429,7 @@ abstract class BaseAccessibilityServiceController( Timber.d("Service received event from UI: $event") when (event) { - is TestActionEvent -> service.lifecycleScope.launch { - performActionsUseCase.perform( - event.action, - ) - } + is TestActionEvent -> testAction(event) is AccessibilityServiceEvent.Ping -> service.lifecycleScope.launch { outputEvents.emit(AccessibilityServiceEvent.Pong(event.key)) @@ -481,6 +477,16 @@ abstract class BaseAccessibilityServiceController( } } + private fun testAction(event: TestActionEvent) { + service.lifecycleScope.launch { + val errorSnapshot = performActionsUseCase.getErrorSnapshot() + + if (errorSnapshot.getError(event.action) == null) { + performActionsUseCase.perform(event.action) + } + } + } + private fun requestFingerprintGestureDetection() { Timber.d("Accessibility service: request fingerprint gesture detection") serviceFlags.value = From 289c30d2aba5146f7261b499a75d39df9d36f41b Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 21 Oct 2026 01:19:42 +0200 Subject: [PATCH 17/19] fix tests --- .../keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt index 16c18a32f1..da7facecfc 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt @@ -110,7 +110,7 @@ class SystemBridgeAutoStarterTest { mockConnectionManager = mock { on { connectionState } doReturn connectionStateFlow - on { canStartSystemBridge() } doReturn true + onBlocking { canStartSystemBridge() } doReturn true } mockSetupController = mock() From 45244791aa62fa00fe154d424f7ee2aa13c8de6f Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 22 Oct 2026 15:17:07 +0200 Subject: [PATCH 18/19] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 26e755a5cb..da1abaf174 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.6.2 -VERSION_CODE=286 +VERSION_CODE=288 From 55a41f9a083ba5445e33df86071d00d34c23cc2d Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 22 Oct 2026 15:47:19 +0200 Subject: [PATCH 19/19] set changelog release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca186545e..44584cf50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [4.6.2](https://github.com/sds100/KeyMapper/releases/tag/v4.6.2) -#### TO BE RELEASED +#### 22 October 2026 ## Fixed