Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Need help using the app?
url: http://keymapper.club?utm_source=issue_template_chooser
url: http://keymapper.app/discord?utm_source=issue_template_chooser
about: Join the Discord server and let us help you
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## [4.0.4](https://github.com/sds100/KeyMapper/releases/tag/v4.0.4)

#### 21 February 2026

## Added

- #2024 support Expert mode on all Android versions supported by Key Mapper (8.0+).
- #2025 add report bug button to home screen menu.
- #2027 Make the key map sorting feature easier to understand.
- #2016 Show a warning when repeating a key code action less than 20 ms with expert mode triggers.
- Show dialog if Expert mode fails to start after 60 seconds instead of waiting indefinitely.

## Fixed

- #2030 do not filter out unknown evdev key events.
- #2028 work around Shizuku bug on Mediatek devices that prevents Expert mode from starting.
- #2034 catch errors when injecting events with Expert mode on Xiaomi devices and show warning to fix on home screen.

## [4.0.3](https://github.com/sds100/KeyMapper/releases/tag/v4.0.3)

#### 07 February 2026
Expand Down
4 changes: 2 additions & 2 deletions app/version.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VERSION_NAME=4.0.3
VERSION_CODE=243
VERSION_NAME=4.0.4
VERSION_CODE=246
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.UserManager
import android.util.Log
import android.widget.Toast
Expand All @@ -23,7 +22,6 @@ import io.github.sds100.keymapper.base.settings.Theme
import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl
import io.github.sds100.keymapper.base.system.notifications.NotificationController
import io.github.sds100.keymapper.base.system.permissions.AutoGrantPermissionController
import io.github.sds100.keymapper.common.utils.Constants
import io.github.sds100.keymapper.data.Keys
import io.github.sds100.keymapper.data.entities.LogEntryEntity
import io.github.sds100.keymapper.data.repositories.LogRepository
Expand Down Expand Up @@ -231,27 +229,25 @@ abstract class BaseKeyMapperApp : MultiDexApplication() {
Timber.i("KeyMapperApp: System bridge is disconnected")
}

if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) {
systemBridgeAutoStarter.init()
systemBridgeAutoStarter.init()

// Initialize SystemBridgeLogger to start receiving log messages from SystemBridge.
// Using Lazy<> to avoid circular dependency issues and ensure it's only created
// when the API level requirement is met.
systemBridgeLogger.start()
// Initialize SystemBridgeLogger to start receiving log messages from SystemBridge.
// Using Lazy<> to avoid circular dependency issues and ensure it's only created
// when the API level requirement is met.
systemBridgeLogger.start()

appCoroutineScope.launch {
systemBridgeConnectionManager.connectionState.collect { state ->
if (state is SystemBridgeConnectionState.Connected) {
val isUsed =
settingsRepository.get(Keys.isSystemBridgeUsed).first() ?: false

// Enable the setting to use PRO mode for key event actions the first time they use PRO mode.
if (!isUsed) {
settingsRepository.set(Keys.keyEventActionsUseSystemBridge, true)
}

settingsRepository.set(Keys.isSystemBridgeUsed, true)
appCoroutineScope.launch {
systemBridgeConnectionManager.connectionState.collect { state ->
if (state is SystemBridgeConnectionState.Connected) {
val isUsed =
settingsRepository.get(Keys.isSystemBridgeUsed).first() ?: false

// Enable the setting to use PRO mode for key event actions the first time they use PRO mode.
if (!isUsed) {
settingsRepository.set(Keys.keyEventActionsUseSystemBridge, true)
}

settingsRepository.set(Keys.isSystemBridgeUsed, true)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper
import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface
import io.github.sds100.keymapper.common.BuildConfigProvider
import io.github.sds100.keymapper.common.models.ShellExecutionMode
import io.github.sds100.keymapper.common.utils.Constants
import io.github.sds100.keymapper.common.utils.KMError
import io.github.sds100.keymapper.common.utils.firstBlocking
import io.github.sds100.keymapper.common.utils.onFailure
Expand Down Expand Up @@ -67,19 +66,11 @@ class LazyActionErrorSnapshot(
}

private val isSystemBridgeConnected: Boolean by lazy {
if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API) {
systemBridgeConnectionManager.isConnected()
} else {
false
}
systemBridgeConnectionManager.isConnected()
}

private val keyEventActionsUseSystemBridge: Boolean by lazy {
if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API) {
preferenceRepository.get(Keys.keyEventActionsUseSystemBridge).firstBlocking() ?: false
} else {
false
}
preferenceRepository.get(Keys.keyEventActionsUseSystemBridge).firstBlocking() ?: false
}

override fun getErrors(actions: List<ActionData>): Map<ActionData, KMError?> {
Expand Down Expand Up @@ -129,8 +120,7 @@ class LazyActionErrorSnapshot(
return isSupportedError
}

if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API &&
action is ActionData.InputKeyEvent &&
if (action is ActionData.InputKeyEvent &&
keyEventActionsUseSystemBridge
) {
if (!isSystemBridgeConnected) {
Expand All @@ -155,8 +145,7 @@ class LazyActionErrorSnapshot(
}

@SuppressLint("NewApi")
if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API &&
ActionUtils.isSystemBridgeRequired(action.id) &&
if (ActionUtils.isSystemBridgeRequired(action.id) &&
!isSystemBridgeConnected
) {
return SystemBridgeError.Disconnected
Expand Down Expand Up @@ -241,6 +230,7 @@ class LazyActionErrorSnapshot(
null
}
}

SettingType.SECURE,
SettingType.GLOBAL,
-> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.HelpOutline
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
Expand Down Expand Up @@ -126,6 +127,29 @@ fun ActionOptionsBottomSheet(
)
}

if (state.showRepeatRateWarning) {
Spacer(Modifier.height(8.dp))

Row(
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.width(16.dp))
Icon(
Icons.Rounded.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(8.dp))
Text(
modifier = Modifier.weight(1f),
text = stringResource(R.string.action_repeat_rate_warning),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.width(16.dp))
}
}

if (state.showRepeatRate) {
Spacer(Modifier.height(8.dp))

Expand Down Expand Up @@ -421,6 +445,7 @@ private fun Preview() {
showEditButton = true,
showRepeat = true,
isRepeatChecked = true,
showRepeatRateWarning = true,

showRepeatRate = true,
repeatRate = 400,
Expand Down Expand Up @@ -480,6 +505,7 @@ private fun PreviewNoEditButton() {
showEditButton = false,
showRepeat = true,
isRepeatChecked = true,
showRepeatRateWarning = true,

showRepeatRate = true,
repeatRate = 400,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package io.github.sds100.keymapper.base.actions
import android.content.pm.PackageManager
import android.os.Build
import androidx.annotation.DrawableRes
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
Expand Down Expand Up @@ -96,8 +95,6 @@ import io.github.sds100.keymapper.system.permissions.Permission

object ActionUtils {

val isSystemBridgeSupported = Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API

@StringRes
fun getCategoryLabel(category: ActionCategory): Int = when (category) {
ActionCategory.NAVIGATION -> R.string.action_cat_navigation
Expand Down Expand Up @@ -750,42 +747,44 @@ object ActionUtils {
else -> emptyList()
}

@RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API)
fun isSystemBridgeRequired(id: ActionId): Boolean {
// Actions are only tested on Android 10 and higher.
return when (id) {
ActionId.ENABLE_WIFI,
ActionId.DISABLE_WIFI,
ActionId.TOGGLE_WIFI,
-> true
-> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q

ActionId.TOGGLE_MOBILE_DATA,
ActionId.ENABLE_MOBILE_DATA,
ActionId.DISABLE_MOBILE_DATA,
-> true
-> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q

ActionId.TOGGLE_HOTSPOT,
ActionId.ENABLE_HOTSPOT,
ActionId.DISABLE_HOTSPOT,
-> true
-> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q

ActionId.ENABLE_NFC,
ActionId.DISABLE_NFC,
ActionId.TOGGLE_NFC,
-> true
-> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q

ActionId.TOGGLE_AIRPLANE_MODE,
ActionId.ENABLE_AIRPLANE_MODE,
ActionId.DISABLE_AIRPLANE_MODE,
-> true
-> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q

ActionId.TOGGLE_BLUETOOTH,
ActionId.ENABLE_BLUETOOTH,
ActionId.DISABLE_BLUETOOTH,
-> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2

ActionId.POWER_ON_OFF_DEVICE -> true
ActionId.POWER_ON_OFF_DEVICE -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q

ActionId.FORCE_STOP_APP, ActionId.CLEAR_RECENT_APP -> true
ActionId.FORCE_STOP_APP, ActionId.CLEAR_RECENT_APP ->
Build.VERSION.SDK_INT >=
Build.VERSION_CODES.Q

else -> false
}
Expand All @@ -796,7 +795,7 @@ object ActionUtils {
ActionId.TOGGLE_MOBILE_DATA,
ActionId.ENABLE_MOBILE_DATA,
ActionId.DISABLE_MOBILE_DATA,
-> return if (isSystemBridgeSupported) {
-> return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
emptyList()
} else {
listOf(Permission.ROOT)
Expand Down Expand Up @@ -869,7 +868,7 @@ object ActionUtils {
ActionId.ENABLE_NFC,
ActionId.DISABLE_NFC,
ActionId.TOGGLE_NFC,
-> return if (isSystemBridgeSupported) {
-> return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
emptyList()
} else {
listOf(Permission.ROOT)
Expand All @@ -887,7 +886,7 @@ object ActionUtils {
ActionId.TOGGLE_AIRPLANE_MODE,
ActionId.ENABLE_AIRPLANE_MODE,
ActionId.DISABLE_AIRPLANE_MODE,
-> return if (isSystemBridgeSupported) {
-> return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
emptyList()
} else {
listOf(Permission.ROOT)
Expand All @@ -903,11 +902,12 @@ object ActionUtils {

ActionId.SECURE_LOCK_DEVICE -> return listOf(Permission.DEVICE_ADMIN)

ActionId.POWER_ON_OFF_DEVICE -> return if (isSystemBridgeSupported) {
emptyList()
} else {
listOf(Permission.ROOT)
}
ActionId.POWER_ON_OFF_DEVICE ->
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
emptyList()
} else {
listOf(Permission.ROOT)
}

ActionId.DISMISS_ALL_NOTIFICATIONS,
ActionId.DISMISS_MOST_RECENT_NOTIFICATION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget
import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegate
import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase
import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate
import io.github.sds100.keymapper.base.trigger.EvdevTriggerKey
import io.github.sds100.keymapper.base.utils.getFullMessage
import io.github.sds100.keymapper.base.utils.isFixable
import io.github.sds100.keymapper.base.utils.navigation.NavDestination
Expand Down Expand Up @@ -261,6 +262,7 @@ class ConfigActionsViewModel @Inject constructor(
)

RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid)

RepeatMode.TRIGGER_PRESSED_AGAIN ->
config.setActionStopRepeatingWhenTriggerPressedAgain(uid)
}
Expand Down Expand Up @@ -395,10 +397,17 @@ class ConfigActionsViewModel @Inject constructor(
Int.MAX_VALUE
}

val showRepeatRateWarning =
keyMap.isRepeatingActionsAllowed() &&
action.data is ActionData.InputKeyEvent &&
(action.repeatRate ?: defaultRepeatRate) < 20 &&
keyMap.trigger.keys.any { it is EvdevTriggerKey }

return ActionOptionsState(
showEditButton = action.data.isEditable(),

showRepeat = keyMap.isRepeatingActionsAllowed(),
showRepeatRateWarning = showRepeatRateWarning,
isRepeatChecked = action.repeat,

showRepeatRate = keyMap.isChangingActionRepeatRateAllowed(action),
Expand Down Expand Up @@ -470,6 +479,7 @@ data class ActionOptionsState(
val isRepeatChecked: Boolean,

val showRepeatRate: Boolean,
val showRepeatRateWarning: Boolean,
val repeatRate: Int,
val defaultRepeatRate: Int,

Expand Down
Loading
Loading