Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
152673b
move fastlane chinese translations to correct folder
sds100 Oct 10, 2026
813028b
bump version to 4.6.2
sds100 Oct 10, 2026
88cf284
refactor system bridge starter code
sds100 Oct 18, 2026
b3de1d2
fix: use timeout when observing next system bridge setup step in Syst…
sds100 Oct 18, 2026
8dc38a4
#2069 fix: dpad triggers work reliably again
sds100 Oct 20, 2026
8ba1dc5
fix: crash when creating launcher shortcut with empty text because th…
sds100 Oct 20, 2026
04dc4a6
fix: crash navigating to new key map with TriggerSetupShortcut that w…
sds100 Oct 20, 2026
e0210b7
remove potential NPEs in ActionsScreen
sds100 Oct 20, 2026
9b2b38b
fix: input dispatching time out ANR when dispatching accessibility se…
sds100 Oct 20, 2026
8620674
fix: crash fetching subgroups if parent group does not exist in database
sds100 Oct 20, 2026
0d30de8
fix: creating answer and end call actions does not crash.
sds100 Oct 20, 2026
14feb49
fix: crash if unable to find app to open Discord server link in Other…
sds100 Oct 20, 2026
a8ae63c
fix: crash if copying system bridge starter files blocked for too long.
sds100 Oct 20, 2026
19765db
fix: do not intentionally crash when a component registered while alr…
sds100 Oct 20, 2026
e3cdb6e
delete unused code in DisplayUtils
sds100 Oct 20, 2026
2298457
fix: do not allow testing actions if there is an error
sds100 Oct 20, 2026
289c30d
fix tests
sds100 Oct 20, 2026
4524479
bump version code
sds100 Oct 22, 2026
55a41f9
set changelog release date
sds100 Oct 22, 2026
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
## [4.6.2](https://github.com/sds100/KeyMapper/releases/tag/v4.6.2)

#### 22 October 2026

## Fixed

- #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.
- 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.
- 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)

#### 10 October 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.6.1
VERSION_CODE=286
VERSION_NAME=4.6.2
VERSION_CODE=288
Original file line number Diff line number Diff line change
Expand Up @@ -375,23 +375,20 @@ 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,
)
}

IconButton(onClick = onEditClick) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.action_list_item_edit),
tint = MaterialTheme.colorScheme.onSurface,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,22 @@ 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,
)
}

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down Expand Up @@ -235,7 +243,7 @@ class ConfigActionsUseCaseImpl @Inject constructor(
if (data is ActionData.InputKeyEvent) {
val containsDpadKey: Boolean =
keyMap.trigger.keys
.mapNotNull { it as? KeyEventTriggerKey }
.filterIsInstance<KeyEventTriggerKey>()
.any { KeyEventUtils.isDpadKeyCode(it.keyCode) }

if (KeyEventUtils.isModifierKey(data.keyCode) || containsDpadKey) {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
}

Expand All @@ -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 }
}

Expand Down Expand Up @@ -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 -> {
Expand All @@ -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 -> {
Expand All @@ -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 -> {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -1036,8 +1038,10 @@ class KeyMapAlgorithm(

val vibrateEffect = when {
trigger.vibrate -> resolveVibrateEffect(trigger)

forceVibrate.value ->
VibrateEffect.CustomDuration(defaultVibrateDuration.value)

else -> null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading