diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba2ce2ef00..e66c03232b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+## [4.0.5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.5)
+
+#### 26 February 2026
+
+## Fixed
+
+- #2047 allow empty text in Text action.
+- #2056 replace old "PRO" in triggers on home screen with "Expert".
+- #2053 reduce latency when a lot of key maps with open app actions.
+- #2054 fix "Fix key event action" bottom sheet done button being hidden on small screens.
+
## [4.0.4](https://github.com/sds100/KeyMapper/releases/tag/v4.0.4)
#### 21 February 2026
diff --git a/app/version.properties b/app/version.properties
index bb6898e9f9..c8c849f38d 100644
--- a/app/version.properties
+++ b/app/version.properties
@@ -1,2 +1,2 @@
-VERSION_NAME=4.0.4
-VERSION_CODE=246
+VERSION_NAME=4.0.5
+VERSION_CODE=247
diff --git a/base/src/main/AndroidManifest.xml b/base/src/main/AndroidManifest.xml
index faddfc687d..c4a4aea9ea 100644
--- a/base/src/main/AndroidManifest.xml
+++ b/base/src/main/AndroidManifest.xml
@@ -5,7 +5,8 @@
-
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BootBroadcastReceiver.kt b/base/src/main/java/io/github/sds100/keymapper/base/BootBroadcastReceiver.kt
index 247b5282bc..7bb321b372 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/BootBroadcastReceiver.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/BootBroadcastReceiver.kt
@@ -3,13 +3,23 @@ package io.github.sds100.keymapper.base
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.os.SystemClock
+import timber.log.Timber
class BootBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
context ?: return
- if (intent?.action == Intent.ACTION_LOCKED_BOOT_COMPLETED) {
- (context.applicationContext as? BaseKeyMapperApp)?.onBootUnlocked()
+ when (intent?.action) {
+ Intent.ACTION_BOOT_COMPLETED -> {
+ Timber.i(
+ "Boot completed broadcast: time since boot = ${SystemClock.elapsedRealtime() / 1000}",
+ )
+ }
+
+ Intent.ACTION_LOCKED_BOOT_COMPLETED -> {
+ (context.applicationContext as? BaseKeyMapperApp)?.onBootUnlocked()
+ }
}
}
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt
index 55b487ceba..a79591be43 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt
@@ -27,6 +27,7 @@ import io.github.sds100.keymapper.system.permissions.PermissionAdapter
import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter
import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter
import io.github.sds100.keymapper.system.settings.SettingType
+import java.util.concurrent.ConcurrentHashMap
class LazyActionErrorSnapshot(
private val packageManager: PackageManagerAdapter,
@@ -65,6 +66,9 @@ class LazyActionErrorSnapshot(
}
}
+ private val isAppEnabledCache = ConcurrentHashMap()
+ private val isAppInstalledCache = ConcurrentHashMap()
+
private val isSystemBridgeConnected: Boolean by lazy {
systemBridgeConnectionManager.isConnected()
}
@@ -250,13 +254,30 @@ class LazyActionErrorSnapshot(
}
private fun getAppError(packageName: String): KMError? {
+ if (isAppEnabledCache.contains(packageName) && isAppInstalledCache.contains(packageName)) {
+ if (isAppEnabledCache[packageName] == false) {
+ return KMError.AppDisabled(packageName)
+ }
+
+ if (isAppInstalledCache[packageName] == false) {
+ return KMError.AppDisabled(packageName)
+ }
+
+ return null
+ }
+
+ val isAppInstalled = packageManager.isAppInstalled(packageName)
+ isAppInstalledCache[packageName] = isAppInstalled
+
packageManager.isAppEnabled(packageName).onSuccess { isEnabled ->
+ isAppEnabledCache[packageName] = isEnabled
+
if (!isEnabled) {
return KMError.AppDisabled(packageName)
}
}
- if (!packageManager.isAppInstalled(packageName)) {
+ if (!isAppInstalled) {
return KMError.AppNotFound(packageName)
}
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt
index ded1fa8050..3525167f70 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt
@@ -843,7 +843,7 @@ class CreateActionDelegate(
"create_text_action",
DialogModel.Text(
hint = getString(R.string.hint_create_text_action),
- allowEmpty = false,
+ allowEmpty = true,
text = oldText,
),
) ?: return null
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt
index 1bca36c182..81ca3c2fc1 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt
@@ -78,9 +78,11 @@ fun FixKeyEventActionBottomSheet(
) {
Column(
modifier = Modifier
+ .animateContentSize()
+ .verticalScroll(rememberScrollState())
.padding(16.dp)
.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
@@ -90,24 +92,68 @@ fun FixKeyEventActionBottomSheet(
overflow = TextOverflow.Ellipsis,
)
- Column(
- modifier = Modifier
- .animateContentSize()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(8.dp),
+ Text(stringResource(R.string.fix_key_event_action_text))
+
+ FixKeyEventActionOptionCard(
+ onClick = onSelectInputMethod,
+ selected = state is FixKeyEventActionState.InputMethod,
+ title = stringResource(R.string.fix_key_event_action_input_method_title),
+ icon = Icons.Rounded.Keyboard,
) {
- Text(stringResource(R.string.fix_key_event_action_text))
+ val annotatedText = buildAnnotatedString {
+ appendInlineContent("icon", "[icon]")
+ append(" ")
+ append(stringResource(R.string.fix_key_event_action_input_method_text))
+ }
+ val inlineContent = mapOf(
+ Pair(
+ "icon",
+ InlineTextContent(
+ Placeholder(
+ width = MaterialTheme.typography.bodyLarge.fontSize,
+ height = MaterialTheme.typography.bodyLarge.fontSize,
+ placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Remove,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ ),
+ )
+ Text(
+ annotatedText,
+ inlineContent = inlineContent,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
- FixKeyEventActionOptionCard(
- onClick = onSelectInputMethod,
- selected = state is FixKeyEventActionState.InputMethod,
- title = stringResource(R.string.fix_key_event_action_input_method_title),
- icon = Icons.Rounded.Keyboard,
- ) {
+ val isExpertModeUnsupported = state.expertModeStatus == ExpertModeStatus.UNSUPPORTED
+
+ FixKeyEventActionOptionCard(
+ onClick = onSelectExpertMode,
+ selected = state is FixKeyEventActionState.ExpertMode,
+ title = stringResource(R.string.expert_mode_app_bar_title),
+ icon = Icons.Outlined.OfflineBolt,
+ enabled = !isExpertModeUnsupported,
+ ) {
+ if (isExpertModeUnsupported) {
+ Text(
+ stringResource(R.string.trigger_setup_expert_mode_unsupported),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ } else {
val annotatedText = buildAnnotatedString {
appendInlineContent("icon", "[icon]")
append(" ")
- append(stringResource(R.string.fix_key_event_action_input_method_text))
+ append(stringResource(R.string.fix_key_event_action_expert_mode_text_1))
+ appendLine()
+ appendInlineContent("icon", "[icon]")
+ append(" ")
+ append(stringResource(R.string.fix_key_event_action_expert_mode_text_2))
}
val inlineContent = mapOf(
Pair(
@@ -116,13 +162,14 @@ fun FixKeyEventActionBottomSheet(
Placeholder(
width = MaterialTheme.typography.bodyLarge.fontSize,
height = MaterialTheme.typography.bodyLarge.fontSize,
- placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
+ placeholderVerticalAlign =
+ PlaceholderVerticalAlign.TextCenter,
),
) {
Icon(
- imageVector = Icons.Rounded.Remove,
+ imageVector = Icons.Rounded.Add,
contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
+ tint = LocalCustomColorsPalette.current.green,
)
},
),
@@ -133,65 +180,13 @@ fun FixKeyEventActionBottomSheet(
style = MaterialTheme.typography.bodyMedium,
)
}
-
- val isExpertModeUnsupported = state.expertModeStatus == ExpertModeStatus.UNSUPPORTED
-
- FixKeyEventActionOptionCard(
- onClick = onSelectExpertMode,
- selected = state is FixKeyEventActionState.ExpertMode,
- title = stringResource(R.string.expert_mode_app_bar_title),
- icon = Icons.Outlined.OfflineBolt,
- enabled = !isExpertModeUnsupported,
- ) {
- if (isExpertModeUnsupported) {
- Text(
- stringResource(R.string.trigger_setup_expert_mode_unsupported),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.error,
- )
- } else {
- val annotatedText = buildAnnotatedString {
- appendInlineContent("icon", "[icon]")
- append(" ")
- append(stringResource(R.string.fix_key_event_action_expert_mode_text_1))
- appendLine()
- appendInlineContent("icon", "[icon]")
- append(" ")
- append(stringResource(R.string.fix_key_event_action_expert_mode_text_2))
- }
- val inlineContent = mapOf(
- Pair(
- "icon",
- InlineTextContent(
- Placeholder(
- width = MaterialTheme.typography.bodyLarge.fontSize,
- height = MaterialTheme.typography.bodyLarge.fontSize,
- placeholderVerticalAlign =
- PlaceholderVerticalAlign.TextCenter,
- ),
- ) {
- Icon(
- imageVector = Icons.Rounded.Add,
- contentDescription = null,
- tint = LocalCustomColorsPalette.current.green,
- )
- },
- ),
- )
- Text(
- annotatedText,
- inlineContent = inlineContent,
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- }
-
- Text(
- stringResource(R.string.fix_key_event_action_change_in_settings_caption),
- style = MaterialTheme.typography.labelMedium,
- )
}
+ Text(
+ stringResource(R.string.fix_key_event_action_change_in_settings_caption),
+ style = MaterialTheme.typography.labelMedium,
+ )
+
HeaderText(text = stringResource(R.string.fix_key_event_action_setup_title))
AccessibilityServiceRequirementRow(
@@ -234,7 +229,10 @@ fun FixKeyEventActionBottomSheet(
}
}
- Button(modifier = Modifier.align(Alignment.End), onClick = onDoneClick) {
+ Button(
+ modifier = Modifier.align(Alignment.End),
+ onClick = onDoneClick,
+ ) {
Text(stringResource(R.string.pos_done))
}
}
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 a895c2b4c2..36959ac9b6 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
@@ -802,6 +802,8 @@ class KeyMapAlgorithm(
val detectedShortPressTriggers = mutableSetOf()
val vibrateDurations = mutableListOf()
+ val errorSnapshot = performActionsUseCase.getErrorSnapshot()
+
/*
loop through triggers in a different loop first to increment the last matched index.
Otherwise the order of the key maps affects the logic.
@@ -815,8 +817,6 @@ class KeyMapAlgorithm(
val lastMatchedIndex = lastMatchedEventIndices[triggerIndex]
- val errorSnapshot = performActionsUseCase.getErrorSnapshot()
-
val actionList = triggerActions[triggerIndex]
.map { actionKey -> actionMap[actionKey]?.data }
.filterNotNull()
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt
index e98a45c12a..c54298cffa 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt
@@ -110,12 +110,17 @@ class SystemBridgeAutoStarter @Inject constructor(
useShizukuFlow.flatMapLatest { useShizuku ->
if (useShizuku) {
+ Timber.i("autoStartTypeFlow: Use shizuku")
flowOf(AutoStartEligibility.Eligible(AutoStartType.SHIZUKU))
} else if (buildConfig.sdkInt >= Build.VERSION_CODES.R) {
+ Timber.i("autoStartTypeFlow: Do not use shizuku")
combine(
permissionAdapter.isGrantedFlow(Permission.WRITE_SECURE_SETTINGS),
networkAdapter.isWifiConnected,
) { isWriteSecureSettingsGranted, isWifiConnected ->
+ Timber.i(
+ "autoStartTypeFlow: Write secure settings: $isWriteSecureSettingsGranted, Wifi connected: $isWifiConnected",
+ )
when {
!isWifiConnected -> {
AutoStartEligibility.NotEligible.WiFiDisconnected
diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt
index ca911abea6..929038eb47 100644
--- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt
+++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt
@@ -323,7 +323,7 @@ class KeyMapListItemCreator(
append(key.getCodeLabel(this@KeyMapListItemCreator))
val parts = buildList {
- add("PRO")
+ add("Expert")
add(key.device.name)
if (!key.consumeEvent) {
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 46249c87f1..7d694ae30b 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -71,7 +71,9 @@ lane :production do
supply(
aab: "../app/build/outputs/bundle/release/app-release.aab",
track: "internal",
- skip_upload_apk: true
+ skip_upload_apk: true,
+ # Skip uploading the title because F-droid should not have "Floating buttons" in its title.
+ skip_upload_metadata: true,
)
whats_new = File.read("../base/src/main/assets/whats-new.txt")
diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt
index 9810cafe1e..19f819ebd7 100644
--- a/fastlane/metadata/android/en-US/title.txt
+++ b/fastlane/metadata/android/en-US/title.txt
@@ -1 +1 @@
-Key Mapper & Floating Buttons
\ No newline at end of file
+Key Mapper
\ No newline at end of file
diff --git a/fastlane/metadata/android/tr_TR/title.txt b/fastlane/metadata/android/tr_TR/title.txt
index 9810cafe1e..19f819ebd7 100644
--- a/fastlane/metadata/android/tr_TR/title.txt
+++ b/fastlane/metadata/android/tr_TR/title.txt
@@ -1 +1 @@
-Key Mapper & Floating Buttons
\ No newline at end of file
+Key Mapper
\ No newline at end of file
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 565c890de4..8cb911dda8 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
@@ -285,7 +285,9 @@ class SystemBridgeSetupControllerImpl @Inject constructor(
isAdbPairedJob?.cancel()
isAdbPairedResult.value = null
+ Timber.d("Launching isAdbPaired job")
isAdbPairedJob = coroutineScope.launch {
+ Timber.d("Enabling wireless ADB")
SettingsUtils.putGlobalSetting(ctx, ADB_WIRELESS_SETTING, 1)
// Try running a command to see if the pairing is working correctly.